iOS Localization: Protect Your Localizable.strings from Accidental Corruption
iOS developers struggling with maintaining Localizable.strings files can prevent accidental data loss using proven backup strategies and automation, ensuring stable multi-language support and smoother app updates.
点击这里查看本文章简体中文版本。
點擊這裡查看本文章正體中文版本。
This post was translated with AI assistance — let me know if anything sounds off!
Get Insurance for Multilingual Strings in iOS!
Using SwiftGen & UnitTest to Ensure Safe Multilingual Operations
Photo by Mick Haupt
Question
Plain Text File
iOS handles multiple languages using Localizable.strings plain text files, unlike Android, which manages them through XML format. This approach carries the risk of accidentally corrupting or missing entries in the language files during development. Additionally, multilingual errors are not detected at build time, often only discovered after release when users from certain regions report issues, significantly reducing user confidence.
In a previous painful case, everyone was so used to writing Swift that they forgot to add ;
in Localizable.strings. This caused the entire language version to break from the line missing the ;
after going live. It was only saved by an emergency hotfix.
If there is a problem with multilingual support, the key will be displayed directly to the user
As shown in the image above, if the DESCRIPTION
key is missing, the app will directly display DESCRIPTION
to the user.
Check Requirements
Localizable.strings Format Validation (Line endings must have
;
, valid Key-Value pairs)The multilingual keys used in the code must have corresponding definitions in the Localizable.strings file.
Each localization in the Localizable.strings file must have the corresponding Key-Value entries.
Localizable.strings files cannot have duplicate keys (otherwise the value will be accidentally overwritten)
Solution
Writing a Complete Inspection Tool Using Swift
The previous approach was to “Use Swift Directly in Xcode to Write Shell Scripts!” referring to the Localize 🏁 tool, which uses Swift to develop a Command Line Tool for external localization file checks, then places the script in the Build Phases Run Script to run the checks during Build Time.
Advantages:
The inspection tool is injected externally, not relying on the project. It can run checks without using XCode or building the project. The inspection can pinpoint the exact line in a specific file. Additionally, it supports formatting functions (sorting multilingual keys from A to Z).
Disadvantages:
Increases build time (approximately +3 mins) and causes process divergence. If the script has issues or needs adjustment due to project structure changes, it is difficult to hand over and maintain. Since this part is outside the project, only those who add this check understand the entire logic, making it hard for other collaborators to handle.
Interested readers can refer to the previous article. This article mainly explains how to use XCode 13 + SwiftGen + UnitTest to verify all functions of Localizable.strings.
XCode 13 Built-in Build Time Check for Localizable.strings File Format Correctness
After upgrading to XCode 13, it now includes a built-in Build Time check for the Localizable.strings file format. Tests show the check is quite thorough; besides missing ;
, it also blocks extra meaningless strings.
Using SwiftGen to Replace the Original NSLocalizedString String Base Access Method
SwiftGen helps us change the original NSLocalizedString string access method to object access, preventing typos and missing key declarations.
SwiftGen is also a Command Line Tool; however, this tool is quite popular in the industry and has comprehensive documentation and community support, so there is no need to worry about maintenance issues after adopting it.
You can choose the installation method based on your environment or CI/CD service settings. Here, the demo uses the simplest method with CocoaPods for installation.
Please note that SwiftGen is not a real CocoaPod and does not depend on the project’s code; using CocoaPods to install SwiftGen only downloads this Command Line Tool executable.
Add the swiftgen pod in the podfile
:
1
pod 'SwiftGen', '~> 6.0'
Init
After running pod install
, open Terminal and cd
into the project directory.
1
/L10NTests/Pods/SwiftGen/bin/swiftGen config init
Initialize the swiftgen.yml
configuration file and open it.
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"
Paste and modify to fit your project’s format:
inputs: Project localization file location (it is recommended to specify the DevelopmentLocalization language file)
outputs: output: The location of the converted Swift file
params: enumName: Object name
templateName: Conversion template
You can run swiftGen template list
to get the list of built-in templates.
flat vs. structured
The difference is that if the key style is XXX.YYY.ZZZ
, the flat template will convert it to camelCase; the structured template will keep the original style and convert it into an XXX.YYY.ZZZ
object.
Pure Swift projects can directly use the built-in templates, but Swift mixed with Objective-C projects require custom templates:
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 %}
The above provides a template collected and customized from the internet that is compatible with both Swift and Objective-C. You can create a flat-swift5-objc.stencil
file and paste the content, or click here to directly download the .zip.
If using a custom template, do not use templateName; instead, declare 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"
Just set the templatePath to the location of the .stencil template within the project.
Generator
After setting up, you can return to the Terminal and manually run:
1
/L10NTests/Pods/SwiftGen/bin/swiftGen
Execute the conversion. After the first conversion, manually drag the converted file (SwiftGen-L10n.swift) from Finder into the project for the code to work.
Run Script
In Project Settings -> Build Phases -> + -> New Run Script Phase -> Paste:
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
This way, the Generator runs every time the project is built to produce the latest conversion results.
How to use in CodeBase?
1
2
L10n.homeTitle
L10n.homeDescription("ZhgChgLi") // with arg
With Object Access, typos are impossible, and keys used in the code will never be missing from the Localizable.strings file.
However, SwiftGen can only generate from a specific language, so it cannot prevent a situation where a key exists in the generated language but is missing in other languages. This issue can only be protected by the UnitTest below.
Conversion
The hardest part is the conversion because the completed project heavily uses NSLocalizedString, which needs to be converted to the new L10n.XXX
format. It’s even more complicated if the strings have parameters, such as String(format: NSLocalizedString
. Additionally, if there is mixed Objective-C, you must consider the syntax differences between Objective-C and Swift.
There is no special solution; you can only write your own Command Line Tool. You may refer to the previous article 上一篇文章 where Swift is used to scan the project directory and parse NSLocalizedString with Regex to create a small tool for conversion.
It is recommended to convert one scenario at a time. Ensure it builds successfully before moving on to the next.
Swift -> NSLocalizedString without parameters
Swift -> NSLocalizedString with Parameters Situation
OC -> NSLocalizedString without parameters
OC -> NSLocalizedString with Parameters Scenario
Use UnitTest to check for missing entries and duplicate keys between localization files and the main language file
We can write UniTests to read the contents of .strings
files from the Bundle and test them.
Read .strings
from Bundle and convert to object:
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
}
}
}
We defined a Localization
to store the parsed data. It searches for lproj
folders in the Bundle
, then finds .strings
files within them. Using regular expressions, it converts multilingual strings into objects and stores them back in Localization
for later testing.
Here are a few points to note:
Using
Bundle(for: type(of: self))
to Access Resources from a Test TargetRemember to set the Test Target’s STRINGS_FILE_OUTPUT_ENCODING to
UTF-8
, otherwise reading file content with String will fail (the default is Binary).The reason for using String reading instead of NSDictionary is that we need to test duplicate keys. Using NSDictionary will overwrite duplicate keys during reading.
Remember to add the
.strings
file to the Test Target
TestCase 1. Test for duplicate key definitions within the same .strings file:
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. Missing/extra keys compared to DevelopmentLocalization language:
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: (Compared to DevelopmentLocalization, other languages lack declared Keys)
Output:
Input: (DevelopmentLocalization does not have this key, but it appears in other languages)
Output:
Summary
Combining the above methods, we use:
New XCode ensures the correctness of .strings file format ✅
SwiftGen ensures that the CodeBase does not have typos or undeclared references when using multiple languages ✅
UnitTest ensures the accuracy of multilingual content ✅
Advantages:
Fast execution speed, does not slow down Build Time
As long as you are an iOS developer, you will maintain it.
Advanced
Localized File Format
This solution cannot be achieved; you still need to use the original Command Line Tool written in Swift to accomplish it. However, the formatting part can be done in the git pre-commit; if there is no diff, no adjustment is made to avoid running it every time you build:
1
2
3
4
5
6
7
8
#!/bin/sh
diffStaged=${1:-\-\-staged} # use $1 if exists, default to --staged.
git diff --diff-filter=d --name-only $diffStaged \| grep -e 'Localizable.*\.\(strings\\|stringsdict\)$' \| \
while read line; do
# do format for ${line}
done
.stringdict
The same principle can also be applied to .stringdict
.
CI/CD
swiftgen does not need to be placed in the build phase because it runs every time you build, and the code only appears after the build is complete. You can change it to run the command only when there are adjustments.
Clearly Identify Which Key Is Wrong
The UnitTest code can be optimized to clearly indicate which Key is Missing/Redundant/Duplicate.
Using Third-Party Tools to Fully Free Engineers from Multilingual Tasks
As mentioned in the previous talk “2021 Pinkoi Tech Career Talk — Secrets of High-Efficiency Engineering Teams”, multilingual work in large teams can be separated through third-party services to manage dependencies in multilingual tasks.
Engineers only need to define the keys, and multilingual content will be automatically imported from the platform during the CI/CD phase, eliminating manual maintenance and reducing errors.
Special Thanks
Wei Cao , iOS Developer @ Pinkoi
If you have any questions or feedback, feel free to contact me.
This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.