- PVSM.RU - https://www.pvsm.ru -

Есть простой способ реализовать переключение языка в Single-Activity приложении. Стек экранов при этом подходе не сбрасывается, пользователь остается там, где переключил язык. Когда пользователь переходит на предыдущие экраны, они сразу отображаются переведенными. А результат локализации чисел, денежных сумм и процентов может удивить дизайнеров.

Далее не будет ничего о:
А пойдет речь о:
Пусть в нашем приложении есть экран с настройками, и мы хотим добавить в него пару новых пунктов, один из которых позволил бы переключать язык приложения, а другой изменять валюту, в которой отображаются денежные суммы. Приведем примеры того, как это может выглядеть.


Кроме перевода текста и отображения верстки справа налево, эти настройки должны влиять на формат отображения числовых значений. Необходимо, чтобы все отображалось согласно выбранной локали.

Представим, что наше приложение написано в соответствии с Single-Activity подходом [1]. Тогда механизм переключения языка может быть реализован следующим образом.

SettingsInteractor является источником текущего значения языка. Он позволяет подписаться на это значение, получить его синхронно и подписаться только на обновления. В случае необходимости можно ввести дополнительную абстракцию над SettingsInteractor по принципу разделения интерфейса [2]. На диаграмме несущественные детали опущены.
AppActivity при создании заменяет контекст на новый, чтобы приложение использовало ресурсы для выбранного языка.
override fun attachBaseContext(base: Context) {
super.attachBaseContext(applySelectedAppLanguage(base))
}
private fun applySelectedAppLanguage(context: Context): Context {
val locale = settingsInteractor.getUserSelectedLanguageBlocking()
val newConfig = Configuration(context.resources.configuration)
Locale.setDefault(locale)
newConfig.setLocale(locale)
return context.createConfigurationContext(newConfig)
}
AppPresenter в свою очередь подписывается на обновления языка и уведомляет View об изменениях.
override fun onFirstViewAttach() {
super.onFirstViewAttach()
subscribeToLanguageUpdates()
}
private fun subscribeToLanguageUpdates() {
settingsInteractor
.getUserSelectedLanguageUpdates()
.subscribe(
{ newLang ->
viewState.applyNewAppLanguage(newLang)
},
{ error ->
errorHandler.handle(error)
}
)
.disposeOnDestroy()
}
AppActivity при получении уведомления о смене языка пересоздается.
override fun applyNewAppLanguage(lang: Locale) = recreate()

AppActivity является единственной в приложении. Все остальные экраны реализованы фрагментами. Поэтому при пересоздании активити стек экранов сохраняется системой. При возврате на предыдущие экраны они будут переинициализированы и отображаться переведенными. Пользователь останется на списке выбора языка и увидит результат своего выбора мгновенно.
Кроме замены контекста необходимо форматировать данные – числа, денежные суммы, проценты. Пусть эту задачу каждая View делегирует отдельному компоненту, назовем его UiLocalizer.

