Engineering

Things that you should know while localizing your iOS app

If you’re an iOS developer and you support many languages in your app, you know that there are many ways to go around the localization process. There are also many things that can go wrong. Therefore, it’s important to establish good practices with your team and once you do, keep discipline and follow them.

NSLocalizedString

The first doubt may arise when you start typing your first NSLocalizedString in a project. You’ll see that Xcode requires you to pass two parameters - key and comment. It is common practice to use the string you want to be localized as the key. It creates a problem though. You may use some English words several times in different contexts and in those contexts they won’t necessarily always translate to the same thing in other languages. In other cases, you may be really space-constrained in some segments of your UI and while an English word will fit just fine, in other languages it may be really long and you might like to go with an alternative word in this specific place.

If you you’d like to know how to handle space constraints well, I encourage you to check Creating Great Localized Experiences with Xcode 11 talk from WWDC 2019, where you can learn how to leverage Xcode snapshot tests to provide context for your translators.

For the aforementioned reasons, while you can quickly type NSLocalizedString("Done", comment: "") and carry on with your work, I encourage you to use context specific keys. I create keys by starting from the string describing the outermost view, going top down to my view of interest and separating those strings with dots. For example settings.navigation_bar.done or home.header.title. If you feel like you don’t want to jump to your Localizable.strings file each time you add a string (which can quickly get daunting at the initial development phase), you can use the value argument of NSLocalizedString, so it would look like this:

NSLocalizedString(
    key: "settings.navigation_bar.done",
    value: "Done",
    comment: "Done button title on settings's scene navigation bar"
)

If you’re like me and use embedded frameworks for parts of your code, you may need to put some of your localized strings in module that isn’t your app’s main target. Keep in mind, that you need to pass a bundle argument then. Otherwise iOS will try to extract your translation from the main bundle. What works for me is keeping strings as static variables in private Localized class. I can use this class then, to safely create bundle that I pass to my NSLocalizedString call. It looks more or less like this:

private class Localized {
    static var done: String {
        return NSLocalizedString(
            key: "settings.navigation_bar.done",
            bundle: bundle,
            value: "Done",
            comment: "Done button title on settings's scene navigation bar"
        )
    }
    
    private static var bundle: Bundle { return Bundle(for: Localized.self) }
}

Localizable.strings

Once you’ve got your keys in place, you can start thinking about managing your reference (development language) Localizable.strings file. If you’re not new to iOS development, you’ve probably heard about genstrings. This is an Apple’s tool, that scans your .swift and .m files and extracts everything that it finds in NSLocalizableString calls to Localizable.strings file.

NOTE! Until recently "genstrings" was very a primitive tool, that wouldn't work with any other NSLocalizableString overloads than the basic one (one with "key" and "comment" arguments). Its more modern and capable counterpart was "extractLocStrings". Xcode 11 introduces several changes in this matter though. Genstrings has been merged with extractLocStrings and Apple recommends using it now. ExtractLocStrings symlinks to genstrings and still works (it will probably be removed in the future) and the old version of genstrings is available via "ogenstrings" command. All three versions should be used by prefixing them with xcrun.

Another thing to keep in mind is that genstrings generated files are utf-16le encoded, which is a bummer, because next time you run git diff this is what you’ll get:

Binary files a/{project directory}/en.lproj/Localizable.strings and b/{project directory}/en.lproj/Localizable.strings differ

On the other hand, Xcode handles utf-8 Localizable.strings files just fine, so why not convert it? (if you happen to know if there’s a valid reason why we’re getting utf-16le and we shouldn’t convert it back to utf-8, reach me please on Twitter @LukaszKasperek. I wasn’t able to find such a thing).

What I do is: for each target that I have (which contains my source files, as well as .lproj folders), I run iconv (check man iconv on your terminal) right after generating my strings files. It looks more or less like this:

find $module_path -name "*.swift" -print0 | xargs -0 genstrings -o $module_path/en.lproj
FILE="${path}/en.lproj/Localizable.strings"
TEMP="${path}/en.lproj/Localizable.strings.temp"
iconv -f utf-16le -t utf-8 $FILE > $TEMP
mv -f $TEMP $FILE

Exporting & Importing

Since you probably don’t know all the languages that you’re localizing your app to, now you need to export your strings for translators to be able to do their job. You can go to your project, click Editor on your menu bar and then Export for Localization…. However doing this each time can’t be called automated flow. Fortunately xcodebuild's got your back. It works like this:

$ xcodebuild -exportLocalizations -localizationPath {path where Localizable.strings file goes} -project {xcodeproj path}

NOTE! If you've accidentally overlooked genstrings step and run xcodebuild right away, you'd notice that it still works. What the heck?! Xcodebuild will actually do genstrings' job, but the problem is somewhere else. The problem is, that your English version of Localizable.strings won't get updated neither by running xcodebuild -exportLocalizations alone, nor when importing your localizations back. And iOS will then use your outdated Localizable.strings file.

It will export an .xcloc folder, which is something that your translator can work on. There are tools out there, however, that will make his job easier and provide you the way to better synchronize your work. One of such tools is POEditor. The great thing about it is that it has a great API with equally great documentation. You can leverage on this fact and build super simple script that will export your English strings using xcodebuild and then send it directly to POEditor. If you use fastlane (which I strongly recommend) and you type fastlane search_plugins poeditor you will find some ready solutions to build on :)

When your translators are done with their job, all of the tools I’ve mentioned before will enable you to import your translations as well.

Validating

At this point, it may seem that we’re done here. There’s one more thing though. People who translate your strings are rarely developers and don’t necessarily have the knowledge about string interpolations formatting, and even if they do, they’re people and therefore, they make human mistakes. Depending on the kind of mistake, it either exposes our app to crashes or will entail incorrect strings showing up. Since our users have already suffered from such errors in the past, validating the translated strings is now the part of our flow.

For this, we use our in-house tool. It’s called Palmyra. Palmyra validates your Localizable.strings files against inconsistencies in interpolation markers occurrences across different languages. It also checks if there are any missing or redundant translations. In my practice I get all of the folder paths that contain the modules of my app and run Palmyra for each one in the following way:

find $module_path -name '*.lproj' ! -name 'en.lproj' -maxdepth 1 -print0 | xargs -0 -I{} palmyra -r "$module_path"/en.lproj/Localizable.strings -t {}/Localizable.strings

Connecting it up…

Phew… That’s a lot of steps. And what kind of automation would it be if you had to run script after script after script? What I do for a while now is use a Makefile as an entry point to all of my automated flows, which are just combined calls of different kind of scripts and tools. If you don’t use either Makefile or any other alternative solution, this is a great place to start. If your more of a Ruby-guy, there are people out there who use Rakefile for this purpose. You can read about it for instance here.

Summary

That’s it! This is how we handle our localization flows. Setting this up isn’t all that time consuming, and it makes updating any of your translation lightning fast and much safer. I hope you liked it :) If you have an idea how to further improve it, please leave a comment or find me on Twitter @LukaszKasperek. If you’d like to contribute to Palmyra, this is also much welcome.

If you'd like to know more about localizing applications on Flutter, take a look here.