iOS16.1 ライブアクティビティとスマートアイランドへの適応
序文
WWDC22 で Apple は、ユーザーがロック画面上で一部のアプリケーションのリアルタイム アクティビティの更新を確認できるように、ライブ アクティビティ (Live Activity) の概念を提案しました。そしてActivityKitはスマートアイランドビューのカスタマイズを実現します。約 2 か月の学習を経て、私はいくつかの経験を要約し、誰もが批判し修正できるように共有しました。
説明する
iOS 16.1ロック画面インターフェースにリアルタイム アクティビティ インターフェースが追加されました。現在、スマート アイランドが搭載されているのはiPhone 14 ProおよびiPhone 14 Pro Maxモデルのみです。リアルタイム アクティビティには、ロック画面インターフェイスとスマート アイランド インターフェイスの 2 つの部分が含まれており、例を下図に示します: iOS16.0
のロック画面ウィジェットと比較して、リアルタイム アクティビティは通知領域に表示され、さらに自由なビューのカスタマイズと更新方法。ウィジェットと同様に、ビュー上のアニメーション効果の表示も制限されます。リアルタイムの更新を使用して、興味深いデザインや機能を実行できます。たとえば、上で示したスポーツのモニタリング、オーダーの進行状況、競技結果などです。
シナリオの制限と提案(参考資料 1 より抜粋)
- 最長 8 時間持続し、使用シナリオを考慮する必要があります。8 時間を超えると更新できなくなります (現在はまだ可能ですが、公式ドキュメントが優先され、それ自体に制限があります)。 12 時間後に消えます (したがって、日をまたぐシナリオは考慮されません)
- 作成時、アプリはフォアグラウンドで能動的に作成する必要があり、アプリが起動していないときに単独で表示することはできません(注文後に表示するなど、特定のビジネスにバインドされています)
- カード自体は測位とネットワーク リクエストを禁止しており、少量 (4KB) のデータは通知を通じて送信したり、バックグラウンド アクティビティを通じて更新したりできます。
- 同じシーン内の複数のカードは、スタイルと折りたたみの収束のため、同時に複数のカードを作成することはお勧めできません。
スマートアイランドの適応必要性(参考1より抜粋)
- ロック画面ライブアクティビティとデータ共有 スマートアイランド対応機種では、ロック画面以外のページを表示している場合、情報の更新がスマートアイランド形式で表示されます
- ライブアクティビティ作成後、スマートアイランドはクリックに反応しますが、それが適切でない場合、スマートアイランドをクリックすると自動的にメインプログラムに入り、長押しすると何も情報のない黒いブロックに変わります。
- iPhone14 Pro、iPhone14 Pro Maxユーザーの割合が徐々に増加
開発の基礎知識(参考2より抜粋)
- このデバイスはiPhoneのみをサポートしており、 「ピル スクリーン」を備えたiPhone14Pro および 14Pro Maxにあります。
- 最大システム バージョン、コンパイラ、およびiOSシステム バージョン: >=MacOS12.4、>=Xcode14.0+beta4、>=iOS16.1+beta ;
- ActivityKitを使用して、 Live Activity機能を構成、開始、更新、終了します。WidgetKit と SwiftUIを使用して、ウィジェットwidgetにライブ アクティビティのユーザー インターフェイスを作成し、ウィジェットとライブ アクティビティのコードを共有できるようにします。
- 現在、 Live Activity は、 ActivityKitを介してメイン プロジェクトからデータを取得するか、リモート通知から最新データを取得することしかできず、ネットワークにアクセスしたり、位置更新情報を受け入れたりすることはできません。
- ActivityKitおよびリモート通知プッシュ更新データは4KBを超えることはできません。
- ライブ アクティビティは、さまざまなディープリンクをさまざまなコントロールにバインドして、さまざまなページにジャンプさせることができます。
- ライブ アクティビティは、ユーザーがアクティブに終了するまで最大8時間存続します。
- 終了したライブ アクティビティは、ロック画面に最大4時間表示し続けることもできるため、ライブ アクティビティは最大12時間維持できます。
- 同時にライブ アクティビティのグループは最大 2 つまであり、配置順序はわかりません。
- Live ActivityにはSwiftバージョンのみがあり、プロジェクトがOCの場合はブリッジする必要があります。
達成
1. 主なプログラム構成
メイン プログラムのInfo.plistにキー値を追加する必要があります。ライブ アクティビティのサポートはYESです。
2. 拡張
1. WidgetExtension を作成する
プロジェクトにすでにWidgetExtension がある場合は、2 番目のステップに直接早送りできます。
2. コードを理解する
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) // ///设置“动态岛”中显示的“活动”的关键帧线色调。
}
}
}
インターフェースの関連設計仕様については、ヒューマン・コンピューター・インタラクションに関する公式文書の参考資料 3 を参照してください。
3. データセクションを定義する
struct ActivityWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var nickname: String // 用户对象的昵称
......
}
// Fixed non-changing properties about your activity go here!
var name: String
}
データはContentState (可変部分)と不変部分で構成されます。更新のために渡す必要があるのは、可変部分のコンテンツだけです。
3、メインプログラム部分
リアルタイム アクティビティの開始、更新、終了はメイン プログラムで管理する必要があります。
1.開く
// 使用方法
public static func request(attributes: Attributes, contentState: Activity<Attributes>.ContentState, pushType: PushType? = nil) throws -> Activity<Attributes>
--------------------------
private var myActivity: Activity<ActivityWidgetAttributes>? = nil
let initialContentState = ActivityWidgetAttributes.ContentState(nickName: "哈哈哈")
let activityAttributes = ActivityWidgetAttributes(name: "嘻嘻嘻")
do {
// 本地更新的创建方式
myActivity = try Activity.request(attributes: activityAttributes, contentState: initialContentState)
// 通知更新的创建方式,需要传递pushType: .token
myActivity = try Activity.request(attributes: activityAttributes, contentState: initialContentState, pushType: .token)
print("Activity id : \(String(describing: cymActivity?.id ?? "nil")).")
} catch (let error) {
print("ActivityError: \(error.localizedDescription)" )
}
2.アップデート
// 使用方法
public func update(using contentState: Activity<Attributes>.ContentState, alertConfiguration: AlertConfiguration? = nil) async
------------------------------------------------------------
// 更新内容
let updateStatus = ActivityWidgetAttributes.ContentState(nickName: "啊啊啊")
// 关于通知的配置
let alertConfiguration = AlertConfiguration(title: "111", body: "2222", sound: .default)
Task {
await myActivity?.update(using: updateStatus, alertConfiguration: alertConfiguration)
}
3.終了
// 使用方法
public func 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.
public static let `default`: 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``.
public static let 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.
public static func after(_ date: Date) -> ActivityUIDismissalPolicy
--------------------------------------------------------------
Task {
await myActivity?.end(using:nil, dismissalPolicy: .immediate)
}
4.ステータス取得
作成が成功したら、activityStateUpdates を使用してリアルタイム アクティビティのステータスを監視できます。
Task.detached {
for awaitactivity in Activity<ActivityWidgetAttributes>.activities {
for await state in activity.activityStateUpdates {
if (state == .active) {
/// The Live Activity is active, visible to the user, and can receive content updates.
} else if (state == .ended) {
/// The Live Activity is visible, but the user, app, or system ended it, and it won't update its content anymore.
} else {
// .dismissed
/// The Live Activity ended and is no longer visible because the user or the system removed it.
}
}
}
}
5.PushToken取得
通知を使用して更新する必要がある場合は、PushToken をサーバーに送信する必要があります。
// 创建时需要 pushType: .token
Activity.request(attributes: activityAttributes, contentState: initialContentState, pushType: .token)
// 创建成功后
Task.detached {
for await activity in Activity<ActivityWidgetAttributes>.activities {
for await pushToken in activitie.pushTokenUpdates {
let mytoken = pushToken.map {
String(format: "%02x", $0)}.joined().uppercased()
// pushToken 是Data 需要经过上面的方法 转换成String传递给服务端使用
print("push token", mytoken)
}
}
}
}
6. 権限
リアルタイムアクティビティの権限は監視および取得できず、能動的にのみ判断できます。作成前にユーザーに確認を促すために判断する必要があります。
// 实时活动是否可用,包括权限是否开启和手机是否支持实时活动
ActivityAuthorizationInfo().areActivitiesEnabled
// 获取已有的实时活动个数
Activity<ActivityWidgetAttributes>.activities.count
4番目、サーバー部分
サーバーはp8 + jwt を使用してliveActivityをプッシュする必要があります
// 推送配置
TEAM_ID=开发者账号里的TEAM_ID
AUTH_KEY_ID=p8推送需要的验证秘钥ID
TOPIC=主程序的Bundle Identifier.push-type.liveactivity
DEVICE_TOKEN=PushToken
APNS_HOST_NAME=api.sandbox.push.apple.com
// APS结构
{
"aps": {
"timestamp":1666667682, // 更新的时间
"event": "update", // 事件选择更新,也可以进行结束操作
"content-state": {
// 需要与程序中的数据结构保持一致
"nickname": "我来更新"
},
"alert": {
// 通知配置
"title": "Track Update",
"body": "Tony Stark is now handling the delivery!"
}
}}
一、質疑応答
ライブイベントのウェビナー中の自由質問タイムにおける開発者の質問と回答の一部
Q:did becomeBackground の瞬間に LiveActivity を開始できますか?
A: バックグラウンドに入ると PushToken を取得できない可能性があるため、失敗する可能性があります。
Q: スマート アイランドは、lottie svgs gif などのアニメーションをサポートしていますか?
A: いいえ、カスタム アニメーションはサポートされておらず、システムがアニメーションを処理します。
Q: スマートアイランドのデバッグ方法、ブレークポイントデバッグは無効です
A: 現時点ではお答えできません
Q: ObjC を使用してリアルタイム アクティビティを実装できますか?
A: いいえ! ActivityKit Swift のみ、ブリッジが必要
Q: ライブ アクティビティで Web イメージを取得する方法
A: アップデートは 4KB を超えることはできません。Web イメージはダウンロードしてローカルに配置するのが最適です。
Q: アプリを開く代わりに、クリック イベントを処理するボタンを作成することは可能ですか
A: いいえ、リンクとウィジェット URL を介して、クリックするとアプリが自動的に開きます。
Q: ユーザーが APP のプッシュ権限を持っていない場合、リアルタイム アクティビティは実装されませんか?
A: いいえ、通知プッシュとリアルタイム イベント プッシュは、リアルタイム アクティビティである限り、2 つの異なるシステムです。許可が有効になっています
Q: スマート アイランドはリアルタイム イベントと同様に最大 8 時間表示しますか?
A: はい
参考文献
私は初心者です。間違いがあれば修正してください。皆さんとコミュニケーションをとり、成長できることを楽しみにしています。
1. 「Hema iOS ライブ アクティビティと「スマート アイランド」配信シナリオの実践」
2. 「iOS スマート アイランド開発の実践」
3. 「Apple 開発者 - 設計 - ライブ アクティビティ」
4. 「iOS はプッシュ通知を使用してダイナミック アイランドとライブ アクティビティを更新する」 5.
「SwiftUIでダイナミックアイランドをマスターする」
深刻な問題を記録する
製品要件: リアルタイム イベントとスマート アイランド上でユーザーのアバターと一部のデータを表示し、AppGroupを使用してアバターを保存し、リアルタイム イベントを作成するときに使用するアバターを取り出しますが、重大な問題が発生しました。
テスターは 4 台の携帯電話iPhone 12、iPhone X、iPhone XR、iPhone 11 (すべてのiOSバージョンは16.1.1 ) を使用してテストを実施しました。iPhone 12 と iPhone X はリアルタイム アクティビティを正常に表示しましたが、iPhone XR と iPhone 11は表示されませんでした。これは、リアルタイム イベントがLogに作成されたにもかかわらず、システムによってすぐに削除されたことを示しています。
午後の実験の後、ついに次のことが分かりました。
// 主程序保存头像
NSURL *imageURL = [NSURL URLWithString:@"https://xxxxxxxxxxxx.png"];
[[SDWebImageDownloader sharedDownloader] downloadImageWithURL:imageURL completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, BOOL finished) {
if (finished && image) {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *pathURL = [fileManager containerURLForSecurityApplicationGroupIdentifier:@"group.app.???.???"];
pathURL = [pathURL URLByAppendingPathComponent:@"ActivityLoverAvatar.png"];
NSData *imageData = UIImagePNGRepresentation(image);
BOOL success = [imageData writeToURL:pathURL atomically:YES];
...
// 创建实时活动
} else {
...
// 处理错误
}
}];
// 实时活动获取对象头像
func getLoveryAvatatImage()->Image {
let manager = FileManager.default
let floderURL:URL = manager.containerURL(forSecurityApplicationGroupIdentifier: "group.app.???.???")!
let fileURL:URL = floderURL.appendingPathComponent("ActivityLoverAvatar.png")
do {
let data: Data = try Data.init(contentsOf: fileURL)
let image = UIImage.init(data: data)
return Image(uiImage: image!)
} catch let error{
print(error.localizedDescription)
return Image("placeholderImage")
}
}
// 实时活动Widget配置
struct ActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ActivityWidgetAttributes.self) {
context in
ActivityWidgetMainView(context: context) // 内部使用了 getLoveryAvatatImage()
} dynamicIsland: {
context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading, priority: 1) {
ActivityWidgetMainDynamicIslandView(context: context) //内部使用了 getLoveryAvatatImage()
.dynamicIsland(verticalPlacement: .belowIfTooWide)
.padding(0)
}
} compactLeading: {
getLoveryAvatatImage()
.resizable()
.frame(width: 22, height: 22)
.clipShape(Circle())
} compactTrailing: {
Image(getIconImageName(context.state.iconName))
.resizable()
.frame(width: 17, height: 17)
} minimal: {
getLoveryAvatatImage()
.resizable()
.frame(width: 22, height: 22)
.clipShape(Circle())
}
}
}
}
コード内にgetLoveryAvatatImage()メソッドを使用している箇所が 4 か所ありますが、 minimumとCompactLeadingのgetLoveryAvatatImage()を削除すると、正常に表示できない 2 台の携帯電話が正常に表示されるようになります。したがって、デザイナーはこれら 2 つの場所をアプリを示すロゴ画像に置き換えることをお勧めします。
// 实时活动Widget配置
struct ActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ActivityWidgetAttributes.self) {
context in
ActivityWidgetMainView(context: context) // 内部使用了 getLoveryAvatatImage()
} dynamicIsland: {
context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading, priority: 1) {
ActivityWidgetMainDynamicIslandView(context: context) //内部使用了 getLoveryAvatatImage()
.dynamicIsland(verticalPlacement: .belowIfTooWide)
.padding(0)
}
} compactLeading: {
Image("AppLogo")
.resizable()
.frame(width: 22, height: 22)
.clipShape(Circle())
} compactTrailing: {
Image(getIconImageName(context.state.iconName))
.resizable()
.frame(width: 17, height: 17)
} minimal: {
Image("AppLogo")
.resizable()
.frame(width: 22, height: 22)
.clipShape(Circle())
}
}
}
}
ただし、一部の携帯電話は正常に使用できるものの、2 台のテスト機だけが正常に使用できないため、この問題の根本的な原因は不明です。理由を知っている人がいたら、私にプライベート メッセージを送ってください。一緒に話し合いましょう。ありがとう! !!