(не)очевидный механизм переводов в Android

Привет! Локализация приложений в Android — достаточно простая и понятная процедура. Именно так думал я, пока не столкнулся с необычным багом. Как оказалось позже, это был вовсе и не баг, а одна очень занимательная особенность, о которой и пойдёт сегодня речь.

Проект, в рамках которого и зародилась вся эта история — мобильная платформа для компаний, занимающихся продажей одежды. Если кратко, клиенты, имея некую CRM и прикрученный к ней сайт интернет-магазин, получают Android и iOS приложения под ключ. Клиенты у нас из разных уголков земного шара, поэтому одной из важных особенностей является поддержка десятка языков из коробки.

Не менее важной составляющей является поддержка так называемых «витрин». Витрина — это, в некотором роде, региональное отделение. Пользователь вправе выбрать подходящий регион независимо от его реального местоположения или выбранного на устройстве языка. Так он получает товары, цены и, конечно, язык той страны/региона, который выбрал. По этой причине, клиенты часто просят нас внести корректировки в различные переводы, чтобы максимально точно отразить языковые особенности определенной области. Так, например, то, что в Великобритании обычно называют Postcode, в США принято называть ZIP Code. На нашей практике бывали случаи, когда пользователи банально не понимали, как правильно оформить заказ, потому что им не до конца было ясно, что вообще от них требовалось.

Одним обычным рабочим днём к нам обратились с очередной подобной просьбой. На сей раз нам нужно было обновить переводы для США. Процесс этот максимально тривиальный, но для полноты картины и тех, кто только разбирается с локализацией в Android, затрону и его. Кто уже прошаренный, переходим сразу к делу.

Мультиязычное приложение

Когда мы создаём проект, у нас есть файл переводов, именуемый strings.xml. Он лежит в папке values ресурсов, и является базовым для всего приложения: если у нас нет переопределений, приложение использует переводы именно отсюда. В случае, если мы хотим добавить поддержку других языков, например, русского и немецкого, мы добавляем дополнительные два файла strings.xml, однако в других папках — values-ru и values-de, соответственно. Чтобы переопределить перевод, достаточно добавить строку в соответствующий файл с тем же ключом, что и в базовом strings.xml, и нужным переводом. Переопределение переводов необязательно, то есть вы можете не создавать все базовые ключи в локализованной версии, хотя Android Studio порекомендует вам это сделать. В другую сторону это не работает: если вы объявите перевод в одном из локализованных strings.xml, но не объявите в базовом, проект попросту не скомпилируется. Таким образом, при переключении языка устройства приложение попытается найти подходящие переводы и в случае их отсутствия будет использовать базовую версию.

Помимо глобальных языков, как упомянутые русский и немецкий, существует возможность добавления диалектных версий. Один из частых примеров — британский и американский английский. Например, для первого мы используем Favourites, тогда как для второго — Favorites. В приложение это реализуется схожим с обычными языками способом. На примере английского, нам нужно создать папки values-en-rGB и value-en-rUS соответственно, а внутри уже знакомые strings.xml.

Что у вас здесь происходит?

Возвращаемся к задаче — обновить переводы для США. Открываем values-en-rUS/strings.xml и добавляем переводы для нужных ключей. Запускаем приложение, выбираем United States регион, убеждаемся, что переводы обновились, собираем билд и отправляем на тестирование. Обычно, на этом работа завершена, но не в этот раз. Буквально через 15 минут прилетает тикет-баг «Некорректные переводы для большинства витрин». Просматривая скриншоты, наблюдаем следующее. Для en-US всё работает отлично. Для других, например, en-GB, можно увидеть переводы из en-US, а также ещё и en-JP и даже en-TR. У клиента много регионов с английским языком, поэтому мы могли видеть переводы из каждого, где были переопределения. Резюмируя, проблема заключалась в том, что приложение берёт переводы из других диалектных вариантов английского, а не из базового strings.xml. Ну что же, будем, значит, разбираться.

В первую очередь, я решил изучить то, как вообще реализовано переключение языка. Оказалось, очень просто и без подводных камней. По сути, берется Application Context и модифицируется под нужную Locale:

private static Context updateResources(Context context, Locale locale) {
   Configuration configuration = context.getResources().getConfiguration();
   configuration.setLocale(locale);
   configuration.setLayoutDirection(locale);
   return context.createConfigurationContext(configuration);
}

Над этим контекстом есть обёртка, через которую и производятся все обращения к ресурсам, включая строки, тем самым позволяя получить подходящий для выбранной Locale перевод. Проще говоря, в нашем коде нет ничего, что бы влияло на подбор перевода, так что собака зарыта где-то глубже.

