PWA
イベント
該当するコンテンツが見つかりませんでした
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
こんにちは、モバイルアプリ開発部の Yao です。 Web サイトの URL とアクセス認証情報を渡すと、サイトをクロールして API 仕様を逆生成し、Compose Multiplatform(CMP)の Android/iOS アプリを自動生成して、原サイトとのスクリーンショット比較で検収まで行う——そんなローカル AI パイプラインを作り、実際に自社サービス KINTO のステージング環境を動くアプリに変換してみました。結論から言うと、 アプリファーストはもはや予算の問題ではなく、ツールチェーンの問題 です。本記事ではパイプラインの設計、生成されたアーキテクチャ、実際のコード、原サイトとの並列比較スクリーンショットまで、その全工程を紹介します。 この記事でわかること Web サイトは、それ自体が 仕様書 (ページとフロー)+ テストデータ (実 API トラフィック)+ 受け入れ基準 (モバイルスクリーンショット)である ローカル AI パイプラインが「クロール → 仕様のリバースエンジニアリング → コード生成 → スクリーンショット照合検収」を閉ループで回す 成果:16 画面の仕様化、P0 の 4 画面をエンドツーエンド実装、API 31 オペレーション生成、 テスト 107 本全グリーン 、Android エミュレーター + iPhone シミュレーターで動作 DTO は録画済み JSON から機械的に導出し、 バックエンドを想像で補わない オフラインデモ(Recorded モード)は同一の Ktor クライアントを MockEngine に載せ替えるだけで、 プロダクションのパース経路をそのまま通る :::message alert 本記事のパイプラインは 自社サイト (認証情報と権利を自分が持つサイト)を変換するためのものです。他社サイトのスクレイピングに使うものではありません。クロール対象はすべて自社のステージング環境です。 ::: 1. なぜアプリファーストは避けられないのか まずデータから。日本では、スマートフォンでインターネットを利用する個人の割合が 2016 年の 57.9% から 2025 年には 74.3% へ上昇し、同期間に PC は 58.6% → 45.6% に下落しました( 総務省 通信利用動向調査 )。 利用時間の差はさらに顕著です。全年代のインターネット平均利用時間(平日)はモバイル機器が 116.8 分/日 に対してパソコンは 50.0 分/日 と 2 倍以上、休日には 133.5 分 vs 23.6 分 と 5 倍超まで開きます( 総務省情報通信政策研究所「令和6年度 情報通信メディアの利用時間と情報行動に関する調査」 )。 さらにスマートフォンの上では、ユーザーはブラウザではなくアプリの中にいます。米国の成人はモバイルのインターネット利用時間の 約 9 割 をアプリに費やしています( eMarketer )。 米国市場では、この議論は 10 年以上前に決着しています。 Instagram :長年、Web 版は閲覧専用だった Uber :Web クライアントはアプリを動かせない端末向けの軽量フォールバック Venmo :2018 年に決済機能を Web サイトから丸ごと撤去 プッシュ通知はリテンションの生命線、ホーム画面のアイコンはブランドの恒久的な陣地、OS 統合(決済・生体認証・ウォレット・共有シート)は当然の前提。あちらでは、サービスにアプリが「要るかどうか」を問う人はもういません。 「PWA でいいのでは?」という反論には先に答えておきます。Web Push は確かに存在します(iOS も 16.4 からホーム画面追加済みの Web アプリに開放)が、ユーザーがまずページを手動で「インストール」する必要があり、配信の信頼性も OS 統合の深さもネイティブプッシュには遠く及びません。10 年経ってもギャップは開いたままで、リテンションの勝負は依然ネイティブ側で決まっています。 2. では、なぜ皆やっていないのか 古典的なコスト表が残酷だからです。 項目 内容 デザイン工程 コードを書く前に、Web の体験をモバイル向けに再設計 コードベース × 2 Swift と Kotlin で同じプロダクトを二度実装 チーム × 2 2 種類のスキルセットで採用 ロジックの二重化 同じバグを二度直す。同じ機能が別々の時期に出る。仕様合わせ会議が永遠に続く その上に QA 倍増 + アプリストア運用 本当の急所は v1 ではありません。 v1 以降のすべての機能が、永遠に二度ずつリリースされる ことです。この繰り返し課税が、「Web で十分」を先送りから既定方針へと硬化させてきました。 3. 計算式を変える 2 つの技術 最近 2 つのことが起き、しかも互いに増幅し合っています。 AI が労働コストを消した。 エージェントは既存の Web サイト——レンダリング済みページ、スクリーンショット、実 API トラフィック——を読み取り、その 証拠 からアプリを書けるようになりました。Web サイトはすでに仕様書でありテストデータであり受け入れ基準です。あとは誰かが読むだけ。エージェントは疲れずに読みます。 CMP が二重化コストを消した。 1 つの Kotlin コードベースから Android アプリと iOS アプリが生まれます。「すべての機能を二度リリースする」税は、規律ではなく 構造 によって消滅します。 ここで決定的なのは AI 側の形態です。魔法のプロンプト 1 発ではなく、 クロール → 仕様のリバースエンジニアリング → 仕様からコード生成 → スクリーンショットで照合検収 という パイプライン であること。以降はこれを実サイトに適用した記録ですが、その前に 2 つの設計判断——なぜ ローカル なのか、なぜ CMP なのか——を片付けます。 4. なぜ「ローカル」AI パイプラインなのか クラウド型の変換 SaaS が不可能とは言いませんが、ローカルを選んだ理由は 2 つ、どちらもモデル性能とは無関係です。 ① 権限と認証情報がローカルに留まる。 使うのは環境変数から読む認証情報だけ(gitignore 済みの .env と Playwright のログイン状態)。今回スコープに入れた 16 画面はすべて公開ページで、セッションなしでも到達できます(一部のパスには別系統のアクセス制限がかかっており、そちらの認証を渡していないので後述の 401 になります)。社外に預ける判断は重く、自分のラップトップで動くなら ただの社内ツール で済みます。 境界も明確です。オーケストレーション(今回は Claude Code)・スクリプト実行・認証情報はローカル、 モデルはクラウドなのでページ内容と API サンプルはマシンを離れます 。クロール対象は会員エリアを含まない公開相当のページに限り、API ログはマスキング済みです。 ② ローカルのモバイル開発環境にそのまま繋がる。 Xcode、Android SDK、エミュレーターが同じマシンにあるので、 クロール → 仕様 → コード生成 → ビルド → スクリーンショット検収を 1 回の実行で通せます 。同じパイプラインがエミュレーターを操作して撮影し、原サイトと並べた差分を P0/P1/P2 に分類、深刻なものはループ内で修正(iOS の操作は現状手動)。クラウドでやるなら、2 つの OS のビルドツールチェーンとデバイスファームを自前で抱えることになります。 ローカルかどうかとは独立した、パイプライン自体の方針も 2 つ。 証拠であって、推測ではない。 Playwright が収集するのは レンダリング済み の 60 ページ——HTML、スクリーンショット、 実 API トラフィック (マスキング済みサンプル)。仕様はここから導かれます。 成果物は契約、フェーズはゲート。 各フェーズは成果物を書き出してから次へ( artifacts/ → spec/ → app/ → report/ )。決定的に実行できる工程はエージェント自身のスクリプトで、各ゲートを人間が見ます。 :::message パイプラインの鉄則: バックエンドを想像で補わない。 トラフィックで観測されなかったフィールドは、存在しない。 ::: 5. なぜ Compose Multiplatform なのか 私は KMM の時代から KMP / CMP を追いかけてきました( これまでに書いた記事はこちら )。今回ターゲットに CMP を選んだ理由は 3 つです。 一貫性。 KMP のデータ層とドメイン層が 1 つで両 OS に仕えるので、iOS と Android のビジネスロジックは 乖離しようがない 。実装が 1 つ、テストスイートも 1 つだからです。 効率。 2 倍ではなく約 1 倍の工数。v1 でも、それ以降のすべての機能でも。1 チームで両ストアに出荷。 性能。 Android では——日本はさておき世界のスマホの過半です——Compose は Google が Android の標準 UI ツールキットとして提供しているものそのもので、ファーストパーティアプリと同じ ART 上、同じ描画パイプライン上で動きます。つまり CMP アプリの Android 側は、 プラットフォーム標準の外に別のレンダリング層を積みません (Compose 自身は View ツリーではなく自前のコンポジションを描画しますが、その先は Android のグラフィックススタックです)。iOS では CMP も Skia で描画します——独自エンジンを積むという点は他のクロスプラットフォームフレームワークと同じアーキテクチャ上の賭けです——が、Kotlin/Native が共有コードをマシンコードにコンパイルし、Android 側は標準ツールキットのまま。今回ベンチマークを取ったわけではないので優劣は断言しませんが、 Android 側で追加の描画層を背負わない という構造上の差は残ります。 スライドに収まらないが実務で効く理由: 既存チームには Android 側がタダ同然。 Compose はすでに Android の標準。CMP アプリの Android 半分は、いまの Android エンジニアが初日から読める慣用的なネイティブコード。Flutter は全員に Dart 乗り換えを要求します。 段階導入できる。 データ層だけの共有も、UI 全体の共有も可。既存ネイティブアプリへの埋め込みも SwiftUI/UIKit との相互運用も可。移行の道筋であって「全面書き直しか、さもなくば」ではない。 両陣営のファーストパーティが支える。 CMP は JetBrains 製。Google は KMP を公式サポートし、自社プロダクションで使用し( Google Docs が採用済み 、Workspace 他アプリも追随中)、androidx の KMP 版(Lifecycle / ViewModel / Room / DataStore。Navigation の CMP 版は JetBrains 管理)を出荷。CMP の iOS 対応は 2025 年 5 月、1.8.0 で stable 到達 。プラットフォームの所有者と言語の生みの親が同じスタックに収斂している——次の 10 年の投資先を示すこれ以上ないシグナルです。 プラットフォーム API に直接アクセス。 expect/actual で Keychain / EncryptedSharedPreferences をネイティブに呼ぶ。待たされるプラグイン層なし。 スタック全体が 1 言語。 Kotlin で端から端まで、Ktor でバックエンドまで。採用市場もツールチェーンも成熟。 そして本記事全体を貫く理由がこれです。 :::message CMP は AI コード生成の理想的なターゲットである。 強く型付けされた単一コードベースでは、コンパイラがエージェントの全出力に対する無料の検証器になる。 kotlinx.serialization の DTO は記録済み JSON から機械的に導出できる。1 回の生成が全プラットフォームをカバーする。AI と CMP は足し算ではなく 掛け算 で効く。 ::: 6. ケーススタディ:KINTO ステージング → 動く CMP アプリ 6.1 パイプライン全体像 5 フェーズ。各フェーズは成果物を書き出してから次へ進み、フェーズ間に人間のレビューゲートがあります。 CRAWL — サイトをモバイル開発のリファレンスに変える 1 ページを 4 つの成果物として記録します。 モバイル(390×844 @2x、iPhone UA、 isMobile / hasTouch )とデスクトップ(1440×900)のフルページスクリーンショット、 networkidle 後の レンダリング済み DOM 、そのページが実際に叩いた API、そして各エンドポイントのレスポンス実体。モバイル側が変換の正解データで、Phase 5 の検収でも同じ画像と並べます。 サイトのモバイル表示がそのまま設計書になる ので、「モバイル向けに作り直す」工程が消えます。 API はページ単位で記録します。 各エントリに「どのページが引き起こしたか」が付くので、画面 → 必要データ → エンドポイントが機械的に繋がります(見積りシミュレーションの ?step=1&memberType=corporate&term=7&bonus=0 が car-models/{carCode}/selectable-contract を叩く、という粒度で)。値を型トークンに置き換えたレスポンス形状( float / iso8601 / url / null )も保存し、これが DTO の型付けの根拠になります。実体サンプルはエンドポイントごとに最大 3 件、ハッシュで重複排除——複数サンプルの和集合が「どのフィールドが nullable か」を決め、同じファイルが後で MockEngine のリプレイと契約テストにも使われます。 URL をテンプレート化して画面を数えます。 パス 1 セグメントずつ、識別子らしいものを型に畳むだけです。 function templatize(pathname: string): string { return pathname.split("/").map((seg) => { if (/^\d+$/.test(seg)) return "{id}"; if (UUID_RE.test(seg)) return "{uuid}"; // CAR-0000003908 → {carCode}、GRD-… → {grdCode} if (PREFIXED_CODE_RE.test(seg)) return `{${seg.split("-")[0].toLowerCase()}Code}`; if (isOpaqueToken(seg)) return "{token}"; return seg; }).join("/") || "/"; } これで同型ページは最大 3 インスタンスだけ辿れば済み、60 ページは 58 テンプレートに整理されて、これが 16 画面の候補になります。ページごとの outlinks はナビゲーショングラフに、 img / table / iframe /繰り返しカードの構造シグネチャはコンポーネント一覧に、参照されている CSS とフォントは実ファイルとして取得してデザイントークンに回します。 Phase 2 で、サイトは機械可読な契約に変わります。 app-spec.json の 16 画面それぞれに、ソース URL・優先度・コンポーネント・必要データ・明示的なモバイル適応プランが与えられます。本件のようにサイトがすでにレスポンシブ対応済みなら、この適応設計はモバイルビューポートのクロール結果から直接導出され、 パイプラインに吸収されます 。 { "id": "Home", "priority": "P0", "components": ["TopBar", "HeroCarousel", "CarCard", "SectionHeader", ...], "dataNeeds": ["CarouselSlide", "RecommendedCarList", "CorpContent", ...], "mobileAdaptation": "Hero carousel → HorizontalPager with page indicator, 16:9 slides using the imageUrl_x2 asset. The desktop 3-column grid collapses to 2 columns ... 51 images on this page: everything below the first two sections must be lazily loaded via AsyncImage." } 念のため補足すると、この抜粋は 実ファイルのままで、正しい仕様ではありません 。 16:9 slides という横長前提が誤りで、CMS が実際に配信しているのは正方形素材でした。6.4 で触れるレターボックス不具合(実装は 16:10 の枠になっていました)の根っこは、この一行です。仕様そのものも検収ループの検査対象だということです。 API 契約は記録トラフィックから合成した OpenAPI で、そのことをファイル自身がヘッダーで宣言しています。 info: title: KINTO (staging) — reverse-engineered API description: |- Reverse-engineered from real traffic captured in artifacts/api-log.json. Nothing here is invented: every schema is the union of observed samples, a field missing from any sample is nullable. No mutation endpoints exist in this spec: the crawl observed GET traffic only. :::message この仕様は自身のカバレッジの穴も正直に記録しています。取得できたのは 60 ページで、それとは別に 12 の URL は取得できませんでした——404 が 6、アクセス制限による 401 が 6( /kinto_one/lineup/ と SUBARU 系。4 章で触れた別系統の認証が必要なパスです)。取得できなかったものは「未取得」として仕様に残し、その画面には手を出していません。 ::: デザイントークンも同じ証拠基準です。色・タイポグラフィ・余白はサイトの 24 個の CSS ファイル(2.3MB)から出現頻度順に抽出、WCAG コントラスト比は 計算 済み。ブランドカラーがモバイルで AA を満たさない箇所には、トークンファイルがアクセシブルな代替色を用意してテキストへの使用を強制します。 6.2 生成されたアーキテクチャ Clean Architecture + MVI、すべて commonMain です。 押さえるべきポイントは 1 つだけ。 :::message Recorded モードはモック実装ではない。 第二のコードパスは存在しない。同じ生成済み ApiClient 、同じ JSON 設定、同じマッパーが両モードで動き、Koin が差し替えるのは足元の Ktor エンジンだけ。 ::: メカニズムの全文がこれです。 single<HttpClientEngine> { val config: AppConfig = get() when (config.dataMode) { DataMode.Recorded -> recordedEngine(get()) // MockEngine replaying the crawl DataMode.Live -> platformHttpEngine() // OkHttp / Darwin } } つまりオフラインデモは プロダクションのパース経路をそのまま通ります 。実ペイロードをパースできない DTO は、ライブサーバー相手とまったく同じように Recorded モードでも失敗します。 もう 1 つ、どんなコードレビューでも擁護したいディテール:リクエストに合致するサンプルがないとき、リプレイエンジンは空の 200 ではなく 501 を返します。沈黙の成功を許すと、実際には何も流れていない画面が「実装済み」に見えてしまうからです。 ビジネスロジックは 100% commonMain 。 expect/actual は本当に避けられない箇所(セキュアなトークン保存 → Keychain / EncryptedSharedPreferences、プラットフォーム HTTP エンジン)にのみ現れ、各箇所に理由がドキュメント化されています。 6.3 AI が生成した CMP コードの実物 DTO は導出されるもので、幻覚ではない。 車種カタログのエンドポイントは、CMS 風味の混沌としたネーミングのフィールドを 80 個以上持つオブジェクトを返します。ジェネレーターは記録済みサンプル全体の和集合を取ります。全サンプルに存在するフィールドは非 null、どれか 1 つでも欠けていればデフォルト値付き nullable。 @Serializable data class CarCatalogEntryDto( val name: String, // in every sample → required @SerialName("fuel_label") val fuelLabel: String? = null, // absent in some → nullable @SerialName("year3_bonus0m_taxin") val year3Bonus0mTaxin: String, // … 80+ fields, mechanically derived from recorded JSON ) このクラスを喜んで手書きする人間はいません。書く必要もありませんでした——そして、どのフィールドも 推測 されていません。判定はこの 1 行だけです。 // そのフィールドが現れたオブジェクト数 === 観測したオブジェクト数 のときだけ required const req = (s.propSeen?.get(k) ?? 0) === s.seen; パイプラインの原理はここに凝縮されています: 記録 → 全サンプルの和集合 → 型 → コード 。どの矢印もモデルの判断ではなくスクリプトの計算なので、同じ記録を入れれば同じ DTO が出ます。モデルの仕事は、この機械的な出力の上に画面を組むことだけです。 API 面は型付きで、自分の出自に正直。 生成されたクライアントの全関数が、どの記録済みリクエスト由来かをドキュメント化しています。 /** * Contract terms and bonus-payment options available for a car model * Recorded as: GET /api/price/v1/web/car-models/{carCode}/selectable-contract */ suspend fun getSelectableContract(carCode: String): ApiResult<SelectableContractDto> = runCatchingApi { client.get(hosts.main + "/api/price/v1/web/car-models/$carCode/selectable-contract") .bodyOrThrow() } すべての画面が同じ固定の MVI 形状。 sealed な Intent が入り、 StateFlow が出て、ナビゲーションはワンショットの Effect。 sealed interface HomeIntent { data object Load : HomeIntent data class CarClicked(val car: CarSummary) : HomeIntent // … } class HomeViewModel( private val getHomeFeed: GetHomeFeedUseCase, private val hosts: ApiHosts, ) : ViewModel() { private val _state = MutableStateFlow(HomeUiState()) val state: StateFlow<HomeUiState> = _state.asStateFlow() fun onIntent(intent: HomeIntent) { /* exhaustive when */ } } この均一性は美学ではなく、 画面生成を安全に並列化できる理由そのもの です。テーマ・ナビゲーショングラフ・共有コンポーネントが契約としてコミットされた後は、独立したエージェントが 1 画面ずつ引き受けられます。 契約テストが、すべてを現実に釘付けにする。 クライアントと同時に生成され、オペレーションごとに 1 本、 すべての 記録済みサンプルをプロダクションのクライアントに通します。 /** Contract terms and bonus-payment options — 3 recorded sample(s). */ @Test fun `getSelectableContract parses every recorded sample`() = runTest { val results = ContractTestEnv.forEachSample("getSelectableContract") { env -> env.api.getSelectableContract(carCode = SAMPLE_CAR_CODE) } // サンプル 0 件のまま緑になる事故を防ぐ。件数は生成時に KDoc と同じ値で埋め込まれる assertTrue(results.isNotEmpty(), "no recorded sample was replayed") assertEquals(3, results.size, "recorded sample count changed") results.forEach { assertTrue(it is ApiResult.Success, "failed to parse: $it") } } 件数のアサートは、あとから付け足した保険ではありません。 forEachSample が空リストを返すと——オペレーション ID の綴りを 1 文字間違えるだけで起こります—— forEach は何も検証せずに通ってしまい、「107 本グリーン」が何も意味しなくなります。生成器はサンプル数を知っているので、その数をテストに焼き込みます。 テスト環境は意図的にプロダクションのオブジェクトを使います——本物のクライアントファクトリ、本物の JSON 設定、本物のリプレイエンジン。スイート全体(生成契約テスト + mapper + 回帰)で 107 テスト、失敗 0 。釘付けにしているのは記録された現実なので、後日ライブ API がずれれば、次の再クロール時に同じスイートが捕まえます——本番クラッシュではなく、 赤くなったテスト として。 6.4 Web vs アプリ、並べて比較 検収ループは Android エミュレーター(API 35、1080×2400)上でアプリ自身のナビゲーションを操作し、実装済み画面を撮影します。全工程 Recorded モードなので、ライブ API には一度もリクエストを出していません(画像だけはサイトの CDN から読み込みます)。 各ペアは原サイトとセクション単位で照合します。上の 4 枚で実際に確認できるのは、ホームのヒーローコピーと CTA、PICK UP セクションの実 CMS バナー(後述の iOS スクリーンショットでは 14 枚ぶんのページインジケーターが見えます)、ラインアップの TOYOTA / LEXUS タブとカテゴリチップ、車種詳細の月額料金と納期目処、見積りのプラン選択と下部固定サマリーバーです。差分は P0/P1(コンテンツ欠落、構造・ナビゲーション破損)→ ループ内で修正、P2(ピクセルレベルの磨き込み)→ 記録、と分類処理します。 このスクリーンショットは差分も正直に写しています。ホームのヒーローは、原サイトでは背景ビジュアルに商品写真の装飾イラストが載っていますが、アプリ側はコピーと CTA だけのテキストブロックになっています。装飾素材がクロールの成果物から機械的に取り出せなかったためで、現時点では既知の差分(8 章の P2 リスト)です。 ループが実際に機能した例も 1 つ。ホームのカルーセルは当初バナーを 16:10 の枠にレターボックス表示していましたが、CMS が実際に配信しているのは 630×630 の正方形素材でした。スクリーンショット比較で発覚 → 修正 → 再撮影 → 解消。 :::message スクリーンショット内の料金・納期・キャンペーン表記は、 2026 年 8 月時点のステージング環境の内容 です。実際のサービスの料金や取扱内容は変わります。最新の情報は KINTO 公式サイト をご確認ください。 ::: そして同じコードベースが、 画面コードの追加ゼロ で 2 つ目のプラットフォームに乗ります。  6.5 正直な数字ボックス 項目 実績 仕様にリバースエンジニアリングされた画面数 16 (優先度・コンポーネント・必要データつき) 現時点でエンドツーエンド実装済みの画面数 4 ——P0 セット:Home、Lineup、CarDetail、Estimate 生成された API オペレーション数 31 、4 ホスト横断——すべて GET。ミューテーションは観測されず、想像で足してもいない テスト(契約 + mapper + 回帰) 107 本、全グリーン 動作環境 Android エミュレーター + iPhone シミュレーター——ライブ API 不使用。画像のみサイトの CDN から読み込み 検収中のライブ API への接触 一度もなし (画像取得のみ CDN にアクセス) :::message alert セキュリティに関わる 3 つの決定: DTO は記録データからのみ導出(バックエンドを想像で補わない) ミューテーション系エンドポイントは、仮に観測されても生成 + モックカバレッジまで。明示的な承認なしにライブサーバーへは撃たない 認証情報(ステージングへのアクセス認証。会員ログインではない)は gitignore 済みの環境ファイルにのみ存在し、実行時に環境変数として読み込む。コードにもモデルのコンテキストにも入らない ::: 7. AI を信頼できるものにした工学(デモの手品ではなく) モデルの能力は必要条件であって、十分条件ではありません。実際に重さを支えたのは、その周りを固める工学でした。 固定アーキテクチャという契約。 Clean Architecture + MVI、Koin、Ktor、Coil——生成の前に決定済み。エージェントは即興で形を発明せず、既知の形に中身を埋める。だから成果物はレビュー可能で、失敗は診断可能。 手作業よりスクリプト。 決定的に実行できるものはすべて、エージェントが書いて自分でデバッグするスクリプト。スクリーンショット取得スクリプトも、アプリのコードと同じくパイプラインの産物。 全コミットにゲート。 iOS ターゲットは全コミットでコンパイル必須——クロスプラットフォームの腐敗は最後ではなく数分で捕まる。 1 画面 = 1 タスク = 1 コミット。 並列化は共有契約(テーマ、ナビゲーション、コンポーネント)のコミット後にのみ解禁。 検収は事後ではなくループの内側に。 人間がレビューする前に、エージェントのスクリーンショットは原サイトのスクリーンショットと対決を済ませている。 この 5 つの根底にある姿勢は 1 つです。 :::message 生成ステップは信頼しない——包囲する。 決定的にできるもの(スクリプト、コンパイルゲート、契約テスト)はすべて決定的にし、エージェントの自己採点によるスクリーンショット比較は、すべてのゲートで人間が再レビューする。 ::: 8. 限界を、正直に 16 画面中 12 画面はまだプレースホルダー。 画面単位で線形にスケールする 作業 であって、一発の魔法ではない。 埋め込みコンテンツ (動画、地図、サードパーティウィジェット)はプレースホルダー + expect/actual の移行プランどまり。共通コードに WebView という逃げ道はない。 見た目の既知の差分(P2)。 クロールで取得できないアイコン素材とヒーローの装飾イラスト、フォントウェイトの機微。見積り画面は固定バーぶんの content inset がなく、下端のコンテンツが隠れます(6.4 のスクリーンショット)。iOS のジェスチャーの質感は実機検証が必要。 クロールが捉えるのはハッピーパス。 エラー状態、深いページネーション、パーソナライズ、A/B バリアントは記録に含まれない。 読み取り専用。 31 オペレーションすべて GET、会員エリアはスコープ外。見積りの「保存」「次へ」もアプリ内では書き込まず、選択内容を積んだ URL で原サイトに引き渡します—— ないものは想像で補わず web に戻す 。トランザクション系は次フェーズで、多くのプロダクトにとってはそちらが難しい半分。 適用範囲は自社サイトのみ。 認証情報と権利を自分が持つサイト向けの道具であって、他社プロダクトのスクレイピング道具ではない。 9. ロードマップにとっての意味 コスト曲線は反転しました。かつてアプリ化で最も高くついた部分——モバイル再デザイン(サイトがレスポンシブ版を持つ場合)、ボイラープレート、二重のデータ層、仕様合わせの維持——が、いまや最も安い部分です。人間の労力は本当に判断が要る場所に集中します:どの画面が P0 か、ブランドは何を要求するか、何を Web に残すか。 現実的なチーム像: エンジニア 1 人 + パイプライン で、両プラットフォームで動くレビュー可能なビルドに到達(本記事の範囲では P0 の 4 画面まで。残り 12 画面は同じ手順の反復です)。ネイティブのスペシャリストは最後の 10%(ジェスチャーの質感、埋め込みプレイヤー、ストア向けの磨き込み)で合流。「2 チームで 1 年」とはまったく別次元の予算会話です。 まとめ アプリファーストは、予算の問題であることをやめ、 ツールチェーンの問題 になりました。 あなたがすでに運用しているその Web サイトは、実は 3 つのものを兼ねています。 仕様書 (ページとフロー) テストデータ (実 API トラフィック) 受け入れ基準 (モバイルスクリーンショット) AI パイプラインの仕事はこの 3 つを読み込むことだけ。Compose Multiplatform の仕事は、その結果をすべての端末で動かすことだけ。 プロダクトの居場所とユーザーの居場所のあいだの距離は、いまや年単位ではなく 日単位 です。埋めにいきましょう。 参考リンク 総務省 通信利用動向調査(令和7年調査) 総務省情報通信政策研究所 令和6年度 情報通信メディアの利用時間と情報行動に関する調査(概要) eMarketer — Mobile Internet: Average Time Spent in the US, App vs. Browser JetBrains — Compose Multiplatform 1.8.0: iOS Is Stable and Production-Ready Android Developers Blog — Android's Kotlin Multiplatform announcements at Google I/O and KotlinConf 25 WebKit — Web Push for Web Apps on iOS and iPadOS
はじめに NTT西日本の中川です。 本記事ではフロントエンド(ブラウザ)だけで利用できるデータの保持方法とデータベースサービスを厳選してご紹介します。 本記事は、2025年12月時点の情報に基づきます。 対象読者 本記事が想定する対象読者は次の通りです。 駆け出しエンジニア フロントエンド縛りでデータの保持が必要な状況の人 データの保持に対して難しそうなイメージを持っている人 背景 プログラミングの勉強を始めたばかりの方や、簡単なアプリケーション開発に挑戦しようとしている方は、多くの場合、まず「どこにデータを保存するか」という課題に直面するのではないでしょうか。 最初はデータを保存しないシンプルなアプリから始めると思いますが、開発を進めるうちに 「データの保持」 というテーマは避けて通れない領域になってきます。 しかし、プログラミング学習を始めたばかりの段階では、本格的なデータベースを用意し、サーバーサイドの環境を構築するのは、個人開発の初期段階では少し敷居が高く感じられます。 「できるだけお金を使わずに」「できるだけ手軽に」 データを保持したいというのが本音ではないでしょうか。 私自身もデータをどう扱うかについて悩んだ経験があります。 サクッと作ってどんどん数をこなしたい、という当時の自分だったらという個人的なニーズが、今回の記事作成のきっかけです。 この記事が、皆さんのエンジニアライフに少しでも役立てば嬉しいです。 本記事で紹介するデータ保持アプローチの全体像 フロントエンド開発だけでデータを保持(ブラウザを閉じる/アプリのタブを閉じてもデータを保持すること)するには、大きく分けて以下の2つのアプローチがあります。 クライアントサイドで保持 : データがユーザーのブラウザ(デバイス)内に保存される サーバーサイドで保持 : データがサーバーに保存される 例:PaaS、BaaS上などのサーバーまたはストレージ 今回は、特に入門に適した以下の方法を解説します。前半のクライアントサイドの2つをメインに見ていきましょう。 カテゴリ 方法 データの保存場所 主な用途 クライアントサイドで保持 LocalStorage ユーザーのブラウザ内 (簡易データ) ユーザー設定、セッション情報の保持、簡易的なオフライン利用 IndexedDB ユーザーのブラウザ内 (構造化データ) 大量の構造化データの保持、本格的なオフラインアプリケーション (PWA) サーバーサイドで保持 Supabase/Firebase サーバー上のデータベース 複数ユーザー間の共有、認証・認可が必要な本格アプリケーション 1. LocalStorage LocalStorageは、JavaScriptの標準機能で利用できる、最も手軽なデータ保持方法の一つです。 データはユーザーのブラウザ内に保存されます。 特徴 事前準備 : JavaScriptの標準機能なのでブラウザだけですぐに利用できます 構造 : キーと値のシンプルなセット(Key-Valueストア) 保存形式 : 文字列 (String) のみ。オブジェクトや配列を保存するには JSON.stringify() で文字列化が必要 データ量 : 比較的小さい(5MB程度) 用途 : ユーザー設定(ダークモード/ライトモード)、セッション情報、簡易なToDoリストなど サンプルコード(データの保存と取得) // 【保存】キー 'username' に 'Nakagawa' という文字列を保存する localStorage . setItem ( "username" , "Nakagawa" ) ; console . log ( "データを保存しました" ) ; // 【取得】キー 'username' の値を取得する const storedUsername = localStorage . getItem ( "username" ) ; console . log ( "取得したデータ:" , storedUsername ) ; // 出力: 取得したデータ: Nakagawa // 【削除】キーを指定してデータを削除する localStorage . removeItem ( "username" ) ; console . log ( "データを削除しました" ) ; データの保存、取得、削除が3行ほどで完結します。 格納されたLocalStorageの値はブラウザの検証ツールで確認できます。 今回は、Google Chromeを利用して確認していきます。 検証ツールの起動方法 起動 Windows : Ctrl + Shift + I または F12 Mac : Command + Option + I 確認箇所 Applicationタブを選択 サイドバーのLocalStorageを選択すると自身のサイトのURLがあるので、それをクリック サンプルコードで登録した username が登録されていることを確認できる 補足(オブジェクトを保存する場合) オブジェクトを保存する場合は、値の保存/取得時に文字列変換↔︎JSON変換が必要なので要注意 const userSettings = { theme : "dark" , notifications : true } ; // ダークモードなどの設定を想定 // オブジェクトを文字列(JSON)に変換して保存 localStorage . setItem ( "settings" , JSON . stringify ( userSettings )) ; // 取得後、JSONを元のオブジェクトに戻す const storedSettingsString = localStorage . getItem ( "settings" ) ; const storedSettingsObject = JSON . parse ( storedSettingsString ) ; console . log ( "取得したオブジェクト:" , storedSettingsObject . theme ) ; Tips かつて広告トラッキングやコンバージョン計測などではよくCookieが使われていました。 数年前からAppleがSafariに ITP(Intelligent Tracking Prevention) と呼ばれるユーザーのウェブ上でのトラッキングを制限することを目的としたプライバシー保護機能を搭載したことで(Cookieの有効期間などへの規制が盛り込まれています)、LocalStorageが代替案として使われてきましたが、 最近、さらに要件が厳しくなってきています。 LocalStorageは意外と身近に使われている機能なので、興味があれば色々調べて見るととても面白いです。 2. IndexedDB IndexedDBは、LocalStorageよりも大容量で、本格的なデータベース機能をブラウザ内で提供してくれます。 非同期処理が前提で、リレーショナルデータベースに近い構造化データを扱えますので、 開発の勉強やちょっとした処理にも便利です。 特徴 構造 : オブジェクトストア(NoSQLのコレクションに相当) 保存形式 : JavaScriptのオブジェクトをそのまま保存可能 データ量 : 比較的大容量(数GBまで) 用途 : オフラインで動作するPWA (Progressive Web App) のキャッシュ、大量の構造化データ保持 サンプルコード(データの保存と取得) IndexedDBは複雑なため、今回はデータの追加 (Add) と取得 (Get) に焦点を当てたシンプルな例を紹介します。 // データベース名とバージョンを定義 const DB_NAME = 'MyTodoDB' ; const DB_VERSION = 1 ; const STORE_NAME = 'tasks' ; // データベースを開く(存在しない場合は作成) const request = indexedDB . open ( DB_NAME , DB_VERSION ) ; let db ; // データベースのバージョンが変わった時(初回作成時も含む)に実行される request . onupgradeneeded = ( event ) => { db = event . target . result ; // オブジェクトストアを作成(ここでは 'id' をキーとする) db . createObjectStore ( STORE_NAME , { keyPath : 'id' , autoIncrement : true }) ; } ; // 接続成功時 request . onsuccess = ( event ) => { db = event . target . result ; console . log ( 'IndexedDB接続成功' ) ; // サンプルデータの追加と取得を実行 addTask ({ text : '記事を完成させる' , priority : 'High' }) ; getAllTasks () ; } ; request . onerror = ( event ) => { console . error ( 'IndexedDBエラー:' , event . target . error ) ; } ; // 【保存】データの追加関数 function addTask ( task ) { // トランザクションを開始し、読み書きを許可 const transaction = db . transaction ([ STORE_NAME ] , 'readwrite' ) ; const store = transaction . objectStore ( STORE_NAME ) ; // オブジェクトをそのまま追加 const addRequest = store . add ( task ) ; addRequest . onsuccess = () => { console . log ( 'タスクが正常に追加されました:' , task ) ; } ; addRequest . onerror = ( e ) => { console . error ( 'タスク追加エラー:' , e . target . error ) ; } ; } // 【取得】全データの取得関数 function getAllTasks () { const transaction = db . transaction ([ STORE_NAME ] , 'readonly' ) ; const store = transaction . objectStore ( STORE_NAME ) ; const getRequest = store . getAll () ; getRequest . onsuccess = () => { console . log ( '全タスク:' , getRequest . result ) ; } ; } 格納された値はLocalStorage同様にブラウザの検証ツールで確認できます。 こちらも、Google Chromeを利用して確認していきます。 検証ツールの起動方法 起動 Windows : Ctrl + Shift + I または F12 Mac : Command + Option + I 確認箇所 Applicationタブを選択 サイドバーのIndexedDBを選択すると先ほど作成した「MyTodoDB」があるので、それをクリック tasksテーブルをクリック サンプルコードで登録した MyTodoDB と tasks が登録されていることを確認できる 実行方法の補足 ここまで紹介したブラウザで動く LocalStorage と IndexedDB は、試すだけなら実はわざわざファイルを作成する必要はありません。 結果を確認するために使っている検証ツールの「 Console 」というタブに、今回紹介しているコードをそのまま貼り付けるだけで動きます。 LocalStorageの章で紹介した内容をConsoleにコピペして実行 3. サーバーサイドにおけるデータ保持の紹介 最後に、クライアントサイドの制約(データ量の制限、複数ユーザー間での共有ができない)を超えて、本格的なアプリケーション開発に進むためのアプローチを簡単に紹介します。 BaaS (Backend as a Service) は、サーバー側の機能(データベース、認証、ストレージなど)を、APIを通じてフロントエンドから手軽に利用できるように提供するサービスです。サーバーサイドの構築作業を行わずに、高度なデータ保持を実現できます。 Supabase と Firebase SupabaseはPostgreSQLベースのリレーショナルなデータベース、FirebaseはNoSQLのリアルタイムデータベースを提供しています。どちらも無料枠があり、駆け出しのエンジニアが本格的なWebサービスを開発する際のデータ保持に適しています。 用途 : 複数ユーザー間のデータ共有、ユーザー認証・認可、リアルタイム通信が必要なチャットアプリなど。 特徴 : フロントエンドのコードから、サービスが提供する専用のライブラリ(SDK)を使って、クラウド上のデータベースに直接データの読み書きができます。 これらのサービスは、さらに次のステップに進むための強力な武器になります。 このブログでも機会があれば紹介したいなと思います。 一層本格的なアプリケーションに挑戦する際には、ぜひ調べてみてください。 まとめ 本記事では、プログラミング初心者やフロントエンド開発を主軸とする方が、手軽かつ安価にデータを保持するための主要なアプローチを解説しました。 この記事を通じて、データの保持は決して難しいものではなく、実は手軽に開発のニーズに合わせて適切なツールを選べることが伝えられていたら嬉しいです。まずはLocalStorageやIndexedDBで実際に手を動かし、開発を加速させていきましょう!💪 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本の法人向けの顧客ポータルサイトを開発・運営しています。 TypeScript, Vue.js, GraphQL, Laravelが好きです。 参考文献 IndexedDB の使用 - Web API | MDN ウェブストレージ API - Web API | MDN 商標 「Supabase」は、Supabase Inc.(米国デラウェア州法人)の商標もしくは商標登録です 「Google Chrome」「Firebase」は、Google LLCまたはその関連会社の商標もしくは登録商標です
動画
該当するコンテンツが見つかりませんでした
書籍
該当するコンテンツが見つかりませんでした









