技術 約13分で読めます

3日でモバイルアプリを作った。難しかったのは接続の維持だった

いけさん目次

DEV Communityに、Web開発者がReact Native + ExpoでAIチャットアプリを3日で作った話が出ていた。
原題は「I Built a Mobile App in 3 Days. The Hard Part Was Keeping It Connected.」。

面白かったのは「AIツールでモバイルアプリも爆速で作れた」ではなく、その後に出てきた壊れ方だった。
Webでは普通に動いていたストリーミング応答が、iPhoneで実利用に入った瞬間に落ちる。
ユーザーがWhatsAppを開く、画面をロックする、Instagramを少し見る。
それだけでアプリがバックグラウンドに回り、iOSが通信を止める。

AIチャットの返信生成はサーバ側で続いているのに、スマホ側はもう聞いていない。
結果として、トークンは消費されるがメッセージは空になる。
これはかなり嫌な壊れ方だ。

3日で作れた部分と、3日では見えなかった部分

原典のSynapseは、妻が毎日使う個人向けAIコンパニオンらしい。
もともとWebアプリ、バックエンド、共有パッケージがTurborepoのモノレポにまとまっていて、そこへReact Nativeアプリを足した。

この構成だと、AIコーディングエージェントが既存のWebコンポーネント、Convexバックエンド、共有型定義を同時に見られる。
画面を作るときに、別リポジトリからAPI仕様をコピペする必要がない。
Clerk認証、Expo Router、Convexのmutation/query、既存テーマを流用して、3日でチャットUI、リアルタイムストリーミング、メモリ管理、ペルソナ切り替えまで動いたという流れだ。

ここだけ読むと「やっぱりモノレポ + AIコーディングは強い」で終わる。
でも本題はその後で、シミュレータと実機の日常利用の差が出ている。

Webのストリーミングなら fetchresponse.body から ReadableStream を読めばいい。
ところが原典では、React Native on iOS のHermes環境ではその形が使えず、XMLHttpRequestonprogress で増えた分のテキストを拾う実装にしていた。

開発中はそれで動く。
でもスマホ利用は「返信が出るまで画面を見続ける」とは限らない。
ここで接続維持の前提が崩れる。

ストリームは表示経路であって、保存経路ではない

原典の修正は、Convexをただの中継ではなく、切断後も生成を完了させるミドルウェアとして扱うものだった。
クライアントが接続している間は、AIサービスから来たdeltaをそのまま書き戻す。
書き込みに失敗したら、クライアント切断フラグを立てて、以後は死んだ接続へ書かない。

ただし生成自体は止めない。
サーバ側で本文を蓄積し、最後に完成メッセージとしてDBへ保存する。
ユーザーがアプリに戻ったとき、ストリームの途中経過ではなく、完成済みのメッセージを読む。

流れとしてはこうなる。

flowchart TD
    A["iPhoneアプリ<br/>送信"]
    B["Convex HTTP endpoint"]
    C["AI生成サービス"]
    D["ストリーム表示"]
    E["iOSがアプリを中断"]
    F["サーバ側で生成継続"]
    G["完成メッセージをDB保存"]
    H["アプリ復帰<br/>保存済みを取得"]

    A --> B --> C
    C --> B --> D
    D --> E
    E --> F --> G --> H

これ、AIチャットではかなり大事な考え方だと思う。
ストリームはユーザー体験のための表示経路であって、正本ではない。
正本はサーバ側のメッセージ状態に残す。

さらに、クライアント側のエラーハンドリングにも競合がある。
スマホ側から見るとXHRは失敗したので「このメッセージはエラー」と報告したくなる。
しかしサーバ側では、その後に生成が完了しているかもしれない。
原典では completedAt が入っているメッセージに対しては、クライアントの失敗報告で上書きしないガードを入れている。

この競合を潰さないと、サーバが正しく完了した応答を、復帰したクライアントが後からエラーに戻してしまう。

PWAでもネイティブでも、スマホは画面を離れる

