Реализация динамического переключения языка в мобильном приложении
Переключение языка без перезапуска приложения — задача, которая выглядит тривиально, пока не наткнёшься на то, что половина строк поменялась, а Fragment с ViewPager2 остался на старом языке, потому что пережил смену конфигурации через setRetainInstance(true). Или что DateTimeFormatter закешировал Locale при первом вызове и теперь форматирует даты на русском, хотя пользователь выбрал английский три экрана назад.
Почему это сложнее, чем кажется
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)
}
}
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 ключи кеша.
Что делаем
- Выбираем механизм хранения:
SharedPreferences/UserDefaultsили серверный профиль - Реализуем locale-провайдер с реактивным стейтом (Riverpod / Room + Flow / Combine)
- Аудит всех мест форматирования дат, чисел, валют —
NumberFormat,DateFormatнельзя кешировать с locale - Тестируем переключение на всех ключевых экранах включая deep link и push-notification landing pages
Срок: 1-3 дня в зависимости от архитектуры. Стоимость рассчитывается после анализа кодовой базы.







