技術メモ

ネイティブより先にPWA、Flutter Webで届けた判断と配信

英会話アプリ「イエタ」は、Windowsの利用者へネイティブ版ではなくFlutter WebのPWAを本体にして届けています。その理由と、紹介ページとアプリを分けた配信の構成、独自ドメイン、Webで困った点の記録です。

公開日
AI の利用
AI が作業記録をもとに下書きし、記録との照合を行ったうえで、運営者が内容を確認して公開しています。 編集方針

英会話アプリ「イエタ」は、Windows の利用者へ、ネイティブアプリではなく Flutter Web の PWA を本体にして届けています。PWA(Progressive Web App)は、ブラウザで開く Web アプリを、パソコンにインストールしてアプリのように使える形式です。2026年10月2日にこう決め、10月5日に紹介ページとアプリを分けた構成で本番に出し、独自ドメイン ieta.warp-studio.com(別タブで開きます) で公開しました。Web にしたことで、認証の作り直しとストア審査を後回しにできました。代わりに、入力欄のフォーカスやフォント、キャッシュ、音声といった問題が Web 版の操作で見つかっています。

決めたこと

再始動の初日、採算の評価で配布の道筋を比べました。Windows ネイティブ(MSIX というパッケージで Microsoft Store に出す形)、Web 版(PWA)、Android です。推奨は Web を本体にすることで、私もそれを選びました。目標の文書には「Windows ユーザーへは Web 版(PWA)を本体にして届ける。Store には PWA として掲載することも検討する。Windows ネイティブ(MSIX)は後回し」と残っています。それまでのリリース手順書は MSIX を作ってストアに出す前提でしたが、Microsoft Store の個人の開発者登録は2025年9月から無料で、登録の費用はどちらもほぼかかりません。差が出たのは、認証、審査、読み上げ、届く範囲でした。

ネイティブを後回しにした理由

当時の Windows 版は Firebase を無効にしていて、認証がありませんでした。使用回数も端末内だけで管理していて、書き換えれば上限なしで AI を呼べる状態です。Windows では Firebase の SDK が使えず、認証は REST API で自作する必要がありました。Microsoft Store のポリシーも、自前の決済には利用者の認証を求めています。Web なら SDK がそのまま動きます。

読み上げも違います。ネイティブ版は PowerShell 経由の SAPI で、古い機械音声に起動の遅れが加わります。Web ならブラウザの音声を使え、Edge には高品質なオンライン音声があります。Web 版は Android や iPhone のブラウザでも動く作りなので、Google Play のクローズドテスト(12人が14日続けて試す)を待たずに届けられます。

観点Windows ネイティブ(MSIX)Web 版(PWA)
認証Firebase の SDK が使えず REST で自作SDK がそのまま動く
審査本人確認、生成 AI の明記、自前決済の申告などなし(特商法の表記は必要)
決済の手数料自前決済なら Microsoft 0%+StripeStripe のみ
読み上げPowerShell 経由の SAPIブラウザの音声
他の環境Windows のみAndroid・iOS のブラウザでも動く

失うものもあります。Flutter Web は画面を canvas(ブラウザの描画領域)に絵として描くので、検索エンジンが文章を読めません。トップがアプリのままでは、検索に何も見せられません。

静的な紹介ページと /app/

そこで10月5日に配信物を組み直しました。Firebase Hosting が配る build/site に、静的な紹介ページ、シーン別のフレーズページ38本、404、robots.txt、sitemap.xml を置き、Flutter のアプリは /app/ の下に入れます。

アプリは flutter build web --release --base-href /app/ で作ります。base href は、アプリが読み込み先の基準にするパスです。サイトをまとめるスクリプトは、ビルドの index.html に <base href="/app/"> が無ければ止まります。Hosting の設定は次のとおりです(hosting の部分を、一部省いて改行しています)。

