Expoで堅牢なアクティビティトラッカーを構築する方法

TaskManager、Expo Modules、カルマンフィルタによるGPS補正を組み合わせ、アプリの強制終了やGPSノイズ、バックグラウンドでの停止に耐える歩行トラッカーをどう作るか。

日本語
コピー
How to build a resilient activity tracker with Expo

この記事の著者 Daniel Vinojčić は Calda のソフトウェアエンジニア。

機能だけを見れば単純だ。ユーザーがウォーキングセッションを開始し、歩数・距離・時間を記録し、目的地まで歩いて QR コードをスキャンして終了する。開始を押して、歩いて、スキャンするだけ。難しいのはその間に行われるすべてだ。セッションは最長 1 時間続き、その間にユーザーはアプリを切り替え、画面をロックし、電話にも出る。そうなれば OS はいつでも予告なくプロセスを殺せる。セッションの状態をメモリに置いていれば、状態もユーザーの進捗も消える。

そこで必要になるのが、バックグラウンドに回っても、アプリが殺されても、クラッシュしても生き残り、しかも Expo のワークフローから外れないセッションだ。以下がその実現方法である。

課題:モバイルでのアクティビティ追跡

最初の実装ではセッション状態をメモリに持っていた。アプリがフォアグラウンドにいる間は問題ない。壊れるのは次の 3 点だ。

  • バックグラウンド実行ができない: JavaScript の実行はアプリのライフサイクルに縛られる。ユーザーがアプリを離れた瞬間、OS は予告もなく、graceful な終了もなくプロセスをサスペンドまたは終了できる。

  • GPS のノイズとドリフト: 生の GPS データは信用できない。静止しているユーザーでも信号のドリフトで偽の距離が積み上がり、バスに乗っているユーザーは数キロ歩いたことにされる。

  • クラッシュ復帰がない: セッションの途中でアプリが殺されれば、30 分歩いた記録がそのまま消える。

ゴール:殺されないセッション

必要なのは、連携して動く 3 つの要素だ。

  • バックグラウンド追跡:アプリがフォアグラウンドになくても動き続ける。

  • GPS フィルタリング:車での移動ではなく、正確な歩行距離を出す。

  • 永続化と復元:殺されたときに状態を保ち、再起動後に復元する。

TaskManager によるバックグラウンド位置追跡

Expo の TaskManager を使うと、React のコンポーネントツリーの外で実行されるタスクを定義できる。expo-location と組み合わせ、モジュールレベルでバックグラウンドタスクを登録すれば、アプリがサスペンドされていても GPS の更新を受け取れる。

これがバックグラウンド追跡タスクの中核部分だ。

import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';

const BACKGROUND_LOCATION_TASK = 'background-location-task';
const FALLBACK_ACCURACY_METERS = 30;

const gpsFilter = new GPSFilter();
const distance = new DistanceAccumulator();
let seededSessionId: string | null = null;

TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }) => {
  if (error) return;

  const { locations } = (data ?? {}) as { locations?: Location.LocationObject[] };
  if (!locations?.length) return;

  const state = await loadActivityState();
  if (!state) return;

  if (seededSessionId !== state.sessionId) {
    seededSessionId = state.sessionId;
    distance.restore(Math.max(distance.totalKm, state.distanceKm ?? 0));
  }

  for (const raw of [...locations].sort((a, b) => a.timestamp - b.timestamp)) {
    const filtered = gpsFilter.process(
      {
        latitude: raw.coords.latitude,
        longitude: raw.coords.longitude,
        accuracy: raw.coords.accuracy ?? FALLBACK_ACCURACY_METERS,
        speed: raw.coords.speed,
        timestamp: raw.timestamp,
      },
      state.activityPausedAt != null
    );

    if (!filtered.accepted) continue;

    const speed = filtered.dopplerSpeedMs ?? filtered.speedMs;
    if (speed != null) {
      distance.addVelocitySample(speed, filtered.dtSeconds ?? 0);
    }
  }

  await updateActivityState({
    sessionId: state.sessionId,
    distanceKm: distance.totalKm,
    elapsedSeconds: deriveElapsedSeconds(state),
  });
});

ここに React はない。useState も hooks もコンポーネントツリーもない。タスクはストレージを直接読み書きし、新しいプロセスは最初の tick でモジュールの状態ではなく永続化されたスナップショットを信頼する。

