Stringalize
← All articles

iOS uses %@, Android uses %s: sharing translations between the two

· 6 min read

If your product ships on both iOS and Android, sooner or later someone suggests sharing the translations. It sounds trivial: the sentences are the same. Then the first shared string reaches a device and the app crashes, or shows %@ in the middle of a sentence. The words were the same. The placeholders were not.

This article explains where the two platforms differ, what goes wrong, and how to keep a single set of strings anyway.

The same idea, two spellings

A placeholder marks the spot where the app inserts a value at run time. Both platforms inherited the idea from C's printf, and both changed it.

InsertingiOSAndroid
Text%@%s
Whole number%d, %ld, %lld%d
Decimal number%f, %.2f%f, %.2f
Numbered argument%1$@, %2$lld%1$s, %2$d
A literal percent sign%%%%

Decimals and the percent sign match. Text and whole numbers do not, and those are the two you use most.

What goes wrong

%s on iOS

On Apple platforms %s means a C string: a pointer to raw bytes. A Swift String or an NSString is an object, and passing one where a C string is expected is undefined behaviour. In practice you get garbage text or a crash, and often only for some values, which makes it hard to track down. The specifier for objects is %@.

%@ and %ld on Android

Android formats strings with Java's Formatter, which knows neither. %@ is not a conversion at all, and Java has no length modifiers, so %ld and %lld are rejected too. Both throw an exception when the string is formatted, which is at run time, on the screen that uses it.

Several placeholders without numbers

Android's build tools refuse a string resource that contains more than one placeholder unless each says which argument it takes. This fails the build:

<string name="greeting">Hi %s, you have %d messages</string>

and this is accepted:

<string name="greeting">Hi %1$s, you have %2$d messages</string>

iOS accepts both, so a string written for iOS can break the Android build the day it is shared.

Whole numbers on iOS

%d reads a 32-bit integer. Swift's Int is 64 bits wide on every current Apple device, and so is the value your code passes. Small numbers print correctly either way, which is why this survives testing; a value above about two billion does not. %lld reads 64 bits and is what Xcode itself writes when it extracts a string that interpolates an Int.

Word order is the other half

Languages do not agree on where things go in a sentence. A translator may need the number before the name. With unnumbered placeholders the values are consumed in the order they appear, so swapping them in the translation swaps the values: the name is printed as a number, or the app crashes.

Numbered placeholders solve this. The number says which argument is meant, wherever it sits:

English   Hi %1$s, you have %2$d messages
Japanese  %2$d件のメッセージがあります、%1$sさん

If a string has more than one placeholder, number them from the start. It costs nothing and saves a bug later.

The percent sign

In a string that is used as a format, a percent sign that should be shown has to be doubled: Save 20%% on %s. In a string with no placeholders, which is displayed as it is, a single % is correct and %% would show two. The rule depends on how the code uses the string, which is exactly what a translator cannot see.

How to keep one set of strings

There are three workable approaches.

  1. Keep two sets. Simple, and the one most teams start with. The cost is paid in translation: every sentence is translated twice, by people who never see the other version, and the two apps slowly start to say different things.
  2. Convert in a script. Store one syntax and rewrite it during the build with a few regular expressions. It works until the edge cases arrive: numbering for Android, literal percent signs, plurals, strings that only look like they contain a placeholder ("20%s off").
  3. Store a neutral form and convert on export. Keep the strings in a syntax that belongs to neither platform and generate each platform's file from it. The conversion lives in one place and every file is correct by construction.

Stringalize takes the third approach. Inside it a placeholder is written {text}, {number} or {decimal}, or {1:text} when it is numbered. Importing an iOS or an Android file rewrites its placeholders into that form. Exporting writes %@ and %lld for iOS, and %1$s and %2$d for Android, numbering them when Android requires it and doubling literal percent signs where the string is a format. Translators only ever see the readable form.

A checklist

  • Never ship %s to iOS or %@ to Android.
  • On iOS, use %lld for whole numbers that come from an Int.
  • Number the placeholders in any string that has more than one.
  • Double the percent sign only in strings that are used as formats.
  • Check every translation against its source: the same placeholders, each exactly once.