iOS-Live Activity开发

文档

官方文档
官方文档-用户交互指南
官方文档-构建推送消息
主要参考文档
参考文档

开发须知

  • 强烈建议阅读下官方文档
  • Live Activity 包括两部分,灵动岛 和 锁定屏幕(通知中心),且必须对所有样式进行支持。
  • 仅 iOS 16.1 以上支持,支持iPhone,iPad,详见官方用户交互指南文档
  • ActivityKit用于管理Live Activities的生命周期。(request、update、end)
  • 支持隐式动画
  • 如果用户或者App没有主动终止Live Activity,灵动岛上最多可以存在8个小时,然后就会被系统终止。 如果在锁定屏幕上,它可以存活12小时
  • Live Activity 使用的图片资源,要小于或等于Live Activity的大小。如果大于的话,可能无法启动。
  • 每个Live Activity 有自己的沙盒
  • 无法访问网络,无法使用定位功能。
  • 可以通过 ActivityKit 或者 ActivityKit push notifications 来配置、启动、更新与终止 Live Activity但二者在更新时的动态数据大小均不能超过 4 KB
  • 每个 Activity 对应一个推送token,Activity 中止后,令牌失效。
  • 使用 ActivityKit push notifications 更新,每小时会有一定的预算,官方文档没具体说是多少。超过预算的消息有可能被系统限制。解决方案有两个,方案一是服务端在推消息的时候,手动指定消息优先级,消息优先级10(也是默认优先级)会被加入预算,消息优先级5不会被加入预算。方案二是在主工程的 info.plist 中加入 NSSupportsLiveActivitiesFrequentUpdates 并设为 YES,添加后会在 App 的设置里面,实时活动选项不再是开关,而是可以点进二级页,二级页里面会有 更频繁更新 的开关,用户可以随时关掉。这个选项也可以通过代码获取状态及监听状态变更。
  • 可以通过Link实现点击不同区域,跳转到不同页面。如果只使用widgetURL的方式跳转,那么仅可以跳转到一个scheme页面。
  • 锁屏小组件和灵动岛,共用一份数据,更新时同步更新。可以设计为不同的UI样式。

开发流程

  • 创建 Widget Extension,并确保 Include Live Activity是勾选上的
  • 在主工程中添加 NSSupportsLiveActivities 并设为 YES
  • 如需要更频繁的推送,在主工程中添加 NSSupportsLiveActivitiesFrequentUpdates 并设为 YES
  • 在主工程中,点击Project - Target - Signing & Capabilities,确认添加了 Push Notificatioins 功能
  • 创建好 Widget,会自动生成模版代码,只需要在上面修改相应代码即可
// 入口代码,确定需要加载 Live Activity
@main
struct PizzaDeliveryWidgets: WidgetBundle {
    var body: some Widget {
        // widget
        FavoritePizzaWidget()
        // Live Activity
        if #available(iOS 16.1, *) {
            PizzaDeliveryLiveActivity()
        }
    }
}
// 配置数据
struct PizzaDeliveryAttributes: ActivityAttributes {
    public typealias PizzaDeliveryStatus = ContentState

    // 动态数据
    public struct ContentState: Codable, Hashable {
        var driverName: String
        var deliveryTimer: ClosedRange<Date>
    }
    
