Android strings.xml 多言語対応 — エスケープ、書式引数、複数形、修飾子
翻訳から戻ってきた strings.xml は、XML として完全に正しいのにビルドが通らないことがあります。逆に、ビルドは通ったのに、その文字列を表示した瞬間にアプリが落ちることもあります。Android のローカライズ不具合はほぼこの隙間で起きていて、ファイルを目で見ても分かりません。
strings.xml を翻訳に出して受け取ると、壊れ方は結果の重さが全く違う二つのグループに分かれます。以下の一覧と表は、意図的に壊したファイルを Android のリソースコンパイラ(aapt2 2.19、build-tools 34.0.0)に通した結果と、同じ書式文字列を Java の String.format に通した結果です。
ビルドで止まる壊れ方と、製品まで流れる壊れ方
aapt2 は Gradle がリソースに対して実行するツールなので、その判定がそのままビルドの判定になります。
- ビルド失敗: エスケープされていないアポストロフィ(unescaped apostrophe in string と報告される)
- ビルド失敗: 名前空間宣言が消えた xliff:g 要素(unbound prefix)
- ビルド失敗: 位置指定のない置換が2個以上(multiple substitutions specified in non-positional format)
- ビルド失敗: quantity 属性に規定外のキーワード(item in plural has invalid value 'singular' for attribute 'quantity')
- そのまま出荷: 翻訳文から書式引数が1個消えた場合。数値が黙って文から消える
- そのまま出荷: 引数の型が入れ替わった場合。整形時に例外が出る
- そのまま出荷: 改行のバックスラッシュが落ちた場合。2行が1行につながる
- そのまま出荷: 半角の % が全角の % になった場合。引数ではなくその文字が表示される
必要な検査は「XML として読めるか」ではなく「各言語ファイルのプレースホルダが、既定ファイルと個数も型も1対1で一致しているか」です。
出荷されてしまう壊れ方はいずれも Java の標準例外になるので、クラッシュレポートで見分けがつきます。整数と文字列の指定子が入れ替わると IllegalFormatConversionException、引数を1個増やすと MissingFormatArgumentException、指定子の構文が壊れると UnknownFormatConversionException です。
XML の検証では捕まらない Android 固有のエスケープ
アンパサンドと山括弧に XML エンティティが必要なことは、どの XML 検証ツールでも教えてくれます。Android は XML の上にもう一層独自の規則を重ねていて、翻訳者が壊すのはこの層です。アポストロフィが全部裸のファイルを xmllint に通してもエラーは1件も出ませんが、aapt2 はコンパイルを拒否します。
リンク後のパッケージからダンプした、実際に格納される値です。
strings.xml の中身 格納される値 Loading... "Loading..." " Loading... " " Loading... " Now loading "Now loading" Line one\nLine two 実際の2行 Press <b>Start</b> to continue "Press Start to continue" + 6〜10文字目に太字 You\'ll lose progress "You'll lose progress" "You'll lose progress" "You'll lose progress"
先頭と末尾の空白は削られ、内部の連続した空白は1個に縮みます。ラベルをスペースで桁合わせしても、その桁合わせは消え、警告は出ません。値全体を直立ダブルクォートで囲めば空白はそのまま保たれ、この引用符は画面には出ません。
アポストロフィの直し方は、前にバックスラッシュを置くか値全体をダブルクォートで囲むかの2通りです。フランス語・イタリア語・ポルトガル語・カタルーニャ語ではアポストロフィが例外ではなく語形の一部なので、どちらの記法を使うか依頼書に書き、初回納品は裸のまま返ってくる前提で組みます。
最後の行は別の理由で重要です。b、i、u のようなタグは削除もされず、文字として表示もされません。コンパイラはプレーン文字列に対する装飾範囲として、文字位置で記録します。翻訳者には、タグは残す、開閉を対応させる、その言語で強調すべき語を囲むように位置を動かしてよい、と伝えます。
書式引数に位置指定が必須である理由
Android はこれらの文字列を Java の String.format で整形します。パーセント記号そのものを出したいときは2つ重ねます。裸の % は次の文字と合わせて2個目の非位置指定の置換と数えられるため、多くは aapt2 が弾きます。見逃すのは末尾に来た場合で、HP %1$d% はコンパイルが通り、整形時に UnknownFormatConversionException になります。
もう1つは位置指定です。aapt2 は位置指定のない置換を2個持つ文字列を拒否します。引数が2個以上ある文では位置指定は好みの問題ではなく必須で、語順が英語と違う日本語にとってはこれは制約より救いです。
<string name="pickup">You picked up %1$s (x%2$d).</string>
<string name="pickup_ja">%1$sを%2$d個 手に入れた。</string>
<string name="hp">HP %1$d%%</string>
String.format("%1$sを%2$d個 手に入れた。", "エーテル", 3) -> エーテルを3個 手に入れた。
String.format("%2$d x %1$s", "Ether", 3) -> 3 x Ether
String.format("%1$s %s %s", "a", "b") -> a a b3行目が罠です。位置指定のない指定子は自前のカウンタを持ち、位置指定つきの指定子はそれを進めないため、裸の1個目は引数1に戻ります。両方の記法を混ぜると、型が偶然合えば引数が二重に出て、合わなければ例外になります。位置指定を使う文字列に裸の指定子を残さないでください。逆に、書式文字列ではないテキストで % を並べたいときは、要素に formatted を false で付けると検査が外れ、値はそのまま保持されます。
複数形は言語ごとに使うキーワードが違う
plurals 要素は、件数による if-else の代わりに文法カテゴリごとに1つの文字列を用意する仕組みです。quantity 属性は自由なラベルではなく、zero、one、two、few、many、other のいずれかで、どれを使うかは CLDR の複数形ルールが言語ごとに決めています。aapt2 はその言語に必要なキーワードが揃っているかを見ず、other が1件もない plurals もそのまま通りました。
以下のカテゴリは Node の Intl.PluralRules から読み出したものです。
使うカテゴリ 言語 other 日本語、韓国語、中国語、タイ語、ベトナム語、インドネシア語 one other 英語、ドイツ語、オランダ語、トルコ語 one many other フランス語、スペイン語、ポルトガル語、イタリア語 one few many other ロシア語、ウクライナ語、ポーランド語、チェコ語 zero one two few many other アラビア語 ロシア語: 1=one 2=few 5=many 21=one 100=many 101=one 102=few フランス語: 0=one 1=one 2=other 1000000=many
つまり日本語の翻訳者が one の項目を削るのは正しい判断です。日本語には単数形という別の形がなく、どの数値でも other が選ばれます。危険なのは逆方向で、one と other だけをロシア語に渡すと、2件、5件、102件がどれも誰も書いていないカテゴリを選びます。
plurals が面倒を見るのは数の一致だけで、性や格は扱いません。それが必要な文には ICU MessageFormat の plural と select 引数 のような構文が向きます。カテゴリの割り当ての出どころは CLDR がロケールごとに何を決めているか にまとめてあります。
修飾子ディレクトリと、省略できない既定の values
翻訳は同じ相対パスに並ぶ別ファイルとして置き、それぞれのディレクトリ名に修飾子を付けます。地域の部分には r が前置されます。両方の形をコンパイルした結果が次のとおりです。
res/values/strings.xml 既定 - 必須 res/values-ja/strings.xml 日本語 res/values-pt-rBR/strings.xml ポルトガル語(ブラジル) - 通る res/values-pt-BR/strings.xml error: invalid configuration 'pt-BR' res/values-b+sr+Latn/strings.xml セルビア語ラテン文字 - 通る
文字体系の副タグのように言語と地域だけで足りない場合は、b とプラス記号で副タグをつなぐ形に変わります。受け付けられる形は公式の App resources ドキュメントの「サポートされる設定修飾子」の章で確認してください。タグの組み立て方は BCP 47 言語タグの構造 に従います。
修飾子のない既定ディレクトリは必須です。日本語ファイルだけがあり既定が無いパッケージをリンクしたところ、aapt2 はエラーを出さず、既定値が無いのでリソースを削除するという警告だけを出して、その文字列はビルド成果物から完全に消えていました。
既定ファイルにあって言語ファイルに無いキーは既定のテキストにフォールバックするので、未翻訳の文字列は失敗せず英語のまま表示されます。それを報告するのは MissingTranslation という lint チェックだけなので、有効にしたまま結果を読みます。意図的な省略は文字列単位で抑制し、翻訳しない文字列には translatable を false で付けます。
<resources xmlns:tools="http://schemas.android.com/tools"
xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="event_id" translatable="false">boss_defeated_01</string>
<string name="debug_note" tools:ignore="MissingTranslation">Debug build</string>
<string name="save_slot">セーブ枠 <xliff:g id="slot" example="3">%1$d</xliff:g></string>
</resources>最後の行が、習慣として取り入れる価値のある保護記法です。xliff:g 要素はプレースホルダを囲んで、翻訳支援ツールに中身を触らせません。example 属性は実際の値の見本を見せるので、翻訳者が語順を判断する材料になります。resources 要素への名前空間宣言が必要です。
string-array の item には name 属性がないので、順序だけが同一性です。翻訳者が並べ替えたり1件落としたりすると、それ以降の添字が全部ずれます。依頼書に順序を変えない制約を書いてください。
Unity・Unreal・Godot でゲームを出す場合にどこまで関係するか
エンジンで作ったゲームの場合、ゲーム内テキストのほとんどは strings.xml を通りません。会話、アイテム名、メニューのラベルはエンジン側のローカライズ機構が持ち、実行時に読むのは Android のリソース機構ではなくエンジンです。
Android ビルドで strings.xml に入るのは OS 側に面した分です。アイコンの下に出るアプリ名、権限の説明文、通知チャンネル名、自分で書いた Android 側のプラグインの文字列。多くは20件に届かない短い一覧ですが、ホーム画面とシステムダイアログに出るので、メニューの3階層下より目立ちます。
実務の切り分けは、担当の違う3つの箱です。エンジンのテーブルが作業量の本体で、その側の整理は Unity のロケール・テーブル・文字列参照の構成 にまとめてあります。strings.xml は上のチェックリストを持つ小さな別納品物、ストア掲載文はコンソールに入力する第3の場所です。テキストがまだ直書きされているなら、先に 直書きされたテキストをコードの外に出す のが仕事です。