iOS 实时活动(live activity)保姆级教学

背景

本篇文章应该是你能搜到的讲解最全最详细的关于实时互动的教程了

基本效果

调研

基本概览

系统版本要求

实时活动功能和 ActivityKit 将包含在今年晚些时候推出的 iOS 16.1 中。

开发语言

灵动岛使用WidgetKitSwiftUI完成 UI 开发工作

开发架构

开发架构

UI展示

基本UI

紧凑型(Compact)

- 系统只有一个实时互动时展示方式(默认方式)。
1.PNG

最小化(Minimal)

- 系统有多个实时互动时展示方式
2.png

拓展(Expanded)

- 用户在紧凑型和最小化模式下长按时,模式切换到扩展模式,展示更多信息
3.png

整体结构

- 扩展模式下,界面的布局如下
4.png

UI适配规格

WX20241031-204545.png

数据

显示时间

  1. 实时活动最多可以保持 8 小时的活动状态,除非应用程序或人为在之前提前结束
  2. 超过 8 小时,系统自动结束,移出灵动岛
  3. 实时活动会停留在锁屏界面上,非人为主动删除,在 8 小时后再停留 4 个小时
  4. 实时活动在锁屏状态下最多停留 12 小时

数据更新

实时活动 无法访问网络接受位置信息

  1. 通过远程推送
  2. 通过 ActivityKit 活动推送

以上两种方式推送内容大小不能超过 4 KB

使用场景

  1. 需在屏幕驻留的文字、图像为主的信息:如地图导航、airdrop 传输情况等;
  2. 后台进行的音频类:如接电话、放音乐、录音、倒计时等;
  3. 即时交互反馈:如充电、静音、人脸识别等。超过这三类信息后,桌面可能会变得杂乱无章


    5.png

实践

前期准备

证书

证书下载:开发者只需下载其中一种证书即可,推荐使用 P8 证书。
P8 和 P12证书的区别:
P8:同一帐户下有多个应用程序,可以使用同一个 P8 证书。P8 证书永久有效。
P12:对于每个应用程序,都需要单独的证书。P12 证书有效期是一年。
注意:灵动岛推送,p12证书不支持;p8支持。

推送概念的理解

6.png

创建项目

  1. 创建iOS工程(忽略)
  2. 创建 WidgetExtension


    7.png
  3. 一定要勾选 Include live Activity(不然不会包含实时活动,后续要自己创建),然后输入名称,点击完成既可


    8.png
  4. 创建好之后的结构如下


    9.png
  5. 在 主工程 Info.plist 文件中声明开启,打开 Info.plist 文件添加NSSupportsLiveActivities,并将其布尔值设置为 YES。
    10.png
  6. 在主工程创建一个 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 的实时活动的动态数据。
  1. WidgetExtension 中会存在三个文件
    7.1 WidgetExtensionBundle用来配置需要展示的内容
// 这里我们不需要小组件卡片 只保留  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")
    }
}
  1. 启动、更新、关闭实时活动
    在主工程中进行活动的的相关操作
    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"}
}}

推送前和推送后的效果


11.png

远端拉起

iOS 17.2之后 支持 远端拉起实时活动,这样就可以不从手机端发起实时活动(有别于美团外卖,滴滴,百度地图之类的,需要app端发起活动)
先说几个关键函数:

// pushToStartTokenUpdates 远程启动实时活动的token 每次有实时活动变更都会回调该函数
//activityUpdates  实时活动有更新的时候会回调该函数
// contentUpdates   推送过来的内容 (可变内容部分,对应上一部分不可变内容)
// pushTokenUpdates  push token acitivy创建(request)后异步得到的 token
// activityStateUpdates   当前试试活动状态发生变更时的回调

// 以上内容实在推送过程中异步回调的内容

以上五个回调是远程拉起更新的关键函数。把这些监听写到 appdelegatedidFinishLaunchingWithOptions 函数中,监听相关内容的变化,相关代码如下:

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 获取


13.png

该回调经过验证在手机重启、app首次安装的情况下会 回调一次返回 push-to-start-token(被动触发条件苛刻),后续只有在 拉起实时活动的时候才会回调,经过查询相关文档确认这一问题,大部分版本都是升级上来的,所以第一次的拉起需要从 app端拉起一次(目前我能想到的解决办法)
苹果官方问答 https://forums.developer.apple.com/forums/thread/741939

状态的相关判断

14.png

上图中的两个状态通过以下函数判断:

// 通过该函数进行判断
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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容