65.9K
CodeProject 正在变化。 阅读更多。
Home

如何在现有项目中添加实时活动 - 第一部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2023 年 1 月 5 日

CPOL

8分钟阅读

viewsIcon

13879

关于如何为项目添加实时活动功能的终极指南的第一部分。

Apple 在推出 iOS 16 和配备灵动岛的新款 iPhone 时,推出了实时活动功能。此升级仅适用于已升级到 iOS 16.1 的用户。

我叫 Aziz,是一名在 inDrive 工作的 iOS 开发者。在这篇文章中,我将告诉您我们是如何为我们的 iOS 应用添加实时活动的。在这里,我将尝试回答我们在开发过程中遇到的所有问题。

首先,为什么要添加实时活动?

基本理念是,作为用户,您不必在需要查看特定时间点的关键相关信息时打开应用程序。

这是一个简单的示例,说明您的实时活动如何工作。假设您已订购某项服务。除了“您的订单正在路上”的通用状态外,锁屏上还会显示一个包含所有必要信息的 widget,例如订单状态、送达时间、有关快递员/司机的详细信息等。

Если у вас iPhone 14 Pro или 14 Pro Max, вам доступна функция Dynamic Island

如果您拥有 iPhone 14 Pro 或 14 Pro Max,则可以使用灵动岛功能。

При нажатии на фичу появляется информационный блок. В нашем случае это статус поездки, сколько до вас ехать водителю, номер и цвет машины

当您点击该功能时,将显示相关信息块。在我们的例子中,这是行程状态,您离司机的距离,以及车辆的车牌号和颜色。

投入生产

市面上有很多文章描述了将实时活动添加到项目中的过程。唯一的区别是,它们都没有分享将此功能添加到现有项目的实践经验。这就是我想在这篇文章中告诉您的。

我们的过程始于向企业演示实时活动功能。目的是“推销”这个想法。我们创建了一个基于相关文章和官方文档的演示应用程序。这帮助我们清晰地展示了基本理念。

那个演示

在短暂的审批期后,我们开始将实时活动功能集成到 inDrive 项目中。我们组建了一个行动小组,除了我之外,还有另外两名 iOS 开发者。

在集成到现有项目中时,我们遇到了一些需要解决的问题

  1. XcodeGen 和首次运行 — 如何向 project.yml 添加新目标,特别是当它必须支持实时活动时?
  2. 深入了解推送通知如何与实时活动功能配合使用。
  3. 我们有自己的设计系统,如何在实时活动中使用它?
  4. 如何连接翻译资源。
  5. 如何将 UDF 链接到实时活动。

XcodeGen 和首次运行

在我们的项目中,我们使用 XcodeGen 生成 *.xcodeproj 文件。这有点棘手,因为我们以前从未在项目中这样做过 widget。我们必须在应用程序主模块的 project.yml 文件中添加特定模板。必须在主目标的 info 部分添加一个标志

NSSupportsLiveActivities: true

然后我们需要为实时活动 widget 本身创建一个模板

LiveActivity:
  type: app-extension
  platform: iOS
  info:
    path: "${target_name}/SupportingFiles/Info.plist"
    properties:
      CFBundleDisplayName: ${target_name}
      CFBundleShortVersionString: *cfBundleShortVersionString
      NSExtension:
        NSExtensionPointIdentifier: "com.apple.widgetkit-extension"
  settings:
    base:
      TARGETED_DEVICE_FAMILY: "$(inherited)"
      PRODUCT_BUNDLE_IDENTIFIER: ${bundleId}
    configs:
      debug:
        PROVISIONING_PROFILE_SPECIFIER: "match Development ${bundleId}"
        CODE_SIGN_IDENTITY: ""
        DEBUG_INFORMATION_FORMAT: ""
      release:
        PROVISIONING_PROFILE_SPECIFIER: "match AppStore ${bundleId}"
        CODE_SIGN_IDENTITY: ""
  dependencies:
    - framework: SwiftUI.framework
      implicit: true
    - framework: WidgetKit.framework
      implicit: true

别忘了主目标中的以下内容

dependencies:
  - target: LiveActivity

请务必指定相应的 bundleId,该 ID 应首先链接到 Provision Profile(也为该 bundle 单独配置)。

在完成所有步骤并成功完成构建 make finished successfully 🎉 后,您的实时活动功能必须首次显示。在这里,我们将省略配置 Content State 的部分,以及定义静态属性和需要更新的属性。重要的是要记住添加“main”,因为没有它您将无法运行 widget。

@available(iOSApplicationExtension 16.1, *)
@main
struct Widgets: WidgetBundle {
    var body: some Widget {
        LiveActivityWidgetView()
    }
}

 See below for how to launch Live Activity (iOS 16.1): 
