iOS16新特性:实时活动-在锁屏界面实时更新APP消息

打印 上一主题 下一主题

主题 803|帖子 803|积分 2409

简介

之前在 《iOS16新特性:灵动岛适配开发与到家业务场景结合的探索实践》 里介绍了iOS16新的特性:实时更新(Live Activity)中灵动岛的适配流程,但其实除了灵动岛的展示样式,Live Activity还有一种非常实用的应用场景,那就是锁屏界面实时状态更新:

上图是部分已经做出适配的APP,锁屏实时活动的展示。可以看到,相比于灵动岛的样式,锁屏更新的展示区域更大,能够显示更多信息,并且是在锁屏界面上进行展示,结合苹果在iPhone14之后推出的“全天候显示”功能,能够让用户在不解锁手机,甚至不拿起手机的情况下就能够获取到APP内最新的消息更新,在某些应用场景下非常实用。
这篇文章主要就介绍Live Activity中锁屏实时活动样式的适配流程,再结合实际开发过程中的遇到的问题进行实际详解:
限制条件

在进行开发之前,需要先了解一下锁屏实时活动的一些限制条件:
1.实时活动显示在通知区域且有更自由的视图定制和刷新方法,但是跟Widget小组件一样,它也限制了视图上的动画开发,所有的动画效果仅能由系统处理。
2.锁屏通知区域内的实时活动在8小时之内可以刷新数据展示,超过8小时不再支持刷新,,超过12小时强制消失
3.实时活动视图本体不支持发起网络请求,所有的动态数据都要经由通知下发,或者后台活动数据刷新,且每次更新的数据不能超过4KB。
4.实时活动可以通过推送下发更新数据,但是推送的类型不同于传统“基于证书”的推送,而是“基于token”的推送类型。
实际开发

1.建立锁屏实时活动扩展项目

这部分建立的过程与灵动岛的适配流程完全一致,请参见 iOS16新特性:灵动岛适配开发与到家业务场景结合的探索实践 中相关的流程描述,如果之前建立过灵动岛项目,则可以直接开始开发:

2.UI开发

Live Activity的全部样式开发均完全采用SwiftUI,锁屏实时活动也不例外,以下是我开发的UI部分代码,大家可以一参考一下:
  1. struct LockScreenLiveActivityView: View {
  2.     let context: ActivityViewContext<DJDynamicIslandAttributes>
  3.    
  4.     var body: some View {
  5.         VStack {
  6.             Spacer(minLength: 10)
  7.             LockScreenLiveActivityStoreHeaderView(imageURL: context.state.logo, title: context.state.title, subTitle: context.state.subTitle)
  8.             Spacer(minLength: 0)
  9.             LockScreenLiveActivityProgressView(progress: context.state.progress)
  10.             Spacer(minLength: 10)
  11.         }
  12.     }
  13. }
  14. struct LockScreenLiveActivityStoreHeaderView: View {
  15.     let imageURL: String
  16.     let title: String
  17.     let subTitle: String
  18.    
  19.     var body: some View {
  20.         HStack(spacing: 10) {
  21.             NetworkImage(imageUrl: imageURL)
  22.                 .frame(width: 50, height: 50)
  23.             
  24.             VStack(alignment: .leading, spacing: 4) {
  25.                 HStack {
  26.                     Text(title)
  27.                         .font(.system(size: 16, weight: .bold))
  28.                         .foregroundColor(Color(hex: 0x333333, alpha: 1))
  29.                 }
  30.                
  31.                 Text(subTitle)
  32.                     .font(.system(size: 13))
  33.                     .foregroundColor(Color(hex: 0x666666, alpha: 1))
  34.                     .padding(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0))
  35.             }
  36.             
  37.             Spacer()  // 填充剩余空间
  38.         }
  39.         .padding(8)
  40.     }
  41. }
  42. struct LockScreenLiveActivityProgressView: View {
  43.     var progress: CGFloat
  44.     let borderOffset = 20.0
  45.    
  46.     var body: some View {
  47.         VStack {
  48.             ZStack(alignment: .bottom) {
  49.                 HStack(alignment: .bottom) {
  50.                     Spacer()
  51.                     NetworkImage(imageUrl: "", placeholdImage: "store")
  52.                         .frame(width: 50, height: 50)
  53.                     Spacer()
  54.                 }
  55.                
  56.                 HStack(alignment: .bottom) {
  57.                     NetworkImage(imageUrl: "", placeholdImage: "knight")
  58.                         .frame(width: 40, height: 40)
  59.                         .offset(x: progress * UIScreen.main.bounds.width - 25)
  60.                     Spacer()
  61.                 }
  62.                
  63.                 HStack(alignment: .bottom) {
  64.                     Spacer()
  65.                     NetworkImage(imageUrl: "", placeholdImage: "pin")
  66.                         .frame(width: 18, height: 25)
  67.                         .offset(x: -borderOffset)
  68.                 }
  69.             }
  70.             .frame(height: 50)
  71.             Spacer(minLength: 0)
  72.             ZStack(alignment: .leading) {
  73.                 RoundedRectangle(cornerRadius: 5)
  74.                     .foregroundColor(Color.gray)
  75.                     .frame(height: 10)
  76.                
  77.                 RoundedRectangle(cornerRadius: 5)
  78.                     .foregroundColor(Color.yellow)
  79.                     .frame(width: (UIScreen.main.bounds.width - borderOffset * 3) * progress, height: 10)
  80.             }
  81.             .frame(height: 15)
  82.             .padding(.horizontal, borderOffset)
  83.         }
  84.     }
  85. }
