iOS 灵动岛开发

2,307 阅读6分钟

官方开发指南:developer.apple.com/documentati…官方设计指南:developer.apple.com/design/huma…

一、核心概念

1.1 设计理念

苹果在 iPhone 14 Pro 及 iPhone 14 Pro MAX 上推出了灵动岛,通过 iPhone 前置镜头和软件通知结合在一起的全新设计,用出色的交互设计掩盖了硬件的缺陷,是一次交互玩法的革新,无论是接收通知还是播放音乐,灵动岛的形状和功能都会随着用户的操作实时调整,使得用户在与手机互动时,能够享受到更流畅、更便捷的操作体验。它允许应用在锁屏界面和灵动岛上显示实时更新的信息,非常适合用来展示需要实时更新的消息,例如外卖配送信息,地图实时导航信息等。

1.2 相关概念

需要注意实时互动与灵动岛是两个不同的概念

1.实时活动(Live Activities)包含锁定屏幕和灵动岛两部分

2.锁定屏幕(Lock Screen) iOS 16.1 及以上系统版本支持,不限设备

3.灵动岛(Dynamic Island)受限于设备,只有 iPhone 14 Pro 及 iPhone 14 Pro MAX 及以上支持

灵动岛支持三种展现形式

1.紧凑模式(Compact)

当系统只有1个实时活动的内容时,灵动岛默认使用紧凑模式。紧凑模式下UI由头部(Leading side)和尾部(Trailing side)组成,用户可以点击灵动岛打开App查看实时活动的内容。

  1. 最小化模式(Minimal)

当系统有多个实时活动的内容时,灵动岛自动切换使用最小化模式。最小化模式下由附着的头部(Leading(attached))和分割开的尾部(Trailing(detached))组成。和紧凑模式一样,最小化模式也支持用户点击打开App。

  1. 扩展模式(Expanded)

当用户在紧凑或最小化模式轻扫或长按灵动岛时,灵动岛可以切换成扩展模式,用于向用户展示更多信息。扩展模式的UI设计尽量保持和紧凑模式一致,这样用户从紧凑模式切换到扩展模式会有一个平滑的体验。

1.3 技术限制

1.硬件限制:仅支持iPhone 14 Pro及以上机型(药丸屏设计)

2.系统约束:iOS 16.1+,Xcode 14.0+

3.架构限制:基于ActivityKit(数据交互)、WidgetKit(UI构建)及SwiftUI

4.运行时限制

◦系统最多支持 2 个应用同时“登岛”(系统自动管理,开发者无法强制控制让应用优先展示)

◦每个应用可以最多开启 5 个实时活动

◦实时活动在灵动岛上最多可以保留 8 小时,超过 8 小时限制后,系统自动结束,并立即将其移出动态岛,锁定屏幕上保留最多保留 12 小时,跨天场景需慎重考虑

◦实时活动的静态和动态数据(包括 ActivityKit 更新和 ActivityKit 推送通知的数据)的总大小不得超过 4 KB

◦禁用 gif 图和网络图片

◦禁用自定义动画

远程通知与实时活动权限独立,关闭远程通知权限不影响实时活动的推送,但必须打开实时活动的权限才可展示锁定屏幕视图与灵动岛

◦在不支持灵动岛的手机上,用户开启实时活动或远程推送更新实时活动时,用户在浏览 app 时是无感知的,只有当用户回到锁屏状态时,实时活动才会以锁定屏幕视图的形式出现

官方表述:developer.apple.com/documentati… Starting with iOS 17.2 and iPadOS 17.2, you can also start Live Activities by sending ActivityKit push notifications to push tokens. developer.apple.com/documentati… A Live Activity can be active for up to eight hours unless your app or a person ends it before this limit. After the 8-hour limit, the system automatically ends it. When a Live Activity ends, the system immediately removes it from the Dynamic Island. However, the Live Activity remains on the Lock Screen until a person removes it or for up to four additional hours before the system removes it — whichever comes first. As a result, a Live Activity remains on the Lock Screen for a maximum of twelve hours.

二、开发流程

2.1 证书

1.p8 证书

◦灵动岛推送仅支持 P8 证书,且该证书可供同一账户下多个应用共享