ループは最新の 1 点だけを取るのではなく、届いたすべての位置情報を古い順に処理する。画面がロックされていると位置情報はまとめて配信されるので、最新の 1 件だけを取ると間の距離が抜け落ち、フィルタに渡す計測値が足りなくなる。

鍵になるのは再シードだ。メモリ圧迫でプロセスが回収され、その後サービスが再起動してデータを配信すると、JS バンドルは最初から実行し直され、モジュールレベルのアキュムレータはすべてゼロに戻る。この処理がなければ、セッションは保存済みの合計を失いはしない(書き込み経路は単調で、それは後述する)が、平坦な線になる。プロセスが殺された後に歩いた 1 メートルごとが、新しいアキュムレータが古い値を再び超えるまで捨てられる。どの殺され方なのかは明示しておく必要がある。ユーザーがアプリを終了した場合、バックグラウンド位置情報は止まり、Android が位置イベントをきっかけにアプリを再起動することもない。この空白は復元できない。だからこそ次の選択肢が効いてくる。

iOS では最適化された GPS の挙動を得るために activityType: ActivityType.Fitness を設定する。Android ではフォアグラウンドサービスの通知がタスクを生かし続ける。間違えやすいオプションが 2 つある。

await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
  accuracy: Location.Accuracy.High,
  timeInterval: 5000,
  distanceInterval: 0,
  foregroundService: {
    notificationTitle: 'Activity in progress',
    notificationBody: 'Tracking your walk…',
    killServiceOnDestroy: false,
  },
  activityType: Location.ActivityType.Fitness,
  showsBackgroundLocationIndicator: true,
  pausesUpdatesAutomatically: false,
});

killServiceOnDestroy: false は省略できない。これがなければ「フォアグラウンドサービスが生かしてくれる」という話は、ユーザーがアプリをスワイプで消すまでしか成り立たず、その後は何の音もなく成り立たなくなる。

distanceInterval はもっと厄介な罠だ。一見「妥当」に見える 10 メートルは最適化のように思えるが、Android では provider へのヒントではなくフィルタとして働く。サンプリングレートは 3 分の 1、場合によっては 4 分の 1 まで落ちる。しかも画面が消えていて位置情報が最も粗くなる場面で起きる。距離は時間で積分するものだから、ほぼ重複した点を間引くよりも安定したリズムのほうが勝る。

権限についても一言。このスタックでは軽く見られがちだからだ。iOS のバックグラウンド追跡には When In Use ではなく requestBackgroundPermissionsAsync による Always の許可が要る。Android には ACCESS_BACKGROUND_LOCATION が必要で、Android 14 以降は FOREGROUND_SERVICE_LOCATION も要る。expo-location は manifest のエントリを自動で追加するが、Google Play はバックグラウンド位置情報とフォアグラウンドサービスのどちらにも書面での説明と審査を求める。ストアに載せるには審査を通す必要があるので、コードを書く時間だけでなく審査の時間も見込んでおくこと。

権限だけが「付与済みなのに位置が取れない」ケースではない。デバイス全体の位置情報サービスが切れていて、アプリごとの権限は揃っている場合、startLocationUpdatesAsync は成功し、通知も出るのに、位置情報はいつまでも来ない。記録を始める前に Location.hasServicesEnabledAsync() を単独で確認する価値がある。

カルマンフィルタによる GPS のフィルタリング

初期のバージョンは 3 メートル閾値だった。3 メートル未満の移動はすべて無視する。これで静止時のドリフトは抑えられるが、本当の本番の問題は解決しない。バスや車に乗ったユーザーが数キロの「歩行」距離を積み上げてしまうのだ。あるユーザーは 1 日 8,600 歩しか歩いていないのに、128 キロを記録した。

そこで、フィットネス系アプリがぶれた位置情報を信頼できる軌跡に変えるのと同じ技術である、互いに独立したゲートの組に置き換えた。

精度ゲート:expo-location は更新のたびに精度をメートルで報告する。30 メートルを超えるものは捨て、精度が正でないものも捨てる(iOS は無効な位置情報に対して -1 を報告する)。ほとんどの実装はこのフィールドを無視していて、情報を無駄にしている。

速度ゲート: 速度が 8 m/s(約 29 km/h)を超えたら、その点は捨てる。これだけで、車両による距離の水増しの大半が消える。問題は、coords.speed が多くの Android の融合測位では null であり、iOS で無効なときは -1 になることだ。しかも負のセンチネル値は欠損より厄介で、speed < threshold のチェックはどれもそれを黙って静止と読んでしまう。プラットフォームが値を返し、それが非負ならそれを使う。そうでなければ変位を時間で割って速度を出す。どちらを使ったかは覚えておくこと——片方はドップラー測定で、もう片方は違う。この違いは後で効いてくる。