public func startWith(_ attributes: Attributes?, 
       state: Attributes.ContentState, pushType: PushType?) {
 // 1
    guard ActivityAuthorizationInfo().areActivitiesEnabled,
    let attributes = attributes,
    activity.isNil
    else { return }

    do {
        // 2
        activity = try Activity<Attributes>.request(
            attributes: attributes,
            contentState: state,
            pushType: pushType
        )
        if let token = activity?.pushToken {
            let unwrappedToken = token.map { String(format: "%02x", $0) }.joined()
            logger.debug("🚀 Live Activity token: \(unwrappedToken)")
            // 3
            props.action.execute(with: .didStartActivityWith(token: unwrappedToken))
        } else {
            logger.error("⛔️ Failed Live Activity")
        }
        // 4
        Task {
            guard let activity = activity else { return }
            for await data in activity.pushTokenUpdates {
                let token = data.map { String(format: "%02x", $0) }.joined()
                logger.debug("🤝🏼 Live Activity token updates: \(token)")
                props.action.execute(with: .didPushTokenUpdates(token: token))
            }
        }
    } catch {
        logger.error("⛔️ Failed Live Activity: \(error.localizedDescription)")
        props.action.execute(with: .didFailStartLiveActivity(error: error))
    }
}

通过应用程序升级 Activity,如果您不想使用推送通知(iOS 16.1)

Task {
    await activity.update(using: state)
}

然后结束此过程

Task {
    await activity.end(using: state, dismissalPolicy: .immediate)
}
  1. 我们必须确保用户可以在设置中显示启用的实时活动,并且没有正在运行的活动。
  2. 创建 Activity 请求时,我们需要通信 pushType
    • .token — 通过推送通知升级实时活动
    • nil — 仅在应用程序生命周期内
  3. 如果通过 .token 升级,pushToken 将异步到达,必须发送到后端。这将确保后端团队知道我们已准备好接收实时活动的更新。
  4. 实时活动令牌的更新基于应用程序是正在运行还是已下载。我们需要跟踪这一点并将其报告给后端。

调用 startWith() 方法后,您应该会在屏幕上看到实时活动。如果您采用此方法,请务必在评论中分享您的情况。

深入了解推送通知如何与实时活动功能配合使用

最初,我们实现了没有推送通知的基本功能。实时活动功能的设计可以不使用任何推送通知来实现 — 仅基于应用内状态。然后我们提出了这些问题

  • 实时活动如何知道哪个推送是为它准备的?这完全是 Apple 的底层魔力。根据传入的推送通知 payload 和 ID,系统会自行确定哪个实时活动与传入的信息相关。
  • 可以在应用程序本身捕获这些推送通知吗?在撰写本文时,Apple 没有提供有关如何在正在运行的应用程序中捕获推送通知的信息。我和我的同事们检查了这个技术是否有效
    application(_:didReceiveRemoteNotification:fetchCompletionHandler:)

    在这种情况下,此方法不会为实时活动推送通知调用。

  • 如何在后端尚未就绪的情况下测试推送通知?我们自己设计了一个合适的 Payload,并在测试应用程序中实现了常规的推送通知流程。接下来,我们 使用了这个

大部分工作都必须在后端完成。我们向 APNS 发送了示例请求,指明了客户端将等待的 Payload。请继续关注有关我们后端实现工作的更多详细信息,请参阅文章的第二部分。

curl -v \
--header "apns-topic:{Your App Bundle ID}.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"aps": {
   "timestamp":1663300480,
   "event": "update",
   "content-state": {
      "playerOnFirst": "Tony Stark",
      "currentLap": 2
   },
   "alert": {
      "title": "Race Update",
      "body": "Tony Stark is now leading the race!"
   }
}}' \
--http2 \
https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN2
{
  "aps": {
    "timestamp": 1669698850,
    "event": "update",
    "content-state": {
      "rideStatus": "on_ride",
      "time": 5
    }
  }
}

专有设计系统和实时活动

我们以前没有在项目中使用过 SwiftUI,所以这对我们来说是另一个挑战。在演示应用程序成功启动后,我们开始解决一些小问题,并尝试使用设计系统中的通用组件。

  1. 颜色、字体和图标等基本组件不是大问题,因为从 iOS 15 开始,Apple 增加了一种在 SwiftUI 中使用 UIKit 组件的便捷方法。

    我们更进一步,并原生实现了它。顺便说一下,我们打算将基本组件开源,以便您可以查看。

  2. Apple 提供了一个简单的机制来将 UIView 包装在 SwiftUI.ViewUIViewRepresentable)中,这本应使我们的生活更轻松,但结果却像这样

    或者像这样:

    虽然计划是这样的。:)

    还有像这样

由于我们未能在线找到解释,我们决定暂时搁置一些功能,直到我们将组件切换为原生 SwiftUI。如果您有任何关于为什么会这样想法,我们很乐意听取您的意见。