自分のかなチャット v2では、iPhoneからAIエージェントを触るためにPWA化し、WebSocketストリーミング、Web Push、Tailscale ServeによるHTTPS化を入れた。
あの記事では「ブラウザを開いていないと通知が来ない」問題をPWAプッシュで潰したが、今回のDEV記事はもう一段下の話だ。

通知ではなく、生成中の接続そのものが切れる。
PWAでもネイティブでも、スマホ利用者は画面を離れる。
だから「接続が維持されれば成功」ではなく、「接続が切れてもジョブとして完了し、復帰後に状態を再取得できる」設計に寄せたほうがいい。

低レイテンシ・リアルタイム同期通信の記事ではWebSocket、SSE、WebRTCなどを比較したが、AIチャットのモバイル対応では低遅延だけ見ても足りない。
常時接続の品質より、切断後の状態遷移が重要になる場面がある。
特にLLM応答は1回の生成にコストがあり、途中で捨てるとユーザー体験だけでなく課金面も痛い。

通信方式そのものより、メッセージ状態の持ち方が効いてくる。

観点失敗時に起きること設計の寄せ方
クライアント中断iOSがXHRやWebSocketを止めるサーバ側で生成を継続する
途中経過ストリーム済みの文字列が失われる完成本文をDBへ保存する
復帰時クライアントが失敗扱いにする完了済みならエラーで上書きしない
コスト生成だけ走って結果が消えるrequestIdやmessageIdで追跡する

「モバイルアプリをAIで速く作れる」はもう珍しくなくなってきた。
でも、毎日使うAIアプリになると、作る速さよりも、移動中・ロック中・アプリ切り替え中にどう壊れるかのほうが効いてくる。

原典の数字では、1か月でWebが546メッセージ、モバイルが239メッセージ。
直近ではモバイル利用がWebに並ぶ日も出ている。
そうなると「たまに切れる」は周辺バグではなく、主要導線の信頼性問題になる。

個人用AIでも、スマホに入った瞬間にプロダクトの性格が変わる。
デスクトップで長く座って話すものから、料理中や移動中に短く何度も触るものになる。
接続が切れたときに途中経過を諦める設計だと、その利用パターンに耐えられない。

iOSだけの話ではない

原典はiOSの挙動がメインだが、Androidにも似た仕組みがある。

Android 6.0で導入されたDozeモードは、画面オフかつ端末が静止した状態が一定時間続くと、ネットワークアクセスやWakeLockを制限する。
さらにAndroid 9以降のApp Standby Bucketsは、アプリの使用頻度に応じてバケットを割り振り、頻度が低いアプリほどジョブやアラームの実行間隔が延びる。

iOSとの違いは、制限のかかり方が段階的なところにある。
iOSはアプリがバックグラウンドに回った瞬間から30秒前後でネットワーク接続を切りにくる。
Androidはすぐには切らないが、Dozeに入ると定期的なメンテナンスウィンドウでしかネットワークを使えなくなる。
iOSは「突然切れる」、Androidは「じわじわ絞られる」。
壊れ方のタイミングが違うだけで、長い生成が途中で止まるリスクは同じだ。

flowchart LR
    subgraph iOS
        A1["バックグラウンド移行"] --> A2["約30秒で<br/>ネットワーク切断"]
    end
    subgraph Android
        B1["画面オフ+静止"] --> B2["Dozeモード突入"] --> B3["メンテナンス<br/>ウィンドウのみ通信可"]
    end

ただしAndroidにはフォアグラウンドサービスという逃げ道がある。
通知バーに常駐アイコンを出す代わりに、バックグラウンドでもネットワーク通信を維持できる仕組みだ。
LINEやWhatsAppのようなメッセージアプリはこれを使っている。
AIチャットアプリでも同じ手は使えるが、ユーザーに「なぜ常駐しているのか」の説明が要る。
Android 14以降はフォアグラウンドサービスのタイプ宣言が厳格化され、用途に合わないタイプで申請するとPlay Storeの審査で弾かれるようになった。

