はじめに ─ 試作学習アプリをホーム画面に並べるために PWA を選んだ
AWS Solutions Architect Professional(SAP)の試験勉強に使うため、資格試験の問題を解くと Claude で作成した解説が表示される学習アプリを Rails 8 で社内試作しています。解説はあらかじめ Claude で生成して保存してあるもので、まずは自分たちの学習に使い、手応えを見ているところです。
少し使ってみたところ、まずは「毎回ブラウザを開いてブックマークから飛んで、ログインして」という導線は面倒くさすぎる。そうじゃなくてもスキマ時間にやる気が起きないのに、、、正直やってられません。
そこで、この学習アプリを PWA(Progressive Web App)化することにしました。狙いはモバイルで使いやすくするためです!
- ホーム画面アイコンをタップしたらすぐに開く
- ログイン状態は保てる
- URL バーやナビバーを消して画面を広く使える
- オフラインでも動かせる
「アプリらしさ」を取りに行く実装です。
ネイティブ(Swift / React Native)ではなく PWA を選んだのは、今回は社内向けの実装であり、Rails + PWA で十分だったからです。
PWA の実装は 3 ページ構成になっており、本記事では Phase 1(最小 PWA)+ Phase 2(アプリシェル キャッシュ)の解説です。対象読者は、Rails 8 で Web アプリを作っているエンジニアと、ネイティブを諦めて PWA に倒すか検討中の開発者です。実装の判断理由・ハマったところを、汎用化したコード例とともに開示します。
1. Rails 8 で PWA を実装する ── 前提とタスク一覧
PWA は、ふつうの Web アプリに「インストールできる」「オフラインでも開ける」を足したものです。最低限、次の部品で成り立ちます。
- Web App Manifest ── アプリ名・アイコン・表示モードなどのメタ情報。これがあるとホーム画面に追加でき、URL バーのない全画面で開ける
- Service Worker ── ページとネットワークの間に常駐するスクリプト。リクエストを横取りしてキャッシュやオフライン応答を返す(Web Push もここ)
- アイコン ── ホーム画面やスプラッシュ画面に使う各サイズの画像
- HTTPS ── Service Worker が動く前提条件(localhost は例外)
1-1. 前提 ── Rails 8 が用意してくれるもの
rails new した時点で、PWA の土台は次のところまで用意されています。
app/views/pwa/
├── manifest.json.erb # マニフェスト (ERB 可)
└── service-worker.js # Service Worker- テンプレートの配置ディレクトリ(
app/views/pwa/の上記 2 ファイル) ── ただし中身はほぼ空のプレースホルダで、これから埋める Rails::PwaControllerの実装 ──render template: "pwa/..."をlayout: falseで配信する。アプリ側にコントローラを書く必要はない- manifest の ERB プリプロセス対応 ── manifest を動的に生成できる
1-2. 実装タスク一覧
ここから、PWA として動かすためのタスクを順番に進めます。それぞれ何のためのタスクかも添えます。
- ルーティングを登録する ──
/manifest・/service-workerを配信できるようにする(これがないとRails::PwaControllerに到達しない) - manifest の中身を書く ── アプリ名・表示モード・アイコン定義を入れ、「ホーム画面に追加できる/全画面で開ける」状態にする
- アイコン画像を用意する(192 / 512 / maskable / apple-touch-icon) ── ホーム画面・スプラッシュ・iOS 用の見た目を整える
- HTML head にリンクとメタタグを追加する ── ブラウザに manifest・アイコン・テーマ色を認識させる
- インストール案内 UI を出す ── iOS / Android の差を吸収して、ユーザにインストールを促す
- Service Worker を実装する ── アプリシェルをキャッシュし、オフラインでも開けるようにする
2. Phase 1 ── 最小 PWA を組み立てる
Phase 1 のゴールは、ホーム画面に追加できて、タップで standalone 起動して、インストール案内が出る ところまでです。Service Worker の中身はまだ空でも構いません(Phase 2 で詰める)。
2-1. ルーティングを登録する
config/routes.rb には PWA エンドポイントが生成されないので、手動で 2 行追加します。これで /manifest・/service-worker が Rails::PwaController に繋がり、app/views/pwa/ のテンプレートが配信されます。
# config/routes.rb
get "manifest" => "rails/pwa#manifest",
as: :pwa_manifest, defaults: { format: "json" }
get "service-worker" => "rails/pwa#service_worker",
as: :pwa_service_worker2-2. manifest.json.erb の整備
生成直後の manifest.json.erb は次のとおりプレースホルダだらけです。
{
"name": "App",
"icons": [
{ "src": "/icon.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icon.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "App.",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e"
}これを今回のアプリ用に書き換えた版が次のものです(色や名前は適宜置き換えて読んでください)。
{
"name": "資格学習アプリ(過去問演習)",
"short_name": "資格学習アプリ",
"description": "資格試験の問題を解き、Claude AI が噛み砕いて解説する学習アプリ。",
"lang": "ja",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"id": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e",
"categories": ["education", "productivity"],
"icons": [
{ "src": "/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
{ "src": "/icon-maskable-192.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" },
{ "src": "/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}ポイントは次の 5 点です。
idを明示する(ルート直下のアプリなら"/"):アプリの一意な識別子。固定しておくことで、将来のメンテナンスなどでstart_urlを変更しても同一アプリとして上書きインストールされるためです。categories:アプリストア等での分類のヒント(指定できる値は FAQ 参照)orientation:画面の向きの指定。学習アプリは縦固定(portrait)が自然(iOS での挙動・値の一覧は FAQ 参照)- アイコンを
any/maskableで分ける:Android Adaptive Icon に対応するにはmaskable用に 余白を含めた専用画像が必要 theme_colorとbackground_colorと icon 背景を揃える:スプラッシュ画面の境目をなくす意図的な選択(後述、5-2)
2-3. アイコン 5 種類の用意と maskable 対応
PWA に必要なアイコンは大きく 5 種類です。最近では AI を使うと簡単に作成してくれます。
| ファイル | サイズ | 用途 |
|---|---|---|
| icon-192.png | 192×192 | Android 標準 |
| icon-512.png | 512×512 | Android 大 / スプラッシュ画面 |
| apple-touch-icon.png | 180×180 | iOS Safari ホーム画面 |
| icon-maskable-192.png | 192×192 | Android Adaptive Icon |
| icon-maskable-512.png | 512×512 | Android Adaptive Icon 大 |
maskable アイコンとは
maskable アイコンは、Android Adaptive Icon の仕組みで OS 側が円形・squircle・滴形など好きにマスクしてホーム画面に表示します。アイコンの中身(ロゴや文字)は 中央 80% の円内に収める必要があり、外側 10% はトリミング想定の安全余白です。素朴な「ロゴだけ」の画像を渡すと、端が切れたり中央がスカスカに見えたりします。
本案件では、既存の icon.png(1024×1024、背景 #1a1a2e + 中央に「解」のロゴ)を ImageMagick で 80% にリサイズ → 同色の背景で 100% まで extent、という 1 コマンドで生成しました。
# 512×512 の maskable アイコンを作る例
magick icon.png \
-resize 410x410 \
-background "#1a1a2e" \
-gravity center \
-extent 512x512 \
icon-maskable-512.pngベース PNG にロゴが既にラスタライズされているので、テキスト処理が要りません。これは「最初に試して詰まった経路を回避した結果」で、詳細は §5-1 にまとめます。
2-4. HTML head のメタタグ
app/views/layouts/application.html.erb の <head> に次の 7 行を追加します。
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="<%= pwa_manifest_path %>">
<meta name="theme-color" content="#1a1a2e">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="資格学習アプリ">apple-mobile-web-app-* 系は iOS 専用の古い独自仕様ですが、現役です(mobile-web-app-capable が標準版で、両方書く)。apple-mobile-web-app-status-bar-style: black-translucent を選ぶと iOS のステータスバーが透けて、コンテンツが画面いっぱいに広がります。theme_color と揃えると統一感が出ます。
SW の登録もここで仕込みます。window.load を待つのは First Contentful Paint を遅らせないため、scope: "/" を明示するのはサブパスでの動作を防ぐためです。
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("<%= pwa_service_worker_path %>", { scope: "/" })
.catch(() => {})
})
}
</script>本案件は CSP を無効化しているのでインライン script で問題ありませんが、CSP を有効化する場合は外部 JS への切り出しか nonce 化が必要です(FAQ 参照)。
2-5. インストール案内 UI(iOS / Android の差を吸収)
インストールフローは、iOS と Android で大きく異なります。
- iOS Safari:
beforeinstallpromptイベントを発火しない。ユーザに「共有メニュー → ホーム画面に追加」を手動操作してもらうしかない - Android Chrome / Edge:
beforeinstallpromptが発火するので、preventDefault()で保留して任意のタイミングで自前ボタンからprompt()を呼べる
この差を Stimulus controller(app/javascript/controllers/install_prompt_controller.js)で吸収しました。ロジックの核は次のとおりです。
import { Controller } from "@hotwired/stimulus"
const DISMISS_KEY = "app.installPromptDismissedAt"
const DISMISS_DAYS = 14
export default class extends Controller {
static targets = ["iosMessage", "androidMessage", "installButton"]
connect() {
if (this.isStandalone() || this.isDismissed()) {
this.element.remove()
return
}
if (this.isIOS()) {
this.iosMessageTarget.classList.remove("hidden")
this.element.classList.remove("hidden")
} else {
this.deferredPrompt = null
this.beforeInstallHandler = (event) => {
event.preventDefault()
this.deferredPrompt = event
this.androidMessageTarget.classList.remove("hidden")
this.installButtonTarget.classList.remove("hidden")
this.element.classList.remove("hidden")
}
window.addEventListener("beforeinstallprompt", this.beforeInstallHandler)
}
}
async install() {
if (!this.deferredPrompt) return
this.deferredPrompt.prompt()
const { outcome } = await this.deferredPrompt.userChoice
this.deferredPrompt = null
if (outcome === "accepted") this.element.remove()
else this.dismiss()
}
dismiss() {
localStorage.setItem(DISMISS_KEY, String(Date.now()))
this.element.remove()
}
isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true
)
}
isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
}
isDismissed() {
const at = parseInt(localStorage.getItem(DISMISS_KEY) || "0", 10)
if (!at) return false
const days = (Date.now() - at) / (1000 * 60 * 60 * 24)
return days < DISMISS_DAYS
}
}ポイントは 3 段の早期 return です。(1) 既に standalone モードで起動していたら(display-mode: standalone または navigator.standalone)、もう案内する必要がないのでバナーごと remove()。(2) 14 日以内に dismiss されていたら同様に消す。(3) これらをパスして初めて、iOS / Android の分岐に入る。
文言は i18n キー(pwa.install.ios_message など)に逃がして、ja / en / vi の 3 言語に対応しています。レイアウト側はバナー本体を <main> の直後に置き、fixed bottom-4 left-1/2 -translate-x-1/2 z-40 で中央下に固定表示。初期は hidden クラスでまったく見えない状態にしておき、controller の判定で初めて表に出します。
3. Phase 2 ── Service Worker の実装
Phase 2 のゴールは、Service Worker を実装して、次の 2 つを満たすことです。
- オフラインでもエラーにならない ── ブラウザの「インターネットに接続できません」画面で止めず、一度開いたページはキャッシュから表示し、未訪問のページには自前の
offline.htmlを返す - 更新した Service Worker をすぐ反映させる ── 修正をデプロイしたら、次の起動で確実に新しい SW が効くようにする
なお、問題データそのもののオフライン対応は Phase 3(IndexedDB) で扱うので、ここでは対象外です。
3-1. Service Worker はどう動くか
Service Worker(以下 SW)は、ページとサーバの間に立つ「中継役」のスクリプトです。一度登録しておくと、ページが出す通信(ページ・画像・データの取得)を SW がいったん受け取れるようになり、「サーバへ取りに行く前に、まずキャッシュで答える」といった割り込みができます。さらに、ページを閉じていてもバックグラウンドで動けるため、プッシュ通知の受信などもここで処理できます。オフラインでも動かせるのは、ファイルを端末内のキャッシュに保存しておき、ネットが無いときは SW がそこから返すからです。「何を・いつ保存し、どう返すか」は、SW が動く各タイミング(ライフサイクル)で決まります。
- install:登録後、SW が最初に一度だけ走る初期化イベント。ここで precache(土台ファイルの先読み)を行う
- activate:有効化のタイミング。古いバージョンのキャッシュを削除する(キャッシュ名にバージョンを付けて、今の世代だけ残す)
- fetch:以後、ページが出す通信を SW が受け取り、キャッシュ戦略にそって返す。使ったものはここで runtime キャッシュ(使いながら保存)に貯める
3-2. 何を先に保存(precache)し、何を使いながら保存(runtime)するか
Service Worker のキャッシュには大きく 2 つのやり方があります。precache(プリキャッシュ)は、Service Worker をインストールした時点で先回りして必要なファイルを保存しておく方式。runtime キャッシュは、ユーザが実際にアクセスして読み込んだものをその場で保存し、次回以降に使い回す方式です。「何を先に確保し、何を使いながら貯めるか」を分けるのがこの節の話です。
Rails 8 は CSS / JS・画像・フォントといった静的ファイル(まとめて「アセット」と呼びます)を Propshaft + importmap で配信します。このうち CSS / JS はデプロイのたびに URL が変わる(ダイジェスト付き。例: /assets/application-abc123.css)ため、precache に固定で書くとすぐ古くなります。そこで precache には URL が安定したものだけを入れ、変わるものは runtime キャッシュに任せます。
- precache(URL が安定):
/offline.html、/manifest、各種アイコン(svg / 192 / 512 / maskable 2 種 / apple-touch-icon の計 6 ファイル) - runtime(URL が変わる):CSS / JS / フォント。
fetchハンドラで初回に取り込み、以後は stale-while-revalidate で配信(digest URL が変わってもキャッシュが自然に追従)
precache に入れた 3 つは、オフラインでも必ず必要になる“土台”です。これがあるので、ネットに繋がっていなくてもアイコンやスプラッシュ画面が欠けず、未キャッシュのページに来てしまっても /offline.html を代わりに表示できます。一方 runtime 側は「一度見たページや読み込んだアセット」が自然に貯まるので、再訪時にはオフラインでも表示されます(/offline.html はあくまで未キャッシュ時の保険です)。
3-3. 基本の実装とオフライン設定
3-1(SW の動き)と 3-2(何を保存するか)を踏まえて、まず基本形を実装します。これが app/views/pwa/service-worker.js の本体です。更新を即反映させる skipWaiting はまだ入れず、3-5 で足します。
const VERSION = "v3"
const PRECACHE = `app-precache-${VERSION}`
const RUNTIME = `app-runtime-${VERSION}`
const OFFLINE_URL = "/offline.html"
const PRECACHE_URLS = [
OFFLINE_URL,
"/manifest",
"/icon.svg",
"/icon-192.png",
"/icon-512.png",
"/icon-maskable-192.png",
"/icon-maskable-512.png",
"/apple-touch-icon.png",
]
// install: 土台ファイルを precache
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS))
)
})
// activate: 古いバージョンのキャッシュを削除
self.addEventListener("activate", (event) => {
const allowed = new Set([PRECACHE, RUNTIME])
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => !allowed.has(k)).map((k) => caches.delete(k))
)
)
)
})
// fetch: 通信の種類で振り分け
self.addEventListener("fetch", (event) => {
const { request } = event
if (request.method !== "GET") return // 更新系(POST 等)は素通し
const url = new URL(request.url)
if (url.origin !== self.location.origin) return // 外部サイトは素通し
// Turbo の遷移はブラウザネイティブな "navigate" ではなく fetch() なので、
// Accept ヘッダに text/html を含む GET も HTML ページ要求として扱う。
const acceptsHtml = (request.headers.get("Accept") || "").includes("text/html")
if (request.mode === "navigate" || acceptsHtml) {
event.respondWith(networkFirstNavigation(request)) // 最新優先→キャッシュ→offline.html
return
}
if (isStaticAsset(url)) {
event.respondWith(staleWhileRevalidate(request)) // キャッシュ即返し+裏で更新
}
})
async function networkFirstNavigation(request) {
try {
const response = await fetch(request)
const cache = await caches.open(RUNTIME)
cache.put(request, response.clone())
return response
} catch (_e) {
const cached = await caches.match(request)
if (cached) return cached
const offline = await caches.match(OFFLINE_URL)
return offline || new Response("Offline", { status: 503 })
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME)
const cached = await cache.match(request)
const networkFetch = fetch(request)
.then((response) => {
if (response && response.ok) cache.put(request, response.clone())
return response
})
.catch(() => null)
return cached || (await networkFetch) || new Response("Offline", { status: 503 })
}
function isStaticAsset(url) {
if (url.pathname.startsWith("/assets/")) return true
if (url.pathname === "/manifest") return true
if (/\.(css|js|svg|png|jpg|jpeg|gif|webp|ico|woff2?)$/i.test(url.pathname)) return true
return false
}fetch の振り分けは「ページ(HTML)は最新優先 → 無ければキャッシュ → 最後に offline.html」と「アセットはキャッシュ即返し+裏で更新」の 2 本柱です(外部サイトと POST 等は素通し)。各行の意図はコード内のコメントを参照してください。
バージョン管理は VERSION 定数で行います。SW のロジックを変えたら手動でバンプすれば、古いバージョンのキャッシュが activate フェーズで消え、既存ユーザにも新ロジックが効きます。なお上の fetch ハンドラの acceptsHtml の一行は、最初に書いたコードには無く、動作確認で見つけた不具合の修正です。次の「Turbo の落とし穴」で、その経緯を書きます。
mode === "navigate" だけで HTML ページ要求を判定する SW は、Rails アプリではほぼ必ず不完全になります。
初稿の fetch ハンドラは、一般的な PWA チュートリアルどおり request.mode === "navigate" だけで判定していました。ところが §4 の Playwright テストでオフライン遷移を確かめると、TOP ページ以外は runtime キャッシュに入らず、一度オンラインで開いたページすらオフラインで開けないことが分かりました(後に iPhone 実機の機内モードでも同じ症状を確認)。
原因は Hotwire/Turbo でした。Turbo のページ遷移はブラウザネイティブなナビゲーションではなく fetch() リクエストで、その request.mode は "navigate" になりません。
- PWA 起動時の
start_url(TOP)読み込み = ブラウザネイティブなnavigate→ SW がキャッシュ → オフラインで見られた - それ以降の Turbo 遷移 =
fetch()、modeは"navigate"でない → SW のfetchハンドラが一切ハンドルせず素通り → オフラインで失敗
Rails 8 は標準で Hotwire/Turbo を使うので、これは Rails 開発者が踏みやすい罠です。修正は、Accept ヘッダに text/html を含む GET も HTML ページ要求として扱うこと(上のコードの acceptsHtml)。この検証と修正確認の詳細は §4 で扱います。
オフラインフォールバックの public/offline.html は、Rails を経由しない静的 HTML(Propshaft が配信)にしました。Tailwind は使わずインライン <style> のみで、SW が確実に precache できる単純な構造です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>オフライン - 資格学習アプリ</title>
<meta name="theme-color" content="#1a1a2e">
<style>
body { /* インラインスタイル */ }
/* ... 略 ... */
</style>
</head>
<body>
<div class="card">
<div class="icon">解</div>
<h1>オフラインです</h1>
<p>インターネットに接続できていません。<br>接続が戻ったら、もう一度お試しください。</p>
<button type="button" onclick="location.reload()">再読み込み</button>
</div>
</body>
</html>「最低限の応答を確実に返す」ことが目的のページなので、依存を最小化するのがポイントです。アプリ側のレイアウトを継承すると、いざオフラインになった時に「レイアウトの CSS が来ない / フォントが来ない」で見栄えが崩れるリスクが上がります。
3-4. 更新した Service Worker をすぐ反映させる
基本形はここまでで動きますが、もう 1 つ要件がありました ──「更新した SW をすぐ反映させる」です。厄介なのが、SW を更新(デプロイ)したときの挙動。SW のファイルを変更してデプロイすると、ブラウザは新しい SW を install しますが、すぐには有効になりません。新 SW はいったん「待機(waiting)」状態に入り、古い SW が制御しているタブ・ウィンドウが全部閉じるまで有効化されない仕様だからです。
ここで効いてくるのが、インストール済み PWA は「完全に閉じる」ことが滅多にないという現実です。ホーム画面から開いたアプリはバックグラウンドに送られるだけなので、古い SW が何日も居座り、SW に入れた修正(キャッシュ戦略やオフライン挙動の直し)がユーザに届きません。学習アプリのように「直したいバグや表示は次の起動で即適用してほしい」ユースケースでは、これがもどかしい挙動です。
3-5. skipWaiting / clients.claim の実装
そこで、3-3 の基本形に skipWaiting()(install フェーズで待たない)と clients.claim()(activate フェーズで既存タブも掌握)を足します。差分は次の 2 か所です。
self.addEventListener("install", (event) => {
event.waitUntil(/* precache */)
self.skipWaiting() // ← 追加: 既存タブが閉じるのを待たない
})
self.addEventListener("activate", (event) => {
event.waitUntil(
/* 古いキャッシュ削除 */
.then(() => self.clients.claim()) // ← 追加: 既存タブも掌握
)
})代わりに払うコストとして、長時間動いている画面(試験モード中に新 SW がデプロイされる、など)では 古い CSS / JS と新しい HTML が混ざるリスクがあります。社内用のシステムとして数人が使うものなので、許容しました。
4. 動作確認 ─ 検証してバグを見つけて直すまで
この Turbo の不具合(§3-3 のコラム)に気づいたのも、実はこの動作確認の最中でした。コードを書いた時点では動くつもりでいたのですが、サーバー側の確認 → Playwright での自動テスト → iPhone 実機、と順に確かめていくと、机上では見えていなかった問題が出てきます。ここでは、実際にやった手順をそのまま書きます。
4-1. サーバー側エンドポイントの確認
まずは地味ですが、precache に並べた URL が全部 200 で返るか、サイズもおかしくないかを curl で一通り確認しました。ここで 1 つでも 404 や 500 があると、SW のコードが正しくても precache が丸ごと失敗するので、先に潰しておきます。結果は 8 ファイルすべて 200、合計 約 140 KB。初回インストール時に一度だけ取りに行けば、あとはオフラインで完結する量でした。
4-2. Playwright で SW のオフライン挙動を自動テストした
Turbo 遷移のオフライン挙動は、手で確かめようとすると地味に面倒でした。「キャッシュを溜めて、オフラインにして、リンクを踏んで…」を毎回手作業でやるのは現実的でない。そこで playwright-core とローカルの Chrome で自動化しました(テストの作成と実行は Claude Code に任せました)。やっていることはこんな流れです。
- テストユーザでログイン
navigator.serviceWorker.getRegistration()で SW がactivatedになるまで待ち、リロードして SW がページを制御する状態にする- nav のリンクをクリック(= Turbo 遷移)して、ガイド・オフライン練習・学習の各ページに移動
caches.keys()/cache.keys()で runtime キャッシュの中身を実際に列挙し、Turbo 遷移先の HTML ページがキャッシュされているか確認context.setOffline(true)でオフライン化- もう一度 nav リンクをクリックし、ページ固有のマーカー文字列が出るか、
offline.htmlのフォールバックになるかを判定
このテストを §3-3 の「最初に書いた版」(mode === "navigate" だけ)で走らせてみると、案の定、Turbo 遷移先がキャッシュに入らず、オフラインでは見事に全滅。acceptsHtml 判定を足した版に直すと、runtime キャッシュに /exams/sap/guides・/exams/sap/offline_practice・/exams/sap/sessions/new がちゃんと入り、オフラインでも中身が出るようになりました。
4-3. iPhone 実機での検証と SW 世代交代の手順
ここまでで自動テストは通っていますが、PWA は実機で見ないと分からないことが多いので、最後は本番(独自ドメイン・HTTPS)を iPhone で触って確かめます。
- Safari で本番 URL を開く(iOS は Safari でないとホーム画面追加が正しく機能しない)
- 共有メニュー → 「ホーム画面に追加」→ 名前欄に manifest の
short_nameが自動で入る - ホーム画面のアイコン(紺色背景に「解」)をタップ → アドレスバーなしの standalone 起動
- オンラインで数ページ巡回(SW のキャッシュを溜める)
- 機内モード ON → 巡回済みページが表示される / 未訪問ページは
offline.htmlが出る
SW を更新したときの注意点
Service Worker を更新(VERSION をバンプ)すると、activate ハンドラが古いバージョンのキャッシュを削除するため、更新直後の runtime キャッシュは空になります。オフライン挙動を確かめるときは「新しい SW を有効化 → オンラインで一度巡回してキャッシュに溜める → それからオフライン」の順で確認しないと、直したはずの挙動が見えないので注意します。
5. ハマったところ・判断の記録
5-1. ImageMagick の Ghostscript エラー
最初は maskable アイコン用の専用 SVG(角丸を消して余白を多めに取った版)を作って、ImageMagick で PNG 化しようとしました。
magick -background "#1a1a2e" icon-maskable.svg -resize 512x512 icon-maskable-512.pngしかしエラーで止まります。
sh: gs: command not found
magick: delegate library support not built-in 'none' (Freetype)ImageMagick の SVG テキストレンダリングは Ghostscript と Freetype デリゲートに依存します。Homebrew の ImageMagick はデフォルトでこれらを含みません。
回避策は 元の icon.png(1024×1024)を直接加工する方式に切り替えるだけでした。ベース PNG にロゴが既にラスタライズされているのでテキスト処理が要らず、-resize 80% + extent だけで maskable 用の余白を持たせられます(§2-2 参照)。
SVG → PNG が必要な場面では、Mac 標準の sips か rsvg-convert(librsvg)の方が確実です。「ImageMagick で何でもできる」と思って深追いすると時間を溶かしがちな領域です。
5-2. theme_color と icon 背景を揃えた理由
一般論として、theme_color はナビゲーションバーやステータスバーの色に使われ、アプリの brand color(例えば今回のアプリならナビバーで使っている indigo-600)を入れるのが普通です。
ただし、PWA の スプラッシュ画面(アイコンタップ後、メインコンテンツが描画されるまでに表示される画面)は theme_color と background_color の組み合わせで描かれます。アイコンの背景色 と theme_color / background_color が違うと、スプラッシュにアイコンを置いたときに背景の境目が見えて、安っぽい印象になります。
今回のアプリでは、アイコン背景 = #1a1a2e、theme_color も #1a1a2e、background_color も同色に揃えました。ナビバーの indigo-600 とは色味が違いますが、スプラッシュの統一感を優先する判断です。apple-mobile-web-app-status-bar-style: black-translucent と組み合わせると、iOS のステータスバーも溶けこんで違和感がなくなります。
5-3. manifest を precache に入れた理由
/manifest は Rails::PwaController 経由で配信される静的 JSON なので、runtime キャッシュに載っても載らなくても動作は同じです。にもかかわらず明示的に precache に含めました。理由は、「PWA インストール時に確実にネット越しに取りに行かなくていい」状態を保証するためです。
ユーザがオフラインで「ホーム画面に追加」フローを始めるシナリオは現実的に少ないですが、地下鉄や機内のような断続的なネット環境でアプリを使い始めたばかりのとき、manifest 取得でつまずく事故は避けられます。precache に 1 行加えるだけのコストで、最初に SW がインストールされた時点で確定する「アプリの素性情報」を、後からのネット状態に依存しなくて済みます。
6. Phase 1+2 で変わったこと、次フェーズの予告
Phase 1+2 を入れた時点で、この学習アプリは 「ブラウザ上で動く Rails アプリ」から「ホーム画面に並ぶアプリのように見える Rails アプリ」 になりました。具体的に得られたものを整理すると次のとおりです。
- ホーム画面アイコンから直接 standalone 起動できる(URL バー / ナビバーが消えて画面が広い)
- ログインセッションを維持したまま再開できる(Cookie が standalone モードに引き継がれる)
- オフライン時に画面遷移しても、白いエラーではなく
offline.htmlが出る - 2 回目以降の起動で、アプリシェル(CSS / JS / アイコン)はキャッシュから配信される
- Android では「ホーム画面に追加」案内が任意のタイミングで表示できる
一方、Phase 1+2 ではまだできないこともあります。これらは続編で扱います。
- オフラインで問題を解く ── 学習問題の本体データはまだサーバ依存。Phase 3(IndexedDB)で扱う
- オフライン中の回答をあとで同期 ── 機内モードで回答 → 接続復帰で送信、を実現する。Phase 4(回答キュー + オンライン復帰時の同期)
- 学習リマインダー通知 ── 1 日 1 問のリマインド等。Phase 5(Web Push 通知 / VAPID 鍵生成 / iOS 16.4+ 対応)
Phase 3 以降は、Phase 1+2 のアプリシェル基盤(networkFirstNavigation でフォールバックさせる構造、precache / runtime キャッシュ分離)を前提に積み上げていく形になります。続編として、第 2 回:Rails 8 の PWA をオフライン対応にした話(Phase 3+4)と 第 3 回:Rails 8 で Web Push 通知を実装した話(Phase 5)を公開しています。本記事を読み終えたら、そのまま続けて読める構成です。
あとがき
本記事は、Rails 8 が用意してくれる PWA ひな型を、PWA として実装するまでの記録です。「manifest と Service Worker のファイル名だけ用意されていて、中身はほぼ空」 という Rails 8 の流儀(必要な枠組みは置いておくが、要件は案件側で決めてください、というスタンス)を理解した上で、その埋め方を一通り具体例で示すことを意識して書きました。
この学習アプリはまだ社内試作の段階で、AWS SAP 試験に合格できるか自分たちで検証している最中です。PWA 化は、その実用性を一段上げるための取り組みでもあります。
MOOBON では Rails / Web アプリケーションの新規開発・既存案件の改修、PWA 化やモバイル体験の設計 を承っています。「ネイティブ vs PWA で迷っている」「既存 Rails アプリに Service Worker を載せる経路を相談したい」といった技術判断のセカンドオピニオンも歓迎です。info@moobon.jp までお気軽にどうぞ。
よくある質問
Qなぜネイティブ iOS / Android アプリではなく PWA を選んだのですか?
この資格学習アプリは社内で AWS SAP 試験勉強のために試作しているもので、まず学習体験を「ブラウザ → ログイン → 学習」から「ホーム画面アイコン → 学習」に短縮したい、というところからスタートしました。ネイティブを選ばなかった理由は 3 つで、(1) App Store 審査と年 $99 / 30% 手数料のコストを案件初期に持ち込みたくない、(2) iOS / Android 両対応を 1 つの Rails コードベースで完結させたい、(3) AI 解説のストリーミング表示(Turbo Streams 経由)など既存実装をそのまま使い回したい。「資格学習」というユースケースなら PWA で必要な体験の大半が取れる判断です。Push 通知や深い OS 連携が必須になってきたタイミングで、改めてネイティブを検討する余地は残しています。
QiOS Safari は beforeinstallprompt を発火しないとのことですが、どう扱うのが現実的ですか?
iOS Safari は beforeinstallprompt を発火しないので、Android Chrome のように「自前ボタン → ネイティブダイアログ」のインストールフローは組めません。ユーザに「共有メニュー → ホーム画面に追加」を手動操作してもらうしかない仕様です。本案件では Stimulus controller でプラットフォームを判定し、iOS の場合は「共有メニュー → 『ホーム画面に追加』を選んでください」という文言バナーを表示、Android の場合は beforeinstallprompt を preventDefault() で保留して自前ボタンから prompt() を呼ぶ二系統に分けました。「あとで」を押された場合は localStorage に dismiss 時刻を保存し、14 日間バナーを抑制。既に standalone モード(display-mode: standalone または navigator.standalone)で起動していたらバナー自体を非表示にする、という整理です。
Qmanifest の orientation で縦固定にしても iPhone で回ってしまいます。なぜですか?
orientation による画面の向きロックは Android Chrome では効きますが、iOS Safari のホーム画面 PWA では基本的に無視されます。そのため manifest で orientation を portrait に指定しても、iPhone では端末の回転に追従して横向きになることがあります。確実に縦前提で使わせたい場合は、manifest の指定だけに頼らず、CSS やレイアウト側でも縦向きを前提にした作り(横向き時の崩れ対策や、横向き時に縦推奨の案内を出すなど)にしておくのが安全です。なお orientation の値は any / natural / portrait / portrait-primary / portrait-secondary / landscape / landscape-primary / landscape-secondary の 8 種類が定義されています。
Qmanifest の categories にはどんな値を指定できますか?
categories はアプリの分類を表す小文字文字列の配列で、アプリストアやカタログ向けのヒントです(必須ではなく、対応するかはカタログ側の実装次第)。W3C が既知のカテゴリ一覧を管理しており、books / business / education / entertainment / finance / fitness / food / games / government / health / kids / lifestyle / magazines / medical / music / navigation / news / personalization / photo / productivity / security / shopping / social / sports / travel / utilities / weather などが定義されています。一覧外の任意の文字列も書けますが、その場合ストアは独自の分類を使います。資格学習アプリなら education(必要に応じて education, productivity)が自然です。
Qmaskable アイコンの safe-zone とは何ですか? どう作るのが楽ですか?
maskable アイコンは Android Adaptive Icon の仕組みで、OS 側が円形・squircle・滴形など任意の形にマスクしてホーム画面に表示します。アイコンの中身(ロゴや文字)は中央の円内(直径 80%)に収める必要があり、外側 10% はトリミング想定の安全余白です。素朴に「ロゴだけ」の画像を渡すと、端が切れたり中央がスカスカに見えたりします。本案件では既存の icon.png(1024×1024、背景 #1a1a2e + 中央に『解』のロゴ)を ImageMagick で 80% にリサイズ → 同色の背景で 100% まで extent、という手順で生成しました。1 コマンドで済みます(後述)。「SVG から ImageMagick で PNG 化」は Ghostscript / Freetype デリゲートが必要で詰まることがあるので、最初から PNG ベースで加工した方が事故が少ないです。
QskipWaiting + clients.claim を入れることにリスクはないですか?
あります。デフォルト挙動では、新しい Service Worker は「すべての既存タブが閉じるまで」古い SW が動き続けます。skipWaiting + clients.claim を入れると、新 SW が起動した瞬間に既存タブも掌握するため、長時間開いている画面で「古い CSS/JS でレンダリングされた DOM」と「新しい HTML/SW」が混ざるリスクが出ます。本案件(学習アプリ、現状ユーザ数も限定的)では、新バージョンへの即時切り替えを優先してリスク受容しました。将来ユーザ規模が増えて長時間セッション(試験モード中など)が中心になってきたら、skipWaiting を外して「タブを閉じれば自動で次回新版」という素直な挙動に戻す選択肢があります。判断軸は「新バージョン即時適用の価値」と「混在リスクの大きさ」の天秤です。
QCSP(Content Security Policy)を有効化したら、SW 登録のインラインスクリプトはどう書き換えますか?
本案件は CSP を無効化したまま(config/initializers/content_security_policy.rb は全コメントアウト)なので、SW 登録は <script>...</script> のインラインで書いています。CSP を有効化する場合、インラインスクリプトは default-src 'unsafe-inline' を許可するか、nonce を発行する必要があります。後者を選ぶなら、Rails のコントローラ層から <%= csp_meta_tag %> 経由で nonce を生成し、<script nonce="<%= request.content_security_policy_nonce %>">...</script> として埋め込みます。よりクリーンな選択は、SW 登録ロジックを app/javascript/ 配下の小さな JS ファイル(例: pwa_registration.js)に切り出し、importmap-rails のエントリポイントに組み込んで配信する方式です。インラインを残さなくて済むので、CSP を強める将来にも耐えます。