外れ値ゲート: 各測位点を、フィルタが予測した位置と比較する。マルチパスによる「テレポート」のスパイクは、精度が良好だと自称していても捨てる。3回連続で棄却されたら、それは実際の移動だ(GPS 断、トンネル出口)。この場合は永久にロックするのではなく、再アンカーする。

カルマンスムージング: 採用した点は各座標軸ごとに等速カルマンフィルタを通す。生の緯度経度ではなく、ローカルのメートル法投影上で動かす(経度1度は固定距離ではないが、市街地スケールではこの近似はほぼ問題にならない)。軸を独立に扱うと軸間の相関は無視することになるが、歩行軌跡の平滑化ならこの単純化は成り立つ。フィルタは推定速度で次の位置を予測し、実際の測定値で報告精度に応じた重みをつけて補正する。

次がほとんどの実装が間違えるステップで、我々も最初はそうだった:フィルタ後の位置をどう距離に変えるか。

最も素直な方法は、採用した隣接2点間の距離を足し上げるものだが、これには正のバイアスがつきまとう。|Δposition| はノルムであり、負にはならない。Kalman を通しても消えずに残るフィルタの揺らぎは、ランダムウォークの経路長のように積み上がっていく一方、正味の変位はゼロ付近にとどまる。最小変位のしきい値を設けても解決しない。実際のゆっくりした移動まで一緒に捨ててしまうだけだ。

そこで速度を時間積分する——distance += speed × dt、0.3 m/s のデッドゾーン付きでノイズには何も寄与させない。上限 8 m/s、dt には 10 秒のクランプをかける:

addVelocitySample(speedMs: number, dtSeconds: number): number {
  if (speedMs < MIN_INTEGRATION_SPEED_MS) return this.accumulatedKm;
  const speed = Math.min(speedMs, MAX_WALKING_SPEED_MS);
  const dt = Math.min(Math.max(dtSeconds, 0), MAX_PREDICT_DT_S);
  this.accumulatedKm += (speed * dt) / 1000;
  return this.accumulatedKm;
}

どの速度を積分するかのほうが、積分そのものより重要だ。フィルタの推定値は位置から来ているので位置の誤差を引き継ぐ。そして hypot(vN, vE)不確かな2つの成分のノルムだ——この不確かさは数値を押し上げる方向にしか働かず、決して押し下げない。しかも測位精度が落ちるほど大きくなる。Android が画面オフ時に返す 25〜30 m の測位精度では、ゆっくり歩く人にとって信号全体と同じ桁になる。

coords.speed のほうが良い値で、しかもタダで手に入る。GNSS はこれを位置の差分ではなく搬送波の周波数偏移から導くので、位置の誤差もマルチパスのドリフトも引き継がない。ほとんどの実装はこれを車両の除去にしか使わない。積分すべきはこちらであり、上のタスクは dopplerSpeedMs を優先し、OS が提供しないときだけフィルタにフォールバックする。

フィルタの外にもゲートが2つある。平滑化された軌跡は滑らかでも間違っていることがあるからだ。正味変位チェックは、スライディングウィンドウ内でデバイスが実際に動いたかどうかを問う——経路長は揺らぎのたびに膨らむが、正味変位は膨らまない——しきい値は測位精度が粗くなるほど大きくする。Android ではモーションアクティビティゲートが、デバイスが移動中と判定されていない間は距離を抑制する。Activity Recognition は加速度計とジャイロに基づくのでマルチパスは騙せないが、位置ベースのチェックは騙される。

expo-location は今 getMotionActivityAsyncwatchMotionActivityAsync を提供しており、同じプラットフォーム API をラップし、型ごとの信頼度が付く——ネイティブコードを書く前にこれを使うべきだ。我々が自前のものを残しているのは、Kotlin モジュール内部で歩数計がセンサーイベントを出すかどうかをネイティブに決めているため、この購読はどうしてもそこに存在する必要があるからだ。JS からもう一度読むのは二重に走らせることになる。

3層の永続化

ここでデータソースを1つに絞って、ストレージに書いて終わりにしたくなる。だが故障モードごとに必要な層が違うので、我々は3層で動かしている:

