プロジェクト一覧

サウンドマインド

オディヤ子ども端末アプリ

管理ポリシーがモバイルデータをオフにし、ログも確認できない端末で、バッテリーを節約しながら位置情報を取りこぼさない方法を探す仕事でした。

役割
位置収集ステートマシンの設計およびAndroidネイティブ実装
期間
2025.07 ~ 現在
使用技術
React NativeAndroid (Java/Kotlin)FusedLocationActivity RecognitionFCM
アプリ / クライアントネイティブサーバーワーカーストレージ外部
端末のモバイルデータは管理エージェントが普段オフにします。アプリは送信直前だけ開放を要求し、サーバー命令はアップロード応答に載って届きます。ブロックにカーソルを合わせると、つながるフローだけが強調されます。ドラッグで移動できます。

発見した問題

  • 45秒 → 357秒

    省電力モードで実測したタイマー遅延 (136~357秒)

改善結果

  • 100%

    送信成功率 (従来は平均1.17秒でデータ接続が切れていました)

  • 10.2KB → 1.1KB

    位置バッチのリクエストボディ · 圧縮で89%削減

  • 43MB → 6.8MB

    端末1台の月間送信量 · 圧縮と接続再利用の適用後

  • 4/4

    地下鉄移動の実測件のうち基地局座標を正確に除外できた割合

01課題

このアプリが動作する端末は、一般的なスマートフォンではありません。子どもの保護のために導入された管理ポリシーが端末全体のモバイルデータを普段オフにしており、開発者モードまでブロックされているため、問題が起きてもログを取得できません。それでも要件は、保護者がいつでも子どもの位置を確認できることでした。データがオフの端末から定期的に位置情報を送信しなければならず、何が悪いのかを観測する手段がない状態で安定性を確保する必要がありました。

  • 端末のモバイルデータが基本的に遮断されているため、送信のたびに管理エージェントへ開放を要求する必要があります
  • 開発者モードがブロックされており、実機からのログ収集ができません
  • 再現しない現場のバグを推測だけで直さなければならない状況が繰り返されました

02制約

よく使われる解決策のほとんどが使えませんでした。ネットワークが普段閉じているためサーバーから先にアプリへ連絡することができず、プッシュで制御する方式は使えませんでしたし、バッテリーのためにGPSを常時オンにしておくこともできませんでした。さらに、Androidの省電力ポリシーが予約したタイマーを勝手に遅らせる問題が重なりました。

このアプリが動く根拠

オンボーディングは両方を確認して初めて通過します

  • このアプリは二つの権限の上に立っています。Knox管理ライセンスがあってこそデータを開けられ、電池最適化の除外があってこそ眠った端末で目覚められます。一つ欠ければ送信は0件です
  • Knox管理ライセンス · アプリが直接データをオンにはできません。管理エージェントへブロードキャストで開放を要求し、代わりにオンにしてもらう構造のため、ライセンスが無効なら要求自体が受け入れられません。機能によってメーカー承認が必要な項目は別途申請しました
  • 電池最適化の除外 · 除外リストになければシステムがアプリを待機ランクの下位へ落とし、正確アラームでさえ発火が遅れ、バックグラウンド通信がさらに制限されます。眠った端末で目覚めること自体が不確実になります
  • データ開放の要求は非同期で3秒から5秒かかり、完了を知らせるシグナルがありません
  • 省電力モードで45秒に予約したタイマーが最大357秒まで遅れることを実測しました
  • GPSを常時オンにするとバッテリーが1日持ちません
  • 精度値だけを見て座標を信じると、基地局の位置をGPSと偽装した値にだまされます

03検討した代替案

ネットワーク開放をどう管理するかが分かれ道でした。3つの方式を比較し、前の2つは実際に作って動かした上で問題を確認して捨てました。

