Post

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.

iOS Localization: Protect Your Localizable.strings from Accidental Corruption

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

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](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

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.

Installation

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 v.s. structured

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 Target

  • Remember 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](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , iOS Developer @ Pinkoi

Wei Cao , iOS Developer @ Pinkoi

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.