iOS 灵动岛Dynamic Island实现+模拟器测试远程推送

灵动岛展示.jpeg

[阅读难度:简单]

准备工作

技术储备:

需对Xcode、Swift、SwiftUI 有一定了解

环境要求:

iOS >= 16.1
Xcode >= 14.1
设备(iPhone14Pro/iPhone14ProMax,模拟器也可以)

灵动岛简介

一句话总结:“灵动岛是展示在锁屏界面与主屏幕状态栏的“特殊”小组件

灵动岛的状态

灵动岛不同状态.png

默认状态:可展示左右两个小视图(LeadingSideTrailingSide
展开状态:用户长按灵动岛的展开状态(LeadingCenterTrailingBottom),
紧凑状态:当有多个APP灵动岛时展示分离状态,依创建顺序展示(MinimalDetached)

灵动岛的生命周期:

灵动岛主要分为StartUpdateEnd三种状态,可由ActivityKit远程推送控制其状态。
一个灵动岛默认展示8小时,结束后可继续在锁屏界面存在4个小时后由系统彻底移除,也可由开发者自行移除。

那么,我们开始吧·····


接入Widget Extension

  1. info.plist 文件中添加 NSSupportsLiveActivities 字段,设置为YES

  2. 选中项目添加target,创建Widget Extension,假设将其命名为“DynamicDemo”:

    target.png

  3. 在Widget中,主要关注WidgetsExtensionBundle,该结构体下的body返回所有小组件的Widget,Xcode 会默认生成小组件卡片的初始代码,不需要的话可以移除。

@main
struct WidgetsExtensionBundle: WidgetBundle {
    var body: some Widget {
//        DynamicDemo()     //原本的小组件卡片Widget
        DynamicDemoLiveActivity() // 灵动岛Widget
    }
}

Tips:模拟器下偶现APP动态小组件权限被关闭,从“设置-对应APP-实时活动”打开即可

2. 配置灵动岛Widget

Xcode14.1及之后的版本引入 WidgetsExtension 后,会默认创建XXXXXXLiveActivity.swift文件:


struct DynamicDemoAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // Dynamic stateful properties about your activity go here!
        var value: Int
    }

    // Fixed non-changing properties about your activity go here!
    var name: String
}

struct DynamicDemoLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DynamicDemoAttributes.self) { context in
            // Lock screen/banner UI goes here
            VStack {
                Text("Hello")
            }
            .activityBackgroundTint(Color.cyan)
            .activitySystemActionForegroundColor(Color.black)
            
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded UI goes here.  Compose the expanded UI through
                // various regions, like leading/trailing/center/bottom
                DynamicIslandExpandedRegion(.leading) {
                    Text("Leading")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("Trailing")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text("Bottom")
                    // more content
                }
            } compactLeading: {
                Text("L")
            } compactTrailing: {
                Text("T")
            } minimal: {
                Text("Min")
            }
            .widgetURL(URL(string: "http://www.apple.com"))
            .keylineTint(Color.red)
        }
    }
}

DynamicDemoLiveActivity: 灵动岛的Widget,通过ActivityConfiguration来配置所有状态下的视图,包括不支持灵动岛设备的设备在锁屏页面的视图。
DynamicDemoAttributes: 灵动岛属性属性结构体,用于数据更新,由对应的Activity使用。

3. 控制灵动岛

在需要控制灵动岛的地方,引入ActivityKit。针对“Start”、“Update”、“End”三种状态分别举个简单的例子:

开启:

        Task{
            // 配置Attributes
            let data = DynamicDemoAttributes(value: 100)
            let state = DynamicDemoAttributes.ContentState()
            // 根据 Attributes 开启一个灵动岛
            do {
                // reqeust 指定 pushType 为 .token 可获取远程推送所需的 token
                try await MainActor.run {
                    let activity = try Activity.request(attributes: data, contentState: state)
                }
            } catch (let error) {
                print(error.localizedDescription)
            }
        }

更新:

            do {
                let data = DynamicDemoAttributes(value:100)
                let state = DynamicDemoAttributes.ContentState(progressMsg: "更新的文案")
                
                if let activity = Activity.activities.first {
                    await activity.update(using: state)
                } else {
                    try Activity.request(attributes: data, contentState: state)
                }
            } catch (let error) {
                print(error.localizedDescription)
            }

代码中取的 activities.first,实际开发中应该根据数据来区分并获取需要更新的灵动岛实例。

通过获取当前需要更新的灵动岛实例,通过调用update方法传入最新的Attributes更新。

