Flutter ARB ファイルの重複キーで死にコードが生まれた話 — 韓国語対応で発覚した罠


月謝袋アプリに韓国語対応(4言語目)を実装している途中、ARB ファイル(Application Resource Bundle)に同じキーが2回定義されているという問題に気づきました。

しかも Flutter は警告を出さず、後勝ちで動いてしまっていたという、なかなか気づきにくいバグです。

発見の経緯と教訓を残しておきます。


目次

起きたこと(3行まとめ)

  • 同じ app_en.arb の中に "pricePerMonth" が 2回定義されていた
  • Flutter / Dart の挙動として 後で書かれた値で上書き(JSONの一般的な後勝ち仕様)される
  • 前半に書かれた古い値は 死にコードとして完全に放置されていた

どうやって発見したか

月謝袋アプリは v1.6.0 時点で日本語・英語・スペイン語に対応済み。今回 v1.7.0 に向けて韓国語対応を進めていました。

ARB ファイル(lib/l10n/app_ko.arb)を作成し、411キー全部を翻訳。flutter pub get で app_localizations_ko.dart も自動生成され、本番画面・デモ画面ともに韓国語表示は正常に動作。

ところが、カレンダーロック画面(プレミアム機能のロック解除を促すモーダル)に表示されている価格が、なぜか他の画面と整合しないことに気づきました。

画面表示
カレンダーロック画面₩4,500/월로 시작
プレミアムプラン詳細평생 구매 $17.99 / 월정액 $1.99/월

同じアプリ内で価格表示が異なる。これはユーザーから見て確実に混乱を招く挙動です。


原因の追跡

カレンダーロック画面のコードを確認:

// lib/pages/premium_calendar/premium_calendar_widget.dart
Text(
  l10n.pricePerMonth,        // 「買い切り ¥2,800 で使う」
  ...
),
Text(
  l10n.promoPriceLifetime,   // 「月額 ¥300/月 で試す」
  ...
),

_priceString(RevenueCat の動的価格)は使っておらず、純粋に ARB の値を表示しているだけ。だとすれば、ARB の pricePerMonth と promoPriceLifetime を見れば原因が分かるはず。

そこで grep -c でキーの登場回数を数えてみたら、衝撃の事実が:

$ grep -c '"pricePerMonth"' lib/l10n/app_en.arb
2

$ grep -c '"promoPriceLifetime"' lib/l10n/app_en.arb
2

同じキーが2回定義されている。

実際の中身を見ると:

// 87行目(古い)
"pricePerMonth": "Start at ¥300/month",

// ... 中略 ...

// 488行目(新しい)
"pricePerMonth": "Buy lifetime for $17.99",

87行目には英語版なのに ¥300 というおかしな値が残ったまま。一方488行目には正しい値。


なぜこうなったか(歴史的経緯)

これは月謝袋アプリの開発の歴史を辿ると見えてきます:

  1. 初期(単一プラン時代): 月額300円のサブスクのみ。pricePerMonth は「月額の価格」という意味で使用
  2. 2プラン展開時: 「買い切り」プランを追加。表示を「買い切り ¥2,800で使う / 月額 ¥300/月で試す」の2段構成に
  3. このタイミングで:既存の pricePerMonth キーを 削除せずに、ARB の末尾に同名キーを追加してしまった
  4. JSON的には不正だが Flutter は警告を出さず、後勝ちで Buy lifetime for $17.99 が表示される

つまり、87行目の古い値は 「死にコード」として完全に放置されていたわけです。


なぜ韓国語版だけ価格表示がズレたのか

英語版・スペイン語版は重複キーの両方を更新して新しい値を末尾に追加していました。

// app_en.arb
87:  "pricePerMonth": "Start at ¥300/month",       ← 古い(死にコード)
488: "pricePerMonth": "Buy lifetime for $17.99",   ← 新しい(後勝ちで採用)
// app_es.arb
87:  "pricePerMonth": "Desde $1.99/mes",                ← 古い(死にコード)
474: "pricePerMonth": "Comprar para siempre",            ← 新しい(後勝ちで採用)

ところが、韓国語版を新規作成する際に、私はお手本として日本語版 ARB を参考にしてしまいました。日本語版は当初から単一プラン構成のキー名で書かれていて、後半の重複キー追加が反映されていなかったようです。

結果、韓国語版には:

// app_ko.arb
87:  "pricePerMonth": "₩4,500/월로 시작",   ← これしかない(後半に新しい値がない)

これが「韓国語版だけロック画面に古い ARB 値が表示される」現象の正体でした。


教訓:ARB ファイルを扱う indie dev へのチェックリスト

同じ罠にハマらないために、ARB ファイルを編集するときのチェック項目を残します:

✅ 1. 新しい言語を追加するときは、必ず英語版を「お手本」にする

日本語版や他の言語版は歴史的経緯で構造が異なる可能性があります。最も新しく更新されている ARB ファイル(多くの場合は英語版)を基準にコピーすると安全です。

✅ 2. キーの重複チェックを習慣化する

for key in $(grep -o '"[a-zA-Z_]*":' lib/l10n/app_en.arb | sort -u); do
  count=$(grep -c "$key" lib/l10n/app_en.arb)
  if [ "$count" -gt 1 ]; then
    echo "⚠️  $key appears $count times"
  fi
done

このシェルスクリプトで重複キーが一発で見つかります。CI に組み込んでもいいかもしれません。

✅ 3. JSON の linter を通す

VS Code で JSON 拡張を有効にすると、重複キーは警告として表示されます。ただし .arb 拡張子だと自動認識されないことがあるので、エディタで明示的に「JSONとして開く」設定をする必要があります。

✅ 4. キー名は「画面の役割」で命名する

pricePerMonth(月額の価格)のような 意味が固定された命名だと、後から仕様が変わったときに名前と実態が乖離します。今回のケースでも、コメントに // 「買い切り ¥2,800 で使う」 と書かれていて、キー名と実態が完全にズレていました。

代替として:

  • lockScreenMainCTA(ロック画面のメインボタン)
  • lockScreenSubText(ロック画面のサブテキスト)

のように 画面と役割で命名すれば、表示内容が変わってもキー名と意味が乖離しません。

✅ 5. 死にコードは早めに削除する

「とりあえず後で消そう」と残した古いキーは、永遠に死にコードとして生き残ります。新キーを追加するタイミングで古いキーは必ず削除するルーチンを作りましょう。


まとめ

ARB ファイルは JSON ベースですが、Flutter のローカライゼーションエンジンは 重複キーがあっても警告を出さず、後勝ちで動いてしまうことが今回の検証で分かりました。

これは便利な反面、死にコードが見えないバグの温床にもなります。多言語対応を進めるたびに ARB ファイルが膨らんでいく以上、定期的なクリーンアップとチェック体制は必須です。

月謝袋アプリの v1.7.0 では、この重複キー問題を含めて ARB ファイルの構造を整理してからリリースする予定です。


余談:韓国語対応自体は順調

ARB の罠に時間を取られましたが、韓国語対応の本筋(411キーの翻訳、ロケール依存フォーマット、サンプルデータの韓国版作成)はスムーズに進みました。

수업료 봉투 앱 として、韓国の音楽教室の先生にも届くアプリになりつつあります 🎸


月謝袋アプリ – SINQWELL
https://sinqwell.net

目次