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.
| Inserting | iOS | Android |
|---|---|---|
| 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.
- 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.
- 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").
- 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
%sto iOS or%@to Android. - On iOS, use
%lldfor whole numbers that come from anInt. - 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.