ストレージ耐えられるもの書き込み頻度
1Zustand(メモリ)何も耐えられない、要は RAMリアルタイム
2AsyncStorageアプリの kill、クラッシュ30秒ごと + バックグラウンド移行時
3Supabase端末の紛失、再インストール30秒ごと + バックグラウンド移行時

最も重要な瞬間は AppState がバックグラウンドに移るときで、OS が我々を殺す前に書ける最後の機会だ:

AppState.addEventListener('change', async (nextState) => {
  const state = useActivityStore.getState();
  if (state.phase !== 'running' && state.phase !== 'paused') return;

  if (nextState === 'inactive') {
    await state.persistToStorage();
  }

  if (nextState === 'background') {
    await state.persistToStorage();
    await flushRemoteProgress();
  }

  if (nextState === 'active') {
    const persisted = await loadActivityState();
    if (persisted && persisted.distanceKm > state.distanceKm) {
      state.updateDistance(persisted.distanceKm);
    }
  }
});

2つの書き手、1つの key

次の故障モードにはデバッグ時間を最も多く持っていかれた。しかも原因は OS の kill ではない。バックグラウンドタスクとフォアグラウンドの store が同じスナップショットを永続化するが、どちらも真実の一部しか握っていない。GPS 距離はタスク側に、歩数はフォアグラウンドのリスナー側にある。どちらか一方からスナップショット全体を書くと、もう一方が書いたばかりの内容を巻き戻してしまう——当時見えていた症状は一時停止したアクティビティが勝手に実行中へ戻るというもので、原因はバックグラウンドの書き込みが一時停止の確定より前の状態を読んでいたことだった。

2つのルールで解決する。同じ key に対する read-modify-write を直列化すること、そして上書きを決してしないこと——マージし、1回のセッションで後退してはいけない値は大きいほうを取る:

function mergeMonotonic(current: PersistedActivityState, patch: Partial<PersistedActivityState>) {
  const merged = { ...current, ...patch };
  if (patch.distanceKm != null) merged.distanceKm = Math.max(current.distanceKm ?? 0, patch.distanceKm);
  if (patch.steps != null) merged.steps = Math.max(current.steps ?? 0, patch.steps);
  return merged;
}

リモート側にも同じ扱いが要る。30秒ごとの自動保存はキューに入れなければならない。そうしないと古い書き込みが後から着地して、リーダーボードで読む数字を押し下げうる。普通の UPDATE は後書き優先だが、GREATEST(...) RPC は違う。単調書き込みは、バックグラウンドタスクのコールドスタートが安全であって破壊的でない理由でもある——ゼロに戻ったアキュムレータは保存済みの合計を消せない。進めなくなるだけで、それはまさに再シードが塞いでいる故障だ。

先に正直に言っておく。iOS で完全に kill されたアプリから永続データを読むのは、エッジケースが最も多い経路だ。AsyncStorage には長い問題の歴史があり、プロセスがすでに終了している状態でヘッドレスのバックグラウンドタスクから読むと null が返る。アプリがバックグラウンドに回っただけなら確実に動くが、kill された後の再起動こそ、このアーキテクチャ全体が依存しているシナリオだ。テスト時はアプリスイッチャーから強制終了すること。バックグラウンドに切り替えるだけでは不十分だ。もしこの問題に実際にぶつかったなら、バックグラウンドタスクがコールドスタート時に必ず読まなければならないデータには expo-sqliteexpo-file-system のほうが信頼できる。

再起動後の復元

アプリ起動時、ストレージに孤立したセッションがないか確認し、バックエンドと突き合わせる。微妙なのは、ネットワークが使えないとき「突き合わせる」とは何を意味するかだ:

async function initRecovery() {
  const persisted = await loadActivityState();
  if (!persisted) return;

  const { status, session } = await lookupSessionById(persisted.sessionId);

  const canRecover =
    status === 'unreachable' || (status === 'found' && session != null && !session.ended_at);

  if (canRecover) {
    setRecoveryData(persisted);
    setIsRecovering(true);
  } else {
    await clearActivityState();
  }
}

鍵になるのは三値の状態だ。素直に実装すると nullable なセッションを返すことになり、「サーバーがこのセッションは存在しないと言っている」と「サーバーに接続できない」が同じ値に潰れる。その結果、ユーザーがオフラインで再起動するたびに歩行記録が捨てられる——復元の仕組みが存在するまさにその場面で。どのバックエンドを使っていても、本当の空結果とリクエスト失敗は別の形で届く。両者を区別し、完全に不明なときはローカルスナップショットを既定にすること。

