iOSで多言語文字列に保険をかけよう!
SwifGen & UnitTest を使って多言語操作の安全性を確保する

Photo by Mick Haupt
問題
純粋なテキストファイル

iOSの多言語対応はLocalizable.stringsというテキストファイルで行われており、AndroidのようにXML形式で管理されているわけではありません。そのため、開発中に誤って言語ファイルを壊したり、追加し忘れたりするリスクがあります。さらに、多言語のエラーはビルド時に検出されないため、リリース後に特定の地域のユーザーからの報告で問題が発覚することが多く、ユーザーの信頼を大きく損なう可能性があります。
以前の痛い経験として、Swiftに慣れすぎてLocalizable.stringsに;を付け忘れることがありました。その結果、ある言語のリリース後に;が抜けている行以降の翻訳がすべて壊れてしまいました。最終的に緊急Hotfixで事なきを得ました。
多言語に問題があると、キーがそのままユーザーに表示される

上図のように、DESCRIPTIONキーが抜けていると、アプリはそのままDESCRIPTIONをユーザーに表示します。
チェック項目
-
Localizable.strings の形式チェック(改行の末尾に
;を付ける、正しい Key-Value 対応) -
コード内で使用する多言語キーは、Localizable.stringsファイルに対応する定義が必要です
-
Localizable.strings ファイルには、各言語ごとに対応するキーと値の記録が必要です。
-
Localizable.strings ファイルには重複したキーを含めてはいけません(そうしないと値が意図せず上書きされます)
解決策
Swiftを使った完全な検査ツールの作成
以前の方法は「 Xcode で直接 Swift を使って Shell Script を作成! 」を参考にし、Localize 🏁 ツールを使って Swift で開発したコマンドラインツールで外部から多言語ファイルをチェックし、そのスクリプトを Build Phases の Run Script に入れて、ビルド時にチェックを実行していました。
利点:
チェックプログラムは外部から注入され、プロジェクトに依存しません。Xcodeを使わず、ビルドせずにチェックを実行でき、どのファイルの何行目かまで正確に特定できます。また、フォーマット機能(多言語KeyのA→Zソート)も可能です。
欠点:
ビルド時間が増加する(約+3分)、プロセスが分散し、スクリプトに問題がある場合やプロジェクト構成に応じて調整が必要な場合、引き継ぎやメンテナンスが難しい。この部分はプロジェクト内に含まれていないため、このチェックを追加した人だけが全体のロジックを理解しており、他の協力者は触れにくい。
興味がある方は以前の記事をご覧ください。本記事では主に Xcode 13+SwiftGen+UnitTest を使って Localizable.strings のすべての機能を検証する方法を紹介します。
XCode 13 内蔵のビルド時にLocalizable.stringsファイルの形式をチェックする機能

XCode 13にアップグレードすると、Build時にLocalizable.stringsファイルのフォーマットをチェックする機能が標準搭載されました。テストしたところ、チェックの仕様は非常に充実しており、;の付け忘れだけでなく、不要で意味のない文字列も検出されます。
SwiftGenを使って元のNSLocalizedStringの文字列ベースのアクセス方法を置き換える
SwiftGen は、元の NSLocalizedString の文字列アクセス方法をオブジェクトアクセスに変更し、誤字やキーの宣言忘れを防ぐのに役立ちます。
SwiftGen のコアもコマンドラインツールですが、このツールは業界で非常に人気があり、充実したドキュメントとコミュニティリソースが維持されているため、導入後のメンテナンスが難しいという心配はありません。
ご利用の環境やCI/CDサービスに応じてインストール方法を選択できますが、ここではデモとして最も簡単なCocoaPodsでのインストールを行います。
SwiftGenは本物のCocoaPodsではなく、プロジェクト内のコードに依存しません。CocoaPodsでSwiftGenをインストールするのは、このコマンドラインツールの実行ファイルをダウンロードするためだけです。
podfile に swiftgen pod を追加する:
pod 'SwiftGen', '~> 6.0'
初期化
pod install の後、Terminalでプロジェクトディレクトリに cd してください。
/L10NTests/Pods/SwiftGen/bin/swiftGen config init
init swiftgen.yml 設定ファイルを作成し、それを開く
strings:
- inputs:
- "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings"
outputs:
templateName: structured-swift5
output: "L10NTests/Supporting Files/SwiftGen-L10n.swift"
params:
enumName: "L10n"
貼り付けて、ご自身のプロジェクトのフォーマットに合わせて修正してください:
inputs: プロジェクトのローカライズファイルの場所(DevelopmentLocalization言語のファイルを指定することを推奨)
outputs:
output: 変換結果の swift ファイルの場所
params: enumName: オブジェクト名
templateName: 変換テンプレート
swiftGen template list コマンドで組み込みテンプレートの一覧を取得できます。

