記事

iOS多言語対応:Localizable.stringsを安全に管理する最適策|文字列破損を防ぐ方法

iOS開発者向けにLocalizable.stringsの誤編集リスクを解消し、文字列ファイルの安全性を確保する具体的な対策を紹介。多言語対応の品質向上と作業効率アップを実現します。

iOS多言語対応:Localizable.stringsを安全に管理する最適策|文字列破損を防ぐ方法

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


iOSで多言語文字列の保険をかけよう!

SwifGen & UnitTest を使って多言語操作の安全性を確保する

Photo by [Mick Haupt](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Mick Haupt

問題

プレーンテキストファイル

iOSの多言語対応はLocalizable.stringsのテキストファイルで行われ、AndroidのようにXML形式で管理されていません。そのため、日常の開発で言語ファイルを誤って壊したり、追加し忘れたりするリスクがあります。さらに、多言語のエラーはビルド時に検出されないため、リリース後に特定の地域のユーザーから報告されて初めて問題が判明し、ユーザーの信頼を大きく損なうことがあります。

以前の痛い経験として、Swift に慣れすぎて Localizable.strings に ; を付け忘れたため、ある言語のリリース後に ; が抜けた行以降がすべて壊れてしまったことがあります;最終的に緊急 Hotfix で事なきを得ました。

多言語に問題があると、キーがそのままユーザーに表示される

上図のように、DESCRIPTION キーが抜けていると、アプリはそのまま DESCRIPTION をユーザーに表示します。

チェック項目

  • Localizable.strings の形式チェック(改行の末尾に ; を付ける、正しい Key-Value 対応)

  • コード内で使用する多言語キーは Localizable.strings ファイルに対応する定義が必要です

  • Localizable.strings ファイルは各言語ごとに対応する Key Value の記録が必要です

  • Localizable.strings ファイルに重複したキーがあってはいけません(そうしないと値が意図せず上書きされます)

解決策

Swiftで完全な検査ツールを作成する

以前の方法は「 Xcode で直接 Swift を使って Shell Script を作成! 」を参考にし、Localize 🏁 ツールを使って Swift で Command Line Tool を開発し、外部から多言語ファイルのチェックを行い、そのスクリプトを Build Phases の Run Script に配置して、ビルド時にチェックを実行していました。

利点:
チェックプログラムは外部から注入されるため、プロジェクトに依存せず、Xcodeを使わずにビルドなしでチェックを実行できます。チェック機能はどのファイルの何行目かまで正確に特定可能です。また、フォーマット機能(多言語キーのA→Zソート)も実行できます。

欠点:
ビルド時間が増加する(約 +3分)、プロセスが分散し、スクリプトに問題があったりプロジェクト構成に応じて調整が必要な場合、引き継ぎやメンテナンスが難しいです。これはプロジェクト内に含まれていないため、このチェック部分を追加した人だけが全体のロジックを理解しており、他の協力者は触れにくいです。

興味のある方は以前の記事をご参照ください。本記事では主に Xcode 13 + SwiftGen + UnitTest を使って Localizable.strings のすべての機能を検証する方法を紹介します。

XCode 13 内蔵のビルド時 Localizable.strings ファイルの形式チェック

Xcode 13 にアップグレードすると、ビルド時に Localizable.strings ファイルの形式をチェックする機能が標準搭載されました。テストしたところ、チェックの仕様は非常に充実しており、; の付け忘れだけでなく、余分で意味のない文字列もブロックされます。

SwiftGen を使って元の NSLocalizedString の String Base アクセス方法を置き換える

SwiftGen は、元の NSLocalizedString の文字列アクセス方法をオブジェクトアクセスに変更し、誤字やキーの未宣言を防ぐのに役立ちます。

SwiftGen のコアもコマンドラインツールですが、このツールは業界で非常に人気があり、充実したドキュメントとコミュニティリソースが維持されているため、導入後のメンテナンスが難しいという心配はありません。

インストール

環境や CI/CD サービスの設定に応じてインストール方法を選択できます。ここではデモとして、最も簡単な CocoaPods を使ったインストール方法を紹介します。

SwiftGen は本物の CocoaPods ではなく、プロジェクトのコードに依存しません。CocoaPods で SwiftGen をインストールするのは、単にこのコマンドラインツールの実行ファイルをダウンロードするためだけです。

podfile に swiftgen pod を追加:

1
pod 'SwiftGen', '~> 6.0'

Init

pod install の後、Terminalでプロジェクトのディレクトリに cd してください。

1
/L10NTests/Pods/SwiftGen/bin/swiftGen config init

init swiftgen.yml 設定ファイルを作成し、開きます

1
2
3
4
5
6
7
8
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 v.s. structured

flat と structured

違いは、Key のスタイルが XXX.YYY.ZZZ の場合、flat テンプレートは小文字のキャメルケースに変換しますが、structured テンプレートは元のスタイルを保持して XXX.YYY.ZZZ オブジェクトに変換します。

純粋な Swift プロジェクトでは組み込みテンプレートを直接使用できますが、Swift と Objective-C が混在するプロジェクトではテンプレートをカスタマイズする必要があります:

flat-swift5-objc.stencil :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 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 %}
  {# カスタムローカライズ関数は主にアプリ内言語選択用なので、呼び出しごとに再計算したい(計算プロパティのため) #}
  {{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 :

1
2
3
4
5
6
7
8
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に戻って手動で以下を実行します:

1
/L10NTests/Pods/SwiftGen/bin/swiftGen

変換を実行したら、最初に Finder から変換結果ファイル(SwiftGen-L10n.swift)を手動でプロジェクトにドラッグしてください。そうすることで、プログラムで使用可能になります。

Run Script

プロジェクト設定 → Build Phases → + → New Run Script Phases → 貼り付け:

1
2
3
4
5
6
if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
  echo "${PODS_ROOT}/SwiftGen/bin/swiftgen"
  "${PODS_ROOT}/SwiftGen/bin/swiftgen"
else
  echo "warning: SwiftGen がインストールされていません。'pod install --repo-update' を実行してインストールしてください。"
fi

これにより、毎回プロジェクトをビルドするたびにジェネレーターが最新の変換結果を生成します。

CodeBase での使い方は?

1
2
L10n.homeTitle
L10n.homeDescription("ZhgChgLi") // 引数付き

Object Access があれば、タイプミスやコード内で使用しているキーが Localizable.strings ファイルに宣言されていない状況は発生しません。

しかし、SwiftGen は特定の言語からのみ生成できるため、生成された言語にキーがあっても他の言語で定義が漏れている状況を防げません。この状況は以下の 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 を使って各言語ファイルと主要言語ファイルの欠落やキーの重複をチェックする

Bundle から .strings ファイルの内容を読み取り、UnitTest を作成してテストすることができます。

Bundle から .strings を読み込みオブジェクトに変換する:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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_ENCODINGUTF-8 に設定してください。そうしないと、String でファイル内容を読み取る際に失敗します(デフォルトは Binary です)。

  • String を使って読み込む理由は、重複したキーをテストする必要があるためです。NSDictionary を使うと、読み込み時に重複したキーが上書きされてしまいます。

  • .strings ファイルは必ず Test Target に追加してください

TestCase 1. 同じ .strings ファイル内で重複したキーがあるかどうかをテスト:

1
2
3
4
5
6
7
8
9
10
11
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) に重複したキーがあります。")
            }
        }
    }
}