{
  "public": "build/site",
  "rewrites": [{ "source": "/app/**", "destination": "/app/index.html" }],
  "headers": [
    { "source": "**/*.@(js|json|html|css|xml|txt)",
      "headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
    { "source": "/",
      "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }
  ]
}

書き換え(rewrites)は /app/ の下だけです。アプリの深い URL を直接開いてもアプリが返り、それ以外の存在しない URL は 404 になります。robots.txt では /app/ をクロールの対象から外しました。

シーン別ページの「練習する」は /app/?scenario=<シーンの ID> へのリンクです。初めて来た人は最初の説明画面を省き、そのシーンのロールプレイから始まります。紹介ページの回数・価格・シーン数は、アプリとサーバーの定義ファイルから読んで埋めます。宣伝の文と実際の上限をずらさないためです。

1年のキャッシュをやめた

上の no-cache には経緯があります。以前は JavaScript に1年のキャッシュ(max-age=31536000)が付いていました。Flutter Web の main.dart.js と flutter_bootstrap.js は、ファイル名に内容のハッシュが入りません。更新を出しても一度開いた人のブラウザには古いアプリが残り、新しいサーバーと食い違って壊れるおそれがありました。

気づいたのは、製品監査の3回目でアプリを操作していた評価用の AI です(評価の仕組みは改善ループのゲート設計の制作ノートに書きました)。設定を no-cache に変え、ブラウザが毎回 ETag(内容の識別子)で変更の有無を確かめ、変わっていなければ取り直さないようにしました。

出したあとに確かめる12項目

本番へは1コマンドで出します。出す前には、できあがった build/site を手元で開いて自動で操作し、本番には通信しません。

出したあとは、ログインしていない呼び出しの拒否、トップが紹介ページであること、/app/ の下の深いパス、存在しないページの 404 など、12項目を確かめます。10月5日の反映では全部通りました。本番のアプリは開かず、静的なページだけをスマホ幅で見ています。本番に利用者のデータを作らないためです。

独自ドメインと証明書

公開の URL は、改名と同じ日に ieta.warp-studio.com にしました(経緯は「はなせるAI」から「イエタ」へ改名した理由の制作ノート)。Firebase Hosting に独自ドメインを登録し、DNS を管理している Cloudflare に CNAME レコード(別名の指定)を足して、Firebase の既定のホスト名を指しました。Cloudflare のプロキシは OFF です。Firebase Authentication の承認済みドメインにも加えました。製品監査の5回目は、本番の Google ログインがこの設定次第で失敗し得ると書いていました。

DNS を足して約6分後と約18分後に確かめたときは、ドメインの所有の確認は済んだものの証明書はまだ検証中で、新しい URL は開けませんでした。約23分後、改名を本番に出し終えたときには発行されていて、新しい URL で紹介ページが開きました。出したあとの確認では、発行中の独自ドメインを失敗扱いにせず、注意だけを出します。

Web 版で見つかった問題

入力欄のフォーカス

会話の入力欄の不具合は、作者の確認で3回 FAIL してから原因にたどり着きました。AI の返事を待つあいだに次の文を打つと、文字が消え、フォーカスが外れたのです。原因は2つです。返事を待つあいだ入力欄そのものを無効にしていたこと。読み上げが始まると入力欄の直前に表示が差し込まれ、キー(Flutter が部品を見分ける印)のない入力エリアの要素がそちらに使い回されて、入力欄が作り直されていたことです。見た目はフォーカスが当たっているのに、キー入力が届きませんでした。原因自体は Web に限りませんが、見つかったのは Web 版の操作でした。

// 入力エリア: キーが無いと、直前に差し込まれた読み上げ中の表示に要素が再利用される
Container(
  key: const ValueKey('conversation-input-area'),
  // …
)

// 入力欄(TextField): 応答待ちでも有効のまま。送信だけを送信処理の側で止める
enabled: _isServiceInitialized && usage.canUse,
textInputAction: TextInputAction.send,
onEditingComplete: () {}, // Enter で送ってもフォーカスを外さない

アクセシビリティを有効にした状態(スクリーンリーダーを使うときの状態)では、Enter で送ると150ミリ秒以内にフォーカスが画面全体へ移り、2通目を打てない現象もありました。送ったあとに付け直すと、通常の利用ではスマホのキーボードが一瞬閉じるので、アクセシビリティが有効なときだけ付け直しています。Windows のナレーターの実機では、まだ確かめていません。

フォントの読み込み

Flutter Web は、日本語や絵文字のフォントを必要になった時点で読み込みます。読み込み前に並べたボタンは、フォントが届くと文字だけ測り直され、外枠は最初の幅のまま残りました。表現ノートの絞り込みが「誤り訂」で切れたのはこのためで、フォントが変わった通知を受けて行を作り直すようにしました。オフラインでは日本語の一部が「□」になる問題も残っていて、よく使う字を同梱するかを検討中です。

読み上げの声と音声入力

読み上げは Chrome の Google の声や Edge の Online Natural の声を優先していましたが、一覧の先頭を取る作りだったため、Edge では豪州英語、Chrome では英国英語の声が選ばれることがありました。品質と en-US かどうかで点数を付ける形に変えています。

int score(Map v) {
  final name = v['name']?.toString() ?? '';
  final locale = v['locale']?.toString() ?? '';
  final highQuality = (name.contains('Google') && name.contains('English')) ||
      (name.contains('Online') && name.contains('Natural'));
  return (highQuality ? 2 : 0) + (locale == 'en-US' ? 1 : 0);
}

音声入力は、マイクを許可していないと押しても何も起きませんでした。ブラウザが返すエラーの種類(not-allowed、no-speech など)ごとに日本語の案内を出し、入力欄にフォーカスを移してキーボードで続けられるようにしました。

白い起動画面と、0時をまたぐタブ

main.dart.js は約3.6MB あり、読み込むあいだ画面が真っ白でした。index.html にアイコンと「読み込んでいます…」を置き、Flutter の最初の描画で消しています。3回目の監査では、手元の配信で0.3秒後に読み込み表示が出ました。

開いたまま0時をまたぐと、サーバーは新しい日なのに、アプリは「残り0回」のままという不具合もありました。評価者は「Windows の PC ではタブを開いたままにしておく使い方が多い」と書いています。今は日付が変わったら、今日の回数や目標を新しい日に切り替えます。

まだ確かめていないこと

自動の操作は画面を出さないブラウザで行うので、音そのものは確かめられません。目標の文書は、Windows の Edge と Chrome で音声認識から読み上げまでの1往復が実機で動くことを完了の条件にしています。 スマホは画面幅を 390×844 にしたヘッドレス Edge で代用しただけで、Android や iPhone の実機のブラウザでは試していません。PWA のインストール案内と Microsoft Store への掲載は、10月5日の時点では次の作業か検討の段階です。

アプリそのものの紹介はイエタの製品ページにあります。

制作ノートの一覧へ