    // 静态数据
    var numberOfPizzas: Int
    var totalAmount: String
    var orderNumber: String
}
// 配置UI,共计需要设计出 4 个 UI,且全都需要实现
struct PizzaDeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // 锁屏UI,出现在所有设备上 - 第一个UI
            // 不支持灵动岛的未锁屏的设备上,显示为 banner UI
            // 两个都使用同一个View组件,在这里配置
            // 系统使用默认的文本颜色和最适合锁定屏幕的实时活动背景色
            VStack {
                Text("Your \(context.state.driverName) is on the way!")
            }
            .activityBackgroundTint(Color.cyan)  // 修改背景颜色
            .activitySystemActionForegroundColor(Color.black)  //  修改文本颜色
        } dynamicIsland: { context in
            // 灵动岛UI
            DynamicIsland {
                // 展开后的UI - 第二个UI
                // 需要组合不同的区域
                DynamicIslandExpandedRegion(.leading) {
                    // 展开后的前面
                    Text("Leading")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    // 展开后的后面
                    Text("Trailing")
                }
                DynamicIslandExpandedRegion(.center) {
                    // 展开后的中间
                    Text("Center")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    // 展开后的底部
                    Text("Bottom")
                }
            } compactLeading: {
                // 紧凑样式的左边 - 第三个UI(左)
                Text("L")
            } compactTrailing: {
                // 紧凑样式的右边 - 第三个UI(右)
                Text("T")
            } minimal: {
                // 当有多个 Live Activity时,灵动岛显示成circular minimal 样式 - 第四个UI
                Text("Min")
            }
            .widgetURL(URL(string: "demo://homepage"))  // 点击跳转到指定页面
            .keylineTint(Color.red)
        }
    }
} 
  • 修改主项目工程文件,配置、启动、更新与终止 Live Activity
    // 启动 Live Activity
    func startDeliveryPizza() {
        // 判断版本号
        guard #available(iOS 16.1, *) else {
            return
        }
        // 判断是否开启了 Live Activity 权限
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            // Live Activity 不可用,上报空 token 给服务端
            uploadTokenToService(nil)
            return
        }
        
        // 创建数据
        let pizzaDeliveryAttributes = PizzaDeliveryAttributes(numberOfPizzas: 1, totalAmount:"$99")
        let initialContentState = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "TIM 👨🏻🍳", estimatedDeliveryTime: Date()...Date().addingTimeInterval(15 * 60))
                                                  
        do {
            // 请求启动 Live Activity
            let deliveryActivity = try Activity<PizzaDeliveryAttributes>.request(
                attributes: pizzaDeliveryAttributes,
                contentState: initialContentState,
                pushType: .token)   // Enable Push Notification Capability First (from pushType: nil)
            
            print("Requested a pizza delivery Live Activity \(deliveryActivity.id)")

            Task {
                // 监听 push token 更新
                for await pushToken in deliveryActivity.pushTokenUpdates {
                    let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
                    print(pushTokenString)
                    // 上传 push token 给服务端,用于推送更新 Live Activity
                    uploadTokenToService(pushTokenString)
                }
            }
            Task {
                // 监听 state 数据内容变化
                for await state in deliveryActivity.contentStateUpdates {
                    print("1content state update: tip=\(state.driverName)")
                }
            }
            Task {
                // 监听 Activity 状态变化
                for await state in deliveryActivity.activityStateUpdates {
                    print("activity state update: tip=\(state) id:\(deliveryActivity.id)")
                    // 当 LiveActivity 结束时,使服务端的推送token失效
                    // LiveActivity 活动状态一共有 4 种
                    // .active 处于活动中
                    // .ended 已经终止且不会有任何更新,但依旧在锁屏界面展示
                    // .dismissed 结束且不再展示
                    // .stale 消息过时,等待最新的消息。(iOS 16.2 以上才支持)
                    if state == .ended || state == .dismissed {
                        uploadTokenToService(nil)
                    }
                }
                
            }
        } catch (let error) {
            print("Error requesting pizza delivery Live Activity \(error.localizedDescription)")
            // Live Activity 不可用,上报空 token 给服务端
            uploadTokenToService(nil)
        }
    }
    // 更新 Live Activity
    func updateDeliveryPizza() {
        // 判断版本号
        guard #available(iOS 16.1, *) else {
            return
        }
        Task {
            // 获取数据
            let updatedDeliveryStatus = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "TIM 👨🏻🍳", estimatedDeliveryTime: Date()...Date().addingTimeInterval(60 * 60))
            
            // 更新数据
            for activity in Activity<PizzaDeliveryAttributes>.activities{
                // 用户可以在锁定屏幕上移除Live Activity 后,ActivityState会变为.dismissed。
                if activity.activityState == .dismissed {
                    continue
                }
                await activity.update(using: updatedDeliveryStatus)
            }
        }
    }
    // 结束 Live Activity
    func stopDeliveryPizza() {
        // 判断版本号
        guard #available(iOS 16.1, *) else {
            return
        }
        Task {
            // dismissalPolicy 有三种
            // .default 会在锁屏屏幕上停留四个小时,以便用户查看最后一个消息,或用户主动移除
            // .immediate 立即结束,不会在屏幕上停留
            // .after() 指定时间结束,最长为当前时间+4小时
            for activity in Activity<PizzaDeliveryAttributes>.activities{
                // 用户可以在锁定屏幕上移除Live Activity 后,ActivityState会变为.dismissed。
                if activity.activityState == .dismissed {
                    continue
                }
                await activity.end(dismissalPolicy: .immediate)
            }

            print("Cancelled pizza delivery Live Activity")
        }
    }