车辆颜色

在我们的主应用程序中,我们不会改变车辆前往用户位置的颜色。此图标已嵌入应用程序中,我们只需替换它。对于 LiveActivity,我从 Figma 中获取了一个图标,将其放在我想要的位置,然后就忘了它。

然后我的一个同事过来对我说,“为什么我们不把车辆变成后端发送的颜色呢?”我们发现颜色只是文本:金色、蓝色等。我们要求后端团队也发送十六进制值,以便我们也能进行着色。

尽管如此,我们还是必须在这里使用一些巧妙的技巧。我们将图像分解成几个图层:我们将要着色的车身,以及不会改变的车辆底部。结果是这样的

这种简单的技术帮助我们使 widget 更具信息量。

如何连接翻译资源?

我们在 47 个国家开展业务,并支持多种语言。在启动一项功能时,我们必须确保所有语言都得到支持,包括阿拉伯语(从右到左书写)。

我们使用 Crowdin 平台来方便地处理翻译,对于 widget 和 island,必须根据涉及的区域设置调整设置。为此,我们使用了 SwiftUI 通过 Environment 带来的“乐趣”。

@Environment(\.layoutDirection) var direction

UDF + 实时活动

我们可以使用 UDF 来实现一个服务组件,该组件将“监控”特定状态的更改事件,并生成我们的实时活动并在必要时进行更新。

如上所示,我们可以通过服务组件封装实时活动的启动、更新和结束。如果您希望通过正在运行的应用程序(不使用远程推送通知)更新状态,这将非常有用。

这一点至关重要。要通过推送通知更新实时活动,它必须通过活动应用程序显式运行。从那时起,Apple 将接管所有魔术。

import UDF
import ActivityKit

@available(iOS 16.1, *)
public typealias LiveActivityAttributes = ActivityAttributes & Equatable

@available(iOS 16.1, *)
open class LiveActivityServiceComponent<Attributes: LiveActivityAttributes> 
    public var activity: Activity<Attributes>?
    public let disposer = Disposer()
    public var props: Props = .init() {
        didSet {
            render(props)
        }
    }

    public init() { }

    open func render(_: Props) {
        // the logic we implement here is based on the props that come in
        // or you can explicitly override this method in the descendants
        // call the startWith(),updateWith(), endWith() methods when needed
    }
}

// MARK: - LiveActivityServiceComponent

@available(iOS 16.1, *)
extension LiveActivityServiceComponent: ViewComponent {
    public func startWith(_ attributes: Attributes?, 
           state: Attributes.ContentState, pushType: PushType?) {
        ...
    }

    public func updateWith(_ state: Attributes.ContentState) {
        ...
    }

    public func endWith(_ state: Attributes.ContentState) {
       ...
    }
}

// MARK: - Props

@available(iOS 16.1, *)
public protocol LiveActivityProps: Equatable {
    associatedtype Attributes: ActivityAttributes & Equatable
    var attributes: Attributes? { get }
    var contentState: Attributes.ContentState? { get }
    var pushType: PushType? { get }
    var action: CommandOf<LiveActivityAction> { get }

    init(
        attributes: Attributes?,
        contentState: Attributes.ContentState?,
        pushType: PushType?,
        action: CommandOf<LiveActivityAction>
    )
}

@available(iOS 16.1, *)
extension LiveActivityProps {
    init() {
        self.init(attributes: nil, contentState: nil, pushType: nil, action: .nop)
    }
}

@available(iOS 16.1, *)
public extension LiveActivityServiceComponent {
    struct Props: LiveActivityProps {
        public let attributes: Attributes?
        public let contentState: Attributes.ContentState?
        public let pushType: PushType?
        public let action: CommandOf<LiveActivityAction>

        public init(
            attributes: Attributes? = nil,
            contentState: Attributes.ContentState? = nil,
            pushType: PushType? = nil,
            action: CommandOf<LiveActivityAction> = .nop
        ) {
            self.attributes = attributes
            self.contentState = contentState
            self.pushType = pushType
            self.action = action
        }
    }
}

// MARK: - Action

public enum LiveActivityAction: Action {
    case didStartActivityWith(token: String)
    case didPushTokenUpdates(token: String)
    case didFailStartLiveActivity(error: Error)
}

然后,您可以在所需的 reducer 中捕获 LiveActivityAction,并向后端发送带有令牌的请求以接收推送通知。您可以使用它们来刷新和结束实时活动。Apple 的 文档对此过程进行了详细描述。

文章的第一部分内容相当多,足以供您消化,但我很快就会分享更多关于实时活动的观察。我想特别感谢 Lyosha Kakoulin 和 Petya Kazakov,没有他们,这一切都不会发生。

有用链接

历史

  • 2023 年 1 月 5 日:初始版本
© . All rights reserved.