利点課題
常時開放実装が最も単純で、送信が失敗することがない管理ポリシーの趣旨に真っ向から反し、バッテリーと通信料金が持たない
要求ごとに開閉 (単一トークン)必要なときだけ開くのでポリシーとバッテリーの両方を満たすトークンを共有しているため、先に終わった側がまだ送信中の要求のネットワークを切断してしまう。実測の維持時間の中央値は1.17秒
単一窓口 + 要求元ごとのカウント採用最後の利用者が終わるまで維持され、最小維持時間をルールとして強制できる返却漏れがあると開いたままになるため、安全装置を別途設ける必要がある

04選択と根拠

単一窓口方式を選びました。開放要求が非同期で完了シグナルがないことが決め手でした。要求の直後に送信するとまだ開いていない状態で送り出してしまい、送信が終わった瞬間に閉じると次の要求がまた3秒待つことになります。そこで要求元をカウントして最後の利用者が終わるまで維持し、最小6秒ルールを加えました。返却漏れのリスクは、安全装置を四重に設けて防ぎました。

  • 要求元ごとのカウントで開放と返却を管理し、最小6秒は維持
  • 返却を保証する4重の仕組み: ウェイクロックの期限、ウォッチドッグ、アラームティックでの二次回収、世代トークン
  • 世代トークンは、遅れて届いた古いコールバックが新しいセッションのネットワークを切断するのを防ぐためのもの
  • GPS点灯の条件を保有位置の経過時間に変え、送信失敗が条件を永久にロックしないようにした
  • 座標は精度ゲート、衛星数の判別、速度の妥当性の順に検証

05省電力モードとの戦い

Androidは画面が消えたまま放置された端末をDoze状態に落とします。CPUを眠らせ、ネットワークアクセスを止め、アプリが予約したアラームを一定の窓にまとめて処理します。問題は、このサービスの主な利用条件がまさにその状態だという点です。子どもの端末はポケットや鞄の中で画面が消えたまま一日を過ごし、位置情報はまさにその間、送られ続けなければなりません。Dozeを例外ではなく基本の舞台として、システムがアプリを眠らせようとする各地点ごとに対策を積み上げました。

Dozeで回る1サイクル

最後の段階が次のアラームを取ります。この輪が切れれば二度と目覚めません

予約に対する実際の発火

45s
136~357s

タイマーではなく壁時計で経過時間を計算し直します

  1. 目覚める権利

    通常のアラームはDozeではメンテナンス窓までまとめて先送りされます。Doze中でも発火する正確アラームAPIで予約しつつ、それすら約9分間隔のグリッドにスロットルされることを前提に収集判定を設計しました。9分の中で何度も起こそうと争う代わりに、目覚めたついでに溜まった判定をまとめて処理します

  2. 電池最適化の除外

    除外リストに入らなければアプリは待機ランクの下位へ落とされ、アラームとネットワークがもう一段締め付けられます。オンボーディングでこの除外を明示的に要求し、承認されなければ次の段階へ進めないゲートとして縛りました

  3. 起きている間の維持

    アラームが起こしても、収集と送信が終わる前にCPUが再び眠れば、そのまま中断されます。作業区間の間は部分ウェイクロックを取りますが期限を設けて90秒以上は保持せず、ウォッチドッグと次のアラームティックで返却漏れを回収します。ウェイクロックを長く握ることはDozeに勝つことではなく、電池を燃やすことです

  4. 時間感覚の補正

    Dozeでは経過時間基準のタイマーが伸びます。45秒で予約したものが実測で136秒から357秒まで開きました。そこでタイマーを信頼せず、目覚めるたびに壁時計で実際の経過時間を計算し直し、遅れた分を補正します

  5. 遅れて届く過去

    伸びたタイマーの古いコールバックが遅れて届き、すでに新しく始まったサイクルのネットワークを返却してしまう事故がありました。サイクルごとに世代トークンを付与し、自分の世代でないコールバックは無視するようにしました

  6. 切れたら終わるチェーン

    アラームは繰り返し予約ではなく、処理の最終行で次のアラームを自ら取る自己再予約チェーンです。一つの輪が欠ければ永遠に沈黙するため、再予約地点をコードの一箇所に固定し、起動やプロセス終了でチェーンが切れた場合の復活経路を別に設けました

  7. メーカーの省電力ポリシー

    Android標準のDozeの上に、メーカーごとの省電力ポリシーがもう一層重なります。標準の除外を得てもここで再び眠らされうるため、端末メーカーのポリシーまで確認して除外処理を合わせる必要がありました

  • 45秒で予約したアラームが実際に発火した時刻

    最大357秒
    45秒 予約