flat と structured
違いは、Keyのスタイルが XXX.YYY.ZZZ の場合、flatテンプレートは小文字のキャメルケースに変換しますが、structuredテンプレートは元のスタイルを保持して XXX.YYY.ZZZ オブジェクトに変換する点です。
純粋な Swift プロジェクトでは内蔵テンプレートを直接使用できますが、Swift と Objective-C が混在するプロジェクトではテンプレートをカスタマイズする必要があります:
flat-swift5-objc.stencil :
// swiftlint:disable all
// SwiftGenを使用して生成 — https://github.com/SwiftGen/SwiftGen
{% if tables.count > 0 %}
{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
import Foundation
// swiftlint:disable superfluous_disable_command file_length implicit_return
// MARK: - Strings
{% macro parametersBlock types %}{% filter removeNewlines:"leading" %}
{% for type in types %}
{% if type == "String" %}
_ p{{forloop.counter}}: Any
{% else %}
_ p{{forloop.counter}}: {{type}}
{% endif %}
{{ ", " if not forloop.last }}
{% endfor %}
{% endfilter %}{% endmacro %}
{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}
{% for type in types %}
{% if type == "String" %}
String(describing: p{{forloop.counter}})
{% elif type == "UnsafeRawPointer" %}
Int(bitPattern: p{{forloop.counter}})
{% else %}
p{{forloop.counter}}
{% endif %}
{{ ", " if not forloop.last }}
{% endfor %}
{% endfilter %}{% endmacro %}
{% macro recursiveBlock table item %}
{% for string in item.strings %}
{% if not param.noComments %}
{% for line in string.translation\\|split:"\n" %}
/// {{line}}
{% endfor %}
{% endif %}
{% if string.types %}
{{accessModifier}} static func {{string.key\\|swiftIdentifier:"pretty"\\|lowerFirstWord\\|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String {
return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %})
}
{% elif param.lookupFunction %}
{# カスタムローカライズ関数は主にアプリ内言語選択用で、呼び出すたびにローカライズを再計算したいためcomputed varを使用 #}
{{accessModifier}} static var {{string.key\\|swiftIdentifier:"pretty"\\|lowerFirstWord\\|escapeReservedKeywords}}: String { return {{enumName}}.tr("{{table}}", "{{string.key}}") }
{% else %}
{{accessModifier}} static let {{string.key\\|swiftIdentifier:"pretty"\\|lowerFirstWord\\|escapeReservedKeywords}} = {{enumName}}.tr("{{table}}", "{{string.key}}")
{% endif %}
{% endfor %}
{% for child in item.children %}
{% call recursiveBlock table child %}
{% endfor %}
{% endmacro %}
// swiftlint:disable function_parameter_count identifier_name line_length type_body_length
{% set enumName %}{{param.enumName\\|default:"L10n"}}{% endset %}
@objcMembers {{accessModifier}} class {{enumName}}: NSObject {
{% if tables.count > 1 or param.forceFileNameEnum %}
{% for table in tables %}
{{accessModifier}} enum {{table.name\\|swiftIdentifier:"pretty"\\|escapeReservedKeywords}} {
{% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %}
}
{% endfor %}
{% else %}
{% call recursiveBlock tables.first.name tables.first.levels %}
{% endif %}
}
// swiftlint:enable function_parameter_count identifier_name line_length type_body_length
// MARK: - 実装詳細
extension {{enumName}} {
private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
{% if param.lookupFunction %}
let format = {{ param.lookupFunction }}(key, table)
{% else %}
let format = {{param.bundle\\|default:"BundleToken.bundle"}}.localizedString(forKey: key, value: nil, table: table)
{% endif %}
return String(format: format, locale: Locale.current, arguments: args)
}
}
{% if not param.bundle and not param.lookupFunction %}
// swiftlint:disable convenience_type
private final class BundleToken {
static let bundle: Bundle = {
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle(for: BundleToken.self)
#endif
}()
}
// swiftlint:enable convenience_type
{% endif %}
{% else %}
// 文字列が見つかりません
{% endif %}
以上は、SwiftとObjective-Cの両方に対応したカスタマイズ済みテンプレートのネットから収集した例です。flat-swift5-objc.stencil ファイルを自分で作成して内容を貼り付けるか、こちらから.zipを直接ダウンロードしてください。
カスタムテンプレートを使用する場合は、templateNameではなく、templatePathを宣言します:
swiftgen.yml :
strings:
- inputs:
- "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings"
outputs:
templatePath: "path/to/flat-swift5-objc.stencil"
output: "L10NTests/Supporting Files/SwiftGen-L10n.swift"
params:
enumName: "L10n"
templatePath のパスをプロジェクト内の .stencil テンプレートの場所に指定するだけです。
ジェネレーター
設定が完了したら、Terminalに戻って手動で以下を実行できます:
/L10NTests/Pods/SwiftGen/bin/swiftGen
変換を実行したら、最初の変換後にFinderから変換結果ファイル(SwiftGen-L10n.swift)をプロジェクトにドラッグして追加してください。そうすることで、プログラムで使用可能になります。
Run Script

プロジェクト設定の中で -> Build Phases -> + -> New Run Script Phases -> 以下を貼り付け:
if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
echo "${PODS_ROOT}/SwiftGen/bin/swiftgen"
"${PODS_ROOT}/SwiftGen/bin/swiftgen"
else
echo "警告: SwiftGenがインストールされていません。'pod install --repo-update' を実行してインストールしてください。"
fi
これにより、毎回プロジェクトをビルドする際にジェネレーターが実行され、最新の変換結果が生成されます。
CodeBaseでの使い方は?

L10n.homeTitle
L10n.homeDescription("ZhgChgLi") // 引数付き
Object Accessを導入すると、タイプミスやコード内で使用しているKeyがLocalizable.stringsファイルに宣言されていないという問題は発生しません。
しかし、SwiftGenは特定の言語からのみ生成可能なため、ある言語にKeyがあっても他の言語で定義を忘れている状況を防ぐことはできません。この問題は以下のUnitTestでのみ検出可能です。
変換
変換こそが最も難しい部分です。既に開発済みのプロジェクトで大量に使用されている NSLocalizedString を新しい L10n.XXX 形式に変換する必要があり、パラメータ付きの文(String(format: NSLocalizedString)はさらに複雑です。さらに、Objective-C と混在している場合は、Objective-C の文法が Swift と異なる点も考慮しなければなりません。
特別な解決策はなく、自分で Command Line Tools を作成するしかありません。参考として、前回の記事で紹介したSwiftを使ってプロジェクトディレクトリをスキャンし、NSLocalizedString の正規表現をパースする小さなツールを作成して変換する方法があります。
一度に一つのシナリオを変換し、ビルドが通ったら次のシナリオに進むことをお勧めします。
-
Swift -> パラメータなしの NSLocalizedString
-
Swift -> パラメータありの NSLocalizedString
-
OC -> パラメータなしの NSLocalizedString
-
OC -> パラメータありの NSLocalizedString
UnitTestを使って各言語ファイルと主要言語ファイルの欠落やKeyの重複をチェックする
UniTestを作成してBundleから.stringsファイルの内容を読み取り、テストすることができます。
Bundleから.stringsを読み込み、オブジェクトに変換する:
class L10NTestsTests: XCTestCase {
private var localizations: [Bundle: [Localization]] = [:]
override func setUp() {
super.setUp()
let bundles = [Bundle(for: type(of: self))]
//
bundles.forEach { bundle in
var localizations: [Localization] = []
bundle.localizations.forEach { lang in
var localization = Localization(lang: lang)
if let lprojPath = bundle.path(forResource: lang, ofType: "lproj"),
let lprojBundle = Bundle(path: lprojPath) {
let filesInLPROJ = (try? FileManager.default.contentsOfDirectory(atPath: lprojBundle.bundlePath)) ?? []
localization.localizableStringFiles = filesInLPROJ.compactMap { fileFullName -> L10NTestsTests.Localization.LocalizableStringFile? in
let fileName = URL(fileURLWithPath: fileFullName).deletingPathExtension().lastPathComponent
let fileExtension = URL(fileURLWithPath: fileFullName).pathExtension
guard fileExtension == "strings" else { return nil }
guard let path = lprojBundle.path(forResource: fileName, ofType: fileExtension) else { return nil }
return L10NTestsTests.Localization.LocalizableStringFile(name: fileFullName, path: path)
}
localization.localizableStringFiles.enumerated().forEach { (index, localizableStringFile) in
if let fileContent = try? String(contentsOfFile: localizableStringFile.path, encoding: .utf8) {
let lines = fileContent.components(separatedBy: .newlines)
let pattern = "\"(.*)\"(\\s*)(=){1}(\\s*)\"(.+)\";"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let values = lines.compactMap { line -> Localization.LocalizableStringFile.Value? in
let range = NSRange(location: 0, length: (line as NSString).length)
guard let matches = regex?.firstMatch(in: line, options: [], range: range) else { return nil }
let key = (line as NSString).substring(with: matches.range(at: 1))
let value = (line as NSString).substring(with: matches.range(at: 5))
return Localization.LocalizableStringFile.Value(key: key, value: value)
}
localization.localizableStringFiles[index].values = values
}
}
localizations.append(localization)
}
}
self.localizations[bundle] = localizations
}
}
}
private extension L10NTestsTests {
struct Localization: Equatable {
struct LocalizableStringFile {
struct Value {
let key: String
let value: String
}
let name: String
let path: String
var values: [Value] = []
}
let lang: String
var localizableStringFiles: [LocalizableStringFile] = []
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.lang == rhs.lang
}
}
}
私たちは解析したデータを格納するために Localization を定義しました。Bundle から lproj を探し、その中から .strings を見つけて、正規表現を使って多言語の文をオブジェクトに変換し、Localization に戻して後のテストで使用しやすくしています。
ここでいくつか注意点があります:
-
Bundle(for: type(of: self))を使って Test Target からリソースを取得する -
Test Target の STRINGS_FILE_OUTPUT_ENCODING を
UTF-8に設定してください。そうしないと、String でファイル内容を読み取る際に失敗します(デフォルトは Binary です)。 -
Stringで読み込む理由は、重複したKeyをテストする必要があるためです。NSDictionaryを使うと、読み込み時に重複したKeyが上書きされてしまいます。
-
.stringsファイルには必ずテストターゲットを追加してください。

TestCase 1. 同じ .strings ファイル内に重複したキーがあるかどうかのテスト:
func testNoDuplicateKeysInSameFile() throws {
localizations.forEach { (_, localizations) in
localizations.forEach { localization in
localization.localizableStringFiles.forEach { localizableStringFile in
let keys = localizableStringFile.values.map { $0.key }
let uniqueKeys = Set(keys)
XCTAssertTrue(keys.count == uniqueKeys.count, "Localized Strings File: \(localizableStringFile.path) に重複したキーがあります。")
}
}
}
}
Input:

結果:

TestCase 2. DevelopmentLocalization 言語と比較して、欠落または余分なキーがあるかどうか:
func testCompareWithDevLangHasMissingKey() throws {
localizations.forEach { (bundle, localizations) in
let developmentLang = bundle.developmentLocalization ?? "en"
if let developmentLocalization = localizations.first(where: { $0.lang == developmentLang }) {
let othersLocalization = localizations.filter { $0.lang != developmentLang }
developmentLocalization.localizableStringFiles.forEach { developmentLocalizableStringFile in
let developmentLocalizableKeys = Set(developmentLocalizableStringFile.values.map { $0.key })
othersLocalization.forEach { otherLocalization in
if let otherLocalizableStringFile = otherLocalization.localizableStringFiles.first(where: { $0.name == developmentLocalizableStringFile.name }) {
let otherLocalizableKeys = Set(otherLocalizableStringFile.values.map { $0.key })
if developmentLocalizableKeys.count < otherLocalizableKeys.count {
XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) に冗長なキーがあります。")
} else if developmentLocalizableKeys.count > otherLocalizableKeys.count {
XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) に欠落しているキーがあります。")
}
} else {
XCTFail("言語: \(otherLocalization.lang) にLocalized Stringsファイルが見つかりません。")
}
}
}
} else {
XCTFail("Bundle: \(bundle) に developmentLocalization が見つかりません。")
}
}
}
入力:(DevelopmentLocalizationと比較して他の言語で宣言されていないキーが不足している)