セッションが復元可能なら、ユーザーには続行するか破棄するかのプロンプトが出る。続行すると、永続化されたスナップショットから Zustand store を復元し、累計距離を戻し、resume モードでバックグラウンド位置情報トラッキングを再起動し(通常の起動ではアキュムレータがゼロになり、次の GPS コールバックで 0 km が永続化されてしまう)、iOS Live Activity をロック画面に再表示する。

ローカル状態とサーバー状態が食い違うときは、サーバーの ended_at を優先する。バックエンドがセッションはすでに閉じたと言っているなら、終了済みのセッションを復活させるのではなく、ローカルコピーを破棄する。

もうひとつ再起動が必要で、しかも忘れられがちなものがある。位置情報タスクそのものだ。攻撃的な OEM のバッテリー管理は、アプリを殺さずにフォアグラウンドサービスだけを殺すことがある。タスクを自動で再登録してくれるものは何もないので、残りのセッションは静かに 0 km を記録し続ける。フォアグラウンドに戻るたびにタスクがまだ登録されているか確認し、なければ resume モードで再起動する。

クロスプラットフォームの歩数計測

歩数計測は iOS と Android が最も大きく食い違う領域だ。iOS では expo-sensors が Core Motion ベースの高レベルな Pedometer API を公開している。現在の歩数セグメントウィンドウ内で getStepCountAsync() をポーリングする。Core Motion はシステムレベルで歩数を記録するので、このクエリはアプリがバックグラウンドにいる間に積み上がった歩数も返す。

Android にそんな近道はない。getStepCountAsync() は iOS 専用で、どちらのプラットフォームもバックグラウンドで歩数計の更新をプッシュしない。Expo のドキュメントは Android ユーザーに Health Connect を勧めていて、これは優先的に評価する価値がある——ただし必要なのはセッション単位のリアルタイムなカウントと、プロセスが kill されても生き残るベースラインなので、センサーを直接使った。

Kotlin でカスタム Expo Module を書き、SensorManager をラップした。Android の推奨プラクティス に従っている。まず STEP_COUNTER を試し(累積値で再起動時にしかリセットされないが、読み取りが数秒遅れてバッチ報告されることがある)、失敗したら STEP_DETECTOR にフォールバックする。どちらも Android 10+ では ACTIVITY_RECOGNITION の実行時パーミッションが必要だ。さらに ActivityRecognitionClient 経由で Google Play の Activity Recognition API を報告の前提条件にしている。デバイスが静止している、あるいは車両内にあると判定されたときは歩数をカウントしない。

ひとつの hook がプラットフォーム差を吸収する。その書き方に注目——ここもまた非同期とクリーンアップに噛まれる箇所だ:

function startAndroidStepTracking(): () => void {
  let isActive = true;
  const removeListener = stepTracker.addStepListener(onSteps);

  void (async () => {
    const started = await stepTracker.startTracking();
    if (!isActive && started) await stepTracker.stopTracking().catch(() => {});
  })();

  return () => {
    isActive = false;
    removeListener();
    void stepTracker.stopTracking();
  };
}

両プラットフォームの start 関数は同期的なクリーンアップ関数を返す。async 関数の結果を useEffect から返すのは、決して呼ばれない Promise を React に渡すのと同じで、ネイティブリスナーはコンポーネントのアンマウント後も生き残る。このリークは、ユーザーがとっくに終えたセッションに歩数が積み上がり続ける形で現れる。

リアルタイムリスナーはインメモリの状態と同じ弱点を持つ。プロセスが生きている間しかカウントしないので、攻撃的な OEM カスタム ROM では、画面ロック中の 20 分の歩行が静かに過少カウントされる。STEP_COUNTER がこれを解決する——起動時からの累積値で、センサー hub が維持するためプロセスとは無関係だ。セッションの最初のイベントでその値をベースラインとして記録し、以降フォアグラウンドに戻るたびに差分を取る。そのうえで GPS パイプラインが実際に歩行速度の移動を検出した時間を上限とする。こうすればフィルタされていないカウンターが合計を水増しできない。

結果