// 展示所有 Live Activity
func showAllDeliveries() {
        // 判断版本号
        guard #available(iOS 16.1, *) else {
            return
        }
        Task {
            for activity in Activity<PizzaDeliveryAttributes>.activities {
                print("Pizza delivery details: \(activity.id) -> \(activity.attributes)")
            }
        }
    }

推送更新数据

  • 主工程开通远程推送功能
  • 主工程启动 LiveActivity,获取 pushToken,且监听 pushToken 变化,获取变化后的pushToken
  • 将 pushToken 通过API上报给服务端
  • 服务端设置推送消息的Header header field,将 apns-push-type 字段的值设置为liveactivity,将 apns-topic 字段的值设置为 <your bundleID>.push-type.liveactivity
  • 服务端设置推送消息的 json,"aps" 字段下的 "content-state" 字段,要和客户端 ActivityAttributes 的 ContentState 中定义的动态数据字段名相对应
  • 服务端设置推送消息的 json,"aps" 字段下的 "events",设置为 "update" 或 "end"。如果是end,需要确保 content-state 数据为最终的数据状态,因为之后LiveActivity就不可以再更新了
  • 如果主App存活期间, LiveActivity 被标记为结束时,需要调用接口,将服务端的 pushToken 置为失效。如果是服务端推送 end 状态,将 LiveActivity 置为结束,需要服务端主动将 pushToken 置为失效
  • 扩展 - 可在 "aps" 下设置 "stale-date" 字段,确保 LiveActivity 不会展示过时的消息内容。例如,用户断网,没有收到最新的 update 推送消息,如果到达了 "stale-date" 的时间,LiveActivity 的状态会自动变为 stale,显示消息已过时的 UI,等最新的消息到达后,或用户主动操作后,再更新 UI 为其他的状态。但 stale 状态支持需 iOS 16.2 以上
  • 扩展 - 可在 "aps" 下设置 "dismissal-date" 字段,修改默认的 end 状态停留时长。如果不设置此字段,且结束时设置的 dismissalPolicy 为 .default,那么结束状态的 UI 会在锁屏界面默认停留4个小时,除非用户主动移除它。
// 官方提供的推送消息内容示例
{
    "aps": {
        "timestamp": 1168364460,
        "events": "update",
        "relevance-score": 75.0,
        "stale-date": 1650998941,
        "content-state": {
            "driverName": "Anne Johnson",
            "estimatedDeliveryTime": 1659416400
        },
        "alert": {
            "title": "Delivery Update",
            "body": "Your pizza order will arrive soon.",
            "sound": "example.aiff" 
        }
    }
}

常见问题

The operation couldn’t be completed. (com.apple.ActivityKit.ActivityInput error 1.)
// 检查主工程的info里面,是否添加了NSSupportsLiveActivities并设置为YES
The operation couldn’t be completed. (com.apple.ActivityKit.ActivityInput error 0.)
// 检查主工程,是否添加了推送功能。Project - Target - Signing & Capabilities,如果没有 Push Notificatioins,则点击 + 添加
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,179评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,229评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,032评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,533评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,531评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,539评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,916评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,813评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,568评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,654评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,354评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,937评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,918评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,152评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,852评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,378评论 2 342

推荐阅读更多精彩内容