iOS 為多語系字串買份保險吧!
使用 SwifGen & UnitTest 確保多語系操作的安全
Photo by Mick Haupt
問題
純文字檔案
iOS 的多語系處理方式是 Localizable.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build Time 檢查出錯誤,往往都是上線後,某個地區的使用者回報才發現問題,會大大降低使用者信心程度。
之前血淋淋的案例,大家 Swift 寫的太習慣忘記 Localizable.strings 要加 ;
,導致某個語系上線後從漏掉 ;
的語句往後全壞掉;最後緊急 Hotfix 才化險為夷。
多語系有問題就會直接把 Key 顯示給使用者
如上圖所示,假設 DESCRIPTION
Key 漏加, App就會直接顯示 DESCRIPTION
給使用者。
檢查需求
- Localizable.strings 格式正確檢查 (換行結尾需加上
;
、合法 Key Value 對應) - 程式碼中有取用的多語系 Key 要在 Localizable.strings 檔有對應定義
- Localizable.strings 檔各個語系都要有相應的 Key Value 紀錄
- Localizable.strings 檔不能有重複的 Key (否則 Value 會被意外覆蓋)
解決方案
使用 Swift 撰寫完整檢查工具
之前的做法是「 Xcode 直接使用 Swift 撰寫 Shell Script! 」參考 Localize 🏁 工具使用 Swift 開發 Command Line Tool 從外部做多語系檔案檢查,再把腳本放到 Build Phases Run Script 中,在 Build Time 執行檢查。
優點: 檢查程式是由外部注入,不依賴在專案上,可以不透過 XCode、不需 Build 專案也能執行檢查、檢查功能能精確到哪個檔案的第幾行;另外還能做 Format 功能 (排序多語系 Key A->Z)。
缺點: 增加 Build Time ( + ~= 3 mins)、流程發散,腳本有問題或需因應專案架構調整時難以交接維護,因這塊不在專案內,除了加入這段檢查進來的人知道整個邏輯,其他協作者很難碰觸到這塊。
有興趣的朋友可以參考之前的那篇文章,本篇主要介紹的方式是透過 XCode 13+SwiftGen+UnitTest 來達成檢查 Localizable.strings 的所有功能。
XCode 13 內建 Build Time 檢查 Localizable.strings 檔案格式正確性
升級 XCode 13 之後就內建了 Build Time 檢查 Localizable.strings 檔案格式的功能,經測試檢查的規格相當完整,除了漏掉 ;
外如有多餘無意義的字串也會被擋下來。
使用 SwiftGen 取代原始 NSLocalizedString String Base 存取方式
SwiftGen 能幫助我們將原本的 NSLocalizedString String 存取方式改成 Object 存取,防止不小心打錯字、忘記在 Key 宣告的情況出現。
SwiftGen 核心也是 Command Line Tool;但是這工具在業界蠻流行的而且有完整的文件及社群資源在維護,不必害怕導入這個工具後續難維護的問題。
可依照您的環境或 CI/CD 服務設定去選擇安裝方式,這邊 Demo 直接用最直接的 CocoaPods 進行安裝。
請注意 SwiftGen 並不是真的 CocoaPods,他不會跟專案中的程式碼有任何依賴;使用 CocoaPods 安裝 SwiftGen 單純只是透過它下載這個 Command Line Tool 執行檔回來。
在 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
差別在如果 Key 風格是 XXX.YYY.ZZZ
flat 模板會轉換成小駝峰;structured 模板會照原始風格轉換成 XXX.YYY.ZZZ
物件。
純 Swift 專案可直接使用內建模板,但若是 Swift 混 OC 的專案就需要自行客製化模板:
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
// Generated using 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 %}
{# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the 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: - Implementation Details
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 %}
// No string found
{% endif %}
以上提供一個網路搜集來&客製化過兼容 Swift 和 OC 的模板,可自行建立 flat-swift5-objc.stencil
File 然後貼上內容或 點此直接下載 .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 模板在專案中的位置即可。
Generator
設定好之後可以回到 Termnial 手動下:
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 is not installed. Run 'pod install --repo-update' to install it."
fi
這樣在每次 Build 專案時都會跑 Generator 產出最新的轉換結果。
CodeBase 中如何使用?
1
2
L10n.homeTitle
L10n.homeDescription("ZhgChgLi") // with arg
有了 Object Access 後就不可能出現打錯字及 Code 裡面有在用的 Key 但 Localizable.strings 檔忘記宣告的情況。
但 SwiftGen 只能指定從某個語系產生,所以無法阻擋產生的語系有這個 Key 但在其他語系忘記定義的狀況;此狀況要用下面的 UnitTest 才能保護。
轉換
轉換才是這個問題最困難的地方,因為已開發完成的專案中大量使用 NSLocalizedString 要將其轉換成新的 L10n.XXX
格式、如果是有帶參數的語句又更複雜 String(format: NSLocalizedString
,另外如果有混 OC 還要考慮 OC 的語法與 Swift 不同。
沒有什麼特別的解法,只能自己寫一個 Command Line Tools,可參考 上一篇文章 中使用 Swift 掃描專案目錄、Parse 出 NSLocalizedString 的 Regex 撰寫一個小工具去轉換。
建議一次轉換一個情境,能 Build 過再轉換下一個。
- Swift -> NSLocalizedString 無參數
- Swift -> NSLocalizedString 有參數情況
- OC -> NSLocalizedString 無參數
- OC -> NSLocalizedString 有參數情況
透過 UnitTest 檢查各語系檔與主要語系檔案有沒有缺漏及 Key 有無重複
我們可以透過撰寫 UniTest 從 Bundle 讀取出 .strings
File 內容,並加以測試。
從 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_ENCODING 設為
UTF-8
,否則使用 String 讀取檔案內容時會失敗 (預設會是 Biniary) - 使用 String 讀取而不用 NSDictionary 的原因是,我們需要測試重複的 Key,使用 NSDictionary 會在讀取的時候就蓋掉重複的 Key 了
- 記得
.strings
File 要增加 Test Target
TestCase 1. 測試同一個 .strings 檔案內有無重複定義的 Key:
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) has duplicated keys.")
}
}
}
}
Input:
Result:
TestCase 2. 與 DevelopmentLocalization 語言相比,有無缺少/多餘的 Key:
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) has redundant keys.")
} else if developmentLocalizableKeys.count > otherLocalizableKeys.count {
XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) has missing keys.")
}
} else {
XCTFail("Localized Strings File not found in Lang: \(otherLocalization.lang)")
}
}
}
} else {
XCTFail("developmentLocalization not found in Bundle: \(bundle)")
}
}
}
Input: (相較 DevelopmentLocalization 其他語系缺少宣告 Key)
Output:
Input: (DevelopmentLocalization 沒有這個 Key,但在其他語系出現)
Output:
總結
綜合以上方式,我們使用:
- 新版 XCode 幫我們確保 .strings 檔案格式正確性 ✅
- SwiftGen 確保 CodeBase 引用多語系時不會打錯或沒宣告就引用 ✅
- UnitTest 確保多語系內容正確性 ✅
優點:
- 執行速度快,不拖累 Build Time
- 只要是 iOS 開發者都會維護
進階
Localized File Format
這個解決方案無法達成,還是需使用原本 用 Swift 寫的 Command Line Tool 來達成 ,不過 Format 部分可以在 git pre-commit 做就好;沒有 diff 調整就不做,避免每次 build 都要跑一次:
1
2
3
4
5
6
7
8
#!/bin/sh
diffStaged=${1:-\-\-staged} # use $1 if exist, default --staged.
git diff --diff-filter=d --name-only $diffStaged | grep -e 'Localizable.*\.\(strings\|stringsdict\)$' | \
while read line; do
// do format for ${line}
done
.stringdict
同樣的原理也可用在 .stringdict
上
CI/CD
swiftgen 可以不用放在 build phase,因為每次 build 都會跑一次,而且 Build 完程式碼才會出現,可以改成有調整再下指令產生就好。
明確得到錯在哪個 Key
可優化 UnitTest 的程式,使之能輸出明確是哪個 Key Missing/Reductant/Duplicate。
使用第三方工具讓工程師完全解放多語系工作
如同之前「 2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密 」的演講內容,在大團隊中多語系工作可以透過第三方服務拆開,多語系工作的依賴關係。
工程師只需定義好 Key,多語系會在 CI/CD 階段從平台自動匯入,少了人工維護的階段;也比較不容易出錯。
Special Thanks
Wei Cao , iOS Developer @ Pinkoi
有任何問題及指教歡迎 與我聯絡 。
===
View the English version of this article here.
本文首次發表於 Medium ➡️ 前往查看