このシステムはしばらく本番環境で動いていて、実ユーザーが都市部で歩行記録を完了している。

  • セッションを一度も落としていない。 永続化、単調マージ、復元をやり切ってからは起きていない。

  • 距離の計測が正確。 精度と速度でゲートしてから速度を積分し、位置の差分を積み上げるのをやめたことで、車両による距離の水増しも低速時のゴースト的な加算も消えた——以前 1 日 128 km を記録していたユーザーは、今はまともな歩行データを表示している。

  • 距離が専用の記録デバイスと一致する。 位置から速度を逆算するのではなくプラットフォームの Doppler 速度を積分したことが、低速歩行時の差を埋めた——位置から逆算した速度は、まさに低速時に最も信頼できない。他の記録デバイスと並んで同じ道を歩くと、ほぼ同じ距離になる。

  • クリーンに復帰する。 バックグラウンドへの移動、プロセスの kill、クラッシュ、オンライン・オフラインのいずれにも耐える。ユーザーがアプリに戻ったときは、前回の位置から続きを再開する。

ほかにも失敗しうる場所

レジリエンスとは、プロセスが kill されても生き残ることだ。kill されないようにすることではないし、そもそも OS が常時実行を許すとは限らない。Android では、素のシステムは単純なケースで、変数を生むのは各 OEM のバッテリー管理だ。Xiaomi/MIUI、Huawei/EMUI、Samsung の One UI、Oppo、Vivo はどれも積極的なタスク清理の仕組みを積んでいて、やるべきことをすべてやっていてもフォアグラウンドサービスが止められることがある(dontkillmyapp.com がメーカー別にこの挙動を記録していて、Expo のバックグラウンドのドキュメントもそこにリンクしている)。こちらの緩和策は、常駐のフォアグラウンドサービス通知、killServiceOnDestroy: false、必要な機種でユーザーにバッテリー最適化の無効化を促すこと、そしてフォアグラウンドの自己修復チェック——だがこれらの端末では、継続実行はベストエフォートとしか見ていない。Doze は同じ圧力の穏やかな版で、素の Android にはどの端末にもある。

iOS はもっと予測しやすいが、制約がないわけではない。長いセッションは今でもサスペンドされうるし、iOS 16.4 以降は生き残るために継続更新を正しく設定する必要がある。kCLDistanceFilterNone、精度およそ 100 m 以上、あるいはバックグラウンドインジケータの有効化だ。精度がそれより粗いと、更新は黙って止まる。

距離には、明言しておく価値のある残存する制約がもうひとつある。ゆっくり歩いているときの精度は、プラットフォームがドップラー速度を提供するかどうかに依存する。提供されなければ、位置から導出した速度にフォールバックするが、その不確かさは測位精度の変化とともに増大する。しきい値はこれを抑え込むだけで、消してはいない。さらに詰めるなら、歩調と GPS で校正した歩幅を融合させることになる。専用フィットネスハードウェアがやっているのはそれだ。

つまり、こちらは「生きていること」では勝てない。システムに切断されたとき、永続化レイヤーと復帰プロンプトが「kill された」を「復帰できる」に変える——そこが要だ。

これが Expo にとって意味するもの

レジリエントな活動トラッカーを作るには、バックグラウンドタスク、ネイティブのセンサー API、リアルタイムウィジェット、そして両プラットフォームにまたがる多層の永続化が要る。4 年前なら、Expo から eject するしかなかった。今は明らかにそうではない。

TaskManager は GPS トラッキングをアプリのライフサイクルの外で動かしてくれる。Expo Modules API のおかげで、Expo のワークフローを離れずに Kotlin でネイティブの Android 歩数計を書ける。Live Activity については、@bacons/apple-targets でウィジェット拡張を Continuous Native Generation 経由のネイティブ Apple target として生成しているので、ActivityKit のコードは /ios の外に置かれ、prebuild のたびに生き残る——しかもこの道は、こちらが通ったときより今のほうが短い。SDK 56 以降、iOS ウィジェットと Live Activity は expo-widgets で安定している。EAS Build がマルチ target のビルド(メインアプリとウィジェット拡張)を担い、Xcode の手動設定は要らない。

このアーキテクチャはひとつの原則に集約される。プロセスを信用するな。 アプリは kill され、GPS は嘘をつき、ネットワークは切れ、自分の 2 人の書き手が互いに競合する。各レイヤー(バックグラウンドタスク、GPS フィルタ、単調なローカル永続化、単調なリモート同期、復帰)は独立して働くので、どれかが壊れても残りがユーザーの進捗を守る。

出典: Expo Blog← ホームへ戻る