06料金プランという制約

これらの端末は子ども向けの低価格プランを使います。データ総量に余裕がなく、位置送信がプランを食い潰すと、いざ子どもに連絡が必要な場面で通信が止まります。ところが実際に送られている中身を開いてみると、位置バッチが平文JSONのまま送信されていました。同じキーが項目ごとに繰り返される構造で、圧縮が最もよく効くデータであるにもかかわらずです。位置30件を含むバッチ一つが平文で約10.2KB。この中で変わるのは座標と時刻だけで、フィールド名は30回繰り返されます。

リクエスト1件の構成

以前15.7KB
以後1.1KB
ハンドシェイク平文JSONgzip · 接続再利用

端末1台の月間43MB → 6.8MB

  • リクエストボディをgzipで圧縮して送るようアプリを変更しました。30件バッチで10.2KBが1.1KBになり約89%削減、バッチが大きくなるほど比率はさらに上がります
  • TomcatとSpringはリクエストボディの圧縮を既定では解除しません。サーバー側で最前段のフィルターとして解除させ、圧縮ヘッダーがなければそのまま通して旧バージョンのアプリとの互換を保ちました
  • 送信のたびに接続を張り直すこともコストです。30秒間隔で短いリクエストが繰り返されるパターンのため、TCPの3ウェイハンドシェイクとTLSネゴシエーションが本文より重い状況でした
  • 前段にプロキシを置き、keep-aliveで接続を生かして再利用し、TLSセッション再利用を有効にして再ネゴシエーションのコストまで削りました。毎回の送信が接続からやり直していた状態をなくしたということです
  • 圧縮を有効にした直後、一部の経路で403が返ってきたため、しばらくは平文へ戻るフォールバックも併置しました。前段の設定を正したうえでそのフォールバックを封じました
  • 同じ理屈で、診断記録や再送待ちの分も別リクエストにせず位置送信に同載させました。リクエスト数そのものが料金だからです
  • すべて合わせると、端末1台の月間送信量は約43MBから6.8MB程度まで下がります。圧縮が本文を削り、接続再利用が1日96回繰り返されていたハンドシェイクのコストを取り除いた結果です
  • 最後に、端末が自身のデータ使用量も報告するようにし、管理ダッシュボードで月平均使用量を確認できるようにしました。減らしたと言うのではなく、どれだけ使っているかが見えるようにしたということで、次にどこを削るかの判断根拠がここから出ます
  • 位置バッチのリクエストボディ (位置30件)

    10.2KB
    1.1KB
  • 端末1台の月間送信量

    43MB
    6.8MB

07サービスセンターに行かないと直せないアプリ

このアプリには通常のデプロイが通じません。データが基本的に遮断された端末のためストアの自動更新が回らず、結局バグを一つ直すにも利用者が端末を持ってサービスセンターへ行き、手動で更新する必要があります。現場に出たバージョンが事実上固定されるということです。実際に大多数の端末が旧バージョンに留まっており、急ぎの修正も一台ずつ手で運ぶしかありませんでした。

修正一つが端末に届くまで

