feather for Mastodon のローカライズの管理方法をString Catalogsにしました

Xcode 15から使えるようになったローカライズの仕組みである String Catalogs をfeather for Mastodon(以下 feather)でも使うようにしたので移行中に感じたことや考えたことを書き留めておきたいと思います。

developer.apple.com

String Catalogs 以前

feather は Localizable.strings と SwiftGen を使ったローカライズを行っていました。

日常的な作業手順としては例えば UserSettingViewtitle をローカライズしたいみたいなときは

日本語と英語の"Localizable.strings" に UserSetting.title = "ユーザー設定";``UserSetting.title = "User Setting";といった具合に追加した後、 SwiftGen を実行して L10n.UserSetting.title のような定数を生成しそれをViewに指定するという流れでした。

このフローはよくあるものですが、以下のような問題がありました。

  1. SwiftGenの SwiftUI のサポートが限定的であるためやや使いにくい(カスタムのテンプレートを作って無理やり解決してた)
  2. 不要になったローカライズがゴミとして溜まっていた
  3. ChatGPTやCopilotの支援が受けにくい
    • 例えば Text(L10n.UserSetting.title) のようなコードが生成された場合には予めローカライズを定義してからじゃないとコンパイルできないので面倒だった

個人的には3が一番微妙な点でした。なんか開発の流れが逆転するような感じでモヤッとしていました。

String Catalogs 導入

以下のような方針で導入しました.

基本的にローカライズのkeyは以前のままにする

String Catalogs では Text("User Setting") としてもいいのですが、既存のコードとのdiffが多くなるのでText("UserSetting.title")` のよう指定することにしました。

これにはメリットもあって、ローカライズを編集する一覧の画面で key で絞り込んだりすることができて必要な文言を見つけるのに役立ちます。

現在のXcodeのローカライズ編集画面はビルド後にローカライズが変更されると画面がリセットされてめっちゃ使いにくいので検索で絞り込んだりできるのはかなりのメリットです。

また例外として非常に一般的な文言はkeyをつけずに指定しています。例えば "Save" とかがそれです。

文脈によって同じKeyで別の日本語訳をつけたくなった場合、例えば Post に対してある画面では"投稿" 、別の画面では送信 とローカライズしたくなったら Hoge.post みたいにすれば大丈夫です。

変数が途中に入る文章は柔軟に...

以下のように複数の変数を埋め込みたいときは以下のようでもいいですし

Text("UserSettings.error\(error.localizedDescription), \(value)")

以下のように文章っぽくしてもいいようにしています。

Text("UserSettings.error.request failure: \(error.localizedDescription), can not update \(value)")

これはどのような形がベストなのかわからないですが、後者のほうがどっちの変数が何なのかがわかりやすくローカライズのときに変数の順番間違えをしにくいメリットがあると考えています。

Text(verbatim:) とかをちゃんと使おう

SwiftUI にはローカライズを明示的に拒否する Text(verbatim:) というイニシャライザがあります。

もしくは String("") と指定するとローカライズをスキップできます。

これらを使わずに例えは Text("\(intValue)") みたいなViewを書いてしまうとローカライズ一覧に "%lld" という文言だけが出てきてしまいます。

ハッシュタグを表示するときも Text("#\(tag)") ではなく Text(verbatim: "#\(tag)") としましょう。

feather でも最初こんな感じになっていて、「どこのやつだ...?」と困りました。大抵の場合 ("\( 的な感じでコード検索すると該当箇所がわかります。

LINT する

ローカライズを編集する画面ではローカライズ漏れやSTALEがあると警告のマークが出ますが、ビルド時にそれを検出して失敗させるみたいな方法が見当たりませんでした(あったら教えてください)。

そこで自前でLINTするスクリプトを作ってCIでチェックしています。

このスクリプトは単純に "ja" と "en" のローカライズのstate が translated になってることだけをチェックしています。新しく言語を増やす場合にはifの中に言語を増やせば大丈夫です。

※ jq を使っています.

#!/bin/zsh

SCRIPT_PATH=$(cd $(dirname $0); pwd)
# Localizable.xcstringsファイルへのパスはコマンドラインオプションから取得する(指定がなかったらエラーにする)
if [[ $# -ne 1 ]]; then
    echo "Error: Please specify Localizable.xcstrings file path"
    echo "usage:"
    echo "./localize-lint.sh <Localizable.xcstrings file path>"
    exit 1
fi

FILE_PATH=${SCRIPT_PATH}/$1
FILTER='.strings | to_entries[] | .key as $key | .value.localizations | if (.en?.stringUnit.state == "translated" and .ja?.stringUnit.state == "translated") then empty else "No localizations found: \($key)" end'

COUNT=$(jq -r ${FILTER} ${FILE_PATH} | tee /dev/tty | wc -l | awk '{print $1}')
if [[ ${COUNT} != "0" ]]; then
    echo "Error: ${COUNT} localizations not found in ${FILE_PATH}"
    exit 1
fi

導入後

導入前に発生していた3の開発体験が下がる問題は解消しました。

自動生成されるコードも Text("UserSetting.title") のようなものになったのでローカライズがなくてもビルドが通ります。

また以前は言語ごとにローカライズのファイル Localizable.strings が別れていて、これもまたファイルを切り替える必要があったり文言を探すのが面倒だったりと微妙な手間があったのですが、Localizable.xcstrings という一つのファイルにまとまるのでスッキリしました。

こんな感じにローカライズ時にもAIの支援が受けやすい状態になりました。

まとめ

導入は非常に大きいdiffが発生して大変な作業でしたが、全体的に開発体験は向上したと思います。

「Keyの指定が文字ベースで型安全じゃないから導入したくない」と思ってる人は、その考え方はちょっともう古いかもしれません。是非導入をご検討してみてください。