Output:

Input: (DevelopmentLocalization にこのキーはありませんが、他の言語には存在します)

Output:

まとめ
以上の方法を総合して、私たちは以下を使用しています:
-
新しいXcodeは.stringsファイルのフォーマットの正確さを保証します ✅
-
SwiftGenはコードベースで多言語を参照する際の誤入力や未宣言の参照を防ぎます ✅
-
UnitTestは多言語コンテンツの正確性を保証します ✅
利点:
-
実行速度が速く、ビルド時間を遅くしない
-
iOS開発者なら誰でもメンテナンスできる
上級
ローカライズファイルの形式
この解決策は実現できず、やはり元のSwiftで書かれたCommand Line Toolを使う必要があります。ただし、Formatの部分はgitのpre-commitで行えばよく、diffがない場合は処理を行わず、毎回のビルドで実行するのを避けられます:
#!/bin/sh
diffStaged=${1:-\-\-staged} # $1が存在すれば使用し、なければデフォルトで--staged。
git diff --diff-filter=d --name-only $diffStaged \\| grep -e 'Localizable.*\.\(strings\\\|stringsdict\)$' \\| \
while read line; do
// ${line}のフォーマットを行う
done
.stringdict
同じ原理は .stringdict にも適用できます。
CI/CD
swiftgen は毎回ビルド時に実行され、ビルド後にコードが生成されるため、Build Phase に入れる必要はありません。変更があった時だけコマンドを実行して生成すれば十分です。
どのKeyが間違っているかを明確に把握する
UnitTestのコードを最適化し、どのKeyがMissing/Redundant/Duplicateであるかを明確に出力できるようにします。
サードパーティツールを使ってエンジニアの多言語対応作業を完全に解放する
以前の「2021 Pinkoi Tech Career Talk — 高効率エンジニアチーム大解密」の講演内容と同様に、大規模チームでは多言語対応の作業を第三者サービスで分割し、多言語作業の依存関係を管理できます。

エンジニアはKeyを定義するだけで、多言語はCI/CD段階でプラットフォームから自動的にインポートされるため、手動でのメンテナンスが減り、ミスも起こりにくくなります。
特別感謝

Wei Cao 、PinkoiのiOS開発者
*Post MediumからZMediumToMarkdownを使って変換。



コメント