iOS 灵动岛上岛指南
零、关于灵动岛的认识
灵动岛,即实时活动(Live Activity)它允许人们以瞥见的形式来观察事件或任务的状态.我的理解是"我不需要一直盯着看,但是我偶尔想看的时候能很方便的看到".这就需要再设计的时候尽可能扔掉没用的信息,保持信息的简洁.
实时活动的事件构成最好是包含明确开始 + 结束的事件.例如:外卖、球赛等.
实时活动在结束前最多存活8小时,结束后在锁屏界面最多再保留4小时.
关于更多灵动岛(实时活动)的最佳实践及设计思路可以参考一下:
知乎-苹果开放第三方App登岛,灵动岛设计指南来了!
一、灵动岛的UI布局
接入灵动岛后,有的设备支持(iPhone14Pro / iPhone14ProMax)展示灵动岛+通知中心,有的设备不支持灵动岛则只在通知中心展示一条实时活动的通知.所以以下四种UI都需要实现:
1.紧凑型
2. 最小型
3. 扩展型
4. 通知
二、代码实现
1.在主工程中创建灵动岛Widget工程
Xcode -> Editor -> Add Target如图勾选即可
2.在主工程的info.plist中添加key
Supports Live Activities = YES (允许实时活动)Supports Live Activities Frequent Updates = YES(实时活动支持频繁更新) 这个看项目的需求,不是强制的
3.添加主工程与widget数据交互模型
在主工程中,新建Swift File,作为交互模型的文件.这里将数据管理与模型都放到这一个文件里了.创建文件后的目录结构
import Foundation
import ActivityKit
//整个数据交互的模型
struct TestWidgetAttributes: ActivityAttributes {
public typealias TestWidgetState = ContentState
//可变参数(动态参数)
public struct ContentState: Codable, Hashable {
var data: String
}
//不可变参数 (整个实时活动都不会改变的参数)
var id: String
}
如果参数过多.或者与OC混编,默认给出的这种结构体可能无法满足要求.此时可以使用单独的模型对象,这样OC中也可直接构造与赋值.注意,此处的模型需要遵循Codable协议
import Foundation
import ActivityKit
struct TestWidgetAttributes: ActivityAttributes {
public typealias TestWidgetState = ContentState
//可变参数(动态参数)
public struct ContentState: Codable, Hashable {
var dataModel: TestLADataModel
}
//不可变参数 (整个实时活动都不会改变的参数)
//var name: String
}
@objc public class TestLADataModel: NSObject, Codable {
@objc var idString : String = ""
@objc var nameDes : String = ""
@objc var contentDes : String = ""
@objc var completedNum : Int//已完成人数
@objc var notCompletedNum : Int//未完成人数
var allPeopleNum : Int {
get {
return completedNum + notCompletedNum
}
}
public override init() {
self.nameDes = ""
self.contentDes = ""
self.completedNum = 0
self.notCompletedNum = 0
super.init()
}
/// 便利构造
@objc convenience init(nameDes: String, contentDes: String, completedNum: Int, notCompletedNum: Int) {
self.init()
self.nameDes = nameDes
self.contentDes = contentDes
self.completedNum = completedNum
self.notCompletedNum = notCompletedNum
}
}
4.Liveactivity widget的UI
打开前文创建的widget,我的叫demoWLiveActivity.swift这里给出了默认代码的注释,具体的布局代码就不再此处赘述了.
import ActivityKit
import WidgetKit
import SwiftUI
struct demoWLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: demoWAttributes.self) { context in
// 锁屏之后,显示的桌面通知栏位置,这里可以做相对复杂的布局
VStack {
Text("Hello")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
/*
这里是长按灵动岛[扩展型]的UI
有四个区域限制了布局,分别是左、右、中间(硬件下方)、底部区域
*/
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.center) {
Text("Center")
}
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)
}
}
}
5.Liveactivity 的启动 / 更新(主工程) / 停止
启动let attributes = TestWidgetAttributes()
let initialConetntState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialConetntState, staleDate: nil),
pushType: nil
// pushType: .token
)
print("请求开启实时活动: \(activity.id)")
} catch (let error) {
print("请求开启实时出错: \(error.localizedDescription)")
}
更新
let updateState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
let alertConfig = AlertConfiguration(
title: "\(dataModel.nameDes) has taken a critical hit!",
body: "Open the app and use a potion to heal \(dataModel.nameDes)",
sound: .default
)
await activity.update(
ActivityContent<TestWidgetAttributes.ContentState>(
state: updateState,
staleDate: nil
),
alertConfiguration: alertConfig
)
print("更新实时活动: \(activity.id)")
结束
let finalContent = TestWidgetAttributes.ContentState(
dataModel: TestLADataModel()
)
let dismissalPolicy: ActivityUIDismissalPolicy = .default
await activity.end(
ActivityContent(state: finalContent, staleDate: nil),
dismissalPolicy: dismissalPolicy)
removeActivityState(id: idString);
print("结束实时活动: \(activity)")
三、更新数据
数据的更新主要通过两种方式:1.服务端推送
2.主工程更新
其中主工程的更新参见(2.5.Liveactivity 的启动 / 更新(主工程) / 停止)
这里主要讲通过推送方式的更新
首先为主工程开启推送功能,但不要使用registerNotifications()为ActivityKit推送通知注册您的实时活动,具体的注册方法见下.
1. APNs 认证方式选择
APNs认证方式分为两种:1.cer证书认证
2.Token-Based认证方式
此处只能选择Token-Based认证方式,选择cer证书认证发送LiveActivity推送时,会报TopicDisallowed错误.
Token-Based认证方式的key生产方法 参见:Apple Documentation - Establishing a token-based connection to APNs
2. Liveactivity 的启动
let attributes = TestWidgetAttributes()
let initialConetntState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialConetntState, staleDate: nil),
pushType: .token//须使用此值,声明启动需要获取token
)
//判断启动成功后,获取推送令牌 ,发送给服务器,用于远程推送Live Activities更新
//不是每次启动都会成功,当已经存在多个Live activity时会出现启动失败的情况
print("请求开启实时活动: \(activity.id)")
Task {
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.reduce("") { $0 + String(format: "x", $1) }
//这里拿到用于推送的token,将该值传给后端
pushTokenDidUpdate(pushTokenString, pushToken);
}
}
} catch (let error) {
print("请求开启实时出错: \(error.localizedDescription)")
}
3. 模拟推送
1.可以使用terminal简单的构建推送,这里随便放上一个栗子2.也可以使用这个工具 - SmartPushP8
此处使用SmartPushP8
发出推送后,在设备上查看推送结果即可.
注意:模拟器也是可以收到liveactivity的推送的但是需要满足:使用T2安全芯片 or 运行macOS13以上的 M1 芯片设备
四、问题排查
推送失败:
1.TooManyProviderTokenUpdates测试环境对推送次数有一定的限制.尝试切换线路(sandbox / development)可以获得更多推送次数.
如果切换线路无法解决问题,建议重新run一遍工程,这样可以获得新的deviceToken,完成推送测试.
2.InvalidProviderToken / InternalServerError
尝试重新选择证书,重新run工程吧...暂时无解.
推送成功,但设备并未收到更新
这种情况需要打开控制台来观察日志
1.选择对应设备
2.点击错误和故障
3.过滤条件中添加这三项进程
liveactivitiesd
apsd
chronod
4.点击开始
demo参考:demo
作者:大功率拖拉机
链接:https://juejin.cn/post/7254101170951192613
来源:稀土掘金