ネイティブは依然として来店が必要なため、旧バージョンは消えません

  • 画面ロジックがJavaScriptバンドルとして分離される構造である点に着目し、ネイティブを再インストールせずバンドルだけを差し替える自前のコードプッシュ経路をアプリに組み込みました
  • ただしバンドルを受け取ること自体が通信を使います。データを別途開かないという原則を守るため、位置送信のためにすでに開く通信の窓にバンドルの確認と受信を載せました。コードプッシュ専用の開放は設けていません
  • 料金プランが厳しいのでバンドルもコストです。全体ではなく変更分だけを受け取るようにし、途中で切れたら次の窓で続きから受け取ります
  • 適用のタイミングを二つに分けました。即時反映が必要な修正は受信後すぐ再起動し、急がないものは次回起動時に静かに適用して、使用中の子どもを妨げません
  • 新しいバンドルが起動に失敗すれば以前のバンドルへ自動で戻ります。サービスセンターへ行かないと直せない端末を遠隔で文鎮にするわけにはいかないため、ロールバックは選択肢ではなく前提でした
  • ネイティブコードの変更は依然としてストアと来店が必要だという境界を明確にし、コードプッシュで覆える範囲を文書で確定させました
  • そのため旧バージョン互換は選択ではなく制約になります。ネイティブを直せないということは、現場の旧バージョンが消えないということであり、今後のすべてのサーバー変更はその旧バージョンとも噛み合い続けなければなりません
  • サーバーAPIはフィールドを追加する方向にのみ変更しました。削除したり意味を変えたりすれば、その瞬間に更新できない端末がまるごと脱落します。圧縮送信を導入するときも、圧縮ヘッダーのないリクエストはそのまま通し、古いバージョンが何も知らないまま動き続けられるようにしました
  • 新機能が送るフィールドを旧バージョンは送らないため、そのデータに依存する画面は子どもアプリのバージョンを確認して最初から出さないようにしました。ないデータを既定値で埋めて見せる代わりに、その機能がないものとして扱います
  • 結果としてサーバーは複数世代のアプリを同時に相手にします。デプロイのたびに最新版だけでなく、現場に残る最も古い版でも壊れないかを併せて確認することが基準になりました
  • 配信は一度に全量ではなく段階的に広げます。一部に先に適用して異常がないことを確認してから範囲を広げます

08実装と試行錯誤

設計どおりに作ったのに、現場ではさらに3回間違えました。ログが見られない環境なので、毎回仮説を立て、診断記録を送信に載せて回収する方法で絞り込んでいきました。

  • ① 最小維持時間を3秒に設定したところ、失敗が残っていました。開放要求に3~5秒かかることを測定してから6秒に引き上げ、それ以降は失敗がなくなりました。
  • ② 静止状態の判定を時間ウィンドウで作ったところ、送信に一度失敗した端末が条件を永久に満たせず沈黙しました。ウィンドウの代わりに保有位置の経過時間をしきい値として再構成しました。
  • ③ 省電力モードで収集が止まる原因を長い間見つけられませんでした。予約タイマーを実際の時刻と照合して記録したところ、45秒の予約が最大357秒まで遅れていました。実時間基準のバックストップを入れて補正しました。
  • ④ 開発者モードがブロックされていてログを取得できないため、診断記録を位置送信に載せて送り、サーバー側で重複を吸収するようにしました。この経路のためにネットワークを別途開くことは禁止しました。

09結果

送信の途中でデータ接続が切れる問題がなくなりました。従来はデータがオンの状態が平均1.17秒しか維持されず、要求を完了できないことが多かったのですが、最小6秒維持ルールを入れてからは送信成功率が100%になりました。GPS点灯の条件を変えてからは、送信に一度失敗したという理由でアプリが沈黙し続ける現象もなくなりました。座標検証は地下鉄移動の実測4件で基地局座標をすべて正確に除外し、確信が持てない場合はそもそも送信しないようにして、保護者アプリが根拠のない位置を描かないようになりました。