◦ P8 证书永久有效

◦⚠️ p8 仅能下载一次!妥善保存

◦创建方法

▪登录苹果开发者账号,进入 Certificates, Identifiers & Profiles

▪选择 Keys,添加新密钥,输入名称,勾选 APNs,生成并下载 p8 文件

2.描述文件

◦创建新 App ID,例如 com.YourCompany.AppName.WidgetName

◦创建对应的 mobileprovision 文件

2.2 工程配置

1.Target 创建与配置

◦通过File → New → Target创建 Widget Extension,​必须勾选 Include Live Activity

◦在Info.plist中添加NSSupportsLiveActivities并设为YES

  1. 权限管理

◦使用 areActivitiesEnabled确定是否可以启动实时活动

◦使用 activityEnablementUpdates监听权限变化

◦使用 frequentPushesEnabled检测设备是否支持频繁更新

2.3 数据

⚠️ 实时活动只能通过主App来开启,ActivityAttributes结构体需要在主App中被引入,需要设置其文件的Target Membership为主App与小组件Target共享

2.3.1 数据模型

静态数据初始化后不可变,动态数据通过update方法更新

struct OrderAttributes: ActivityAttributes {
    typealias ContentState = Codable & Hashable
    // 静态属性(初始化后不可变)
    let orderId: String
    let productName: String
    
    // 动态属性(支持更新)
    struct ContentState {
        var progress: Double
        var estimatedTime: Date
    }
}

2.3.2 数据获取

// 静态属性的获取
let orderId = activity.attributes.orderId
// 动态属性获取与更新
let initProgress = activity.content.state.progress
let newState = OrderAttributes.ContentState(progress: 1.0, estimatedTime: Date())
await activity.update(using: state)

2.3.3 监听数据变化

Task {
    //监听内容数据状态变化, contentState是视图对应的数据
    for await contentState in activity.contentStateUpdates {
        Logger.debug("Activity contentState update: (contentState))")
    }
}

2.4 生命周期管理

2.4.1 App 启动、更新、结束实时活动

启动:使用Activity.request()传入静态数据与初始动态数据

更新:通过Activity.update()推送新状态

终止:调用Activity.end()或由系统超时自动结束

// 启动
let attributes = OrderAttributes(orderId: "123", productName: "iPhone")
let state = OrderAttributes.ContentState(progress: 0.2, estimatedTime: Date())
let activity = try Activity.request(attributes: attributes, contentState: state)
// 将 pushToken 发送到服务端,并使用它来发送更新或结束实时活动的远程推送通知
for await tokenData in updates.pushTokenUpdates {
    //监听token更新 注意线程
    let mytoken = tokenData.map { String(format: "%02x", $0) }.joined()
    Logger.debug("Activity list update token=(mytoken) id:(updates.id) ")
    //上报token到服务器
    sendPushToken(mytoken, msgActId: msgActId)
}

// 更新
Task {
    await activity.update(using: newState)
}

// 终止
// 直接结束
await activity.end(dismissalPolicy: .immediate)
// 指定十分钟后时结束
await activity.end(dismissalPolicy: .after(Date().addingTimeInterval(60*10)))

2.4.2 APNs 更新、结束实时活动

event 设为 update/end

{
    "aps": {
        "timestamp": 1685952000,
        "event": "update", 
        "content-state": {
            "progress": 0.0
        },
        "alert": {
            "title": {
                "loc-key": "%@ is knocked down!",
                "loc-args": ["Power Panda"]
            },
            "body": {
                "loc-key": "Use a potion to heal %@!",
                "loc-args": ["Power Panda"]
            },
            "sound": "HeroDown.mp4"
        }
    }
}

2.4.3 监听生命周期

Task {
    //监听视图的声明周期,状态变化:active,end,dismissed等
    for await activityState in activity.activityStateUpdates {
        Logger.debug("Activity activityState update:(activityState) msgActId:(activity.attributes.orderId ?? "")")
    }
}

2.5 交互

通过.widgetURL()绑定Deeplink,跳转至App内特定页面,需要注意因为点击直接跳转到主App,因此只能将埋点参数加入URL参数,在主App解析时获取埋点参数。