厄介なのは、stock Androidの仕様だけでは済まないところだ。
Samsung、Xiaomi、Huawei、OPPOといったメーカーは、DozeやApp Standby Bucketsに加えて独自の省電力機構を載せている。
Samsungのデバイスケアはバッテリー最適化対象のアプリをDozeより積極的に殺すし、XiaomiのMIUIは自動起動を許可されていないアプリのバックグラウンド通信をデフォルトで遮断する。
dontkillmyapp.comがメーカー別の挙動と回避策をまとめているくらい、開発者にとっては定番の頭痛の種だ。

stock Androidのエミュレータで通ったテストが、実ユーザーのGalaxyでは再現しない。
iOSは全端末で同じ挙動だから条件分岐が明確だが、Androidはメーカーごとの差をグラデーションで吸収しなければならない。
React Nativeでクロスプラットフォーム対応を謳っても、バックグラウンド制限の実機検証は端末単位で必要になる。

React Nativeの場合、Android側のfetch実装はiOS/Hermesとは異なり、ReadableStreamが使えるケースもある。
だが接続が切れる原因はストリーミングAPIの対応状況ではなく、OSがアプリの通信を止めることにある。
サーバ側で生成を完了させてDBに保存する設計は、OSを問わず必要になる。

デスクトップでは目立たない理由と、それでも起きるとき

デスクトップのブラウザはOSからバックグラウンド制限をほぼ受けない。
タブを切り替えてもWebSocketやSSE接続は維持される。
ChromeはバックグラウンドタブのJavaScriptタイマーを1秒間隔に絞るが、通信自体は殺さない。

だから同じAIチャットをデスクトップブラウザで使う分には、この問題は表面化しにくい。
ChatGPTやClaudeをPCで使っていて「タブ切り替えたら返信が消えた」という経験がある人は少ないと思う。

ただしデスクトップでも似た状況は起きる。

ノートPCのスリープがひとつ。
生成開始後に蓋を閉じると、OSがネットワークインターフェース自体を落とす。
復帰後にブラウザはタブを再描画するが、ストリーム接続は切れている。
macOSのPower Napが有効なら一部の通信は維持されるが、WebSocketのような長時間接続は対象外だ。
WindowsのModern Standby対応機も同様で、省電力状態でのネットワーク維持はOS側が選別する。

もうひとつはネットワーク切り替え。
自宅のWi-Fiからテザリングへの移行、VPNの再接続、企業プロキシの再認証。
どれもTCPコネクションは一度切れる。
デスクトップでは頻度が低いだけで、起きたときの壊れ方はモバイルと同じだ。

デスクトップで問題になりにくいのは、OSの制限が緩いからだけではない。
利用パターン自体が違う。
PCの前に座ってAIチャットを送ったら、大抵は返信が終わるまでそのタブを見ている。
スマホだと通知が来れば反射的にアプリを切り替えるし、ポケットに入れれば画面はロックされる。
接続の寿命がOSの制限とユーザーの行動パターンの両方から短くなるのが、モバイル固有の事情だ。

AIチャット以外でも同じ壊れ方をする

接続が途中で切れて、サーバ側の処理結果が宙に浮く。
このパターンはAIチャットに固有ではない。

モバイルからの動画アップロードは古典的な例だ。
数百MBのファイルをアップロード中にアプリがバックグラウンドに回ると転送が中断する。
Tusプロトコルはこの問題に対して分割アップロードと再開をプロトコルレベルで組み込んだし、AWS S3のマルチパートアップロードも同じ発想で、途中で切れても完了したパートから再開できる。

ECアプリの決済フローも危ない場面のひとつ。
決済APIを叩いた直後にアプリが中断されると、決済は成功しているのにクライアントは結果を受け取れない。
StripeがIdempotency Key(べき等キー)を提供しているのは、まさにこの状況を吸収するため。
復帰後にリトライしても二重課金にならないことをAPIレベルで保証している。

動画のトランスコードや画像生成も同じ構造で、サーバ側に数十秒〜数分のジョブが走る間、クライアントが待てる保証はない。
「リクエストを投げる」と「結果を受け取る」を同じ接続で処理する設計は、モバイルでは前提が成り立たない。