Для преобразования числа в строку UiLocalizer использует соответствующие инстансы NumberFormat.
private var numberFormat = NumberFormat.getNumberInstance(lang)
private var percentFormat = NumberFormat.getPercentInstance(lang)
private fun getNumberFormatForCurrency(currency: Currency) =
NumberFormat
.getCurrencyInstance(lang)
.also { it.currency = currency }
Обратите внимание, что валюту необходимо устанавливать отдельно.
Если вы экономите такты CPU и биты памяти, а переключение валюты и языка – основная и часто используемая функция вашего приложения, то здесь, конечно, необходим кэш.
Экземпляры класса Locale создаются по языковому тегу [3], который состоит из двухбуквенного кода языка и двухбуквенного кода региона. А экземпляры класса Currency – по трехбуквенному ISO коду [4]. В этом виде язык и валюта должны сериализовываться для сохранения на диск или передачи по сети, и тогда будет хорошо. Приведем примеры.
// IETF BCP 47 language tag string.
private val langs = arrayOf(
Locale.forLanguageTag("ru-RU"),
Locale.forLanguageTag("en-US"),
Locale.forLanguageTag("en-GB"),
Locale.forLanguageTag("he-IL"),
Locale.forLanguageTag("ar-SA"),
Locale.forLanguageTag("ar-AE"),
Locale.forLanguageTag("fr-FR"),
Locale.forLanguageTag("fr-CH"),
Locale.forLanguageTag("de-DE"),
Locale.forLanguageTag("de-CH"),
Locale.forLanguageTag("da-DK")
)
// ISO 4217 code of the currency.
private val currencies = arrayListOf(
Currency.getInstance("RUB"),
Currency.getInstance("USD"),
Currency.getInstance("GBP"),
Currency.getInstance("ILS"),
Currency.getInstance("SAR"),
Currency.getInstance("AED"),
Currency.getInstance("EUR"),
Currency.getInstance("CHF"),
Currency.getInstance("DKK")
)
Результат форматирования чисел в соответствии с региональными стандартами может разойтись с ожидаемым. Символ валюты или ее трехбуквенный код на разных языках будет выводиться по-разному. Знак минуса у отрицательных денежных значений будет появляться в неожиданных местах, а кое-где вместо него будут выводиться скобки. Знак процента может оказаться не совсем тем знаком, к которому мы привыкли.
Дело в том, что с точки зрения региональных шаблонов итоговая строка состоит из префикса и суффикса для положительных и отрицательных чисел, разделителя тысячных и десятичного разделителя, а они разные для разных локалей.
| Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
|---|---|---|---|---|---|---|
| ru-RU | "-" | " " | "," | |||
| en-US | "-" | "," | "." | |||
| iw-IL | "-" | "," | "." | |||
| ar-AE | "-" | "٬" | "٫" | |||
| fr-FR | "-" | " " | "," | |||
| de-DE | "-" | "." | "," | |||
| de-CH | "-" | "'" | "." | |||
| da-DK | "-" | "." | "," |
| Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
|---|---|---|---|---|---|---|
| ru-RU | "-" | " ₽" | " ₽" | " " | "," | |
| en-US | "-$" | "$" | "," | "." | ||
| iw-IL | "-" | " ₪" | " ₪" | "," | "." | |
| ar-AE | "-" | " د.إ." | " د.إ." | "٬" | "٫" | |
| fr-FR | "-" | " €" | " €" | " " | "," | |
| de-DE | "-" | " €" | " €" | "." | "," | |
| de-CH | "CHF-" | "CHF " | "'" | "." | ||
| da-DK | "-" | " kr." | " kr." | "." | "," |
| Language | Negative Prefix | Negative Suffix | Positive Prefix | Positive Suffix | Grouping Separator | Decimal Separator |
|---|---|---|---|---|---|---|
| ru-RU | "-" | "%" | "%" | " " | "," | |
| en-US | "-" | "%" | "%" | "," | "." | |
| iw-IL | "-" | "%" | "%" | "," | "." | |
| ar-AE | "-" | " ٪" | " ٪" | "٬" | "٫" | |
| fr-FR | "-" | " %" | " %" | " " | "," | |
| de-DE | "-" | " %" | " %" | "." | "," | |
| de-CH | "-" | "%" | "%" | "'" | "." | |
| da-DK | "-" | " %" | " %" | "." | "," |
Более того, результаты форматирования для Android SDK и JDK могут быть разными. При этом все варианты правильные, каждый из них используется в определенных контекстах.

Когда мы создаем NumberFormat для форматирования тех или иных значений, мы получаем объекты класса DecimalFormat, которые просто сконфигурированы разными шаблонами. Приведя объект к типу DecimalFormat и используя его интерфейс, можно изменить части шаблона, чтобы все сломать. Но лучше поклоняться данности.

Также можно написать тест, чтобы насладиться разнообразием. Не для всех локалей одна и та же валюта выводится символом.

Общая схема решения выглядит следующим образом.

Жизненный цикл AppActivity является жизненным циклом всего приложения. Поэтому достаточно пересоздать ее, чтобы перезапустить все приложение и применить выбранный язык. А поскольку активити одна, подписку на изменение языка достаточно держать в одном месте – в AppPresenter.
Как мы увидели, региональные форматы вывода чисел нетривиальны. Не стоит жестко задавать единый шаблон на все случаи жизни. Лучше доверить форматирование SDK и договориться, что числа будут выводиться по стандарту, а не как нарисовано на макетах.
Для экономии времени можно воспользоваться следующим флагом.
android {
...
buildTypes {
debug {
pseudoLocalesEnabled true
}
}
...
}
Выбрать необходимую псевдолокаль в настройках телефона.

И наблюдать, как едет верстка из-за длинного текста, а некоторые элементы UI упорно не хотят отображаться справа налево.

Более подробную информацию можно прочитать в документации [5].
Стоит отметить, что псевдолокали не будут работать, если вы подменяете контекст, как в решении выше. Вы ведь подменяете контекст. Поэтому необходимо добавить en-XA и ar-XB в список выбора языка внутри приложения.
На этом все. Хорошей вам локализации и хорошего настроения!

Автор: Руслан Калбаев
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/325414
Ссылки в тексте:
[1] Single-Activity подходом: https://habr.com/ru/company/redmadrobot/blog/426617
[2] принципу разделения интерфейса: https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF_%D1%80%D0%B0%D0%B7%D0%B4%D0%B5%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F_%D0%B8%D0%BD%D1%82%D0%B5%D1%80%D1%84%D0%B5%D0%B9%D1%81%D0%B0
[3] языковому тегу: https://en.wikipedia.org/wiki/IETF_language_tag
[4] трехбуквенному ISO коду: https://en.wikipedia.org/wiki/ISO_4217
[5] документации: https://developer.android.com/guide/topics/resources/pseudolocales
[6] Источник: https://habr.com/ru/post/461085/?utm_source=habrahabr&utm_medium=rss&utm_campaign=461085
Нажмите здесь для печати.