// 绑定Deeplink
ActivityConfiguration(for: OrderAttributes.self) { context in
   .widgetURL(Utils.createLiveActivityLinkUrl(by: context))
}
dynamicIsland: { context in
    DynamicIsland {
    }
    .widgetURL(Utils.createLiveActivityLinkUrl(by: context))
}

// 自定义widgetUrl
private func createLiveActivityLinkUrl(context: ActivityViewContext<OrderAttributes>) {
    guard let url = URL(string: "myapp://activity/(context.attributes.orderNumber)") else { return }
    UIApplication.shared.open(url, options: [:], completionHandler: nil)
 }

// 获取widgetUrl
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
    // host根据业务自行定义,此处只是示例
    if ([url.host isEqualToString:@"activity"]) {
        //解析url获取埋点参数
    }
}

2.6 UI 界面

2.6.1 布局规范

需要注意灵动岛长按后展开的完整模式分为Leading、Trailing、Center和Bottom四个部分,在系统自动为我们生成的代码中,ActivityConfiguration的dynamicIsland中可以分别找到对应控制的代码段。

设计要点

1.注意各个区域的范围限制

2.锁定屏幕与灵动岛的展开模式最大高度限制 160pt ,如果其高度超过 160,系统可能会截断锁定屏幕上的实时活动,高度范围详情可见官方设计指南

An illustration that shows the center, leading, trailing, and bottom positions for content for the expanded presentation in the Dynamic Island.

ActivityConfiguration(for: AttributesType.self) { context in
    // 锁屏界面 UI(非灵动岛设备展示)
}
dynamicIsland: { context in
    DynamicIsland {
        // ① 展开模式(长按触发)
        DynamicIslandExpandedRegion(.leading) { /* 左侧区域 */ }
        DynamicIslandExpandedRegion(.trailing) { /* 右侧区域 */ }
        DynamicIslandExpandedRegion(.bottom) { /* 底部扩展 */ }

        // ② 紧凑模式(单应用展示)
        compactLeading: { /* 头部 */ } 
        compactTrailing: { /* 尾部 */ }

        // ③ 最小模式(多应用共存)
        minimal: { /* 两个应用同时登岛时形态 */ }
    }
    .keylineTint(.accentColor) // 边框颜色控制
}

2.6.2 适配

1.安全区域适配

DynamicIslandExpandedRegion(.trailing) {
    Text("Notifications")
        .safeAreaPadding(.top) // 自动适配安全区域
}

2. 跨区域适配

因为每个区域的范围是有限制的,可以使用 offset 实现跨区域适配

DynamicIslandExpandedRegion(.bottom) {
     Text("Notifications")
        .offset(y: -6)
}

3. 日夜间适配

方法一:系统颜色

Color(.systemBackground)

方法二:自定义颜色

let titleColor = UIColor { (trainCollection) -> UIColor in
    if trainCollection.userInterfaceStyle == .dark {
        return UIColor.white
    } else {
        return UIColor.black
    }
}

方法三: colorScheme

struct ActionButton: View {
    @Environment(.colorScheme) var colorScheme
    var body: some View {
        Text(text)
            .foregroundColor((isIsland || colorScheme == .dark) ? .white : Color(hex: "F41919"))
    }
}

方法四:自定义图片

•打开Assets.xcassets把图片拖拽进去

•在右侧工具栏中点击最后一栏,点击Appearances选择Any, Dark

•把 DarkMode 的图片拖进去

  1. 睡眠专注模式

此点并不复杂,但可能会被遗漏,睡眠模式下锁屏会变为黑色,UI 展示可能会异常,使用 colorScheme 是无法判断专注模式的 相关 bug issue 链接 简单的解决办法是可以直接设置一个背景色

ActivityConfiguration(for: OrderAttributes.self) { context in
    VStack(alignment: .center, spacing: 0) {
        Text(title)
    }
    .background(Color(.systemBackground)) // 自动适配深浅模式
}

2.7 高级功能

2.7.1 指定优先级控制顺序

通过 relevanceScore 可以指定实时活动的优先级,在 Apple 的示例代码和第三方最佳实践中,常用0-100的整数范围。其中需要注意一点,通过实践发现灵动岛创建个数限制为 5,当超过 5 个时再次调用 Activity request 会报错 error= targetMaximumExceeded

