個人開発2026年11月5日約6分
電波の 無い 場所でも すぐ 開く アプリに する

ツモルは、その日やったことを書き留める記録アプリです。「圏外でも開ける」と書いて出していたのに、実際には開けない時期がありました。Web の画面をそのまま iPhone アプリにするとき、圏外対応のどこでつまずいたか、最後に画面をアプリへ同梱してどう解いたかを書きます。
記録アプリは圏外で開けないと意味がない
ツモルにはワークアウトの記録があります。種目ごとに重量と回数を、セットの合間に打ち込みます。ところがジムは電波が弱いことがあります。構想の段階で「オフライン対応」を入れたのはこのためです。
記録は、やった直後に書けないと忘れます。開くのに待たされたり、「つながっていません」が出たりすると、その日の記録はそこで止まります。
最初の作り:本番の画面を読みに行く
ツモルの画面は Next.js(Web の画面を作る道具)で作っています。最初は「ホーム画面に追加する Web アプリ」として配り、そのあと Capacitor(Web の画面を iPhone アプリの中に入れる道具)でアプリにしました。
アプリにした最初の形は、起動するたびに本番の Web サイトを読みに行くものでした。Web を直せばアプリもすぐ変わるので、配る手間がかかりません。
弱点は、電波が無いと画面そのものが取れないことです。そこで Service Worker(一度読んだ画面を端末に取っておき、圏外ではそれを出す仕組み)を入れました。ここで3つ穴がありました。
- 取っておく中身の版を手で上げていた。 配るたびに JavaScript のファイル名が変わるのに、版を上げ忘れると古いファイルしか残りません。「取ってあるつもりで何も無い」状態です。ビルドのたびに版を自動で振るように直しました。
- 取っておく画面の一覧が古かった。 消した画面が残り、ヘルプの画面が抜けていました。
- JavaScript の中から読みに行くファイルを拾えていなかった。 ビルドの道具がファイルの住所を独自の書き方で埋め込んでいて、それが全部漏れていました。ログインと同期に使う部品(242KB)も取れていませんでした。
直したあと、本番で画面が読み込む14件がすべて手元に取ってあることを確かめました。
それでも iPhone では動いていなかった
Web では直りましたが、アプリの中では Service Worker 自体が動いていませんでした。iPhone のアプリ内で Web を表示する部品(WKWebView)では、「このアプリが扱う住所はこれだけ」と宣言しないと Service Worker が動きません。宣言前のアプリの保存領域を見ると、取っておいた中身が1つも作られていませんでした。圏外では「つながっていません」の画面が出るだけです。
審査メモには「圏外でも記録が読める」と英語で書いていました。出していたビルドでは、それが成り立っていなかったことになります。宣言を足したビルドを出し直し、取っておく中身が作られることを確かめました。
宣言が広告と両立しなかった
次に、ツモルに広告を入れることにしました。置き場所は「記録一覧の最後まで見たときだけ」です。ところがシミュレーターで広告が出ません。
原因は、さっきの宣言でした。宣言すると、アプリの中のすべての Web 表示が「宣言した住所以外では JavaScript を動かせない」状態になります。広告は住所の無いページの中で描かれるので、宣言に広告の住所を足しても直りませんでした。
つまり「本番を読み、Service Worker で圏外に備える」作りのままでは、圏外と広告のどちらかしか取れません。ここで作業を止めて、私が選びました。選んだのは「画面をアプリに同梱して、両方取る」です。
画面をアプリに同梱する
同梱とは、画面のファイルをアプリの中に入れて配ることです。起動時に通信しないので、電波が無くてもそのまま開きます。Service Worker も宣言も要らなくなります。
Next.js には、画面を静的なファイル(サーバー無しで開ける HTML と JavaScript)として書き出す機能があります。同じコードから Web 用とアプリ用の2通りを作れるように、設定を1つの切り替えで分けました。
const forApp = process.env.TSUMORU_TARGET === "app";
const nextConfig: NextConfig = {
...(forApp ? { output: "export", pageExtensions: ["tsx"] } : {}),
};
TSUMORU_TARGET=app のときだけ静的に書き出します。書き出しではサーバーの処理(API)を作れないので、読むファイルを .tsx に限って API のファイルを外しています。Web の本番は今までどおりです。アプリ用は1つのコマンドで書き出しからアプリへの取り込みまで進みます。
同梱にして出てきた落とし穴
住所が変わり、記録が消えて見える
同梱すると、アプリの画面の住所が本番の Web の住所から、アプリ内の住所に変わります。端末に保存された記録は住所ごとの持ち物なので、そのままでは前の版の記録が見えません。アカウントなしで使っている人には、記録が全部消えたように見えます。
そこで初回だけ、古い住所の中身を通信せずに読み、新しい画面に書き込むようにしました。ここは3通り試して失敗しています。別の Web 表示で書く、書いた直後に読み直す、本番へ移って戻る。どれもその場では読めますが、アプリを閉じると消えました。新しい画面自身に、どのスクリプトより先に書かせる形でやっと残りました。
住所の無いページで、別の画面が出る
Capacitor は、拡張子の無い住所を全部トップの画面として返します。書き出しは画面ごとに study.html のようなファイルを作るので、学習の画面で読み直すとトップの画面が出てしまいます。拡張子が無ければ同じ名前の .html を探して返すようにしました。
ログインのメールと API
アプリ内の住所には、API もメールのリンクの戻り先もありません。カレンダーを読む API は本番を呼び、本番側はアプリ内の住所からの呼び出しだけを通すようにしました。ログインやパスワード再設定のメールの戻り先も、本番の住所にしています。
Web を直してもアプリは変わらない
同梱の代わりに、Web を直しただけではアプリに届かなくなりました。アプリを変えるには、書き出してストアに出し直します。最初の作りの「配る手間がゼロ」は手放したことになります。
古い中身が出たまま止まる
これは同梱の前、手元の中身から先に画面を出す作りにしたときに出た不具合です。シミュレーターのアプリが12日間、8月30日を「今日」として出し続けていました。iPhone のアプリは、画面が裏に回っても「戻ってきた」という知らせが来ないまま生き残ることがあります。日付の読み直しをその知らせだけに頼っていたのが原因でした。30秒ごとに自分で時計を見るようにしました。
起動直後の同期(端末とアカウントの記録をそろえること)が落ちると、次は5分後だったのも直しました。画面が手元からすぐ出るぶん、通信の支度より先に走って落ちやすくなります。落ちたら間をあけながら数回やり直すようにしました。
審査メモも書き直す
審査メモの圏外の説明も、同梱に合わせて書き直しました。今は「画面はアプリに入っていて、外の Web を読まない。記録は端末にあり、通信なしで開いて記録できる。通信はログインと同期、カレンダー、広告だけ」という中身です。ストアの新機能の欄には「電波の無い場所でも、すぐに開けるように」と書きました。
前に一度、「圏外で動く」と書いておきながら、出したビルドでは動いていなかったことがあります。今回は、通信を遮ったビルドで動くのを見てから出しました。
結果
通信をすべて遮った試験用のビルドで、起動、タブの移動、ワークアウトの記録ができ、再起動しても記録が残ることをシミュレーターで確かめました。前の版の記録が引き継がれ、閉じて開いても残ることも確かめています。広告は記録一覧の最後で試験用の広告が出て、シートやタイマーの帯が出ている間は引っ込みます。この版を審査に出し、10月27日に公開しました。
まとめ
- サーバーの画面を読む作りは、配るのは楽でも、圏外対応を足すほど穴が増えました。
- 画面を同梱すると圏外と広告を両立できますが、住所の変化と配り直しの手間を引き受けます。