天天看点

Qunar客户端iOS实时活动接入实践

一、背景

在 iOS16 上,苹果推出了实时活动( Live Activities )。实时活动可以出现在用户锁屏界面,并可实时展示用户关心的一些核心信息。由于使用远程推送通道更新实时活动不需要主 app 保持开启,并且相比小组件需要用户手动添加到桌面这个门槛,实时活动功能是默认开启的,所以这一机制保证了信息的触达概率会远高于小组件。

笔者认为,实时活动是一种更高级的推送形态,对比传统的推送,实时活动显得灵活许多。传统推动在用户接收并读取到内容的瞬间信息就失效了,如果想要为用户提供持续更新的信息需要依赖一条接一条的推送才可以实现,这无论从成本还是用户体验上,都是难以接受的。而实时活动则会在持续期间,无干扰的保持着信息的更新,并且又会在合适的时机自行消失,因此用户体验极佳。

作为一家在线旅行产品平台,我们一直追求为用户提供极致的用户体验,在今年的上半年,我们上线了基于实时活动开发的实时出行信息展示功能,用户可以在出行全周期内,通过锁屏或者灵动岛看到最新实时的出行信息。

Qunar客户端iOS实时活动接入实践

- 效果图 -

根据线上统计,项目五月份上线至今,开启实时活动的用户当中有一半用户已与该功能产生了交互,80%+的用户体验到了更加便捷的出行信息展示。在用户体验提升方面,以上被覆盖到的用户会更及时地了解自己的出行进度及细节;在品牌提升方面,基于适合的场景对客户端各项新技术进行持续的接入,体现了我们对用户出行体验的提升的努力与思考,同时也会大大增加用户对Qunar品牌的技术能力和服务的接受度与认可度。另外,现阶段实时活动项目主要覆盖了航班与火车的出行场景,标示着基于实时活动的出行信息实时展示的基础设施已搭建完成,后续还可低成本接入船票、汽车票、景点门票等更多的业务线场景,为整个Qunar的产品生态提供助力。

本文旨在介绍实时活动的特性机制以及分享接入过程中的具体每一步细节,相信读完这篇文章,读者也可以对实时活动的适用场景有初步的了解,并顺利地完成实时活动的接入,来为 APP 用户的体验带来巨大提升。

二、实时活动介绍

下面简单介绍一下实时活动的原理和机制。在 iOS 16.0 版本的 SDK 中,苹果引入了实时活动功能,它支持用户在锁屏界面查看一些应用实时活动的更新。然后在后续的 iOS 16.1 SDK 中,实时活动支持了灵动岛机型的展示,除了上述的锁屏界面,拥有灵动岛的机型还会额外支持灵动岛样式。

主 APP 会通过苹果提供的 ActivityKit SDK,来启动一个 Live Activity 实例,在启动时,可以通过Activity Attributes 对象进行初始化参数的传递,实时活动持续期间,还可以通过类似方式在主 APP 端触发实时活动的数据更新。同时,通过相关的设置,实时活动还可以脱离主 APP 的依赖,只通过苹果的推送服务(APNS)即可进行后续的数据更新。

不过这里还需要额外说明一下,可能是苹果为了兼顾功耗与用户体验,实时活动还是会存在以下的一些限制。

  1. Live Activities 小组件出现在锁屏之后会存在 8 小时,8 小时后实时活动会进入结束状态,不再接收数据更新。灵动岛会消失,但是锁屏卡片会在锁屏上再存在 4 小时。
  2. 实时活动无法自主进行网络请求和位置更新,数据刷新需要依赖主 APP 或 PUSH 。
  3. 每一次更新的数据不能超过 4KB 。
  4. 动画会被系统过滤,例如 withAnimation(::) 等 Api 是无法生效的。
  5. 每一个 App 可以开启多个 Live Activities ,每个设备可以同时开启多个 App 的 Live Activities ,一个用户同一时间可能会达到一个上限而开启失败,因此在开启、更新、结束每一个 Activities 时,检查功能是否可用以及是否操作成功非常的重要。

三、实时活动与 iOS14 Widget 的一些对比

