朝の瞑想アプリを自分で使っていて、いちばん不満だったのは「残り時間を見るためだけにアプリを開き直す」動作でした。目を閉じて呼吸を整えている最中に、画面を点けてアプリに戻る。それだけで集中が切れます。私自身、個人開発で癒し系のアプリをいくつか App Store で運営していますが、タイマー系の機能はこの「開き直し」をどれだけ減らせるかが体験の質に直結します。
Rork Max が出力するのは本物の Swift なので、ここは iOS の Live Activities に素直に乗せられます。ロック画面に残り時間のカードを出し、対応端末ではダイナミックアイランドにも畳んで表示する。瞑想タイマーを題材に、ActivityKit を使った実装を、つまずいた順番で並べていきます。配送状況やスポーツのスコアなど、ほかの「短時間ずっと気になる情報」にもそのまま応用できます。
Live Activitiesが向いている情報とそうでない情報
最初に線引きをしておきます。Live Activities は「数分から数時間のあいだ、刻々と変わる一つのイベント」を出すための仕組みです。瞑想の残り時間、料理のタイマー、配車の到着、注文の調理状況などが典型です。
逆に、通知でいい情報をここに載せてはいけません。たとえば「新着記事が3件あります」のような一覧性の情報は Live Activities の設計思想と合いませんし、審査でも指摘されます。私が最初に作ったときは欲張って複数の状態を一枚に詰め込みましたが、結局いちばん見たい残り時間が小さくなって本末転倒でした。一画面に出す主役は一つに絞ることを強く推奨します。
まず器を用意する 〜Info.plistとWidget Extension
ActivityKit を動かすには下準備が要ります。手順は次の3つです。
アプリ本体の Info.plist に NSSupportsLiveActivities を YES で追加します。これがないと、後述の Activity.request が静かに失敗します。
Live Activities の見た目は Widget Extension の中に書きます。Xcode のターゲット追加から Widget Extension を作り、ActivityConfiguration を実装します。
アプリ本体と Widget Extension で ActivityAttributes の型定義を共有します。別々に書くと、デコードが食い違って更新だけが無言で落ちます。
Rork Max が生成したプロジェクトに Widget Extension が無い場合は、自分で一つ足す必要があります。ここは生成任せにせず、手で確認したほうが確実です。
状態の型を決める
ActivityKit では、アクティビティが持つデータを ActivityAttributes で定義します。変化しない値(セッションの種類など)と、更新で書き換わる値(残り秒数など)を分けて持たせるのが肝心です。
import ActivityKit
struct MeditationAttributes : ActivityAttributes {
// 更新で書き換わる動的な状態
public struct ContentState : Codable , Hashable {
var remainingSeconds: Int
var phase: String // "breathing" / "rest" など
}
// セッション開始時に固定する静的な値
var sessionTitle: String
var totalSeconds: Int
}
ここで意識したいのは、ContentState に載せる JSON が 16KB を超えると更新が拒否される点です。画像のBase64文字列のような重いデータを直接持たせず、識別子だけを渡してウィジェット側で描画する。これを守らないと、TestFlight では小さなデータで通っていたのに、本番で長いテキストを載せた瞬間に更新が落ちる、という再現しづらい落とし穴になります。
start / update / end の3手順
アクティビティのライフサイクルは、開始・更新・終了の3つだけです。瞑想セッションを開始する関数はこうなります。
import ActivityKit
final class MeditationLiveActivity {
private var activity: Activity<MeditationAttributes> ?
func start ( title : String , total : Int ) {
// 端末側で Live Activities が許可されているか必ず確認する
guard ActivityAuthorizationInfo ().areActivitiesEnabled else { return }
let attributes = MeditationAttributes ( sessionTitle : title, totalSeconds : total)
let initial = MeditationAttributes. ContentState (
remainingSeconds : total, phase : "breathing"
)
do {
activity = try Activity. request (
attributes : attributes,
content : . init ( state : initial, staleDate : nil ),
pushType : nil // ローカル更新のみ。Push更新は後述
)
} catch {
// ここは握りつぶさず、必ずログに残す
print ( "Live Activity start failed: \( error ) " )
}
}
func update ( remaining : Int , phase : String ) {
let state = MeditationAttributes. ContentState (
remainingSeconds : remaining, phase : phase
)
Task {
await activity ? . update (. init ( state : state, staleDate : nil ))
}
}
func end () {
Task {
await activity ? . end ( nil , dismissalPolicy : .immediate)
}
}
}
areActivitiesEnabled のチェックを省くと、設定でオフにしているユーザーの端末で Activity.request が例外を投げます。私はここを最初に握りつぶしていて、「一部のユーザーだけタイマーが出ない」という問い合わせへの対処に半日かかりました。例外は必ずログに出すことを、Live Activities に限らず本番運用の基本だと考えています。
なお更新は毎秒呼ぶ必要はありません。私は残り時間の表示を5秒間隔に間引いていて、それだけでこの機能まわりのバッテリー消費を体感で20〜30%ほど抑えられています。秒単位の見た目はウィジェット側のタイマー表示に任せ、状態更新は粗く送るのが実用的です。
ロック画面とダイナミックアイランドの描き分け
Widget Extension 側では、同じデータを二つのレイアウトに描き分けます。ロック画面用の大きいカードと、ダイナミックアイランドの畳んだ表示です。
import WidgetKit
import SwiftUI
struct MeditationWidget : Widget {
var body: some WidgetConfiguration {
ActivityConfiguration ( for : MeditationAttributes. self ) { context in
// ロック画面・バナー表示
VStack {
Text (context.attributes.sessionTitle)
Text ( format (context.state.remainingSeconds))
. font (. system ( size : 36 , weight : .semibold, design : .rounded))
. monospacedDigit ()
}
. padding ()
} dynamicIsland : { context in
DynamicIsland {
DynamicIslandExpandedRegion (.center) {
Text ( format (context.state.remainingSeconds)). monospacedDigit ()
}
} compactLeading : {
Image ( systemName : "leaf.fill" )
} compactTrailing : {
Text ( format (context.state.remainingSeconds)). monospacedDigit ()
} minimal : {
Image ( systemName : "leaf.fill" )
}
}
}
}
monospacedDigit() を付けるかどうかで、秒数が1桁減るたびに数字がガタつくかどうかが変わります。細かい点ですが、瞑想中にチラチラ動く表示はノイズなので、私はタイマー系では必ず等幅数字を採用します。
ローカル更新とPush更新、どちらを選ぶか
更新には二通りあります。アプリがフォアグラウンド、もしくはバックグラウンドで生きているうちに activity.update を呼ぶローカル更新と、APNs 経由でサーバーから差し込む Push 更新です。
瞑想タイマーのように、ユーザーが画面を見ているか、せいぜい数分の離席であれば、ローカル更新で十分です。一方、配送状況のようにアプリが完全に終了していても更新したい場合は、pushType: .token を指定して APNs から送る必要があります。
ここでよくあるのが、開発中はローカル更新で動いていたのに、本番で「アプリを閉じると残り時間が止まる」という相談です。これはバグではなく、ローカル更新の仕様です。完全終了後も動かしたいなら Push 更新へ切り替える、という設計判断を最初にしておくことを推奨します。後からの作り直しを避けられます。
8時間の上限と、消し忘れへの対処
Live Activities は開始から最大8時間で OS が自動的に終了させます。さらに、アプリ側で end を呼び忘れると、ロック画面に止まったタイマーが残り続けてユーザーを不安にさせます。
私が決めているルールはシンプルで、セッションを終える経路をすべて洗い出し、そのどこを通っても必ず end が呼ばれるようにすることです。正常終了、ユーザーによる中断、アプリのクラッシュからの復帰の3経路を必ず確認します。とくにクラッシュ後は、起動時に Activity.activities を走査して、宙ぶらりんのアクティビティを片付ける処理を入れておくと安全です。
func cleanupStaleActivities () {
for activity in Activity < MeditationAttributes > .activities {
Task { await activity. end ( nil , dismissalPolicy : .immediate) }
}
}
リリース前に確認したこと
申請まわりで私がチェックしているのは次の3点です。Live Activities は対応端末(ダイナミックアイランドは iPhone 14 Pro 以降)と非対応端末で見え方が変わるため、実機での確認を省けません。
設定アプリで Live Activities をオフにしたユーザーでもクラッシュしないか
機内モードや圏外で更新が届かないとき、表示が破綻しないか
セッションを途中で消したとき、ロック画面のカードが確実に消えるか
地味な確認ですが、ここを通しておくと審査リジェクトと低評価レビューの両方を減らせます。広告収益を AdMob で得ているアプリでも、こうした体験の作り込みが継続率を押し上げ、結果として表示回数を伸ばすという実感があります。
残り時間をそっとロック画面に置けるようになると、アプリは「開かせる」ものから「寄り添う」ものに近づきます。次の一歩として、自分のアプリで「アプリを開かずに確認したい情報」が一つだけ何かを書き出してみてください。それが Live Activities に載せる最初の主役になります。