本文字数:3158字
预计阅读时间:20分钟
用最通俗的语言,描述最难懂的技术
01
前情描述
苹果在WWDC22
中,提出了实时活动(Live Activity
)的概念,以便于用户在锁屏查看一些应用实时活动的更新。通过ActivityKit
实现了灵动岛视图的自定义。部门最近也最近实现了这一功能需求,用于某一事件的实时动态展示,接下来的这一篇文章将为大家揭开实时活动的面纱,让你对实时活动不再说陌生。
02
场景及其配置
iOS 16.1+
锁屏界面新增了实时活动界面,设备上iOS 14 Pro+
设备拥有灵动岛区域。相较于iOS16.0的锁屏小组件,实时活动是显示在通知区域,且拥有更自由的视图定制和刷新方式。和小组件一样,它也限制了视图上的动画效果显示。我们可以使用实时更新来做一些有意思的设计和功能。例如运动监测、订单进度、赛事成绩和实时新闻等。
场景限制
最多持续8小时,使用场景需要考虑,8小时之后无法再刷新(目前实际还可以,但是以官方文档为准,自行限制),12小时后强制消失(因此跨天场景不考虑);
创建时,需要
app
在前台主动创建,没启动应用的时候不能自己出现(与特定业务绑定,比如下单后显示);实时活动的依赖项目本身禁止定位以及主动网络请求,少量数据可通过
push
发送,传输的最大数据包为4KB(实际测试中如果超过这个限制,苹果会给你们的通知的后台发回不为200code
错误);同场景多卡片由于样式趋同且折叠,不建议同时创建多卡片;
如果是通过
push
生成的实时活动,不建议设计动态数据较大的场景,也是因为实时活动push
的自动化程度比较高,另外一方面是push
的数据包限制。
项目配置
1. OC&swift混编
如果你们的项目是通过Objc
和Swift
的混编:
需要先在主项目中创建一个
Swift
文件,进行桥接流程,因为实时活动的提供框架ActivityKit
只有Swift
版本,并无OC
版本,而且对应的界面强制要求使用SwiftUI
新的界面框架进行开发;创建完成以后该文件就是负责管理实时活动的声明周期了;
然后在主项目中添加一个
Target,
该Target
依赖默认依赖于主项目,然后一些通用的文件需要勾选上两个Target,
一个是主项目一个是你的实时活动的Target
;Target
项目就是你的实时活动的最终响应模块,这个模块就是你的实时活动展示界面和更新数据的最终载体。
2. Swift
如果你的项目是Swift
:
唯一的不同就是不需要桥接文件,其余的都和上面的一致。
另外两种项目都需要在主项目中的
Info.plist
添加一个实时活动权限的字段NSSupportsLiveActivities
,对应值是YES
即可。

03
整体流程实现
初识代码


这个文件是存在于Target
项目中,只属于Target
,不属于主项目。
struct LiveActivitiesWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivitiesAttributes.self) { context in
Text("锁屏上的界面")
.activityBackgroundTint(Color.cyan) // 背景色
.activitySystemActionForegroundColor(Color.black) // 系统操作的按钮字体色
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("灵动岛展开后的左边")
}
DynamicIslandExpandedRegion(.trailing) {
Text("灵动岛展开后的右边")
}
DynamicIslandExpandedRegion(.center) {
Text("灵动岛展开后的中心")
}
DynamicIslandExpandedRegion(.bottom) {
Text("灵动岛展开后的底部")
}
} compactLeading: {
Text("灵动岛未展开的左边")
} compactTrailing: {
Text("灵动岛未展开的右边")
} minimal: {
// 这里是灵动岛有多个任务的情况下,展示优先级高的任务,位置在右边的一个圆圈区域
Text("灵动岛Mini")
}
.widgetURL(URL(string: "http://www.apple.com")) // 点击整个区域,通过deeplink将数据传递给主工程,做相应的业务
.keylineTint(Color.red) // 设置灵动岛中显示的活动的关键帧线色调。
}
}
}