リアルタイム共同編集も同じ構造を持っている。
Google DocsやNotionをスマホで使っているとき、アプリが中断されるとローカルの編集がサーバに同期されないまま残る。
復帰時にサーバ側のバージョンと衝突したら、どちらを正にするか解決しなければならない。
CRDTやOperational Transformationがこの競合解決のために存在するが、それらが必要な時点で、接続の連続性は前提にできていない。

WebRTCの音声・映像通話もモバイルでは切断が日常だ。
LINEやDiscordの通話中にアプリを切り替えるとマイクがミュートされたり、セッション自体が落ちる。
音声ストリームはHTTPリクエストと違ってリトライが効かないから、切断後は新しいセッションを張り直すしかない。
「切れても自動で繋ぎ直す」再接続ロジックの品質が通話体験を大きく左右する。

切断を前提にした設計の選択肢

原典のConvex方式は「サーバ側で生成完了→DB保存→復帰時に取得」だった。
これを一般化すると、いくつかのやり方がある。

ジョブキュー方式が最も直球だ。
クライアントはリクエストIDを受け取って、あとはポーリングかリアルタイムサブスクリプションで完了を待つ。
ストリーム表示は「接続があれば見せる」程度のオプションにして、正本は常にサーバ側に持つ。
Firebase Realtime DatabaseやConvex、Supabase Realtimeのようなリアクティブバックエンドを使えば、復帰時の再取得はクライアントが接続した瞬間に自動で走る。

メッセージの状態遷移を明示的に定義するのも有効。
pending → streaming → completed という正常系だけでなく、pending → streaming → interrupted → completed という中断経由のパスも用意しておく。
クライアントが interrupted を見たとき、サーバが生成を続けているのか、本当に失敗したのかを区別できる。
原典の completedAt ガードはこの状態遷移の簡易版にあたる。

stateDiagram-v2
    [*] --> pending
    pending --> streaming
    streaming --> completed
    streaming --> interrupted
    interrupted --> completed
    interrupted --> failed
    completed --> [*]
    failed --> [*]

ストリーム再開を組むなら、サーバ側にオフセット管理が要る。
SSEの Last-Event-ID ヘッダは仕様上この用途をサポートしていて、クライアントが再接続時に最後に受け取ったイベントIDを送り、サーバがそこから先を返す。
WebSocketにはこの仕組みがないので、アプリケーション層で自前のシーケンス番号を振ることになる。

コストの観点も見落とせない。
LLMの応答生成はトークン単位で課金されるから、生成したのに結果が届かないのはユーザー体験だけでなく実費の問題だ。
原典でも触れられているが、messageIdrequestId で生成済みの結果を追跡できるようにしておかないと、同じ質問の再送で二重に課金される。
StripeのIdempotency Keyと同じ考え方がここでも活きる。

プラットフォームのバックグラウンドAPIを部分的に活用する手もある。
iOSの BGProcessingTask はバックグラウンドで数分間の処理を許可するし、NSURLSession のバックグラウンド転送はアプリが中断されてもOS側がダウンロードを引き継ぐ。
ただしSSEやWebSocketのようなストリーム接続の維持には使えない。
Androidの WorkManager はDoze中でもメンテナンスウィンドウで実行される制約付き非同期タスクのスケジューラで、完了ポーリングには向いている。
万能ではないが、「生成完了を検知して結果を取りに行く」部分に限れば使いどころがある。

プッシュ通知で完了を知らせるパターンはシンプルかつ堅い。
サーバ側で生成が終わったらAPNsかFCMでプッシュを飛ばす。
ユーザーがアプリを閉じていても通知は届くし、タップすれば完成済みのメッセージが表示される。
ストリーム表示は「接続中に見えるおまけ」になり、正本は常にサーバ側にある。
接続が切れてもユーザー体験として破綻しない。
ChatGPTのiOSアプリも、長い応答が完了したときにプッシュ通知を出すことがある。あれがまさにこの割り切りだ。

参照