О, вы из Англии? Локализация Ozon Seller на iOS
Всем привет! Меня зовут Андрей, и я делаю iOS-приложение для продавцов Ozon Seller. Наша команда поставляет фичи, красит кнопки, работает над перформансом и всячески улучшает пользовательский опыт работы продавцов на Ozon.
Нашим приложением активно пользуются юзеры из разных стран, и поэтому день Х, когда мы решим добавить поддержку разных языков, был неизбежен. В этой статье я расскажу про стандартный процесс локализации iOS-приложения, про то, с какими трудностями мы столкнулись и как их решили. Разберём системный алгоритм определения языка и объясню, почему он нам не подошёл. Сделаем перевод приложения «на лету», а также посмотрим, как изменился наш флоу работы со строками.
Стандартная локализация в iOS
Для начала посмотрим, что нам предлагает Тим Кук, если вдруг мы решили добавить новый язык в приложение, и как это можно сделать (спойлер — легко). Открываем настройки проекта, жмём на плюс и добавляем языки.
Затем создаем строковый файл, например, с расширением .strings. Нажимаем в боковом меню на Localize.
В выпадающем списке ставим нужный язык. В примере у меня выбран English. Затем выбираем, почти как Нео, между красной серой и синей кнопкой. Нажимаем на синюю, и приложение локализовано. На этом моменте можно было бы заканчивать статью. XCode дальше сам создаст нужные файлы, в которых остаётся лишь разместить строки в формате “key” = “value”
.
Само обращение к локализованным строкам из SwiftUI будет выглядеть так:
Это работает потому, что при передаче строкового литерала вызывается инициализатор Text’a, который ищет строки в таблицах. Выглядит он следующим образом:
init(
_ key: LocalizedStringKey,
tableName: String? = nil,
bundle: Bundle? = nil,
comment: StaticString? = nil
)
По умолчанию поиск будет происходить в таблице Localizable.strings
в main bundle. Если строка не найдена, то будет отображён переданный текст. Если локализация не нужна, то стоит использовать инициализатор Text(verbatim: String)
. Как всегда, у Apple, базовую реализацию сделать легко, но если погружаться детальнее в вопрос, то начинают всплывать нюансы. Дальше мы рассмотрим, какой именно язык подбирается системой.
Системный алгоритм выбора подходящего языка
В iOS существует собственный алгоритм определения языка, который подробно описан в архивной документации. Если кратко, то приложение смотрит на первый язык в системных настройках девайса, а затем ищет соответствующий из поддерживаемых приложением. Если прошлись по всем языкам, которые установлены на девайсе, но так и не нашли подходящий в приложении, то будет выбран дефолтный язык приложения. Также учитывайте, что, начиная с iOS 13, появилась поддержка выбора per-app-языка для приложений. Теперь есть возможность устанавливать язык в системных настройках не для всей системы, а для конкретного приложения. На девайсе может стоять, например, английский язык, а приложение использовать русский. В этом случае приоритетным будет выбранный per-app-язык.
Рассмотрим этот алгоритм подробнее на примере нашего приложения. Для начала поддержали 4 языка:
русский ??
английский ??
турецкий ??
китайский ??
Допустим, у пользователя на устройстве установлены следующие языки (типичный набор):
белорусский ??
хинди ??
Тогда диалог приложения с системой мог бы выглядеть так:
В этом случае будет предложен дефолтный язык — английский. Но поскольку у пользователя основной язык системы белорусский, то мы хотели бы, чтобы в этом случае алгоритм по дефолту предложил бы наиболее близкий русский, так как это второй официальный язык в Беларуси, и люди его используют. Это был один из примеров, благодаря которому мы решили реализовать кастомную логику для определения подходящей локали.
Локаль
Для того чтобы работать с локалью девайса, стоит понимать, что она из себя представляет. Класс Locale
инкапсулирует информацию о лингвистических и культурных стандартах и особенностях языков и диалектов. Например, с помощью неё легко узнать десятичный разделитель, который стоит использовать в числах с плавающей точкой в выбранном регионе.
let formatter1 = NumberFormatter()
formatter1.locale = Locale(identifier: "EN")
print(formatter1.decimalSeparator)
// Optional(".")
let formatter2 = NumberFormatter()
formatter2.locale = Locale(identifier: "RU")
print(formatter2.decimalSeparator)
// Optional(",")
Локаль можно легко создать, используя определённый ID. Он представляет собой строку, которая выглядит следующим образом — zh-Hans_HK
. Как можно заметить, ID состоит из нескольких частей:
Первая часть обозначает язык — en, fr, zh и так далее.
Вторая обозначает страну или регион, например,
en_AU
(английский диалект, используемый в Австралии). Но если вторая часть начинается не с нижнего подчёркивания, а с дефиса, то это обозначение системы письменности, например,az-Cyrl
илиaz-Latin
. В первом случае азербайджанский язык на кириллице (как бы странно это ни звучало), а во втором — на латинице.А могут быть и три части вместе, например,
zh-Hans_HK
. Так обозначается китайский с упрощёнными иероглифами и с выбранным регионом Гонконгом.
Определение локали
Теперь попробуем узнать текущую локаль юзера. Это можно сделать с помощью нескольких свойств:
Locale.current
Locale.autoupdatingCurrent
Locale.prefferedLanguges
Locale.current
и Locale.autoupdatingCurrent
как раз возвращают то, что нужно. По дефолту первая часть ID локали будет совпадать с языком приложения, который подобрала система, используя рассмотренный алгоритм. Я ожидал, что если что-то поменять в настройках, то при повторном обращении к current вернутся устаревшие данные, в отличие от autoupdatingCurrent. Но на практике оказалось по-другому. Оба свойства возвращают актуальные значения. Окончательно запутавшись, я захотел уйти в Android-разработку, но все же смог найти разницу между этими свойствами. Она будет, если создать переменные, содержащие копии локалей:
let autoupdatingCurrentCopy = Locale.autoupdatingCurrent
let currentCopy = Locale.current
И если обратиться к свойствам переменных autoupdatingCurrentCopy и currentCopy, то при изменении, например, календаря, будет отличие. В currentCopy будет находиться версия календаря до изменения.
С prefferedLanguges
чуть проще, но нюансы также присутствуют. По дефолту вернётся массив, соответствующий языкам, которые выбраны на устройстве, например ["ru-US"
, "be-US"
, "en-US"
, "da-US"
]. При этом порядок будет совпадать с настройками. Если самостоятельно выбрать какой-то язык в per-app-настройках приложения, то он встанет на первое место в этом массиве.
Опираясь на значение системной локали и на поддерживаемые приложением языки, можно осуществить свою логику подбора подходящего языка. Остаётся вопрос: как искать строки в зависимости от выбранного языка?
SwiftGen и поиск строки
В начале статьи был простой пример использования строк в SwiftUI, но в большинстве проектов для строк используются кодогенераторы. Мы не стали исключением, потому что используем SwiftGen. Это удобный инструмент, с которым многие знакомы. Его можно гибко настраивать в зависимости от конкретных задач. Подробную настройку рассматривать не буду, но остановлюсь на одном важном параметре — lookupFunction. Через него можно передавать функцию, которая будет вызываться при обращении к строкам. Этот параметр стоит использовать как точку входа, если нужно влиять на поиск строк или выполнять дополнительную логику. Теперь при обращении к сгенерированным строкам у нас вызывается следующий метод:
/// Helper, метод которого вызывается при обращении к строкам из swifgen
public enum LocalizationStringsProvider {
/// Возвращает локализованную строку в зависимости от текущей локали.
/// Если ключ не был найден, то вернёт value.
/// - Parameters:
/// - bundle: bundle, в котором будет искаться строка
/// - key: Ключ
/// - table: Название таблицы, в которой будет поиск
/// - value: Дефолтное значение, если ключ не будет найден
/// - language: Текущий язык
@inlinable
public static func localizedString(
bundle: Bundle,
key: String,
table: String,
value: String,
language: Language
) -> String {
let currentLocaleRaw = language.rawValue
let localeType = "lproj"
let path = bundle.path(forResource: currentLocaleRaw, ofType: localeType)
guard let path, let bundle = Bundle(path: path) else { return value }
return bundle.localizedString(forKey: key, value: value, table: table)
}
}
Параметры key, table, value напрямую проксируются из SwiftGen. Параметр language — это наша бизнес-логика.
public enum Language: String, CaseIterable {
/// Русский
case ru
/// Английский
case en
// Китайский(упрощенный)
case zhHans = "zh-Hans"
/// Турецкий
case tr
}
Текущее значение Language мы храним в UserDefaults. Трюк заключается в том, что теперь поиск строки зависит от значения этого enum’a. Если программно поменять его, то будут найдены нужные переводы. Папки, в которых лежат строки, должны совпадать по названию с текущим языком в нашей реализации.
Смена языка внутри приложения
Поговорим о смене языка внутри приложения. Apple советует так не делать, а уводить пользователя в системные per-app-настройки и менять оттуда. Но у этого подхода есть неоспоримый минус — язык поменяется не бесшовно. Для начала откроется алерт, пользователь нажмёт на кнопку «Перейти», а после выбора языка ему ещё нужно вернуться в приложение. Но и это ещё не все — если у вас есть какая-то тяжёлая работа на старте, то нужно немного подождать, потому что приложение перезапускается. Не очень быстро, нам хотелось сделать это переключение на лету. А какое поведение сейчас, может быть все сработает автоматически и не придётся ничего делать?
Итак:
Пользователь открывает шторку с языками, выбирает язык.
Мы уведомляем бэкенд (чтобы он присылал контент на нужном языке).
Локально сохраняем «новый» язык.
Последующие обращения к строкам (по факту вызовы lookup function) вернут актуальные значения.
Но загвоздка в том, что именно последующие обращения будут возвращать актуальные строки. В нашем случае SwiftUI должен вызвать body у вьюшек ещё раз, но этого не произойдёт. Плюс на открытых экранах уже будут загруженные данные с бэкенда на «старом» языке. Дальше было два варианта.
Первый — после смены языка уведомлять экраны, чтобы они перерисовались и вызывали запросы локализованного контента при необходимости.
Второй — пересоздать рутовый таббар с экранами.
Первый занял бы гораздо больше усилий, обязал бы думать об этой нотификации на новых экранах, в общем, вариант крайне дорогой, поэтому мы от него отказались в пользу второго.
Второй занял у нас буквально 10 строк с подменой старого таббара на такой же новый. Дополнительно в новом таббаре нужно установить открытый экран, который будет соответствовать тому, с которого была инициирована смена языка и —готово! Почти бесшовно, при этом дёшево. Для пользователя это выглядит как дефолтная анимация открытия экрана. Единственное: так теряется иерархия экранов, открытых в табах, но это легко исправить при необходимости (у нас её, правда, нет), предварительно сохранив стэк экранов.
Баг с системными настройками
У нас осталась одна проблема, которая заключалась в том, что при изменении языка внутри приложения он не менялся в per-app-настройках. Допустим, у нас стоял русский, меняем язык на английский, всё отлично — контент приложения на английском, но открываем настройки и видим, что остался русский.
Ради интереса попробовал этот тест-кейс в разных приложениях, и в некоторых как раз есть подобный баг, например, в телеграме. Я начал искать пути решения и человеческий метод у Apple, но, как оказалось, его нет (как неожиданно). Но в UserDefaults существует интересный ключ — “AppleLanguages”. Он возвращает массив локалей, расставленных в порядке приоритета, и соответствует тому, который возвращает свойство Locale.prefferedLanguges. Но внятной доки с подробным описанием я так и не нашёл, зато нашёл хак, когда записывают значение по этому ключу. И, на удивление, это работает — галка в системных настройках совпадает с первым элементом в массиве “AppleLanguages”. Поэтому при изменении языка мы дополнительно меняем порядок элементов в этом массиве, ставя выбранный язык на первое место и перезаписывая значение в UserDefaults. Но, опять же, будьте осторожны, это не задокументированное апи, но нами было проверено, что работает отлично.
Что ещё необходимо локализовать
Контент с бэкенда.
Конечно, для этого сервер должен поддерживать локализацию. А клиенту достаточно передавать заголовок с нужным языком.
Пуш-уведомления.
Текст уведомлений также формирует бэкенд, но для этого ему необходимо запомнить актуальный язык. Для этого у нас есть отдельная ручка, которую мы вызываем в нескольких случаях:
первый запуск после инсталла;
при смене языка в приложении;
при смене языка в настройках. В этом случае система перезапустит приложение, поэтому необходимо отправить изменение локали на старте.
Форматтеры.
В нашем подходе смены языка форматтерам необходимо проставлять локаль сразу после смены языка. У нас в проекте форматтеры лежат в фабрике, которую мы подписали на нотификацию о смене языка. После её получения проходимся по форматтерам и выставляем новую локаль.
Ссылки на webview.
Ещё есть ссылки на webview, контент которых должен быть переведён. Мы лишь можем передать параметры, а сами переводы будут на стороне сервера. Какие-то ссылки требуют новые query-параметры для локализации, а для некоторых нужны разные пути. Поэтому константные ссылки теперь стали динамичным и обзавелись конструкцией switch currentLanguage.
Строки для системных алертов.
Перевод Info.plist делается аналогично тому, как Apple предлагает переводить обычные строки. Нужно добавить локализованные InfoPlist.strings-файлы. При этом сам файл InfoPlist важно не трогать. Мы же решили его удалить, подумав, почему бы и нет, ведь он же теперь дублируется с переводами. Но получили предупреждение на ревью в AppStore и вернули его.
Сервис локализации
Осталось разобраться с тем, как мы делаем переводы. Для работы со строками используем собственный сервис локализации. Каждая строка хранится в определённом контексте. Контекст — это подобие папки, контексты могут быть вложены друг в друга, а также могут содержать строки. У сервиса есть следующие основные возможности:
1. Автоперевод
2. Валидация строк переводчиками
3. API для загрузки и выгрузки переводов
Этот сервис является общим, его используют и другие платформы, например бэкенд. Загружаем строки, как правило, вручную, а скачиваем переводы в наш iOS-проект с помощью собственного скрипта. Он забирает переводы из выбранного контекста и раскладывает по папкам так, чтобы корректно работал поиск нужного перевода в приложении.
Флоу работы
После того как мы решили использовать сервис локализации, поменялся флоу работы со строками.
Как видно из схемы, количество кофе не изменилось, а это главное. Поскольку фичи для iOS и Android у нас всегда идентичны, то и строки мы используем одни и те же, соответственно, сервис локализации стал для обеих платформ точкой синхронизации. Заводим переводы один раз, а обе платформы их используют. Звучит как отличный план, но для начала необходимо было решить, что делать со строками, которые уже были на клиентах.
Сложность в том, что существующие строковые ключи на iOS и Android отличаются, и сделать так, чтобы эти legacy-строки были в общих контекстах и имели одинаковые ключи не так быстро.
Это сильно увеличило бы время локализации приложения, поэтому решили делать итеративно. Legacy-строки продублировали в сервис локализации отдельно для iOS и Android. Таким образом, не пришлось менять существующие ключи. Разбивать эти два legacy-контекста будем постепенно.
Для работы с переводами мы придумали два флоу:
Если в сервисе добавляются новые строки или меняются переводы, то это не ломает клиентов, пока мы сами не запустим скрипт загрузки строк. Очевидно, работа со строками стала труднее, но зато теперь Seller’ы используют наше приложение на 4-х различных языках. И мы планируем увеличивать их количество. С нашим подходом это будет сделать очень просто, лишь добавив файлы с переводами и новые значения в enum Language.
Заключение
Так выглядел наш путь джедаев. Он оказался тернистым, вопросов было много, а документации мало, но разве этим можно удивить iOS-разработчика?
В наших планах есть переход на новые String Catalog’и, с которым пока есть сложности c кодогенерацией. Если эта тема будет интересна, дайте знать — выпустим отдельную статью. Локализуйте свои приложения и выходите на новые рынки, жизнь одна — кайфуйте!