入力:

結果:

TestCase 2. DevelopmentLocalization 言語と比較して、欠落または余分なキーがあるかどうか:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 File が見つかりません。")
                    }
                }
            }
        } else {
            XCTFail("Bundle: \(bundle) に developmentLocalization が見つかりません。")
        }
    }
}

Input: (DevelopmentLocalization と比較して他の言語で宣言されていない Key)

Output:

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

Output:

まとめ

以上の方法を総合して、私たちは次のものを使用します:

  • 新しい Xcode は .strings ファイルのフォーマットの正確さを保証します ✅

  • SwiftGen はコード内で多言語を参照する際の誤字や未宣言の参照を防ぎます ✅

  • UnitTest で多言語コンテンツの正確さを保証します ✅

利点:

  • ビルド時間を遅くせず、高速に実行

  • iOS 開発者なら誰でもメンテナンス可能

上級

ローカライズファイルのフォーマット

この解決策は実現できず、やはり元の Swiftで書かれたコマンドラインツールを使う 必要があります。ただし、フォーマット部分は git の pre-commit で行えばよく、diff に変更がなければ実行しないので、毎回ビルド時に走らせる必要はありません:

1
2
3
4
5
6
7
8
#!/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 のコードを最適化して、どのキーが Missing/Reductant/Duplicate であるかを明確に出力できるようにします。

サードパーティツールを使ってエンジニアの多言語作業を完全に解放する

以前の「2021 Pinkoi Tech Career Talk — 高効率エンジニアチーム大解密」の講演内容と同様に、大規模チームでは多言語対応の作業をサードパーティサービスで分割し、多言語作業の依存関係を管理できます。

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

特別な感謝

[Wei Cao](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , PinkoiのiOS開発者

Wei Cao 、PinkoiのiOS開発者

ご質問やご意見がございましたら、こちらからご連絡ください

*PostZMediumToMarkdown によって Medium から変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。