复制代码
运行起来以后大概长这个样子:

坑1:

由于实时活动不允许加载网络请求,所以网络图片的URL也无法加载,可以通过:
1.直接通推送通知过下发图片的Data,再转成img,但是要注意数据大小,不要超过4Kb
2.本地图片
来解决
3.Live Activity的生命周期

Live Activity的生命周期由ActivityKit管理,其中,数据部分的模型类为ActivityAttributes,自定义数据模型需要继承自ActivityAttributes,静态数据变量直接生命在结构体内,动态数据变量需要声明在ActivityAttributes的ContentState中,这部分变量在接收到推送更新数据时,会自动根据json数据的key值进行解析并更新:
  1. struct DJDynamicIslandAttributes: ActivityAttributes {
  2.    
  3.     public typealias DJDynamicIslandStatus = ContentState
  4.    
  5.     public struct ContentState: Codable, Hashable {
  6.         // 动态数据
  7.         var logo: String = ""
  8.         var title: String = ""
  9.         var subTitle: String = ""
  10.         var progress: Double = 0
  11.     }
  12.     // 静态数据
  13.     var totalAmount: String
  14.     var orderId: String
  15. }
复制代码
Live Activity的生命周期分为:
创建(start)
利用Activity的request方法创建
  1. func startActivity() throws {
  2.          
  3.         let attributes = DJDynamicIslandAttributes(
  4.             // 静态数据
  5.         )
  6.         let initialContentState = DJDynamicIslandAttributes.ContentState(
  7.             // 动态数据
  8.         )
  9.         let activity = try Activity.request(
  10.             attributes: attributes,
  11.             content: .init(state: initialContentState, staleDate: nil),
  12.             pushType: .token)
  13.     }
复制代码
更新(update)
利用Activity的update方法更新,传入的参数即为ActivityAttributes的ContentState,也就是动态数据部分
  1. func updateActivity(){
  2.         Task{
  3.             let updatedStatus = DJDynamicIslandAttributes.ContentState(
  4.                 // 动态数据
  5.             )
  6.             for activity in Activity<DJDynamicIslandAttributes>.activities{
  7.                 await activity.update(using: updatedStatus)
  8.                 print("已更新灵动岛显示 Value值已更新 请展开灵动岛查看")
  9.             }
  10.         }
  11.     }
复制代码
结束(end)
利用Activity的end方法结束,并从锁屏通知界面上移除
  1. func endActivity(){
  2.         Task{
  3.             for activity in Activity<DJDynamicIslandAttributes>.activities{
  4.                 await activity.end(dismissalPolicy: .immediate)
  5.                 print("已关闭灵动岛显示")
  6.             }
  7.         }
  8.     }
复制代码
4.数据同步

通过
  1. ActivityConfiguration(for: DJDynamicIslandAttributes.self) { context in
  2. }
复制代码
方法创建实时活动视图的时候,回调的参数context类型是ActivityViewContext,可以通过context.state取到动态化数据的属性:
  1. struct LockScreenLiveActivityView: View {
  2.     let context: ActivityViewContext<DJDynamicIslandAttributes>
  3.    
  4.     var body: some View {
  5.         VStack {
  6.             Spacer(minLength: 10)
  7.             LockScreenLiveActivityStoreHeaderView(imageURL: context.state.logo, title: context.state.title, subTitle: context.state.subTitle)
  8.             Spacer(minLength: 0)
  9.             LockScreenLiveActivityProgressView(progress: context.state.progress)
  10.             Spacer(minLength: 10)
  11.         }
  12.     }
  13. }
复制代码
利用这些属性刷新视图
使用推送通知更新实时活动