定义数据模型
该部分的代码是同属于主项目和Target,
然后我放在了主项目中,因为主项目是实时活动的发起方,而Target
只是载体。该结构体就是你要展示的实时活动的模型,只不过他继承自一个系统的属性,该属性来自ActivityKit
框架中,每一个实时活动都要继承自它,这是强制要求,否则你无法创建实时活动。
struct ActivityWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// 动态属性
var process: Double
var title: String
......
}
// 静态属性
var icon: String
}
数据由ContentState
(可变部分) 和 不可变部分组成。更新所需要传递的就是动态部分的内容。
主程序部分
这部分代码同属于主项目和Target
,在主项目中。
1. 创建
// 使用方法
publicstaticfunc request(attributes: Attributes, contentState: Activity<Attributes>.ContentState, pushType: PushType? = nil)throws -> Activity<Attributes>
--------------------------
privatevar myActivity: Activity<ActivityWidgetAttributes>? = nil
let initialContentState = ActivityWidgetAttributes.ContentState(process: 0.6, title: "this is a title")
let activityAttributes = ActivityWidgetAttributes(icon: "XiaoBu")
do {
// 本地更新的创建方式
// myActivity = try Activity.request(attributes: activityAttributes, contentState: initialContentState)
// push更新的创建方式,需要传递pushType: .token
myActivity = tryActivity.request(attributes: activityAttributes, contentState: initialContentState, pushType: .token)
Task {
var lastString: String?
for await data in activity.pushTokenUpdates {
let myToken = data.map {String(format: "%02x", $0)}.joined()
let activityId = activity.id
print("Activity id : \(activityId) and token \(myToken)")
// 添加异步观察
observeActivity(activity: activity)
}
}
} catch (let error) {
print("ActivityError: \(error.localizedDescription)" )
}
这里需要强调说下通过push
的创建方式,因为该方法是异步方法,所以需要在框架提供的一个异步队列中进行等待有token
返回,也就是说不一定这个方法执行完就会有token返回,所以你需要进行安全校验以及token
监控监制,确保可以安全的拿到token,
拿到token
以后就可以发送给你的后台了。
另外iOS17.2
及以后,可以在不启动实时活动的情况下获取推送令牌。为此,我们需要观察pushToStartTokenUpdates
异步序列。一旦我们收到并将其发送到后台,就可以随时使用push
通知启动实时活动!
for try await pushToken in Activity<ActivityWidgetAttributes>.pushToStartTokenUpdates {
// 这里我们拿到有效的token就可以发送给我们的后台进行处理
}
2. 更新
// 使用方法
public func update(using contentState: Activity<Attributes>.ContentState, alertConfiguration: AlertConfiguration? = nil) async
------------------------------------------------------------
// 更新内容
let updateStatus = ActivityWidgetAttributes.ContentState(nickName: "Augus123")
// 关于通知的配置
let alertConfiguration = AlertConfiguration(title: "111", body: "2222", sound: .default)
Task {
await myActivity?.update(using: updateStatus, alertConfiguration: alertConfiguration)
}
17.2及以后的接口更新,新增了提醒配置的属性,让开发者可以增加对alert
的配置。
public func update(_ content: ActivityContent<Activity<Attributes>.ContentState>, alertConfiguration: AlertConfiguration? = nil, timestamp: Date) async
3.结束
/// 使用方法
publicfunc end(using contentState: Activity<Attributes>.ContentState? = nil, dismissalPolicy: ActivityUIDismissalPolicy = .default) async
/// 结束策略有3种
/// The system's default dismissal policy for the Live Activity.
///
/// With the default dismissal policy, the system keeps a Live Activity that ended on the Lock Screen for
/// up to four hours after it ends or the user removes it. The ``ActivityKit/ActivityState``
/// doesn't change to ``ActivityKit/ActivityState/dismissed`` until the user or the system
/// removes the Live Activity user interface.
publicstaticletdefault: ActivityUIDismissalPolicy
/// The system immediately removes the Live Activity that ended.
///
/// With the `immediate` dismissal policy, the system immediately removes the ended Live Activity
/// and the ``ActivityKit/ActivityState`` changes to
/// ``ActivityKit/ActivityState/dismissed``.
publicstaticlet immediate: ActivityUIDismissalPolicy
/// The system removes the Live Activity that ended at the specified time within a four-hour window.
///
/// Provide a date to tell the system when it should remove a Live Activity that ended. While you can
/// provide any date, the system removes a Live Activity that ended after the specified date or after four
/// hours from the moment the Live Activity ended — whichever comes first. When the system
/// removes the Live Activity, the ``ActivityKit/ActivityState`` changes to ``ActivityKit/ActivityState/dismissed``.
///
/// - Parameters:
/// - date: A date within a four-hour window from the moment the Live Activity ends.
publicstaticfunc after(_ date: Date) -> ActivityUIDismissalPolicy
--------------------------------------------------------------
Task {
await myActivity?.end(using:nil, dismissalPolicy: .immediate)
}
17.2以后的新增接口,增加了延迟多久结束的扩展参数。
public func end(_ content: ActivityContent<Activity<Attributes>.ContentState>?, dismissalPolicy: ActivityUIDismissalPolicy = .default, timestamp: Date) async
4. 状态获取以及push更新的回调
创建成功后可以使用activityStateUpdates
监听到实时活动的状态。
func observeActivity(activity: Activity<SNActivityLiveTextImagetAttributes>) {
Task {
await withTaskGroup(of: Void.self) { group in
// 监测活动状态的更新
group.addTask { @MainActorin
for await activityState in activity.activityStateUpdates {
if activityState == .dismissed { // 被手动或者系统移除
SNLog("image text dismissed \(activity.id)")
} elseif activityState == .ended { // 手动或者系统结束
SNLog("image text end \(activity.id)")
self.endTextImageActivity()
}
}
}
// 监测动态数据的更新
group.addTask { @MainActorin
for await contentState in activity.contentUpdates {
do {
// 先处理下载图片,图片返回后进行更新
let_ = try await loadImageTextIcon(iconURL: contentState.state.iconUrl, activity: activity, contentState: contentState)
print("contentUpdates iconUrl \(contentState.state.iconUrl) ")
await activity.update(contentState)
} catch {
SNLog(error)
}
}
}
// 监测pushToken的更新,及时通知后台
group.addTask { @MainActorin
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.hexadecimalString
Logger().debug("New push token: \(pushTokenString)")
do {
let frequentUpdateEnabled = ActivityAuthorizationInfo().frequentPushesEnabled
try await self.sendPushToken(hero: activity.attributes.hero,
pushTokenString: pushTokenString,
frequentUpdateEnabled: frequentUpdateEnabled)
} catch {
SNLog(error)
}
}
}
}
}
}
这里强调说明下,以上的监控的系统方法全都是异步回调,实际开发测试中,这个方法回调的准确率为93.33
,也就是你的后台发送10次请求,其中到你这回调的只有6次,也就是说你完成了push
类型的实时活动创建以后,系统框架会自动帮你完成所有的更新和显示,你什么都不需要操作,文档也强调说明了,异步队列来获取更新回调,所以是不安全的回调,具体原因官方也没有回复,也有其他的开发者反馈类似问题,没有答复具体参考。
https://forums.developer.apple.com/forums/thread/742725
https://forums.developer.apple.com/forums/thread/725658
5. 权限
实时活动的权限无法监听获得,只能主动进行判断,需要在进行创建前进行判断来提示用户。
这个是一个可读属性,开发者无法进行设置。
// 实时活动是否可用,包括权限是否开启和手机是否支持实时活动
ActivityAuthorizationInfo().areActivitiesEnabled

