一文读懂实时活动

发布于:2025-08-03 ⋅ 阅读:(20) ⋅ 点赞:(0)

本文字数: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混编

如果你们的项目是通过ObjcSwift的混编:

  • 需要先在主项目中创建一个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


网站公告

今日签到

点亮在社区的每一天
去签到