在之前的 iOS 14 上,苹果还推出过桌面小组件的功能,与实时活动相比,它们之间是有一些相似之处的,比如都是一个脱离主 APP 独立运行的功能,通过一个卡片来展示一些核心信息。但是仔细比较的话,它们还是存在一些差异,这些差异也决定了它们适用于不同的使用场景,希望读者通过这些对比对实时活动的使用场景有更清晰的了解。

Qunar客户端iOS实时活动接入实践

可以看到,由于实时活动权限是默认开启的,而且展示在锁屏推送的顶部和灵动岛区域,更容易引起用户的注意,当然这个优势更像是一个双刃剑,因为当实时活动在错误的场景展示了一些用户不太关心的信息,会有可能引起用户反感,所以展示哪些信息以及何时展示,是一个产品层面值得斟酌的关键问题。

四、需求介绍

此次我们需要上线的功能是,当用户在出行周期内,可通过实时活动查看出行的核心信息,以火车票业务为例,用户在发车前可通过锁屏或者灵动岛,实时查看检票口信息、正晚点信息、列车状态,在旅程中可查看即将到达站点,到站后如果需要中转则可查看中转信息,从而完成出行全周期的信息展示。

这个需求描述起来比较简单,但是涉及细节的话会遇到以下的一些问题:

1.用户何时应该展示实时活动?

2.最新的行程数据如何保证实时更新至实时活动?

3.PUSH通道如何下发给指定用户他所需要的信息?

4.实时活动如何实现及时关闭?

带着这些问题,我们对整个需求的实现进行了以下的设计。

五、方案设计

首先,我们梳理出了整个需求中的角色及相关职责,来完成整个业务的实现。

  1. 订单中心,通过订单中心我们可以获取到用户与出行产品的绑定关系,从而决定向指定用户推送指定的行程信息。
  2. 各业务线,需要监控各个行程所属业务的信息,如机票需要关注航变信息,登机口信息,火车票需要监控检票口、登车时间等。
  3. 触达平台相关业务,各个业务线(如机票、火车票)会在合适的时机通过 QMQ 消息队列向触达平台同步最新出行信息,由触达平台来进行最新出行信息的下发。
  4. 客户端,在主 APP 侧,需要启动/更新/关闭实时活动组件,获取 pushToken 并向后端同步;在实时活动组件侧,需要接收到数据后进行相应信息的展示。
  5. PUSH 通道,需要向触达平台提供下发实时活动类型的 PUSH 数据的能力,以支持实时活动组件不依赖主 APP 来保持信息更新。

功能层级如下图所示:

Qunar客户端iOS实时活动接入实践

数据流程如下图所示:

Qunar客户端iOS实时活动接入实践

可以看到,在出行前某个时间点,通过自动化任务,触达平台会生成行程信息。当用户打开客户端时,会查询是否有需要展示的行程信息。当存在可用的行程信息时,APP 会尝试启动实时活动,

实时活动启动后,开发者可以获取到当前实施活动的实例 id ,通过该实例可以获取当前展示的数据及实时活动的运行状态等信息,延迟数百 ms 后,可以收到用来针对该实时活动进行推送更新的 push token,这里有个细节,因为服务端是通过用户id而不是 push tokon 来进行的最新行程信息的下发,所以需要客户端在获取到 push token 之后进行一次用户 id + push token 的绑定关系同步,这样后续如果有行程数据的更新,会通过用户ID获取到当前可用的 PushToken ,从而完成 Push 数据的下发。

六、实时活动接入实现

接下来说一下客户端的接入实时活动的具体细节,这个部分我放了一些具体的代码,希望可以通过借助对代码中各个 API 以及参数的说明,让读者对接入的流程有更直观的了解。

启动实时活动

在主 APP 代码中,引入 ActivityKit ,为了传递初始化数据及后续更新数据,我们需要首先定义一个数据模型类,继承自 ActivityAttributes 。

struct MyLiveAcitityAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // ContentState用来封装动态(会发生变化的)数据,我们这里为了灵活可以直接接收一个JSON字符串
        var content: String
    }
    // 不变的信息可以在此处声明,在初始化Attributes实例时传入,用来传递启动实时活动之后就不会变化的数据,如来源、业务标识之类
  var channelID: String
}           

这里有一个小细节, Constate 属性是 Codable 的,这是因为主 APP 和实时活动直接的数据通信是跨进程的,要传递的信息会有编码解码的过程。