服务端部分
// 推送配置
TEAM_ID=开发者账号里的TEAM_ID
// 这个是你的开发者账号开启实时活动能力后会给你提供一个`p8`结尾的密钥
AUTH_KEY_ID=p8推送需要的验证秘钥ID
// 实时活动的属于那个项目
TOPIC=主程序的${Bundle Identifier}.push-type.liveactivity
// 实时活动push
DEVICE_TOKEN=PushToken
// 测试环境
APNS_HOST_NAME=api.sandbox.push.apple.com
// 正式环境
APNS_HOST_NAME=api.push.apple.com
// APS结构
{"aps": {
"timestamp":1666667682, // 更新的时间
"event": "update", // 事件选择更新,也可以进行结束操作
"content-state": { // 需要与程序中的数据结构保持一致
"process" : 0.7,
"title": "更新的title"
},
"alert": { // 通知配置
"title": "Track Update",
"body": "Tony Stark is now handling the delivery!"
}
04
技术难点及策略
网络图片的展示
1. 技术限制
因为Live Activity
内部禁用网络,所以你在Target
中不要以任何的希望进行网络请求,比如AsyncImage
或者URLSession
都是无效的,如果你希望展示网络图片,一种是在创建的时候提前在主项目中下载好,然后存储到一个公共区域(App Group
)这个也需要你的开发者账号进行配置,然后在Target
项目中需要展示的时候去公共区域进行加载。
2. 解决方案
该方法写在主项目中的管理实时活动的延展类中,下载图片方法:
private func downloadImage(from url: URL) async throws -> URL? {
guardvar destination = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.test.liveactivityappgroup")
else { returnnil }
destination = destination.appendingPathComponent(url.lastPathComponent)
guard !FileManager.default.fileExists(atPath: destination.path()) else {
print("No need to download \(url.lastPathComponent) as it already exists.")
return destination
}
let (source, _) = try await URLSession.shared.download(from: url)
tryFileManager.default.moveItem(at: source, to: destination)
print("Done downloading \(url.lastPathComponent)!")
return destination
}
然后在Target
项目中增加一个私有方法,用于读取下载到公共区域的数据。
if let imageContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.test.liveactivityappgroup")?
.appendingPathComponent(context.state.imageName),
let uiImage = UIImage(contentsOfFile: imageContainer.path()) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 50, height: 50)
}
参考文档:
Live Activities UIhttps://developer.apple.com/design/human-interface-guidelines/live-activities
Live Activities APIhttps://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities
Live Activity Push Notificaitonshttps://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
How to fetch an image in live activityhttps://forums.developer.apple.com/forums/thread/716902