实现实时活动和灵动岛

1 概览

实时活动是在 iOS 16.1 上面推出的新 API,用于在锁屏和灵动岛上显示最新数据。为了实现实时活动,需要在小部件实现。实时活动使用 WidgetKit 和 SwiftUI 来构建用户界面,而 ActivityKit 则用于处理实时活动的生命周期——请求、更新、和结束。

2 实时活动的要求和限制

实时活动最多能保持 8 小时,超过时系统会自动结束。当实时活动结束时,会从灵动岛上移除,但是会留在锁屏上最多 4 小时。因此理论上来说,实时活动可以在锁屏上最多保留 12 小时。
实时活动运行在独立的沙盒中,不能像小部件一样访问网络或者接收定位更新。所以只能在应用中使用 ActivityKit 或者远程推送通知来刷新活动的数据。

注意
不论使用何种方式更新,其数据的大小不能超过 4KB。

3 为应用添加实时活动支持

添加实时活动只需要如下几步:

  1. 创建小组件,如果应用中已存在小组件,略过。
  2. 在主应用的 Info.plist 文件中添加实时活动的支持,设置Supports Live Activities 为 YES。
  3. 添加 ActivityAttributes 来描述实时活动的数据。
  4. 使用 ActivityAttributes 创建 ActivityConfiguration。
  5. 实现实时活动的启动、更新和结束的功能。

此处略过1、2步。

3.1 定义活动数据类型

1
2
3
4
5
6
7
8
9
10
11
struct FitnessAttributes: ActivityAttributes {
    public typealias ContentState = FitnessState
   
    public struct FitnessState: Codable, Hashable {
        var item: String
        var time: ClosedRange<Date>
    }

    var username: String
    var fitnessGoal: String
}

上面代码定义了一个健身的实时活动 attributes。其中,FitnessAttributes 中定义的为静态数据类型,而 FitnessState 中所定义的则为动态数据类型。

3.2 创建实时活动的视图

有了实时活动所需的数据类型,接下来就是在灵动岛和锁屏上将其显示出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct FitnessWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FitnessAttributes.self) { context in
            FitnessLockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.username + "正在" + context.state.item + "!")
                    Label {
                        Text(timerInterval: context.state.time, countsDown: true)
                    } icon: {
                        Image(systemName: "timer")
                    }
                    .font(.title2)
                    .foregroundColor(.orange)
                }
            } compactLeading: {
                CompactLeadingView(context: context)
            } compactTrailing: {
                CompactTrailingView(context: context)
            } minimal: {
                Image(systemName: "figure.step.training")
                    .foregroundColor(.white)
            }
        }
    }

}

接下来就来看看以上代码具体是什么意思。

首先是构造一个实时活动的配置并返回,这个方法的签名如下:

1
public init<Content>(for attributesType: Attributes.Type = Attributes.self, @ViewBuilder content: @escaping (ActivityViewContext<Attributes>) -> Content, dynamicIsland: @escaping (ActivityViewContext<Attributes>) -> DynamicIsland) where Content : View

方法签名虽然看起来很长,但实际上只有三个参数,第一个是支持的实时活动的数据类型,也就是前文定义的 ActivityAttributes。第二个参数是个闭包,用于构建锁屏状态下的实时活动视图。第三个参数同样是个闭包,用于构建灵动岛的视图。

前面两个参数都比较简单,一看就会用,这里主要讲一讲灵动岛的实现。

灵动岛由 DynamicIsland 进行初始化,看一下初始化的方法签名:

1
public init<Expanded, CompactLeading, CompactTrailing, Minimal>(@DynamicIslandExpandedContentBuilder expanded: @escaping () -> Expanded, @ViewBuilder compactLeading: @escaping () -> CompactLeading, @ViewBuilder compactTrailing: @escaping () -> CompactTrailing, @ViewBuilder minimal: @escaping () -> Minimal) where Expanded : DynamicIslandExpandedContent, CompactLeading : View, CompactTrailing : View, Minimal : View

