Локализация мобильных приложений: i18n, RTL, динамическое переключение языка
Локализация — не просто перевод строк. Дата «04/05/2024» в США означает 5 апреля, в Европе — 4 мая. Сумма «1,000.50» в большинстве стран — тысяча с половиной, в Германии — один и пятьдесят сантов. Число слов для «1 файл», «3 файла», «5 файлов» в русском — три разных формы; арабский имеет шесть форм множественного числа. Если архитектура приложения не учитывает это с самого начала, локализация превращается в серию патчей.
Базовая инфраструктура: строки, форматы, плюрализация
iOS: Localizable.strings и String Catalogs
Исторически строки в iOS хранились в Localizable.strings — простой key=value формат. С Xcode 15 появились String Catalogs (.xcstrings) — JSON-based формат, который хранит все локали в одном файле, отображает статус перевода (переведено/устарело/отсутствует) и интегрирован с Xcode UI.
String(localized: "welcome_title") в Swift 5.7+ вместо NSLocalizedString("welcome_title", comment: ""). Короче, типобезопаснее. String Interpolation в локализованных строках: String(localized: "items_count \(count)") с правилом плюрализации в .xcstrings — система автоматически выберет нужную форму для языка.
Плюрализация через .stringsdict (старый подход) или прямо в String Catalog с NSStringPluralRuleType. Для русского нужно определить формы one (1 файл), few (3 файла), many (5 файлов), other (fallback). Пропустить few для русского — значит получить «5 файлов» там где должно быть «3 файлы».
Android: XML resources и строковые форматы
res/values/strings.xml для базовой локали (en), res/values-ru/strings.xml для русского. Plural strings через <plurals> с <item quantity="one">, <item quantity="few">, <item quantity="many">. resources.getQuantityString(R.plurals.file_count, count, count) — первый count выбирает форму, второй подставляется в строку.
В Compose: stringResource(R.string.key) и pluralStringResource(R.plurals.file_count, count, count). Типобезопасная альтернатива — Lyricist библиотека, которая генерирует типизированные строки из аннотаций.
Android App Bundle с android:splitByLocale="true" в bundle.gradle — ресурсы доставляются только для языков устройства. APK уменьшается, ресурсы нужных локалей догружаются через Play Asset Delivery. Важно: на Android 8+ Configuration.locales — список, не один язык.
Flutter: intl и слои абстракции
Flutter intl пакет — стандарт. AppLocalizations.of(context).welcomeTitle генерируется из ARB-файлов (app_en.arb, app_ru.arb). flutter gen-l10n генерирует типизированный код. Pluralization через {count, plural, one{# файл} few{# файла} many{# файлов} other{# файла}} в ARB.
Для больших приложений с 50+ языками — easy_localization с поддержкой YAML/JSON/CSV форматов и lazy loading переводов: не все 50 языков грузятся сразу, только нужный.
RTL: правостороннее написание
Арабский, иврит, персидский, урду — языки RTL (Right-to-Left). Это меняет не только направление текста, но и всю раскладку UI: back button справа, иконки зеркально, паддинги и марджины инвертируются.
На iOS всё делается через semanticContentAttribute и Auto Layout. Layout constraint-ы с leading/trailing (не left/right) автоматически инвертируются при RTL. UIView.semanticContentAttribute = .forceRightToLeft для конкретного компонента. Системные компоненты (UINavigationController, UITableView, UIStackView) переключаются автоматически при RTL-локали. Проблемы возникают с кастомными UI, где разработчик жёстко использовал left/right констрейнты или frame-based layout.
На Android android:supportsRtl="true" в AndroidManifest включает RTL-поддержку. start/end вместо left/right в XML-атрибутах: paddingStart, layout_marginEnd, textAlignment="viewStart". LayoutInflater с android:layoutDirection="rtl" для превью. Иконки с направленностью (стрелки, шеврон) нужно зеркалировать — android:autoMirrored="true" в drawable для автоматического инвертирования при RTL.
На Flutter Directionality виджет с TextDirection.rtl управляет направлением для поддерева. Padding(EdgeInsetsDirectional.fromSTEB(...)) вместо EdgeInsets.only(left:...). Row автоматически учитывает TextDirection из Directionality. Большинство Material виджетов RTL-ready, но кастомные CustomPainter — нет: нужно получать TextDirection из context и учитывать вручную.
Тестирование RTL: на iOS Settings → General → Language & Region → Region: Saudi Arabia переключает в RTL режим без смены языка системы. На Android adb shell setprop debug.force.rtl 1 форсирует RTL для отладки.
Динамическое переключение языка
Переключение языка без перезапуска приложения — нетривиальная задача, особенно если система построена на системной локали.
iOS не поддерживает смену языка приложения без перезапуска нативно. Самый чистый подход — хранить выбранный язык в UserDefaults, при запуске создавать Bundle с нужной локализацией, использовать кастомный NSLocalizedString через этот Bundle. Bundle.setLanguage("ru") через swizzling Bundle.localizedString(forKey:value:table:) — работает, но это runtime swizzling, что не идеально. Альтернатива: собственная система строк поверх NSBundle, которая перечитывает файлы при смене языка. При переключении — пересоздать корневой ViewController.
Android с API 33: LocaleManager.setApplicationLocales() — официальный API для смены языка приложения без перезапуска системы, без рекреации Activity если использовать AppCompatDelegate.setApplicationLocales(). До API 33 — Configuration.setLocale() + recreate() для Activity. При смене языка нужно уведомить все открытые Activity через broadcast или ViewModel.
Flutter — самый простой из трёх. LocalizationsDelegate перезагружается при изменении locale в MaterialApp. Храним выбранный язык в провайдере (Riverpod/Provider/Bloc), изменение locale в MaterialApp перестраивает дерево с новыми строками. Практически без бойлерплейта при использовании easy_localization.
Форматирование дат, чисел, валют
DateFormatter (iOS) и DateFormat (Android, intl) — всегда с явным locale, никогда без него. DateFormatter().dateStyle = .medium с locale = Locale(identifier: "ru_RU") даст «4 мая 2024 г.», с Locale(identifier: "en_US") — «May 4, 2024».
NumberFormatter / NumberFormat.currency() для валют. Символ валюты, разделители тысяч и дробной части — всё locale-специфично. Хардкодить «₽» или «.» как разделитель — ошибка. Locale(identifier: "ru_RU") + NumberFormatter.numberStyle = .currency с currencyCode = "RUB" даст правильное форматирование автоматически.
Относительное время («2 часа назад», «вчера»): RelativeDateTimeFormatter (iOS 13+) и RelativeTimeFormatter через intl пакет на Flutter/Android — не изобретайте велосипед с ручным форматированием.
Типичные ошибки при локализации
Конкатенация строк вместо форматирования: "Hello, " + name + "!" работает для SVO-языков, но в японском имя идёт перед обращением. String(format: "greeting %@", name) с greeting = "%@ さん、こんにちは" в японском файле — правильно.
Фиксированный размер UI под текст. Немецкий в среднем на 30% длиннее английского. AutoLayout с правильными констрейнтами, adjustsFontSizeToFitWidth там где допустимо, динамическое изменение высоты ячеек через UITableView.automaticDimension.
Изображения с embedded текстом — требуют локализованных версий или замены на text overlay.
Сроки
Добавление одного нового языка в уже локализованное приложение (только перевод строк, без RTL) — 2–3 дня технической работы + время перевода. Первичная настройка инфраструктуры локализации с нуля, включая RTL-поддержку и динамическое переключение языка — 2–4 недели.







