背景
本篇文章应该是你能搜到的讲解最全最详细的关于实时互动的教程了
调研
基本概览
系统版本要求
实时活动功能和 ActivityKit
将包含在今年晚些时候推出的 iOS 16.1 中。
开发语言
灵动岛使用WidgetKit
和SwiftUI
完成 UI 开发工作
开发架构
UI展示
基本UI
紧凑型(Compact)
- 系统只有一个实时互动时展示方式(默认方式)。
最小化(Minimal)
- 系统有多个实时互动时展示方式
拓展(Expanded)
- 用户在紧凑型和最小化模式下长按时,模式切换到扩展模式,展示更多信息
整体结构
- 扩展模式下,界面的布局如下
UI适配规格
数据
显示时间
- 实时活动最多可以保持 8 小时的活动状态,除非应用程序或人为在之前提前结束
- 超过 8 小时,系统自动结束,移出灵动岛
- 实时活动会停留在锁屏界面上,非人为主动删除,在 8 小时后再停留 4 个小时
- 实时活动在锁屏状态下最多停留 12 小时
数据更新
实时活动 无法访问网络 或 接受位置信息
- 通过远程推送
- 通过 ActivityKit 活动推送
以上两种方式推送内容大小不能超过 4 KB
使用场景
- 需在屏幕驻留的文字、图像为主的信息:如地图导航、airdrop 传输情况等;
- 后台进行的音频类:如接电话、放音乐、录音、倒计时等;
-
即时交互反馈:如充电、静音、人脸识别等。超过这三类信息后,桌面可能会变得杂乱无章
实践
前期准备
证书
证书下载:开发者只需下载其中一种证书即可,推荐使用 P8 证书。
P8 和 P12证书的区别:
P8:同一帐户下有多个应用程序,可以使用同一个 P8 证书。P8 证书永久有效。
P12:对于每个应用程序,都需要单独的证书。P12 证书有效期是一年。
注意:灵动岛推送,p12证书不支持;p8支持。
推送概念的理解
创建项目
- 创建iOS工程(忽略)
-
创建 WidgetExtension
-
一定要勾选 Include live Activity(不然不会包含实时活动,后续要自己创建),然后输入名称,点击完成既可
-
创建好之后的结构如下
- 在 主工程 Info.plist 文件中声明开启,打开 Info.plist 文件添加
NSSupportsLiveActivities
,并将其布尔值设置为 YES。
- 在主工程创建一个 model(swift file) 作为 app 和 WidgetExtension 的数据交换媒介,文件 Target MemberShip 选择 主工程和 WidgetExtension 实现共用,或者创建的时候直接勾选上主工程和WidgetExtension
// 一个简易的 充电 模型
enum ChargeState: Int {
case start = 1
case stop = 2
case recover = 3
case cancel = 4
}
extension ChargeState: Codable { }
struct LAAttributes: ActivityAttributes {
var type: String
var desc: String
public typealias LAStatus = ContentState
public struct ContentState: Codable, Hashable {
var state: ChargeState
var callingTimer: ClosedRange<Date>
}
}
// 上述代码校验分析:
// 实时互动通过 ActivityAttributes (描述实时活动的内容的协议)来描述实时活动将展示的数据
public protocol ActivityAttributes : Decodable, Encodable {
/// The associated type that describes the dynamic content of a Live Activity.
///
/// The dynamic data of a Live Activity that's encoded by `ContentState` can't exceed 4KB.
associatedtype ContentState : Decodable, Encodable, Hashable
}
// 除了包括实时活动中出现的静态数据,
// 开发者还可以使用 ActivityAttributes 来声明所需的自定义 Activity.ContentState 类型,
// 该类型描述 App 的实时活动的动态数据。
- 在
WidgetExtension
中会存在三个文件
7.1WidgetExtensionBundle
用来配置需要展示的内容
// 这里我们不需要小组件卡片 只保留 IMActivityWidget
@main
struct WidgetExtensionBundle: WidgetBundle {
var body: some Widget {
// WidgetExtension()
ActivityWidget()
}
}
7.2. WidgetExtension 小组件卡片我们暂时先不关心
7.3. ActivityWidget 这个是我们需要用到的实时通知的内容
struct ActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LAAttributes.self) { context in
// 创建显示在锁定屏幕上的演示,并在不支持动态岛的设备的主屏幕上作为横幅。
// 展示锁屏页面的 UI
LockScreenView(state: context.state.state, callingTimer: context.state.callingTimer)
} dynamicIsland: { context in
// 创建显示在动态岛中的内容。
DynamicIsland {
//这里创建拓展内容(长按灵动岛)
DynamicIslandExpandedRegion(.leading) {
LeadingView(state: context.state.state)
}
DynamicIslandExpandedRegion(.trailing) {
TrailingView(callingTimer: context.state.callingTimer)
}
DynamicIslandExpandedRegion(.center) {
CenterView(desc: context.attributes.desc)
}
DynamicIslandExpandedRegion(.bottom) {
BottomView(desc: context.attributes.desc)
}
}
//下面是紧凑展示内容区(只展示一个时的视图)
compactLeading: {
CompactLeadingView(state: context.state.state)
} compactTrailing: {
CompactTrailingView(callingTimer: context.state.callingTimer)
}
//当多个Live Activities处于活动时,展示此处极小视图
minimal: {
minimalView()
}.keylineTint(.accentColor)
}
}
}
// 页面相关的内容
func getStateStr(state: ChargeState) -> String {
switch(state) {
case ChargeState.start:
return "充电中"
case ChargeState.stop:
return "停止中"
case ChargeState.recover:
return "充电中"
default:
return "结束"
}
}
func getStateIcon(state: ChargeState) -> String {
switch(state) {
case ChargeState.start:
return "ev.charger"
case ChargeState.recover:
return "ev.charger"
case ChargeState.stop:
return "ev.charger.exclamationmark"
default:
return "ev.charger.slash"
}
}
// 锁屏视图
struct LockScreenView: View {
var state: ChargeState
var callingTimer: ClosedRange<Date>
var body: some View {
HStack (alignment: .center, spacing: 10) {
VStack (alignment: .listRowSeparatorLeading, spacing: 5) {
Image(systemName: getStateIcon(state: state)).frame(width: 30, height: 30)
Text(getStateStr(state: state)).font(.subheadline)
}.padding(.leading, 20)
Text("该区域是锁屏展示的内容").font(.subheadline).foregroundColor(.green).padding(20)
VStack (alignment: .listRowSeparatorTrailing, spacing: 5) {
Image(systemName: "timer").frame(width: 30, height: 30)
Text(timerInterval: callingTimer, countsDown: false, showsHours: false).font(.subheadline).multilineTextAlignment(.trailing)
}.padding(.trailing, 20)
}
}
}
// 左侧内容
struct LeadingView: View {
var state: ChargeState
var body: some View {
Label(getStateStr(state: state), systemImage: "ev.charger").font(.caption).padding(10)
}
}
// 左侧内容
struct TrailingView: View {
var callingTimer: ClosedRange<Date>
var body: some View {
Label {
Text(timerInterval: callingTimer, countsDown: false)
.multilineTextAlignment(.trailing)
.frame(width: 50)
.monospacedDigit()
.font(.caption2)
}
icon: {
Image(systemName: "timer")
}.font(.title2)
}
}
// 中间部分内容
struct CenterView: View {
var desc: String
var body: some View {
VStack {
Text("\(desc)")
.lineLimit(1)
.font(.caption)
.foregroundColor(.red)
.background(.yellow)
}
}
}
// 中间部分内容
struct BottomView: View {
var desc: String
var body: some View {
Button(
action: {
print("停止充电")
},
label: { Text("停止充电").font(.title) }
).background(.gray)
}
}
// 紧凑型左侧
struct CompactLeadingView: View {
var state: ChargeState
var body: some View {
Label {
Text(getStateStr(state: state))
}
icon: {
Image(systemName: getStateIcon(state: state))
}.font(.caption2)
}
}
// 紧凑型右侧
struct CompactTrailingView: View {
var callingTimer: ClosedRange<Date>
var body: some View {
Text(timerInterval: callingTimer, countsDown: true)
.multilineTextAlignment(.center)
.frame(width: 40)
.font(.caption2)
}
}
// 最小型只有一个小圆圈
struct minimalView: View {
var body: some View {
Image(systemName: "ev.charger")
}
}
- 启动、更新、关闭实时活动
在主工程中进行活动的的相关操作
func startLiveActivity(attributes: LAAttributes, state: LAAttributes.ContentState) {
//启用灵动岛
//灵动岛只支持Iphone,areActivitiesEnabled用来判断设备是否支持,即便是不支持的设备,依旧可以提供不支持的样式展示
if ActivityAuthorizationInfo().areActivitiesEnabled == true {
do {
liveActivity = try Activity<LAAttributes>.request(
attributes: attributes,
contentState: state,
pushType: nil)
//判断启动成功后,获取推送令牌 ,发送给服务器,用于远程推送Live Activities更新
//不是每次启动都会成功,当已经存在多个Live activity时会出现启动失败的情况
if liveActivity!.activityState == .active {
_ = liveActivity!.pushToken
}
// deliveryActivity.pushTokenUpdates //监听token变化
print("Current activity id -> \(liveActivity!.id)")
} catch (let error) {
print("Error info -> \(error.localizedDescription)")
}
} else {
print("不支持 灵动岛")
}
}
func updateLiveActivity(state: LAAttributes.ContentState) async {
guard let liveActivity = liveActivity else {
return
}
if (!ActivityAuthorizationInfo().areActivitiesEnabled) {
return
}
// await liveActivity.update(ActivityContent<IMAttributes.ContentState>(
// state: state,
// staleDate: nil
// ))
// 或者
Task {
for activity in Activity<LAAttributes>.activities {
await activity.update(using: state)
}
}
print("Current activity id -> \(liveActivity.id)")
}
func stopLiveActivity() {
Task {
for activity in Activity<LAAttributes>.activities {
await activity.end(dismissalPolicy: .immediate)
}
}
}
}
演示效果
视频和gif过大,上传不了演示效果
远端同步
前期准备中的证书和推送概念等相关了解后
启动调整
灵动岛数据需要通过远程推送进行变更,需要再单独做远程推送,需要ActivityPushToken
// 开启灵动岛的方式做部分修改
// try Activity<LAAttributes>.request 函数 pushToken 参数需要传入 .token
liveActivity = try Activity<LAAttributes>.request(
attributes: attributes,
contentState: state,
pushType: .token)
// 注意点:
// Activity 返回 pushToken是异步的,所以不会立刻返回,需要监听 token的update
// 拿到 token 之后,传给后端,然后作为后端的推送token
Task {
for await data in liveActivity!.pushTokenUpdates {
let token = data.map { String(format: "%02x", $0) }.joined()
print("pushToken -> \(token)
}
}
本地测试push 推荐使用推送脚本,苹果官方提供了一个标准的上岛推送格式:
curl \
--header "apns-topic: com.example.apple-samplecode.Emoji-Rangers.push-type.liveactivity" \
--header "apns-push-type: liveactivity" \
--header "apns-priority: 10" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data '{
"aps": {
"timestamp": '$(date +%s)',
"event": "update",
"content-state": {
"currentHealthLevel": 0.941,
"eventDescription": "Power Panda found a sword!"
}
}
}' \
--http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN
- apns-topic,配置bundile Id
<bundileId>.push-type.liveactivity - event 推送类型
Start / Update / End - content-state
content-state 对应的内容,只能是LiveActivityModel中可更新的部分
远程推送更新试一下效果
{"aps": {
"timestamp":'$(date +%s)000',
"event": "update",
"content-state": {"state":2,"chargeTime":"-- Hour"}
}}
推送前和推送后的效果
远端拉起
iOS 17.2之后 支持 远端拉起实时活动,这样就可以不从手机端发起实时活动(有别于美团外卖,滴滴,百度地图之类的,需要app端发起活动)
先说几个关键函数:
// pushToStartTokenUpdates 远程启动实时活动的token 每次有实时活动变更都会回调该函数
//activityUpdates 实时活动有更新的时候会回调该函数
// contentUpdates 推送过来的内容 (可变内容部分,对应上一部分不可变内容)
// pushTokenUpdates push token acitivy创建(request)后异步得到的 token
// activityStateUpdates 当前试试活动状态发生变更时的回调
// 以上内容实在推送过程中异步回调的内容
以上五个回调是远程拉起更新的关键函数。把这些监听写到 appdelegate
的 didFinishLaunchingWithOptions
函数中,监听相关内容的变化,相关代码如下:
Task {
await withTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in
if #available(iOS 17.2, *) {
for await pushtoken in Activity<LAAttributes>.pushToStartTokenUpdates {
let pushtokenstr = pushtoken.map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
print("[LACSStartTokenAttributes] start token: \(pushtokenstr)")
}
}
}
//监听各类活动 初始化
group.addTask { @MainActor in
for await activityData in Activity<LAAttributes>.activityUpdates {
self.handleObserver()
}
}
}
func handleObserver() {
Task {
await withTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in
for await tokenData in liveActitity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
print("[LAAttributes], token: \(token)")
}
}
group.addTask { @MainActor in
for await content in liveActitity.contentUpdates {
print("[LAAttributes], content: \(content)")
}
}
group.addTask { @MainActor in
for await state in liveActitity.activityStateUpdates {
print("[LAAttributes], state: \(state)")
}
}
}
}
}
这个监听代码中 我监听的是 LAAttributes 对象,如果有别的实时活动对象需要在单独监听其他类型
push to start token
通过 异步回调 pushToStartTokenUpdates 获取
该回调经过验证在手机重启、app首次安装的情况下会 回调一次返回 push-to-start-token(被动触发条件苛刻),后续只有在 拉起实时活动的时候才会回调,经过查询相关文档确认这一问题,大部分版本都是升级上来的,所以第一次的拉起需要从 app端拉起一次(目前我能想到的解决办法)
苹果官方问答 https://forums.developer.apple.com/forums/thread/741939
状态的相关判断
上图中的两个状态通过以下函数判断:
// 通过该函数进行判断
bool flag = ActivityAuthorizationInfo().areActivitiesEnabled
// 允许频繁更新试试活动
bool flag = ActivityAuthorizationInfo().frequentPushesEnabled
效果
视频转gif,在压缩之后,大小还是超过10M无法上传,但是经过这个教程的做法,基本上就可以实现全部效果。
本地 拉起、更新、结束
远程 拉起 、更新、结束
即使app已经被杀死 也可以进行 实时活动的 拉起、更新、结束操作。
参考资料
ActivityKit:
https://developer.apple.com/documentation/activitykit
界面布局:
https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities
启动实时活动:
https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications