WWDC 2022 Keynote 中蘋果給我們介紹了 iOS 16 中一個比較亮眼的更新:Live Activity(實時活動),開發者可以在鎖屏頁面上放置一個可以“實時”更新的 Widget,比如外賣或者打車應用,在開啟實時活動之后我們可以在鎖屏頁上實時看到外賣小哥/司機與我們的距離及預計到達時間。但是這一 API 及對應功能并沒有第一時間放出,而是隨著 iOS 16 Beta4 一起放出:【實時活動現已推出 Beta 版本】[1]。
這篇文章主要是對官方 API 做一個簡單提煉,并梳理下一些需要注意的點。
寫在前面
- Live Activity 功能及正式 API 不會隨 iOS 16 的首個正式版本釋出,而是在今年晚些時候釋出,具體時間沒有給;
- 只有在 Live Activity 正式釋出后,才可提交帶對應功能的 App 版本到 App Store;
- Live Activity 僅 iPhone 可用;
- 下面提及到的代碼及示例都是 Beta 版的,可能隨時會發生變化,建議在正式版釋出后著重關注
Live Activity 后續均使用實時活動來翻譯。
實際使用/開發體驗
設備及開發環境:iPhone 12 iOS 16 Beta 4、Xcode 14 Beta 4、macOS 12.4
- 當前版本(iOS16 Beta 4)鎖屏頁面展示實時活動,不需要用戶授權也不需要用戶手動添加,開啟實時活動后會自動展示在鎖屏頁上,但是用戶可以在設置中手動關閉。猜測后續大概率需要用戶授權,不然可能會被某些開發者利用;
- 同一個應用可以展示多個實時活動,但是會被折疊(類似通知),鎖屏頁面可以有多個 app 同時展示,會按 app 分組,可以看下截圖;
3. 沒有付費賬號,還沒嘗試使用遠程推送來更新/停止實時活動;
4. 實時活動鎖屏 UI 必須使用 SwiftUI,相對來說比較簡單,實時活動組件的高度不能超過 220px(原文是 220 pixels,但實際測試發現是 220pt),否則系統會自動裁切;
5. 基于 Widget,但刷新機制不一樣,widget 是根據時間線來更新,而實時活動則不受這個控制,可以使用遠程推送或者宿主應用代碼來更新,暫時沒看到更新頻率相關限制;
6. 因為是基于 Widget,所以還是可以給控件綁定不同 deep link,使其可以跳轉到對應頁面;
7. 整體上來說實時活動的適配比較容易,重要的還是結合 App 的實際場景合理運用應該能取得不錯效果,后續應該會有很多有創意的 idea 出現,可以期待一波。
寫了個 Demo,模擬地鐵到站預估時間的場景,代碼放在 GitHub/iOS16LiveActivityDemo[2] 上了,有啥疑問可以留言或者提 issue。
以下內容主要對文檔做個翻譯.
實時活動的要求和約束
- 一個實時活動在應用或用戶結束前能夠存活8 個小時,如果 8 小時內沒有結束,系統會自動結束該實時活動;
- 已結束的實時活動會在鎖屏頁上保留4 個小時,之后系統會自動將其移除,當然期間用戶也可以手動移除;
- 綜上,一個實時活動在鎖屏頁上最長可以停留12 個小時;
- 實時活動有自己的沙盒,跟 Widget 不一樣的一點是:它無法使用網絡也不能接受地理位置更新;
- 我們可以在應用內使用
ActivityKit
來更新實時活動,也可以在實時活動的 Widget 中接收遠程推送來更新,下面會具體說到;
讓應用適配實時活動
如果應用之前已經有 Widget,那么可以在已有的 Widget Extension 中添加實時活動的相關實現;如果之前沒有的話可以新建一個。值得注意的是:實時活動并不是 widget,他們的更新機制有較大區別。上面也有提到,實時活動是通過應用內的 ActivityKit 或者遠程推送來更新的,而 widget 則依賴系統的 timeline 機制。
下面是適配的相關步驟:
- 創建一個 Widget Extension,如果已有,則可跳過這一步;
2. 在
Info.plist
文件中添加一個鍵值對,key 為 NSSupportsLiveActivities
,value 為 YES
;
3. 在代碼里定義一組
ActivityAttributes
以及 Activity.ContentState
,后續會用它們來開始、更新及結束實時活動;
4. 創建 Widget 并返回一個 ActivityConfiguration
;
5. 添加開始、更新、結束實時活動的相關代碼,并設計對應的 UI 樣式;
6. 運行查看效果。
import ActivityKit
import SwiftUI
import WidgetKit
// 示例代碼,展示披薩配送的實時活動
// 繼承 ActivityAttributes ,定義自定義屬性用于widget UI展示
// Attributes 用來定義不可變的靜態數據,比如這里的披薩數量和花費
struct PizzaDeliveryAttributes: ActivityAttributes {
public typealias PizzaDeliveryStatus = ContentState
// ContentState用來封裝動態(會發生變化的)數據,比如這里的配送員名字、預計送達時間
public struct ContentState: Codable, Hashable {
var driverName: String
var estimatedDeliveryTime: Date
}
var numberOfPizzas: Int
var totalAmount: String
}
struct PizzaDeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(attributesType: PizzaDeliveryAttributes.self) { context in
// 根據數據創建鎖屏widget UI,系統默認情況下文字顏色使用白色,然后使用最適合鎖屏頁的背景色;也可以像下面這樣使用 activityBackgroundTint(_:) 來設置自定義顏色
VStack {
Text("\(context.attributes.numberOfPizzas) ordered for \(context.attributes.totalAmount).")
HStack {
Text("\(context.state.driverName) is on their way with your pizza!")
Text(context.state.estimatedDeliveryTime, style: .timer)
}
}.activityBackgroundTint(Color.cyan)
// 或者像這樣使用ZStack的方式在最底下放置背景視圖
ZStack {
Color.cyan
VStack {
Text("\(context.attributes.numberOfPizzas) ordered for \(context.attributes.totalAmount).")
HStack {
Text("\(context.state.driverName) is on their way with your pizza!")
Text(context.state.estimatedDeliveryTime, style: .timer)
}
}
}.activitySystemActionForegroundColor(Color.cyan)
}
}
}
值得注意的是實時活動 Widget 的最大高度不能超過 220px(原文是 220 pixels,但實際測試發現是 220pt),否則系統會自動裁剪。
檢查實時活動是否可用
由于實時活動僅在 iPhone 上生效,同時用戶也可以在設置中手動關閉某個應用的實時活動,所以在使用前最好要做一個檢測。
- 使用
areActivitiesEnabled
來同步判斷開始實時活動前是否顯示鎖屏 UI; - 使用異步接口
activityEnablementUpdates
來檢測用戶授權狀態的變更。
需要注意的是:每個應用可以開啟若干個實時活動,同時系統也能展示多個 app 的實時活動;所以我們在啟動、更新、結束實時活動時,也要考慮出錯的情況以提供更好的用戶體驗。
啟動實時活動
應用在前臺的時候,我們可以使用 request(attributes:contentState:pushType:)
方法來啟動實時活動,對應參數 attributes 作為實時活動的初始值,contentState
作為動態變化的數據。如果應用實現了遠程推送,也可以提供 pushType
參數,后面遠程推送部分會講到。
// 啟動實時活動示例代碼
// 提供初始化值
let pizzaDeliveryAttributes = PizzaDeliveryAttributes(numberOfPizzas: 42, totalAmount:"$420,-")
// 提供動態變化數據,預估配送到達時間為1小時后
let initialContentState = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Bill James", estimatedDeliveryTime: Date().addingTimeInterval(60 * 60))
// 啟動實時活動,這里 pushType 暫時置 nil
do {
let deliveryActivity = try Activity<PizzaDeliveryAttributes>.request( attributes: pizzaDeliveryAttributes, contentState: initialContentState, pushType: nil)
print("Requested a pizza delivery Live Activity \(deliveryActivity.id)")
} catch (let error) {
print("Error requesting pizza delivery Live Activity \(error.localizedDescription)")
}
更新實時活動
啟動實時活動后我們可以得到一個 Activity
實例,接著我們可以調用該實例 update(using:)
方法來更新實時活動。我們也可以通過 Activity。activities
方法來獲取當前所有的實時活動實例。
更新一個已結束的實時活動會被忽略。
let updatedDeliveryStatus = PizzaDeliveryStatus(driverName: "Anne Johnson", estimatedDeliveryTime: Date().addingTimeInterval(60 * 60))
do {
// deliveryActivity是上面啟動時拿到的Activity實例
// 更新的數據大小不能超過4KB
try await deliveryActivity.update(using: updatedDeliveryStatus)
} catch(let error) {
print("Error updating activity \(error.localizedDescription)")
}
內容更新動畫
系統會忽略實時活動 widget 的所有動畫修飾符,比如withAnimation(_:_:)
、animation(_:value:)
。系統會自動給動態變化的內容添加動畫,比如會給 Text 添加模糊的過渡效果,給 Image 以及 SF Symbol 添加過渡動畫。如果更新過程中有視圖的添加或移除,系統也會給他們加上淡入淡出的過渡動畫。
我們也可以使用系統內置的過渡動畫:opacity
、move(edge:)
、slide
、push(from:)
或者將它們組合使用,對于那種計時的 Text,我們也可以使用 numericText(countsDown:)
修飾符來做文本變化動畫。
結束實時活動
在關聯的事件/任務結束時,我們也應該結束對應的實時活動。上面也有提到結束后的實時活動在用戶手動移除前還會在鎖屏頁上停留 4 小時。當然我們也可以使用 end(using:dismissalPolicy:)
方法指定實時活動結束后的移除策略。
let updatedDeliveryStatus = PizzaDeliveryStatus(driverName: "Anne Johnson", estimatedDeliveryTime: Date())
do {
// 指定移除策略為默認,即用戶手動移除前停留4小時
// 還有 immediate 即立即移除,以及可以指定一個移除的時間 after(Date)
try await deliveryActivity.end(using: updatedDeliveryStatus, dismissalPolicy: .default)
} catch(let error) {
print("Error ending activity \(error.localizedDescription)")
}
需要注意的是,用戶可以在任意時間將實時活動從鎖屏頁面移除,該操作相應的也會結束對應的實時活動,但是他不會取消用戶在啟動實時活動時的一些行為。比如上面披薩配送示例里,盡管用戶可以移除披薩配送信息的實時活動,但不代表取消了對應的披薩訂單。
使用遠程推送更新/結束實時活動
除了上述的更新和結束的方式,我們還可以通過推送通知來實現,具體實現流程和邏輯其實普通的推送通知區別不大,這里不做贅述。有一點不同的是:實時活動不需要使用 registerForRemoteNotifications()
來注冊推送通知,我們使用 ActivityKit 來獲取推送 token。具體流程如下:
- 啟動實時活動時需要指定 pushType 參數為
.token
,或者不傳該參數(參數默認值就為.token
); - 成功啟動后,將拿到的
pushToken
發送給服務端,后續使用該token
來給對應實時活動發送推送通知; - 服務端使用對應 token 發送推送時需要必須要指定
content-state
字段的值和代碼里的Activity.ContentState
匹配上,這樣系統才能解碼對應 JSON 內容來更新實時活動; - 使用推送內容來更新或結束對應的實時活動;
- 使用
pushTokenUpdates
監聽實時活動實例的 pushToken 變化,并將新值發送給服務端同時廢棄舊值, - 當實時活動結束后,通知服務端廢棄對應 token。
模擬器上測試實時活動的遠程推送需要使用 T2 或 M 系列芯片的 Mac,并且要求系統 >=macOS 13。
如果你不清楚自己電腦是否是 T2,可以通過如圖方式確認,按住 Option 鍵,并點擊左上角 ? 查看 系統信息->控制器即可
也可以使用直接在下方列表查找,基本上 18 年后的都支持。
下面是一個對應上方披薩配送示例的 push payload 數據
{
"aps": {
"timestamp": 1650998941,
"event": "update",
// 這里和上述 PizzaDeliveryAttributes.ContentState 里的字段一一對應
"content-state": {
"driverName": "Anne Johnson",
"estimatedDeliveryTime": 1659416400
}
}
}
跟新追蹤
Activity 這個類除了擁有一個 id 的唯一標識外,還提供了一系列的狀態變化的監聽,比如內容狀態、活動狀態以及 push token 的變更。我們可以使用對應的監聽來更新應用,讓實時活動與應用保持同步。
- 使用
activityStateUpdates
來監聽實時活動的狀態,判斷是否已結束; - 使用
contentState
來監聽實時活動的動態內容變化; - 使用
pushTokenUpdates
來監聽實時活動的 push token 變化。
查看實時活動列表
一個應用可以同時開啟多個實時活動,比如用戶可以同時關注多個球賽直播,我們可以使用 activityUpdates
來獲取當前正在進行中的實時活動。在某些場景下我們可能也會用到這個方法來獲取當前進行中的實時活動,比如應用閃退后再次打開應用,如果想要結束或更新某些實時活動,就可以通過這個方法來獲取到所有的實時活動。
// Fetch all ongoing pizza delivery activities.
let activityStream = Activity<PizzaDeliveryAttributes>.activityUpdates() for await activity in activityStream { print("Pizza delivery details: \(activity.description)") }
參考資料
[1]【實時活動現已推出 Beta 版本】: https://developer.apple.com/cn/news/?id=hi37aek8
[2]GitHub/iOS16LiveActivityDemo: https://github.com/wang9262/iOS16LiveActivityDemo