ウィジェットの国旗:ロケール依存の国名よりISO国コードを使う理由
気付きにくい不具合でした。iOSウィジェットは英語では正しい国名を表示していましたが、ドイツ語では誤った国旗の絵文字を表示していました。フランス語ロケールでは、別の国の国旗が間違っていました。国旗が無作為に選ばれていたわけではありません。少し調べると、一定のパターンで間違っていることが分かりました。検索処理は、現在のロケールでの国名が保存済みの表示名に似ている国の国旗を返していたのです。
根本原因は、ローカライズされた国名を辞書のキーに使う検索処理でした。あるロケールでは変わらない文字列でも、別のロケールでは異なる文字列になります。修正には、検索処理全体をISO 3166-1 alpha-2コードを基準に作り直す必要がありました。この教訓は、国旗の絵文字以外にも広く当てはまります。
バグが現れた経緯
ウィジェットは、他のデータとともに国の一覧と対応する国旗の絵文字を表示していました。当初の実装では、「Germany」や「France」といった国の表示名を識別子として保存し、それをキーにした辞書で対応する国旗の絵文字を検索していました。
英語ロケールでは正常に動作していました。保存された名前も辞書のキーも「Germany」なので、正しい国旗が返されます。しかし端末のロケールをドイツ語にすると、NSLocale.current.localizedString(forRegionCode: "DE")は「Deutschland」を返します。ウィジェットはこのメソッドで表示名を動的に取得していたため、表示名は「Deutschland」になりました。表示としては正しいものの、英語名をキーにした辞書の検索には失敗し、既定値にフォールバックして誤った国旗を表示していました。
さらに見つけにくいのが、部分一致による問題です。ロケールによっては、ある国のローカライズされた名前が、別の国の英語名と同じ接頭部分を持ったり、よく似た文字列になったりします。厳密な一致を確認しなければ、あいまいな検索がもっともらしく見える誤った結果を返します。英語ロケールだけでQAを行っても、この問題は見つかりません。
ロケール依存の名称を識別子に使う問題
根本的な問題は、ロケールに依存する文字列を識別子として使うことです。識別子は、誰が、いつ、どの言語環境で読んでも同じ値でなければなりません。現在のロケールでの国名は、表示用の値です。ユーザーに表示するものとしては正しく適切ですが、検索キー、データベースの識別子、キャッシュキーなど、変わらない参照値として使うと誤りを招きやすくなります。
これは国名に限りません。通貨名、言語名、ロケールの表示名、タイムゾーンの説明も同じ性質を持ちます。システムが生成するこれらの文字列はロケールに依存し、識別ではなく表示のために使うものです。NSLocale.current.localizedString(forCurrencyCode: "EUR")は英語でもドイツ語でも「Euro」を返し、偶然一致します。一方、localizedString(forCurrencyCode: "GBP")は英語では「British Pound」、ドイツ語では「Britisches Pfund」を返します。どちらかをキーにすれば、同じ種類の不具合が発生します。
原則は単純です。ロジックでは常にISOコードを識別子として使い、ローカライズ用のメソッドを呼ぶのはユーザーへの表示文字列を作るときだけにします。ローカライズされた文字列を保存せず、キーに使わず、異なるロケール間でプログラムによる比較もしないことです。
ISOコードから国旗の絵文字を生成する
国旗の絵文字は、Unicodeでは2つのRegional Indicator Symbol(地域指示記号)の組として符号化されます。各国の国旗を表す文字列は、その国のISO 3166-1 alpha-2コードの各文字を対応する地域指示記号に置き換えたものです。地域指示記号は、Aに対応するU+1F1E6からZに対応するU+1F1FFまであります。
Swiftでは、短いユーティリティ関数でISOコードから国旗の絵文字を生成できます。
func flagEmoji(for isoCode: String) -> String {
let base: UInt32 = 0x1F1E6 - 65 // offset from A
return isoCode.uppercased().unicodeScalars.compactMap {
UnicodeScalar(base + $0.value)
}.map { String($0) }.joined()
}
この関数に「DE」を渡すと🇩🇪が返り、「SG」を渡すと🇸🇬が返ります。辞書も対応表のファイルも不要で、保守も必要ありません。対応関係はUnicode標準で定義され、ISO 3166-1 alpha-2コードに直接対応しています。
対処すべき例外は、すべての2文字コードに対応する国旗の絵文字があるわけではないことです。UNやEUなどは、ISO 3166-1で国に割り当てられたコードではなく予約済みのコードであり、対応する国旗の絵文字はプラットフォームによって表示が異なります。また、上記の関数は入力を検証せず、渡された文字をそのまま変換します。そのため、変換前に、入力が割り当て済みのISO 3166-1 alpha-2コードの一覧に含まれること(A〜Zの英字でちょうど2文字であること)を確認する必要があります。入力を国と地域に割り当てられたコードに限定し、対象プラットフォームでテストすれば、このケースに対応できます。
それでも必要だった150以上の国の対応表
ISOコードから国旗の絵文字をアルゴリズムで生成すれば、表示側の処理は解決します。ただし、国を構造化して保存・検索する必要もありました。ウィジェットは国を地域別に絞り込み、さまざまな基準で並べ替え、各国に追加のメタデータを関連付けていたためです。これには適切な対応表が必要でした。
当社が作成した対応表は、ISO 3166-1 alpha-2コードに、正規の英語名(内部利用とログ用)、ISO 3166-1の数字コード(数値の識別子を必要とするシステム用)、地域区分を対応付けています。英語名を検索キーに使うことはありません。ローカライズされない環境でのデバッグや管理用の表示にのみ使います。ユーザー向けの名前はすべて、描画時にNSLocale.current.localizedString(forRegionCode:)で生成します。
この分離が、アーキテクチャ上の重要な判断です。データ層はISOコードを使い、表示層で現在のロケールに合わせて翻訳します。その中間で両者を混在させません。国旗の検索、地域区分、並び順はすべてISOコードを基準に処理し、最終的にユーザーへ表示するテキストだけをローカライズします。
修正を他のデータに拡張する
国旗の不具合を修正した後、コードベースの残りにも同じパターンがないか調べました。同じ問題が他に3か所見つかりました。ローカライズされた通貨名をキャッシュキーに使う通貨表示モジュール、ローカライズされた言語名をユーザー設定に保存する言語選択機能、ロケール依存の月名を比較する日付書式モジュールです。
いずれも同じ方針で修正しました。ロケール依存の文字列を、対応する変わらないISOまたはIETFの表現に置き換えます。通貨名は通貨コード(ISO 4217)に、ローカライズされた言語名はBCP 47言語タグに、月名の文字列は月を表す数値に置き換えました。ローカライズされた文字列は、表示層だけで扱います。
体系的に調査する価値がありました。これらの不具合はどれも、英語ロケールのテストでは見えず、別の言語を設定した端末で本番利用されて初めて表面化するものです。多言語のユーザーを対象とするアプリだったため、影響は大きくなっていたはずです。
本番環境での問題の広がり
この種の不具合が残り続ける理由の1つは、単一ロケールの開発環境では見えないことです。英語ロケールで国際的なユーザー向けのアプリを開発する場合、国表示機能の実装からテストまで、不具合を一度も起こさずに完了できてしまいます。不具合が現れるのは、端末のロケールが辞書や対応表を作成したときのロケールと一致しない場合だけです。そして、それこそが対象ユーザーの大半に当てはまる条件です。
ウィジェットでは、制約のある拡張機能の環境で実行されるため、メインアプリ以上にこの隔たりが大きくなります。ウィジェット拡張機能は現在のシステムロケールで動作し、開発者がテストしたロケールとは異なることがあります。デバッグもメインアプリほど直接的ではありません。Xcodeでウィジェット拡張機能のスキームを実行するか、Debug > Attach to Processを使いますが、タイムラインとビューのコードは、開発者自身の操作に応じてではなく、WidgetKitのスケジュールに従って実行されます。ウィジェットのロケール依存の描画不具合を特定するには、対象のロケールで拡張機能を実行し、サンプルのタイムラインエントリーを使ったXcodeのプレビューを利用して、拡張機能が実際に描画される環境を注意深く確認する必要があります。この手順は、ユーザーから本番環境の不具合を報告されるまで省略されがちです。
実務では、ロケール依存の表示ロジック全体を、個別に直す不具合ではなく、共通のリスクを持つ処理として扱う必要があります。コードレビューには、ロケール依存の文字列が識別子として使われていないかの確認を含めます。確認事項は単純です。この文字列は、キーや比較値として使われる前にローカライズ用のメソッドを通っているか?通っているなら設計を変える必要があります。最終表示以外のすべての層で、ローカライズされた文字列をISOコード、数値の識別子、その他のロケールに依存しない参照値に置き換えるべきです。
ロケールをまたいだテスト
この不具合から得られる最も直接的な教訓は、ロケールのテストを後付けにせず、標準のテスト項目に組み込む必要があるということです。iOSでは比較的簡単に対応できます。シミュレーターはシステム言語を変えずにロケールを切り替えられ、UIテストにもロケールをパラメータとして指定できます。
国旗と国名の表示については、英語、ドイツ語、フランス語、日本語、アラビア語の固定の端末ロケールで実行するスナップショットテストを追加しました。固定のISOコード群に対応する国旗の絵文字が、どのロケールでも正しいことを確認します。表示経路にロケール依存の検索処理が持ち込まれても、これらのテストですぐに回帰を検出できます。
より広い原則として、ローカライズ、国際化、地域依存の表示に関わる機能には、複数のロケールで実行する自動テストが必要です。単一ロケールでの手動QAでは代替できません。ロケールに依存する前提が原因の不具合はよく発生し、単一ロケールのテストでは一貫して見えないからです。
関連記事
- タイマーアプリのDynamic Island:実装と教訓:アプリ内のテストでは分からないプラットフォームの挙動の違いが現れる、別のiOS拡張機能の事例
- 関係計算のための閉包テーブル:名前ではなくIDという変わらない識別子で、信頼性の高い検索・走査ロジックを構築する方法