Tips: 灵动岛可由宿主APP与远程推送更新,普通APP会在退到后台后10s内被挂起,例如音乐、健身、定位、通话等可常驻后台的应用可直接通过宿主APP及时更新。对于退出APP后需要更新的场景,可由远程推送实现。具体可参考文末。

结束:


            //移除所有
            Activity.activities.forEach { item in
                Task{
//                    await item.end() //默认结束后,会在锁屏界面等待4小时彻底移除
                    await item.end(dismissalPolicy:.immediate) // 立即结束
                }
            }

Tips: 指定.immediate可以立即移除


Q&A

Q1:如何识别用户点击的视图:

当前的灵动岛不支持非系统自定义交互(截止20221121),支持DeepLinkLink跳转。点击灵动岛默认跳转我们的宿主APP,我们可以在灵动岛视图中配置widgetURL()Link()来达到识别用户点击的效果、以Link()举例:


···············
DynamicIslandExpandedRegion(.bottom) {
                    Link(destination: URL(string: "xxxxxx://hotel/contact")!) {
                        Label("Contact Hotel", systemImage: "phone").padding()
                    }.background(Color.accentColor)
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                }
···············

上面代码可以在用户点击了灵动岛的bottom视图跳转进APP时在 AppDelegateSceneDelegate 方法中识别其所带入的 url ,以此来做对应处理。

Q2:多个灵动岛展示的优先级

  1. 单个APP的多个灵动岛:默认视图(依次展示)
  2. 多个APP的灵动岛:紧凑视图/分离视图(依次展示)

Q3:如何在模拟器模拟远程推送更新

常规的推送可查看《iOS 在模拟器上测试远程推送》。在调研灵动岛的过程中,需要模拟器测试远程推送的功能。查阅一圈资料后成功实现,具体步骤如下:

环境要求

Xcode >= 14.1
MacOS >= 13.0

准备工作
如何获取远程推送的token:

        let activity = try 
        // 指定pushType为.token
        Activity.request(attributes: data, contentState: state, pushType: .token)
        orderProgressActivity = activity
        Task {
              for await data in activity.pushTokenUpdates {
                 let myToken = data.map {String(format: "%02x", $0)}.joined()
                  print(myToken);
                 // 将token告知后端用于远程推送
              }
        }

运行CommandLine
配置所需要使用的宏:

$ export TEAM_ID={TEAM ID}
$ export TOKEN_KEY_FILE_NAME={Token Key file path}                       
$ export AUTH_KEY_ID={your Auth Key ID}
$ export DEVICE_TOKEN={myToken from the activity push token}                设备Token(灵动岛是Activity start后返回的pushtoken)
$ export APNS_HOST_NAME=api.sandbox.push.apple.com

说明:
TEAM_ID:开发者TeamID(开发者账户可查)
TOKEN_KEY_FILE_NAME:开发者账户生成的Keys下载后的文件路径(.p8格式)
AUTH_KEY_ID:开发者账户生成的keys的ID(开发者账号内可查、下载的.p8文件默认后缀前的十个字符)
DEVICE_TOKEN:设备Token(灵动岛场景下是Activity指定pushType为.token后start返回的pushtoken)

配置命令行推送(命令行逐行运行,注意中英字符):

$ 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}"

发送推送:

curl -v \
--header "apns-topic:po.test.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "authorization:bearer $AUTHENTICATION_TOKEN" \
--data \
'{"Simulator Target Bundle": "po.test",
   "aps": {
   "timestamp":1668764100,            
   "event": "update",
   "content-state": {
      "progressMsg": "酒店订单已确认",
   }
}}' \
--http2 \
https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN

apns-topic:固定为{BundleId}.push-type.liveactivity
apns-push-type:固定为liveactivity
Simulator Target Bundle:测试模拟器推送,设置为对应应用的{BundleId}
timestamp:刷新时间戳。需设置正确,否则灵动岛的推送不会生效
event:可填入update、end,对应灵动岛的更新与结束。
content-state:对应灵动岛的Attributes

参考资料

本文只是简单介绍了基础功能的实现,更丰富的探索可查阅官方文档:

https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities
https://developer.apple.com/documentation/widgetkit/dynamicisland/
https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications
https://developer.apple.com/design/human-interface-guidelines/components/system-experiences/live-activities
https://developer.apple.com/news/?id=mis6swzt

你可能感兴趣的:(iOS 灵动岛Dynamic Island实现+模拟器测试远程推送)