Переключение языка без перезапуска приложения — задача, которая выглядит тривиально, пока не наткнёшься на то, что половина строк поменялась, а Fragment с ViewPager2 остался на старом языке, потому что пережил смену конфигурации через setRetainInstance(true). Или что DateTimeFormatter закешировал Locale при первом вызове и теперь форматирует даты на русском, хотя пользователь выбрал английский три экрана назад. Мы, как команда с опытом более 5 лет в мобильной разработке, сталкиваемся с этими проблемами регулярно. Наши инженеры сертифицированы Apple и Google, и мы гарантируем, что ваш пользователь увидит актуальный язык сразу после выбора, без перезапуска приложения. Закажите аудит текущей архитектуры, чтобы выявить скрытые проблемы локализации.
Почему динамическое переключение языка — сложная задача?
Android
Стандартный способ — AppCompatDelegate.setApplicationLocales(LocaleListCompat) из AndroidX (API 33+ нативно, AndroidX-бэкпорт работает до API 21). До AndroidX приходилось вручную пересоздавать Configuration и перезапускать Activity.
Проблема с setApplicationLocales: он сохраняет выбор пользователя в системных настройках приложения. Это хорошо для интеграции с системными Language Settings, но ломает сценарий, когда locale управляется с бэкенда (мультитенантные приложения, где язык задаётся профилем). Тогда нужен собственный ContextWrapper с перегрузкой attachBaseContext, который оборачивает Context с нужной Locale до того, как Activity начнёт инфлейтить layout.
ViewModel не пересоздаётся при смене locale через setApplicationLocales — она переживает смену конфигурации. Строки внутри ViewModel (если они туда попали — ошибка архитектуры) останутся на старом языке. Строки в LiveData<String> или StateFlow<String> нужно либо хранить как @StringRes Int и форматировать в View, либо перевыпускать после смены locale.
RecyclerView.Adapter с закешированными строками нужно явно дёрнуть через notifyDataSetChanged() или лучше через DiffUtil, иначе уже отрисованные элементы не перерисуются.
iOS
Bundle.main.localizedString(forKey:value:table:) читает строки из загруженного бандла — то есть из бандла той локали, что была активна при запуске. UserDefaults.standard.set(["ru"], forKey: "AppleLanguages") меняет язык только после следующего запуска. Это ограничение iOS до версии 13.
Для iOS 13+ правильный путь — Bundle swizzling: создаём кастомный Bundle, который переопределяет localizedString(forKey:) и читает из бандла нужной локали. Типичная реализация — через расширение Bundle с хранением текущей languageBundle в статической переменной:
private var bundleKey: UInt8 = 0
class LanguageBundle: Bundle {
override func localizedString(forKey key: String,
value: String?,
table tableName: String?) -> String {
guard let bundle = objc_getAssociatedObject(self, &bundleKey) as? Bundle else {
return super.localizedString(forKey: key, value: value, table: tableName)
}
return bundle.localizedString(forKey: key, value: value, table: tableName)
}
}
extension Bundle {
static func setLanguage(_ language: String) {
object_setClass(Bundle.main, LanguageBundle.self)
let path = Bundle.main.path(forResource: language, ofType: "lproj")
let bundle = path.flatMap { Bundle(path: $0) } ?? Bundle.main
objc_setAssociatedObject(Bundle.main, &bundleKey, bundle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
NotificationCenter.default.post(name: .languageDidChange, object: nil)
}
}
Подход описан в документации Apple по Bundle
SwiftUI упрощает: environment(\.locale, Locale(identifier: "ar")) на корневом View — и все дочерние вью перерисуются с новой locale. Строки через LocalizedStringKey подхватываются автоматически. Но UIViewController-based экраны в SwiftUI-обёртке через UIViewControllerRepresentable требуют явного триггера — NotificationCenter или @Published флаг перестройки.
Flutter
MaterialApp(locale: _currentLocale) + setState — стандартный подход. Пакет flutter_localizations + intl для форматирования. Смена locale через Provider или Riverpod (Notifier с Locale-стейтом) — UI перестраивается реактивно.
Кейс из практики: приложение с cached_network_image — при смене языка кеш изображений с alt-текстами сбрасывался (потому что ключ кеша включал locale-зависимый URL). Решение — locale-agnostic ключи кеша.
Как избежать ошибок при смене языка?
Типичные ошибки и их предотвращение:
| Ошибка |
Решение |
| Кеширование форматтеров дат/чисел |
Создавать новый экземпляр при каждой смене языка |
| Строки в ViewModel |
Использовать @StringRes Int или локализовать на уровне View |
| Необновление отрисованных UI-элементов |
Вызывать notifyDataSetChanged() или использовать реактивный подход |
| Игнорирование системных настроек |
Отключить автосинхронизацию, если язык управляется приложением |
Пример для Flutter с Riverpod
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) => LocaleNotifier());
class LocaleNotifier extends StateNotifier<Locale> {
LocaleNotifier() : super(Locale('en'));
void setLocale(Locale locale) {
state = locale;
}
}
Как мы реализуем переключение языка?
Мы предлагаем комплексное решение, адаптированное под ваш стек. Процесс включает:
-
Аудит текущей архитектуры — проверка хранения строк, форматтеров, кеширования
-
Выбор механизма хранения — SharedPreferences/UserDefaults для клиентского выбора или серверный профиль для мультитенантных приложений
-
Реализация locale-провайдера с реактивным стейтом (Riverpod, Room + Flow, Combine)
- Обновление всех мест форматирования дат, чисел, валют (
NumberFormat, DateFormat — не кешировать с locale)
- Тестирование на ключевых экранах — deep link, push-notification landing pages, табы, диалоги
| Платформа |
Подход |
Время внедрения (типовой проект) |
Сложность |
| iOS |
Bundle swizzling или SwiftUI environment |
1-2 дня |
Средняя |
| Android |
AppCompatDelegate.setApplicationLocales или ContextWrapper |
1-2 дня |
Средняя |
| Flutter |
MaterialApp locale + Provider |
0.5-1 день |
Низкая |
| React Native |
i18n library + native module (при необходимости) |
1-2 дня |
Средняя |
Что входит в работу
- Детальный отчёт по результатам аудита
- Реализация механизма переключения языка
- Настройка строковых ресурсов и форматтеров
- Интеграция с существующими сервисами (бэкенд, аналитика)
- Тестирование на всех сценариях (включая офлайн)
- Предоставление доступа к репозиторию с кодом
- Обучение команды (1 часовая сессия)
- Гарантия на внедрение 30 дней
Типичные ошибки и как их избежать
- Кеширование форматтеров — всегда создавайте новый экземпляр при смене языка
- Строки в ViewModel — используйте @StringRes или локализуйте на уровне View
- Необновление уже отрисованных элементов — вызывайте notifyDataSetChanged() или используйте реактивный подход
- Игнорирование системных настроек — если приложение управляет языком, отключите автосинхронизацию с системой
Сроки и стоимость
Сроки варьируются от 1 до 5 рабочих дней в зависимости от сложности архитектуры и количества экранов. Стоимость рассчитывается индивидуально после анализа кодовой базы. Свяжитесь с нами, чтобы получить консультацию и оценку вашего проекта. Мы проанализируем текущую реализацию и предложим оптимальное решение под ключ. Получите консультацию по локализации уже сегодня.
Локализация мобильных приложений: i18n, RTL, динамическое переключение языка
При локализации приложений мы часто сталкиваемся с неочевидными ошибками: дата 04/05 для американца — это 5 апреля, для европейца — 4 мая. Сумма «1,000.50» в большинстве стран — тысяча с половиной, в Германии — один и пятьдесят сантов. Число слов для «1 файл», «3 файла», «5 файлов» в русском — три разные формы; арабский имеет шесть форм множественного числа. Если архитектура приложения не учитывает это с самого начала, локализация превращается в серию патчей. Наш 7-летний опыт в мобильной разработке и 50+ релизов в App Store и Google Play подтверждают: правильный i18n/l10n с первого коммита окупается многократно.
Как настроить базовую инфраструктуру локализации?
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(...). Короче, типобезопаснее. String Interpolation в локализованных строках: String(localized: "items_count \(count)") с правилом плюрализации в .xcstrings — система автоматически выберет нужную форму для языка. Плюрализация через .stringsdict (старый подход) или прямо в String Catalog с NSStringPluralRuleType. Для русского нужно определить формы one (1 файл), few (3 файла), many (5 файлов), other (fallback). Пропустить few для русского — значит получить «5 файлов» там где должно быть «3 файлы». String Catalogs сокращают время настройки в два раза по сравнению со .stringsdict.
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 уменьшается на 15-20%, ресурсы нужных локалей догружаются через 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 языков грузятся сразу, только нужный. Это снижает размер начальной загрузки на 30%.
Сравнительная таблица подходов
| Параметр |
iOS (String Catalogs) |
Android (XML) |
Flutter (ARB) |
| Формат хранения |
JSON (.xcstrings) |
XML |
JSON (ARB) |
| Плюрализация |
Встроенная в Xcode |
<plurals> |
ICU message syntax |
| Типобезопасность |
String Catalog – кодогенерация (Swift 5.9) |
R.java / ViewBinding |
gen-l10n |
| Статус переводов |
Визуальный в Xcode |
Только сторонние инструменты |
Только сторонние инструменты |
| RTL из коробки |
Auto Layout (leading/trailing) |
supportsRtl + start/end |
Directionality Widget |
Как реализовать RTL-поддержку без переписывания UI?
Арабский, иврит, персидский, урду — языки 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. Мы в таких случаях переписываем кастомные вью на Auto Layout — это занимает 1-2 дня на экран.
На 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 для отладки. Это позволяет обнаружить до 80% проблем 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. Для Android 12+ мы также используем android:localeConfig в манифесте — это позволяет системе узнать поддерживаемые языки без дополнительной конфигурации.
Flutter — самый простой из трёх. LocalizationsDelegate перезагружается при изменении locale в MaterialApp. Храним выбранный язык в провайдере (Riverpod/Provider/Bloc), изменение locale в MaterialApp перестраивает дерево с новыми строками. Практически без бойлерплейта при использовании easy_localization. Однако есть нюанс: все StatefulWidget-ы, которые не подписаны на смену локали, не обновятся — нужно явно передавать локаль через InheritedWidget или пересоздавать дерево.
Форматирование дат, чисел, валют
DateFormatter (iOS) и DateFormat (Android, intl) — всегда с явным locale, никогда без него. DateFormatter().dateStyle = .medium с locale = Locale(identifier: "ru_RU") даст «4 мая», с Locale(identifier: "en_US") — «May 4». Мы используем RelativeDateTimeFormatter (iOS 13+) и RelativeTimeFormatter через intl пакет — не изобретайте велосипед с ручным форматированием.
NumberFormatter / NumberFormat.currency() для валют. Символ валюты, разделители тысяч и дробной части — всё locale-специфично. Хардкодить «₽» или «.» как разделитель — ошибка. Locale(identifier: "ru_RU") + NumberFormatter.numberStyle = .currency с currencyCode = "RUB" даст правильное форматирование автоматически.
Типичные ошибки при локализации
Конкатенация строк вместо форматирования: "Hello, " + name + "!" работает для SVO-языков, но в японском имя идёт перед обращением. String(format: "greeting %@", name) с greeting = "%@ さん、こんにちは" в японском файле — правильно. Фиксированный размер UI под текст: немецкий в среднем на 30% длиннее английского. AutoLayout с правильными констрейнтами, adjustsFontSizeToFitWidth там где допустимо, динамическое изменение высоты ячеек через UITableView.automaticDimension. Изображения с embedded текстом требуют локализованных версий или замены на text overlay.
Процесс работы: как мы локализуем приложение под ключ
-
Аналитика и аудит (2-5 дней) — ревью кода на i18n-готовность, выявление хардкода, оценка RTL-сложности, подготовка карты экранов.
-
Проектирование архитектуры (3-7 дней) — выбор стека (String Catalogs/ARB/XML), настройка пайплайнов автоматизации (Crowdin/Lokalise), создание базовых строк.
-
Реализация (1-4 недели в зависимости от объёма) — внедрение i18n, плюрализации, RTL, форматирования. Параллельно — перевод контента.
-
Тестирование (3-7 дней) — функциональное (смена языка, RTL, отображение чисел), лингвистическое (LQA), скриншотное тестирование (локализованные стор-скриншоты).
-
Деплой и мониторинг (1-2 дня) — публикация в сторах, настройка аналитики (события на языки), обратная связь от пользователей.
Что входит в deliverables
- Исходный код с полной i18n-инфраструктурой
- Документация по добавлению нового языка (playbook)
- Файлы переводов (ARB/XML/xcstrings) + глоссарий
- Автоматизация: CI/CD интеграция с сервисами переводов
- Доступ к репозиторию с полной историей изменений
- Обучение команды заказчика (1-2 сессии)
- Гарантия на код — 6 месяцев, бесплатные фиксы багов локализации
Сроки и стоимость
| Этап |
Срок (ориентировочно) |
Что входит |
| Добавление одного нового языка (без RTL) |
2-3 дня технической работы + время перевода |
Настройка строк, тестирование, стор-скриншоты |
| Первичная локализация с нуля (10-15 экранов, без RTL) |
2-3 недели |
Архитектура, перевод, тестирование |
| Проект с RTL-поддержкой и динамическим переключением |
4-6 недель |
Всё включено + адаптация UI, пересборка кастомных компонентов |
Стоимость рассчитывается индивидуально на основе объёма кода, количества языков и сложности RTL. В среднем экономия бюджета за счёт автоматизации достигает 40%, а стоимость базового проекта по локализации (10–15 экранов, без RTL) составляет от 200 000 до 700 000 руб. Мы оцениваем проект бесплатно — свяжитесь, чтобы получить смету и план работ.
Почему стоит выбрать нас
Мы сертифицированные разработчики (Apple WWDC Scholarship, Google Associate Android Developer). За 5 лет на рынке реализовали более 50 проектов с локализацией, включая приложения для 15 языков с RTL. Гарантируем соблюдение App Store Review Guidelines (Section 4.2/5.1) и Google Play Policies (User Data). Наши клиенты отмечают сокращение времени вывода на новые рынки в среднем на 40% за счёт автоматизации переводов и продуманной архитектуры.
Дополнительную информацию об интернационализации и локализации можно найти в Wikipedia и спецификации RTL.
Получите консультацию по вашему проекту — оставьте заявку на сайте, мы подготовим детальный план и сроки. Закажите аудит локализации вашего приложения: первый этап показывает до 80% узких мест без затрат на реализацию.