// 指定优先级
let state = ActivityContent<OrderAttributes.ContentState>(
    progress: 0.2, 
    estimatedTime: Date(),
    relevanceScore: relevanceScore
)
let activity = try Activity<OrderAttributes>.request(attributes: attributes, content: state, pushType: .token)

2.7.2 获取实时活动列表

Task {
    for await updates in Activity<OrderAttributes>.activityUpdates {
        Logger.debug("Activity list update count: (Activity<Attributes>.activities.count)")
        Logger.debug("Activity list update new:(updates.id)")
    }
}

2.7.2 Push Console 联调

可以通过苹果官方远程通知平台进行远程推送测试,通知平台使用方法很简单,详细可看苹果官方远程通知平台说明文档,唯一卡点是在数据结构,可以参考下面的 json ,其中 content-state 是灵动岛的数据,不过需要注意下面几点:

•type:推送灵动岛消息时,传入的类型必须为liveactivity

•laId:更新/结束灵动岛时必传,laId需要与创建灵动岛时的liveActivityId值保持一致

•timestamp:必传,秒级10位时间戳,需要传入当前时间或未来时间,否则灵动岛无法收到更新通知

•event:必传,更新灵动岛时,需要传入update;结束灵动岛时,需要传入end

可能会遇到的问题

Q1: 控制台发送消息突然收不到了

1.若APP证书或描述文件有改动,卸载重装APP

2.timestamp 是过去的时间

Q2: 控制台发送消息后灵动岛一直呈现加载态未更新

A: 这种情况通常是数据结构有问题,检查下灵动岛 model 与控制台数据的对应关系,是否缺少字段或字段名称有错误

2.7.3 APNs Push Online 联调

控制台地址 apnspush.com/

使用要点

1.上传 p8 证书,否则会报错[apns]not found precreate apns2 client for bundleId:com.xxx.push-type.liveactivity

2.apns-push-type标头字段的值设置为liveactivity

3.apns-topic标头字段:<your bundleID>.push-type.liveactivity

三、常见问题

3.1 Live Activity not appear

图片资源过大,需要找设计师重新切图

官方表述 developer.apple.com/documentati…:The system requires image assets for a Live Activity to use a resolution that’s smaller or equal to the size of the presentation for a device. If you use an image asset that’s larger than the size of the Live Activity presentation, the system might fail to start the Live Activity. For example, an image you use for the minimal presentation of your Live Activity shouldn’t exceed 45x36.67 points. For size guidance of Live Activity presentations, refer to Human Interface Guidelines > Live Activities.

3.2 push token 回调未执行

Activity.request 未指定 pushType: .token

// 错误示范:let activity = try Activity<OrderAttributes>.request(attributes: attributes, contentState: state)
let activity = try Activity<OrderAttributes>.request(attributes: attributes, content: initContent, pushType: .token)

3.3 The device token doesn't match the specified topic

苹果推送通知控制台报错截图

A: 查看是不是使用的消息推送 deviceToken,实时活动使用的是ActivityKit框架创建实时活动时生成的 pushToken 它是实时活动的唯一标识,获取的代码如下

Task {
  // 获取实时活动的唯一推送Token
  for await data in activity.pushTokenUpdates {
    let token = data.map { String(format: "%02x", $0) }.joined()
    print("push token: (token)")
  }
}

3.4 发送灵动岛消息后UI界面没有变化

1.检查客户端是否成功上报给服务端 activityToken(灵动岛 push token)只有上报成功后才能更新灵动岛

2.检查 timestamp 是否是传入的当前最新时间或未来时间,过去时间不会触发灵动岛更新

3.检查服务端传的 content-state 数据结构是否和客户端完全一样,只有完全一样一一对应才能更新灵动岛,建议前期调试的时候只传1-2个参数去验证功能

参考文档

Displaying live data with Live Activities

Start and Update iOS Live Activities With Push Notifications

iOS灵动岛实时活动推送

实时活动(Live Activity) - 在锁定屏幕和灵动岛上显示应用程序的实时数据