Live Activity を初めて入れたときに、まずぶつかった3つのこと
iOS アプリを 2014 年から個人で出してきて、累計 5,000 万ダウンロードを超えるアプリ群を運営しているのですが、Live Activities を最初に実機投入したとき、ドキュメントだけでは見えなかった壁が三つありました。
ロックスクリーンでは綺麗に出るのに、Dynamic Island のコンパクト表示にすると情報が崩れる
APNs のペイロードを送っても、なぜか一定確率で更新が無声に落ちる
staleDate を入れていなかったため、アプリ復帰時に過去の状態が「いま」のように表示されて、ユーザーから苦情が来る
どれも公式ドキュメントを読み直してもピンと来ず、Xcode のコンソールも沈黙したままでした。試行錯誤の途中で Antigravity の Planning Mode に「配達追跡アプリに Live Activity を後付けしたい。すでに WidgetKit のウィジェットはある」とだけ伝えたところ、ファイル構成・ターゲット設定・APNs ペイロード・stale 処理まで含めた実装計画を返してきて、そこから一気に整理が進みました。
この記事は、その実装を 1〜2 日で本番投入できるところまで持ち込むのに必要な情報を、私自身が個人開発の現場でぶつかった順番で書いています。iOS 26 で更新頻度の制限が緩和され、Xcode 26 の Dynamic Island プレビューも安定したので、いま Live Activity を入れる障壁はかなり下がりました。
対象読者は SwiftUI の基本知識がある iOS 開発者 です。WidgetKit を触ったことがあると進みが速いですが、未経験でも追えるように ActivityAttributes のところから書きました。
第1章:ActivityKit の設計思想とデータモデル
1-1. Live Activities のアーキテクチャ
Live Activities は技術的には WidgetKit の拡張 として実装されています。通常の Widget と異なるのは、データをリアルタイムに更新できる 点と、ユーザーの操作に応じてライフサイクルを管理できる 点です。
アーキテクチャは以下の 3 層から成ります:
App 本体(ActivityKit) : Live Activity の開始・更新・終了を制御
Widget Extension : ロックスクリーンと Dynamic Island の UI を定義
ActivityAttributes プロトコル : 静的データと動的データを分離したデータモデル
1-2. ActivityAttributes の設計
ActivityAttributes プロトコルの最も重要な概念は「静的データ(attributes) 」と「動的データ(ContentState) 」の分離です。
import ActivityKit
// 配達追跡アプリを例に設計する
struct DeliveryAttributes : ActivityAttributes {
// 静的データ:Live Activity 開始時に固定される情報
public struct ContentState : Codable , Hashable {
// 動的データ:update() で随時変更可能
var status: DeliveryStatus
var estimatedArrival: Date
var currentLocation: String
var progressPercentage: Double // 0.0 ~ 1.0
}
// 静的フィールド(attributes)
var orderID: String
var restaurantName: String
var itemSummary: String
}
enum DeliveryStatus : String , Codable {
case preparing = "準備中"
case pickedUp = "集荷済み"
case nearBy = "近くに来ています"
case delivered = "配達完了"
}
Antigravity に「ActivityAttributes を設計して、配達追跡の Live Activity を作りたい」とプロンプトすると、このような雛形を自動生成してくれます。さらに「Codable 準拠を確認して、Hashable に必要な == 演算子も生成して」と続けることで、コンパイルエラーのない完全なモデルを素早く得られます。
1-3. Antigravity でのモデル設計のコツ
Antigravity を使ったモデル設計で特に有効なプロンプトパターンがあります:
# Antigravity へのプロンプト例
「SwiftUI の Live Activities 用に ActivityAttributes を設計してください。
条件:
- アプリ名:FoodRunner(フードデリバリー)
- 静的データ:注文ID、店舗名、注文内容のサマリー
- 動的データ:配達ステータス、推定到着時刻、現在地点名、進捗率
- iOS 26 の新しい pushToken 更新方式に対応
- Sendable 準拠も含める」
このプロンプトで Antigravity が生成するコードは コンパイルエラーがほぼなく 、iOS 26 の API 変更にも対応しています。モデル設計の試行錯誤に費やす時間を 70% 以上削減できた経験があります。
第2章:Live Activity の開始・更新・終了
2-1. Live Activity の開始
import ActivityKit
class LiveActivityManager {
static let shared = LiveActivityManager ()
// Live Activity を開始する
func startDeliveryActivity ( order : Order) async throws -> Activity<DeliveryAttributes> {
// まず許可状態を確認
guard ActivityAuthorizationInfo ().areActivitiesEnabled else {
throw LiveActivityError.notAuthorized
}
let attributes = DeliveryAttributes (
orderID : order.id,
restaurantName : order.restaurantName,
itemSummary : order.itemsSummary
)
let initialContentState = DeliveryAttributes. ContentState (
status : .preparing,
estimatedArrival : order.estimatedDeliveryTime,
currentLocation : "店舗で準備中" ,
progressPercentage : 0.1
)
let content = ActivityContent (
state : initialContentState,
staleDate : Calendar.current. date ( byAdding : .minute, value : 30 , to : .now)
)
// iOS 16.2+ では pushType を指定してサーバーサイドプッシュにも対応
let activity = try Activity. request (
attributes : attributes,
content : content,
pushType : .token // APNs 経由の更新を有効化
)
// pushToken を取得してサーバーに送信
Task {
for await tokenData in activity.pushTokenUpdates {
let tokenString = tokenData. map { String ( format : "%02x" , $0 ) }. joined ()
await sendPushTokenToServer ( token : tokenString, activityID : activity.id)
}
}
return activity
}
// Live Activity を更新する
func updateDeliveryActivity (
activity : Activity<DeliveryAttributes>,
newStatus : DeliveryStatus,
location : String ,
progress : Double
) async {
let updatedState = DeliveryAttributes. ContentState (
status : newStatus,
estimatedArrival : activity.content.state.estimatedArrival,
currentLocation : location,
progressPercentage : progress
)
let updatedContent = ActivityContent (
state : updatedState,
staleDate : Calendar.current. date ( byAdding : .minute, value : 15 , to : .now)
)
await activity. update (updatedContent)
}
// Live Activity を終了する
func endDeliveryActivity ( activity : Activity<DeliveryAttributes>) async {
let finalState = DeliveryAttributes. ContentState (
status : .delivered,
estimatedArrival : .now,
currentLocation : "配達完了" ,
progressPercentage : 1.0
)
let finalContent = ActivityContent (
state : finalState,
staleDate : nil
)
// 配達完了後 5 分でロックスクリーンから消える
await activity. end (finalContent, dismissalPolicy : . after (.now + 300 ))
}
}
enum LiveActivityError : Error {
case notAuthorized
case activityNotFound
}
2-2. バックグラウンド更新の実装
Live Activities の真価はサーバーサイドからのプッシュ更新です。APNs を使って、アプリがバックグラウンドまたは終了した状態でも更新できます。
// AppDelegate または App struct で pushToken を監視
func observePushTokenUpdates ( activity : Activity<DeliveryAttributes>) {
Task {
for await tokenData in activity.pushTokenUpdates {
let tokenHex = tokenData. map { String ( format : "%02x" , $0 ) }. joined ()
// サーバー API にトークンを送信
var request = URLRequest ( url : URL ( string : "https://api.yourapp.com/live-activity/token" ) ! )
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.httpBody = try? JSONEncoder (). encode ([
"activityID" : activity.id,
"pushToken" : tokenHex,
"bundleID" : Bundle.main.bundleIdentifier ?? ""
])
_ = try? await URLSession.shared. data ( for : request)
}
}
}
サーバーサイドから APNs に送るペイロードの形式はこちらです:
{
"aps" : {
"timestamp" : 1712000000 ,
"event" : "update" ,
"content-state" : {
"status" : "pickedUp" ,
"estimatedArrival" : "2026-04-03T12:30:00Z" ,
"currentLocation" : "〇〇通り付近" ,
"progressPercentage" : 0.6
},
"stale-date" : 1712003600 ,
"alert" : {
"title" : "配達員が集荷しました" ,
"body" : "あと約15分で到着予定です"
}
}
}
第3章:Dynamic Island の UI 設計
Dynamic Island は iPhone 14 Pro 以降で利用可能な、ノッチ代わりのインタラクティブなディスプレイ領域です。3つの表示モードを意図的に使い分ける設計が、Dynamic Island を活かす分かれ目になります。
3-1. 3つの表示モードの使い分け
// Widget Extension の LiveActivity.swift
struct DeliveryLiveActivity : Widget {
var body: some WidgetConfiguration {
ActivityConfiguration ( for : DeliveryAttributes. self ) { context in
// ロックスクリーン・スタンバイ表示
LockScreenView ( context : context)
} dynamicIsland : { context in
DynamicIsland {
// 展開時(ユーザーがタップして展開した状態)
DynamicIslandExpandedRegion (.leading) {
ExpandedLeadingView ( context : context)
}
DynamicIslandExpandedRegion (.trailing) {
ExpandedTrailingView ( context : context)
}
DynamicIslandExpandedRegion (.bottom) {
ExpandedBottomView ( context : context)
}
DynamicIslandExpandedRegion (.center) {
ExpandedCenterView ( context : context)
}
} compactLeading : {
// コンパクト左側(通話中の赤い点のような位置)
Image ( systemName : deliveryIcon ( for : context.state.status))
. foregroundStyle (.orange)
} compactTrailing : {
// コンパクト右側
Text (context.state.progressPercentage. formatted (.percent. precision (. fractionLength ( 0 ))))
. font (.caption2. bold ())
. foregroundStyle (.white)
} minimal : {
// 最小表示(他の Live Activity がある場合)
Image ( systemName : "bag.fill" )
. foregroundStyle (.orange)
}
. keylineTint (.orange)
}
}
func deliveryIcon ( for status: DeliveryStatus) -> String {
switch status {
case .preparing : return "fork.knife"
case .pickedUp : return "bicycle"
case .nearBy : return "location.fill"
case .delivered : return "checkmark.circle.fill"
}
}
}
3-2. ロックスクリーン UI の設計
struct LockScreenView : View {
let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack ( spacing : 12 ) {
// ヘッダー
HStack {
Image ( systemName : "bag.fill" )
. foregroundStyle (.orange)
Text (context.attributes.restaurantName)
. font (.headline)
Spacer ()
Text (context.state.status. rawValue )
. font (.caption)
. padding (.horizontal, 8 )
. padding (.vertical, 4 )
. background ( statusColor (context.state.status). opacity ( 0.2 ))
. foregroundStyle ( statusColor (context.state.status))
. clipShape ( Capsule ())
}
// 進捗バー
VStack ( alignment : .leading, spacing : 4 ) {
ProgressView ( value : context.state.progressPercentage)
. tint (.orange)
HStack {
Text (context.state.currentLocation)
. font (.caption2)
. foregroundStyle (.secondary)
Spacer ()
// 到着予定時刻
Text (context.state.estimatedArrival, style : .relative)
. font (.caption2. bold ())
+ Text ( "後に到着予定" )
. font (.caption2)
}
}
// 注文内容サマリー
Text (context.attributes.itemSummary)
. font (.caption2)
. foregroundStyle (.secondary)
. lineLimit ( 1 )
}
. padding ( 16 )
. activityBackgroundTint (Color.black. opacity ( 0.8 ))
. activitySystemActionForegroundColor (.white)
}
func statusColor ( _ status: DeliveryStatus) -> Color {
switch status {
case .preparing : return .blue
case .pickedUp : return .orange
case .nearBy : return .green
case .delivered : return .gray
}
}
}
3-3. Dynamic Island 展開時の UI
struct ExpandedBottomView : View {
let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack ( spacing : 8 ) {
// 進捗バー(展開時はより詳細に)
HStack {
ForEach (DeliveryStatus.allCases, id : \. self ) { step in
Circle ()
. fill ( isCompleted (step, current : context.state.status) ? Color.orange : Color.gray. opacity ( 0.3 ))
. frame ( width : 8 , height : 8 )
if step != DeliveryStatus.allCases. last {
Rectangle ()
. fill ( isCompleted (step, current : context.state.status) ? Color.orange : Color.gray. opacity ( 0.3 ))
. frame ( height : 2 )
}
}
}
HStack {
VStack ( alignment : .leading) {
Text ( "現在地" )
. font (.caption2)
. foregroundStyle (.secondary)
Text (context.state.currentLocation)
. font (.caption. bold ())
}
Spacer ()
VStack ( alignment : .trailing) {
Text ( "到着予定" )
. font (.caption2)
. foregroundStyle (.secondary)
Text (context.state.estimatedArrival, style : .time)
. font (.caption. bold ())
. foregroundStyle (.orange)
}
}
}
. padding (.horizontal, 16 )
. padding (.bottom, 12 )
}
func isCompleted ( _ step: DeliveryStatus, current : DeliveryStatus) -> Bool {
let order: [DeliveryStatus] = [.preparing, .pickedUp, .nearBy, .delivered]
guard let stepIndex = order. firstIndex ( of : step),
let currentIndex = order. firstIndex ( of : current) else { return false }
return stepIndex <= currentIndex
}
}
extension DeliveryStatus : CaseIterable {}
第4章:Antigravity を活用した実装加速テクニック
4-1. SwiftUI プレビューで Dynamic Island を確認する
Xcode 26 では Dynamic Island のプレビューが大幅に改善されています。Antigravity を使って、プレビューコードを効率的に生成しましょう。
// Antigravity に「DeliveryAttributes の全状態をプレビューするコードを生成して」
#Preview ( "Dynamic Island - Compact" , as : . dynamicIsland (.compact),
using : DeliveryAttributes.preview) {
DeliveryLiveActivity ()
} contentStates : {
DeliveryAttributes.ContentState.preparing
DeliveryAttributes.ContentState.pickedUp
DeliveryAttributes.ContentState.nearBy
DeliveryAttributes.ContentState.delivered
}
#Preview ( "Dynamic Island - Expanded" , as : . dynamicIsland (.expanded),
using : DeliveryAttributes.preview) {
DeliveryLiveActivity ()
} contentStates : {
DeliveryAttributes.ContentState.nearBy
}
#Preview ( "Lock Screen" , as : .content, using : DeliveryAttributes.preview) {
DeliveryLiveActivity ()
} contentStates : {
DeliveryAttributes.ContentState.preparing
DeliveryAttributes.ContentState.pickedUp
}
// プレビュー用の拡張
extension DeliveryAttributes {
static var preview: DeliveryAttributes {
DeliveryAttributes (
orderID : "ORD-2026-001" ,
restaurantName : "さくら食堂" ,
itemSummary : "唐揚げ定食、みそ汁"
)
}
}
extension DeliveryAttributes.ContentState {
static var preparing: DeliveryAttributes.ContentState {
. init ( status : .preparing,
estimatedArrival : Date (). addingTimeInterval ( 1800 ),
currentLocation : "さくら食堂で準備中" ,
progressPercentage : 0.1 )
}
static var pickedUp: DeliveryAttributes.ContentState {
. init ( status : .pickedUp,
estimatedArrival : Date (). addingTimeInterval ( 900 ),
currentLocation : "〇〇通り付近" ,
progressPercentage : 0.6 )
}
static var nearBy: DeliveryAttributes.ContentState {
. init ( status : .nearBy,
estimatedArrival : Date (). addingTimeInterval ( 180 ),
currentLocation : "あなたの近く" ,
progressPercentage : 0.9 )
}
static var delivered: DeliveryAttributes.ContentState {
. init ( status : .delivered,
estimatedArrival : Date (),
currentLocation : "配達完了" ,
progressPercentage : 1.0 )
}
}
4-2. よくあるエラーと Antigravity での解決法
エラー1: ActivityAttributes が Sendable に準拠していない
// エラーメッセージ
Stored property 'orderID' of 'Sendable'-conforming struct 'DeliveryAttributes' has non-sendable type 'String'?
Antigravity に「ActivityAttributes の Sendable 準拠エラーを修正して」とプロンプトすると、@unchecked Sendable の追加や、非 Sendable 型のラップ方法を提案してくれます。
エラー2: Widget Extension のターゲット設定ミス
ActivityConfiguration を使うには Widget Extension ターゲットの Info.plist に NSSupportsLiveActivities キーが必要です。Antigravity は Xcode のプロジェクト設定まで把握しているため、「Info.plist に必要なキーを追加するには?」と聞くと具体的な手順を教えてくれます。
エラー3: staleDate の設定ミス
staleDate を過去の日時に設定すると、Live Activity が即座に「古い情報」として表示されます。Antigravity に「staleDate の適切な計算方法を教えて」と聞くと、アプリの更新頻度に応じた推奨値を算出するコードを生成してくれます。
4-3. アニメーションで Live Activity を印象付ける
struct AnimatedProgressView : View {
let progress: Double
@State private var animatedProgress: Double = 0
var body: some View {
ProgressView ( value : animatedProgress)
. tint (.orange)
. animation (. spring ( response : 0.6 , dampingFraction : 0.8 ), value : animatedProgress)
. onAppear {
animatedProgress = progress
}
. onChange ( of : progress) { _ , newValue in
withAnimation (. spring ( response : 0.6 , dampingFraction : 0.8 )) {
animatedProgress = newValue
}
}
}
}
Live Activities における .animation() の注意点として、Widget Extension 内では withAnimation が制限されることがあります。Antigravity で「Live Activity 内で安全に使えるアニメーション手法」を確認すると、ContentTransition を活用した安全なアニメーション実装を提案してくれます。
第5章:本番環境への準備とテスト戦略
5-1. シミュレーター vs 実機テスト
Live Activities はシミュレーターでの動作確認が可能ですが、Dynamic Island の確認は 実機推奨 です。以下のテスト戦略を Antigravity と組み合わせて実施します:
// デバッグ用:状態を素早く切り替えるテストヘルパー
# if DEBUG
class LiveActivityTestHelper {
static func simulateDeliveryProgress () async {
guard let activity = Activity < DeliveryAttributes > .activities. first else { return }
let states: [(DeliveryStatus, String , Double )] = [
(.preparing, "さくら食堂で準備中" , 0.1 ),
(.pickedUp, "渋谷区道玄坂" , 0.5 ),
(.nearBy, "目的地まで200m" , 0.9 ),
(.delivered, "配達完了" , 1.0 )
]
for (status, location, progress) in states {
let state = DeliveryAttributes. ContentState (
status : status,
estimatedArrival : Date (). addingTimeInterval ( Double (states. count ) * 60 ),
currentLocation : location,
progressPercentage : progress
)
let content = ActivityContent ( state : state, staleDate : nil )
await activity. update (content)
try? await Task. sleep ( nanoseconds : 2_000_000_000 ) // 2秒待機
}
}
}
# endif
5-2. エラーハンドリングの実装
// 本番品質のエラーハンドリング
func startActivityWithRetry ( order : Order, maxRetries : Int = 3 ) async throws -> Activity<DeliveryAttributes> {
var lastError: Error ?
for attempt in 1 ... maxRetries {
do {
return try await LiveActivityManager.shared. startDeliveryActivity ( order : order)
} catch let error as ActivityAuthorizationError {
switch error {
case .denied :
// ユーザーが許可を拒否 → 設定アプリへ誘導
throw LiveActivityError.notAuthorized
default:
lastError = error
if attempt < maxRetries {
try await Task. sleep ( nanoseconds : UInt64 ( pow ( 2.0 , Double (attempt))) * 1_000_000_000 )
}
}
} catch {
lastError = error
if attempt < maxRetries {
try await Task. sleep ( nanoseconds : 1_000_000_000 )
}
}
}
throw lastError ?? LiveActivityError.activityNotFound
}
5-3. App Store 審査チェックリスト
Live Activities の App Store 審査で引っかかりやすいポイントをまとめます:
目的の明確性 : Info.plist の NSSupportsLiveActivitiesFrequentUpdates(頻繁更新)を使う場合は、必要性を明確に説明できるよう審査メモを用意する
バッテリー消費への配慮 : 更新頻度が高すぎると審査で指摘されます。staleDate を適切に設定してバッテリー影響を最小化する
Dynamic Island の必須確認 : iPhone 14 Pro 未満のデバイスではロックスクリーン表示のみになることをアプリ内で案内する
プライバシー : Live Activity で表示する情報が、ロックスクリーンで他人に見えることへの考慮
終了処理 : アプリが削除された際の Live Activity クリーンアップ処理を実装する
第6章:高度な実装パターン
6-1. 複数の Live Activities を管理する
iOS 26 では同時に表示できる Live Activities の数が増加しました。複数を管理する場合は以下のパターンを使います:
// アクティブな Live Activity を安全に取得
func getActiveActivities () -> [Activity<DeliveryAttributes>] {
return Activity < DeliveryAttributes > .activities. filter {
$0 .activityState == .active || $0 .activityState == .stale
}
}
// 特定の注文IDに紐づく Live Activity を更新
func updateActivity ( forOrderID orderID: String , newState : DeliveryAttributes.ContentState) async {
guard let activity = Activity < DeliveryAttributes > .activities. first ( where : {
$0 .attributes.orderID == orderID
}) else { return }
let content = ActivityContent (
state : newState,
staleDate : Calendar.current. date ( byAdding : .minute, value : 20 , to : .now)
)
await activity. update (content)
}
6-2. SwiftUI と UIKit の混在プロジェクトへの統合
既存の UIKit アプリに Live Activities を追加する場合、Antigravity が特に役立ちます:
// UIKit の ViewController から Live Activity を管理
class OrderDetailViewController : UIViewController {
private var liveActivityTask: Task< Void , Never > ?
func startTracking ( order : Order) {
liveActivityTask = Task {
do {
let activity = try await startActivityWithRetry ( order : order)
// アクティビティの状態変化を監視
for await state in activity.activityStateUpdates {
await MainActor. run {
self . updateUI ( for : state)
}
}
} catch {
await MainActor. run {
self . showError (error)
}
}
}
}
override func viewDidDisappear ( _ animated: Bool ) {
super . viewDidDisappear (animated)
liveActivityTask ? . cancel ()
}
func updateUI ( for state: ActivityState) {
switch state {
case .active :
trackingStatusLabel. text = "ライブ追跡中"
case .stale :
trackingStatusLabel. text = "情報が古くなっています"
case .ended, .dismissed :
trackingStatusLabel. text = "追跡終了"
@unknown default:
break
}
}
}
公式ドキュメントには書かれていない、運用してから気づいた落とし穴
実装ガイドの大部分は WWDC セッションや Apple Developer Documentation で追えますが、リリース後に実機ユーザーへ届けた段階で初めて見えてくる挙動が幾つかあります。私が運用中のアプリ群(壁紙アプリ・癒し系アプリ・引き寄せ系アプリ群、累計 5,000 万 DL)で実際に効いた対策を、観測した数値とセットで共有します。
7-1. APNs の Live Activity 更新は意外と落ちる
サーバーから送った Live Activity 更新が「無声」に落ちる現象は、設計レビュー段階で軽視されがちですが、本番環境では 3〜5% 程度の確率で起きました。私のアプリでログを集めた範囲では、原因の比率はおおむね以下のようになっていました。
apns-priority: 10 を指定し忘れ(即時配信になっていない)— 約 40%
content-state の Codable 不一致(フィールド名がキャメル⇄スネークでズレる)— 約 30%
apns-push-type: liveactivity ヘッダ漏れ — 約 15%
pushToken のローテーション後にサーバー側が古いトークンで送っている — 約 10%
それ以外(APNs 側の一時的なエラー)— 約 5%
「意外と落ちる」ものとして設計しておく、というのが運用 1 年やってきての結論です。具体的には、Live Activity 経由で大事な情報を一度だけ伝えるという設計は避け、必ずアプリ本体の通知(バナー)か、アプリ起動時のフェッチでも同じ情報を取得できるように二重化しました。
// ❌ Live Activity の更新だけに依存する設計
func notifyDeliveryArrival ( activity : Activity<DeliveryAttributes>) async {
try? await activity. update ( using : . init ( status : .arrived, eta : 0 ))
}
// ✅ Live Activity + 通常通知の二重化
func notifyDeliveryArrival ( activity : Activity<DeliveryAttributes>) async {
try? await activity. update ( using : . init ( status : .arrived, eta : 0 ))
// 重要な状態遷移は UNUserNotificationCenter からも送る
let content = UNMutableNotificationContent ()
content.title = "配達が完了しました"
content.body = "玄関先に置きました"
content. sound = .default
let request = UNNotificationRequest (
identifier : "delivery_arrived_ \( activity. id ) " ,
content : content,
trigger : nil
)
try? await UNUserNotificationCenter. current (). add (request)
}
7-2. staleDate を入れないと「過去の現在」が表示される
これは最初のリリースで私がやってしまった失敗です。staleDate を nil のまま運用していたら、アプリを終了して数時間経った後にロックスクリーンを見ると「ETA 3分」のような古い情報が「いまの状況」のように表示され続けていました。
ユーザーから「もう届いたはずなのに、まだ配達中になっている」という問い合わせが来て、その時点で気づいて修正しました。staleDate には「この時点を過ぎたら情報は古い」とシステムに伝える役割があり、過ぎた瞬間に UI を「stale 表示」用のレイアウトに切り替えられます。
// 開始時に staleDate を「想定される最大の有効時間」で設定
let staleDate = Date (). addingTimeInterval ( 60 * 60 ) // 1時間後
let content = ActivityContent (
state : DeliveryAttributes. ContentState ( status : .delivering, eta : 15 ),
staleDate : staleDate,
relevanceScore : 50
)
let activity = try Activity < DeliveryAttributes > . request (
attributes : attributes,
content : content,
pushType : .token
)
stale になったときの UI は、ロックスクリーン側で context.isStale を見て表示を切り替えます。私はグレーアウト + 「最新情報を取得中…」というメッセージに置き換える設計にしています。
ActivityConfiguration ( for : DeliveryAttributes. self ) { context in
if context.isStale {
DeliveryStaleView () // グレーアウト + 取得中表示
} else {
DeliveryLockScreenView ( state : context.state)
}
}
ここを入れるだけで、ユーザー問い合わせのうち「届いたのに表示が直らない」系の件数が体感で 1/3 以下に減りました。
7-3. pushToken のローテーションは思ったより頻繁に起こる
Activity.pushTokenUpdates は、Live Activity が開始されるたびに pushToken をストリーミングしてきます。Apple のドキュメントには「ローテーションされる可能性がある」と一文書かれていますが、運用してみると 同一 Activity の途中でローテーションが発生する ケースもありました。
私のアプリでは、トークンが届くたびに即座にサーバーへ POST して上書きする実装にしています。古いトークンをサーバーが掴んだままだと、その Activity への更新は全て静かに落ちます。
Task {
for await tokenData in activity.pushTokenUpdates {
let token = tokenData. map { String ( format : "%02x" , $0 ) }. joined ()
// 古いトークンを置き換えるため、activity.id を主キーに upsert
try await DeliveryAPIClient.shared. upsertPushToken (
activityId : activity.id,
token : token,
appVersion : Bundle.main.appVersion
)
}
}
サーバー側は activity.id を主キーにしておき、新しい token が来たら旧 token は破棄します。私のアプリでは Cloudflare Workers + KV で activity_token:{activity_id} というキーで管理しており、TTL は 24 時間としています。
7-4. Dynamic Island のコンパクト表示は「数字だけ」「アイコンだけ」に割り切る
ここは設計の話なのですが、Dynamic Island のコンパクト領域は左右合わせて実測で 16〜20pt 程度しか入りません。コンパクト領域に「ETA 15分」のような複合表現を入れようとして潰れる例を多く見ます。
私が運用している壁紙系アプリの実体験では、コンパクト表示は 片側 1 要素ずつ・最大 3 文字 に割り切ったとき、ユーザーから「分かりやすい」というフィードバックが圧倒的に増えました。
DynamicIsland {
// expanded — 情報量を最も載せられる
...
} compactLeading : {
Image ( systemName : "shippingbox.fill" ) // ← アイコン1個だけ
. foregroundStyle (.blue)
} compactTrailing : {
Text ( "15m" ) // ← 数字 + 最小限の単位
. monospacedDigit ()
. font (.caption)
} minimal : {
Image ( systemName : "shippingbox.fill" ) // ← 最も小さいモードはアイコンのみ
}
「数字 + 単位 1 文字」というルールに揃えると、Dynamic Island 内で情報が崩れず、複数の Live Activity が同時起動したときの最小表示モードへの遷移も綺麗に見えます。
7-5. App Store 審査では「いつ終わるのか」を必ず問われる
私の経験では、Live Activity を含むアプリは審査時に「この Live Activity は何分以内に自動で終わるのか」「ユーザーが手動で終了する方法はあるか」を高確率でレビューされます。実機検証メモに必ず以下を書いておくと、リジェクト率が体感で大きく下がりました。
staleDate で何時間後に自動で「stale 表示」に切り替わるか
Activity.end(_:dismissalPolicy:) をどのタイミングで呼んでいるか
ロックスクリーン UI 内に「終了する」ボタンが存在するか、あるいはアプリ内のどこから終了できるか
Live Activity をユーザーの意思で終了できる導線が見当たらない場合、現状 (2026 年 5 月時点) の審査ガイドラインでは Guideline 4.1 系で指摘される事例が報告されています。アプリ内に「現在進行中の Live Activity」を一覧表示し、そこから個別に終了できる設定画面を入れておくのが安全です。
struct ActiveLiveActivityListView : View {
@State private var activities: [Activity<DeliveryAttributes>] = []
var body: some View {
List (activities, id : \.id) { activity in
VStack ( alignment : .leading) {
Text ( "配達 # \( activity. id . prefix ( 6 ) ) " )
Text (activity.content.state.status.label)
. font (.caption)
. foregroundStyle (.secondary)
}
. swipeActions {
Button ( "終了" , role : .destructive) {
Task {
await activity. end ( nil , dismissalPolicy : .immediate)
}
}
}
}
. task {
for await update in Activity < DeliveryAttributes > .activityUpdates {
activities = Activity < DeliveryAttributes > .activities
_ = update
}
}
}
}
Antigravity Planning Mode と Sandbox の使い方(実運用テンプレート)
Live Activity の実装は、アプリ本体・Widget Extension・サーバー側 APNs 処理の 3 層にまたがります。私は Antigravity の Planning Mode を、各層の責務分割と「最初に書くファイル」を決めるための地図として使っています。
8-1. Planning Mode に渡しているプロンプト雛形
私は iOS の個人開発者で、既存のアプリ(SwiftUI / iOS 17+ ターゲット)に Live Activity を後付けしたいと考えています。
要件:
- 配達追跡(status: 受付中 / 配達中 / 完了、ETA を分単位で表示)
- バックエンドは Cloudflare Workers + KV
- 既存の WidgetKit Widget あり(ホーム画面用)
以下の観点で計画を立ててください:
1. 追加すべきファイル一覧(Xcode target ごとに)
2. ActivityAttributes / ContentState のフィールド定義
3. APNs 連携で必要なヘッダー・ペイロード形式
4. staleDate の設計方針
5. テスト戦略(シミュレータ・実機・サンドボックス)
6. App Store 審査で確認されそうな観点
返ってきた計画をそのまま実装に落とすのではなく、私は「7-1〜7-5 で挙げた運用上の落とし穴を踏まえているか」を必ず人間側でレビューしています。Antigravity は公式ドキュメントベースの計画を出すので、現場で起きた「落ちる確率」までは織り込めません。そこは私の経験値を後から足す、という分担にしています。
8-2. Sandbox での挙動確認チェックリスト
実装途中で Antigravity Sandbox に投げるときは、以下のチェックリストを毎回貼っています。
ActivityAttributes.ContentState が Codable と Sendable を満たしているか
APNs payload の content-state の JSON キー名が ContentState の CodingKeys と一致しているか
pushTokenUpdates の購読が Task 内で適切に保持されているか
staleDate が設定されているか / 設定されていない場合は意図的か
Dynamic Island の compact / minimal モードが iPhone 14 Pro 実機で崩れていないか
この 5 つを Sandbox の自然言語チェックに毎回流すだけで、実装中のリグレッションの 8 割は防げています。
8-3. 既存 WidgetKit Widget との共存
既存のホーム画面 Widget を持っているアプリに Live Activity を後付けする場合、私のアプリで効いたのは 共通の Intent / Configuration を一つにまとめる やり方でした。
@main
struct AppWidgets : WidgetBundle {
var body: some Widget {
// 既存のホーム画面 Widget
DeliveryHomeWidget ()
// 後から追加した Live Activity
DeliveryLiveActivity ()
}
}
WidgetBundle でまとめておくと、新しい Live Activity 用のターゲットを増やさずに済み、Stripe / Firebase / KeychainAccess などの依存をビルドターゲットごとに重複させずに済みます。
観測すべき指標(私のダッシュボードに常時並べているもの)
Live Activity を運用するときに、私が Mixpanel と Firebase の混在ダッシュボードに必ず並べているメトリクスは以下です。アプリ事業として AdMob 月次収益と紐づけてみていて、Live Activity を入れたアプリは平均してリテンションが 1.1〜1.3 倍になっていました。
Live Activity 起動数 / DAU 比率 — 全 DAU のうち、その日 1 回でも Live Activity を起動したユーザーの割合
平均アクティブ継続時間 — Activity.activityState の .active 区間の累計
APNs 更新成功率 — サーバー側ログ。200 OK 以外を全部失敗としてカウント
stale 表示への遷移率 — context.isStale == true で初めて UI 切り替えたタイミングをアプリ側からイベント送信
手動 end 率 vs 自動 end 率 — 「ユーザーが終わらせた」割合がどれだけあるか
特に 4 と 5 は、設計の妥当性を測る指標として有用でした。stale 表示への遷移率が極端に高ければ staleDate の設計が雑だったということ、手動 end 率が低すぎれば「Live Activity が長く残りすぎている」可能性が高い、というふうに使っています。
次のステップ
Live Activity は、ロックスクリーンと Dynamic Island という iOS の中で最も目に入りやすい場所を占有する機能です。それゆえに「常駐させてもユーザーが嫌がらない情報量」と「終わらせ方」の設計に、機能の数倍の時間をかける価値があります。
この記事を読み終えたら、まず以下のどれか一つから着手することをおすすめします。
既存のホーム画面 Widget があるなら、WidgetBundle に Live Activity を追加して 1 ステータスだけ表示してみる
既存の通知の中で「タイムラインがあるもの」を 1 つ選び、それを Live Activity にリプレースしてみる
Antigravity Planning Mode に上記 8-1 のプロンプト雛形を貼って、自分のアプリ固有の計画を作ってもらう
WidgetKit との組み合わせを深めたい方は WidgetKit を組み合わせたホーム画面ウィジェット を、オンデバイス AI と組み合わせて表示内容を賢くしたい方は Core ML を活用したオンデバイスAI を続けて読んでいただくと、Live Activity 単体では得られない実装の引き出しが広がります。
Live Activity に直接踏み込んだ書籍はまだ少ないので、本記事のような実装ノウハウは現場で蓄えていくのが現実的だと感じています。
私自身、まだ Live Activity を入れた段階で「もっとこうしておけば」と感じる場面が日々あります。同じように個人開発でアプリを育てている方の、明日からの実装の足がかりになっていれば嬉しいです。最後までお読みいただきありがとうございました。