另外在启动实时活动前,最好要做一下实时活动权限的判断,防止后续无意义的逻辑。

guard ActivityAuthorizationInfo().areActivitiesEnabled else { … }           

通过调用 ActivityKit 中的 request(attributes: contentState:pushType:) 方法,可以启动实时活动,接下来我们逐个解释一下各个参数的意义。

  • attributes 是实时活动的初始值,后续不可更新。
  • contentState 是可刷新数据的实例,实时活动启动后,后续可通过相关 API 传入新的 ContentState 实例来进行实时活动内容的更新。
  • pushType 是指定实时活动是否通过远程推送通知接收其动态内容的更新,传入 PushType.token 会支持远程推送更新,传入 nil 则只能接收 APP 触发的内容更新。
do {
    let myActivity =  try Activity<QnrWidgetAttributes>.request(attributes: arrtibute, contentState:contentState, pushType: PushType.token)
    print(“start my Live Activity \(myActivity.id)”)
} catch (let error) {
    print(“fail to request my Live Acitivity \(error.localizedDescription)")
}           

更新实时活动

实时活动启动后,如果想要实时更新展示内容,有以下两个途径:

1. 主APP在前台通过 update(using:) API 将封装最新的信息 ContentState 实例传递给实时活动,从而触发实时活动的数据更新,或者主 APP 拥有后台权限也可在后台时使用此 API 进行内容更新,但是 iOS 对后台权限限制较多,只在导航、音乐这种场景下才会开放给开发者,大部分 APP 不适用此情况。参考代码如下:

let contentState = MyWidgetAttributes.ContentState(content: unwrapJsonStr)
                await liveActivity.update(using:contentState)           

2. 通过苹果的 APNS 以推送的方式进行数据的更新,这个对应了上文提到的 PushType.token 相关内容。考虑到大部分应用的使用场景是不适合后台模式的,所以使用 PUSH 更新无疑是想要实时活动更新的最佳方式。

关闭实时活动

如果想要关闭实时活动,有以下两种方式:

1. 主 APP 调用 end(dismissalPolicy:) 方法关闭指定的实时活动实例,这里可以通过关闭策略参数来控制是立即移除还是指定时间后移除,如果选择默认选项,系统会在 4 小时后进行实时活动的移除。可参考以下代码:

//MARK:停止实时活动
    @available(iOS 16.1, *)
    @objc public func stop(activtyId: String?) -> Bool {
        let liveActivity: Activity<QnrWidgetAttributes>? = getLiveActivity(ID: activtyId)
        guard let liveActivity else{
            return false
        }
      await liveActivity.end(dismissalPolicy: .immediate)//立刻结束
        return true
    }           

2. 通过 Push 下发 end 事件,并可以在消息体中通过 dismissal-date 参数来指定实时活动的移除时间点。PUSH 消息体中的内容实例:

{"aps": {
   "timestamp":'$(date +%s)000',
   "event": "end",
   "content-state": {"content":"$JSON_STRING"},
   "dismissal-date": '$endDate'
}           

实时活动推送配置

如果实时活动确定要接入推送能力,以下是需要我们做的:

  • 确保 APP 的推送证书是基于 token 的认证方式 (Token-Based) ,如果现有的认证方式是基于证书的,则需要推送业务来改造成 Token-Based 的。
  • 客户端获取实时活动的 PushToken 。需要注意的是,推送时使用的 Token 并非 App 启动时通过 UIApplication 注册时所获得的 Device Token,而是实时活动启动之后,通过监听实时活动的实例回调(pushTokenUpdates)来获取到的。客户端获取到 PushToken 之后,需要将 PushToken 同步给服务器,这样后续如果有新的信息需要下发,服务器通过该 PushToken 来进行推送操作即可。

七、开发过程中的一些问题与思考

数据中心模式

因为实时活动是无法自己进行网络请求的,UI 展示的数据来自主 APP 或者推送数据,所以可以把实时活动理解为一个时间点数据流截面的展示。我们发现,由于不用关心数据的上下文,我们可以通过维护一个数据中心并让所有的 UI 组件共享一份数据来用于 UI 展示。

具体的做法是,我们在收到数据并提供锁屏卡片及灵动岛的回调中,完成数据解析并将接收到的数据同步到数据中心内,这样的一个好处是所有的 UI 组件在代码中不用再额外声明初始化的入参,直接访问数据中心获取共享数据即可。

同时,因为苹果的实时活动 API 设计,当接收到数据更新时,必须要返回一个有效的视图用来渲染至锁屏页面及灵动岛,那如果这个数据是无效的怎么办?使用数据中心的模式则有效的避免了这个问题,当数据无效时,由于我们还保存有上一个有效的数据,所以可以舍弃掉此次无效数据,并用上次有效数据来做兜底处理即可。

UI 设计规范要求

根据官方文档说明,实时活动锁屏卡片宽度固定,高度在 84 到 160 像素之间根据内容高度调整;灵动岛拓展视图与锁屏卡片宽高类似,但是设计时需要规避开灵动岛硬件区域。另外在实际开发中发现,灵动岛拓展视图会在左、右、底部方向存在约 13 像素的边线区域,设计时也需要注意避开。在投入开发前,最好提前与设计同学对相关的规范进行沟通,防止出现无法实现的问题。

各个形态的具体细节可参考下图:

Qunar客户端iOS实时活动接入实践

卡片消失时间的控制

在设计之初,我们计划在实时活动启动时就设置一个关闭的时间,到达指定时间点后会自动移除。但是在调研中发现,目前实时活动并未提供类似的 API ,这样会导致一旦启动实时活动后,如果用户关闭了 APP ,实时活动将无法控制它的关闭时间。最终我们使用了 PUSH 能力来更新数据,在合适的时机触达平台服务会下发 end 类型的 PUSH 数据来关闭实时活动。

实时活动推送的模拟

在开发过程中,需要通过 PUSH 来测试触发数据更新,如果没有联调环境的话,可以本地通过脚本工具来进行模拟推送的下发,以下是我们使用的脚本部分内容,供大家参考:

# 生成AUTHENTICATION_TOKEN
export JWT_ISSUE_TIME=$(date +%s)
export JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
export JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"


# 发送end类型push,需要添加这个key到aps节点
# "dismissal-date": '$endDate',
curl -v \
--header "apns-topic:{YOUR_BUNDLE_ID}.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"aps": {
   "timestamp":'$(date +%s)000',
   "event": "update",
   "content-state": {"content":"{\"businessType\":\"train\",\"trainData\":{}}"}
}}' \
--http2 \
https://$APNS_HOST_NAME/3/device/$DEVICE_TOKEN           