前面已经介绍过,实时活动可以通过推送通知来更新数据展示,下面来介绍具体做法以及开发过程中遇到的坑
ActivityKit 提供了从应用程序启动、更新和结束实时活动的功能。我们可以使用Token通过从服务器发送到 Apple 推送通知服务 (APNs) 的 ActivityKit 推送通知来更新实时活动, 苹果WWDC:《Update Live Activities with push notifications》教程视频
要使用 ActivityKit 推送通知更新实时活动:
1.获取APP的推送Token

使用 ActivityKit ,在启动实时活动时获取实时活动的唯一推送Token。
  1. func startActivity(orderId:String) throws {   
  2.         let attributes = DJDynamicIslandAttributes(
  3.             // 静态数据
  4.         )
  5.         let initialContentState = DJDynamicIslandAttributes.ContentState(
  6.             // 动态数据
  7.         )
  8.         let activity = try Activity.request(
  9.             attributes: attributes,
  10.             content: .init(state: initialContentState, staleDate: nil),
  11.             pushType: .token)
  12.             
  13.         Task {
  14.         // 获取实时活动的唯一推送Token
  15.             for await data in activity.pushTokenUpdates {
  16.                 let token = data.map { String(format: "%02x", $0) }.joined()
  17.             }
  18.         }
  19.     }
复制代码
使用Activity.request方法时注意传入pushType参数为.token,指定实时活动更新方式为“基于token”的推送更新,这个token就标识了是哪部手机的哪个实时活动来接受推送通知。拿到token后,前端要把它发送给后端服务器,由后端处理发给苹果进行推送
坑2:

Activity.request方法后,token不会立刻生成,而是会异步生成,过一段时间才能取到,所以要建一个Task使用for await方式来获取
坑3:

只有真机调试才能获取token,模拟器无法生成token(苹果APNs不会为模拟器下发推送通知)
2.为APP开启推送通知能力

在苹果开发者中心developer.apple.com 申请一个用于通知的key

之后可以获得:
一个10个字符的Key ID,后续的推送中会用到
一个authentication token signing key,是一个.p8类型的文件,后续的推送中需要传入它的存储路径。
3.将要推送的数据进行封装,准备进行通知推送
  1. "aps": {
  2.     "timestamp":'$(date +%s)',
  3.     "event":"update",
  4.     "content-state":{
  5.         "logo": "https://img.duoziwang.com/2016/12/17/16485364877.jpg",
  6.         "title": "订单已经开始配送",
  7.         "subTitle": "快递员正在加急配送",
  8.         "progress": 0.6
  9.         }
  10. }
复制代码
aps内的数据就是推送通知内容,timestamp是时间戳;event是通知类型,分为update和end两种;content-state就是上文中定义的ActivityAttributes动态数据属性部分,这里的key要与属性名对应,接到通知后就可以自动解析并更新数据
坑4:

所有的属性,在content-state里都要有对应的key-value,就算是空的也要写上,不然会解析失败

4.编写服务器脚本

上面封装好的数据,要由后端服务器负责发送给苹果推送服务器(APNs),这个过程就要用到之前几步拿到的信息。这里我把推送脚本的模版提供给大家,大家可以在这个基础上进行修改:
  1. #!/bin/bash
  2. # Set and export your shell variables
  3. export TEAM_ID="苹果开发者账号的teamID"
  4. export TOKEN_KEY_FILE_NAME="第二步拿到的.p8文件存储路径"
  5. export AUTH_KEY_ID="第二步拿到的Key ID"
  6. export TOPIC="app的BundleIdentifier.push-type.liveactivity"
  7. export ACTIVITY_PUSH_TOKEN="第一步拿到的token"
  8. export APNS_HOST_NAME="api.sandbox.push.apple.com"
  9. # Calculate JWT components
  10. export JWT_ISSUE_TIME=$(date +%s)
  11. export JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
  12. export JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
  13. export JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
  14. 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 =)
  15. export AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"
  16. # Send APNs request
  17. curl -v --header "apns-topic: $TOPIC" \
  18. --header "apns-push-type: liveactivity" \
  19. --header "apns-priority: 10" \
  20. --header "authorization: bearer $AUTHENTICATION_TOKEN" \
  21. --data '{
  22.      "aps": {
  23.          "timestamp":'$(date +%s)',
  24.          "event":"update",
  25.          "content-state":{
  26.              #动态数据
  27.          }
  28.      }
  29. }' \
  30. --http2 "https://${APNS_HOST_NAME}/3/device/${ACTIVITY_PUSH_TOKEN}"
复制代码
此部分请求头部信息格式来源:
Establishing a token-based connection to APNs
Sending push notifications using command-line tools
Updating Live Activities with ActivityKit push notifications
运行成功后控制台显示“HTTP/2 200”代表成功了!

更新视图:

作者:京东零售 姜海
来源:京东云开发者社区 转载请注明来源

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

老婆出轨

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表