Перемикання мови без перезапуску додатку — задача, яка виглядає тривіально, поки не натрапиш на те, що половина рядків змінилася, а 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-uk/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_uk.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("uk") через 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: "uk_UA") дасть «4 травня», з Locale(identifier: "en_US") — «May 4». Ми використовуємо RelativeDateTimeFormatter (iOS 13+) та RelativeTimeFormatter через intl пакет — не винаходьте велосипед з ручним форматуванням.
NumberFormatter / NumberFormat.currency() для валют. Символ валюти, роздільники тисяч та дробової частини — все locale-специфічно. Хардкодити «₴» або «.» як роздільник — помилка. Locale(identifier: "uk_UA") + NumberFormatter.numberStyle = .currency з currencyCode = "UAH" дасть правильне форматування автоматично.
Типові помилки при локалізації
Конкатенація рядків замість форматування: "Hello, " + name + "!" працює для SVO-мов, але в японській ім'я йде перед зверненням. String(format: "greeting %@", name) з greeting = "%@ さん、こんにちは" в японському файлі — правильно. Фіксований розмір UI під текст: німецька в середньому на 30% довша за англійську. AutoLayout з правильними констрейнтами, adjustsFontSizeToFitWidth там де допустимо, динамічна зміна висоти комірок через UITableView.automaticDimension. Зображення з вбудованим текстом вимагають локалізованих версій або заміни на 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%. Ми оцінюємо проект безкоштовно — зв'яжіться, щоб отримати кошторис та план робіт.
Чому варто обрати нас
Ми сертифіковані розробники (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% вузьких місць без витрат на реалізацію.