如何进行实时活动代码的断点调试

开发中,如果遇到需要断点调试实时活动来进行问题定位的情况,可以按照如下流程进行:

  1. 模拟器或者真机启动主 APP ,在 XCode 中的 Debug 菜单中找到相应实时活动的 Extention Target 的进程进行 Attach

2. 回到主 APP ,触发启动或者更新实时活动操作即可触发断点。

Qunar客户端iOS实时活动接入实践

跳转处理

在 WidgetKit 中,通过 widgetURL(_ url: URL?) API 可以进行点击跳转链接的绑定,如果直接绑定跳转的落地页,会不便于实时活动的跳转的统计与监控,因此我们对所有的跳转链接进行了二次包装,通过统一格式的 Scheme 进行收口后再进行真正落地页的跳转。

用户点击实时活动调起客户端后,会走到统一分发的逻辑,在这里我们处理数据埋点、页面重置(防止用户重复点击实时活动导致页面栈里堆叠多个相同的页面)、落地页 scheme 还原等相关业务逻辑。

八、结语

以上就是 Qunar 大客户端接入实时活动过程中的一些设计、接入细节及相关思考,目前有很多 APP 已经陆续接入了实时活动的功能,如果在合适的场景中,用户在使用我们的服务时突然发现这个小小的功能,能展示他此刻正关注的内容,无疑会为产品增色不少,也能在一众竞品中脱颖而出。希望本文能为热爱技术、相信技术的你带来一些启发和帮助,我们也会为带给用户极致的出行体验,不断探索。

作者:

林书辉 大前端开发工程师

2018年加入Qunar,大前端开发工程师。目前负责大客户端首页、用户中心、IM等功能的开发及维护。

来源:微信公众号:Qunar技术沙龙

出处:https://mp.weixin.qq.com/s/4IGK-YoEgUyNaK0M2Nt6YQ