一共有四个闭包,分别是:

  • Expanded,代表了灵动岛展开时的视图;
  • CompactLeading,代表摄像头之前的视图;
  • CompactTrailing,代表摄像头之后的视图;
  • Minimal,存在多个实时活动时的视图;

后三个都是常见的 SwiftUI 视图,Expanded 中需要根据对应的区域来构建不同的视图。
如开发者官网文档的图片所示:
灵动岛显示区域划分

展开的视图分为四个区域,分别为 leading、trailing、center 和 bottom。所以在构建视图的时候根据需要显示的内容设置不同的视图:

1
2
3
4
5
6
7
8
9
10
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.username + "正在" + context.state.item + "!")
Label {
Text(timerInterval: context.state.time, countsDown: true)
} icon: {
Image(systemName: "timer")
}
.font(.title2)
.foregroundColor(.orange)
}

上述代码实现了灵动岛展开情况下中间部分的视图,其余几个部分都大同小异。另外需要说明的是,当左右两边的空间不够时,可以使用 belowIfTooWide 修饰符将其内容显示在摄像头下方,但是这一条在实际使用时并没有生效,不知是使用方式的问题还是苹果的bug。

同样借用官网的图来表示左右两边的视图可以占据的最大空间。
灵动岛优先级展示

4. 实时活动的创建、更新与结束

4.1 创建

为了创建实时活动,首先需要创建实时活动所需要的数据,注意,动态数据和静态数据要分别创建。

1
2
3
4
let future = Calendar.current.date(byAdding: .minute, value: 20, to: Date())!
let date = Date.now...future
let fitnessAttributes = FitnessAttributes(username: "Ludwig", fitnessGoal: "早日回到BMI正常区间。")
let fitnessState = FitnessAttributes.FitnessState(item: "跑步", time: date)

然后使用 Activity 类就可以轻松地创建实时活动了。

1
2
3
4
5
6
do {
let fitnessActivity = try Activity.request(attributes: fitnessAttributes, contentState: fitnessState)
print("Created a fitness activity \(String(describing: fitnessActivity?.id)).")
} catch {
print(error.localizedDescription)
}

4.2 更新

更新则只需要创建一个新的动态数据,然后将其设置到实时活动就行了。

1
2
3
4
5
6
var future = Calendar.current.date(byAdding: .minute, value: 10, to: Date())!
let date = Date.now...future
let fitnessState = FitnessAttributes.FitnessState(item: "跑步", time: date)
let alertConfiguration = AlertConfiguration(title: "Fitness update", body: "10 minutes left", sound: .default)

await fitnessActivity?.update(using: fitnessState, alertConfiguration: alertConfiguration)

需要注意的是,更新的同时,还会有一个提示,提示的声音既可以使用默认的,也可以自定义。

在后台更新可以参考使用 [[BackgroundTasks 实践#^a71227|BackgroundTasks]] 来做。

4.3 结束

结束就更简单了,需要传递两个参数。
第一个参数是动态数据,也就是实时活动结束时你想让它处于什么样的状态,那么这里就设置成什么样的数据。
第二个参数是结束时的策略,有三种可选:

  1. default,如果用户不手动清除屏幕上的实时活动,则系统会在四小时后自动清除;
  2. immediate,就是在活动结束时马上清除实时活动;
  3. after,需要提供一个日期,在这个时间到来时清除实时活动,此日期不能超过现在的四小时,超过的话,以四小时计。
    1
    await fitnessActivity?.end(using: fitnessState, dismissalPolicy: .default)

注意
需要注意的是,更新和结束都可以在后台执行,具体执行的方法参考 Background Task

以上就是关于实时活动和灵动岛的全部内容了,后面再写利用远程通知来更新或结束实时活动的内容。

参考文章
官网文档