Естественно, следующим местом поиска ответов стал Интернет, в частности, StackOverflow, с помощью которого мне удалось выйти на документ, проливающий свет на сложившуюся ситуацию, и объясняющий, что формально перед нами даже не баг.

Для наглядности я создал мини-проект. На экран выводится 5 сообщений, для каждого из которых есть свой ключ в strings.xml:



    LocalizationSample
    The locale of the text is EN (default)
    The locale of the text is EN (default)
    The locale of the text is EN (default)
    The locale of the text is EN (default)
    The locale of the text is EN (default)

На экране видим, что всё отображается в соответствии с тем, что мы указали:

Всё ОКВсё ОК

Итак, наш базовый strings.xml содержит базовые английские переводы. Давайте добавим 4 англоязычных региона, в каждом из которых переопределим по одному переводу:



    The locale of the text is AU


    The locale of the text is GB


    The locale of the text is NZ


    The locale of the text is US

Сымитируем ситуацию, когда выбран en-GB язык на устройстве или же через приложение:

val localizedContext = createConfigurationContext(
    resources.configuration.apply {
        locale = Locale("en", "GB")
    }
)

findViewById(R.id.localeTextView1).text = localizedContext.getString(R.string.locale_1)
findViewById(R.id.localeTextView2).text = localizedContext.getString(R.string.locale_2)
findViewById(R.id.localeTextView3).text = localizedContext.getString(R.string.locale_3)
findViewById(R.id.localeTextView4).text = localizedContext.getString(R.string.locale_4)
findViewById(R.id.localeTextView5).text = localizedContext.getString(R.string.locale_5)

Запускаем приложение и видим вот такую интересную картину:

Переводы разных стран, объединяйтесь!Переводы разных стран, объединяйтесь!

Меняем на en-AU, en-NZ, к примеру, — результат тот же. А вот с en-US выглядит уже иначе:

Ох уж эти исключенияОх уж эти исключения

Разбираемся, что происходит. Пусть выбран en-GB. В поисках подходящей строки система обращается в первую очередь к values-en-rGB/strings.xml. В случае, если нужная строка не найдена, мы ожидаем, что будет взята строка из values/strings.xml. Но происходит другое. Системе неизвестно, что наш базовый strings.xml содержит переводы на английском языке и должен быть использован при отсутствии локального перевода. Поэтому будет выполнен поиск в values-en/strings.xml, который и является основным глобальным источником английских переводов. И если он не обнаружен, будет осуществлён поиск по всем диалектным формам, результат чего мы и видим на экране. Это сделано для того, чтобы условный британец смог получить английский перевод приложения. Ведь в базовом файле может быть русский, китайский или любой другой помимо английского язык, поэтому очевидно, что шансов понять австралийский английский больше, чем вообще чужой язык. И только в случае отсутствия перевода в основном (en) и диалектных (en-GB, en-US, en-AU и т.д.) strings.xml будет взят перевод из базового. В этом правиле есть исключение — en-US. Для США схема, описанная выше, не работает — диалектные переводы не применяются, сразу происходит переход к базовым. Всё или ничего, как говорится.

Подытоживая, цепочка поиска нужного перевода следующая:

en‑GB (целевой) → en (глобальный) → en‑regional (дочерние en‑AU, en‑US, en‑NZ и т. д.) → базовый (values/strings.xml)

Теперь, зная, как происходит подбор перевода, решение проблемы достаточно очевидное. Так как мы не можем пометить, на каком языке переводы в базовом strings.xml, нам нужно добавить глобальный английский values-en/strings.xml. Отсюда возникает проблема дублирования: оба файла values/strings.xml и values-en/strings.xml будут содержать полностью одинаковые перeводы. Более того, нам даже нужно, чтобы они были одинаковыми, ведь если какой-то ключ будет пропущен, он может быть взят из диалектной версии. К счастью, есть решение — Gradle-задача, которая копирует содержимое values/strings.xml перед каждой сборкой (спасибо duongdt3):

task copyEnResource {
    copy {
        from ('src/main/res/values/strings.xml')
        into ('src/main/res/values-en/')
    }
}

preBuild.dependsOn copyEnResource

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

После добавления values-en/strings.xml, дублирующим values/strings.xml наши сообщения отображаются именно так, как мы этого ожидаем:

Бинго!Бинго!

Ещё одной интересной особенностью является то, что если у пользователя на устройстве выбрано несколько предпочитаемых языков, то в приложениях, перед тем, как будет выбран базовый перевод, будет осуществлён поиск по глобальному и диалектным версиям каждого из языков. Вот тут показано, как это работает.

На этом у меня всё. Баг исправлен, знаний стало чуть больше. Делитесь своими интересными случаями, с которыми вам приходилось сталкиваться в Android.

© Habrahabr.ru