Приветствуем! Если ваше приложение поддерживает разные языки, то наверняка вы сталкивались с проблемами, связанными с локализацией: ошибки в написании ключей, отсутствующие значения для языков, необходимость пересборки приложения в случае экстренных правок перевода. Не самые приятные моменты разработки, не правда ли? В этой статье пойдет речь о том, как устроена локализация в Vivid.Money: мы расскажем о том, какой инструмент для локализации выбрали, с какими проблемами столкнулись и как их решили. Для лучшего понимания специфики проекта вы можете ознакомиться с этим материалом, а данную статью предлагаем начать без лишних слов. Наши требования к локализации включают в себя следующие пункты: Синхронизация между платформами (iOS, Android, Backend) для наличия единственного источника правды; Проверка на корректность написания использующихся ключей во время компиляции для исключения возможности сделать опечатку в названии ключа; Отсутствие необходимости для разработчиков самостоятельно вносить локализации для разных языков для того, чтобы разработчики больше времени могли заниматься тем, чем и должны - реализовывать фичи; Простота взаимодействия с переводчиками; Возможность изменения значений ключей без пересборки приложения. Для локализации приложений Apple предоставляет стандарты файлов с расширениями .strings и .stringsdict. Файлы .strings используется для хранения строковых данных в виде ассоциативного массива (“ключ”: “значение”), поддерживаются плейсхолдеры. Файлы .stringsdict используются для хранения plural форм значений ключей и представляют собой plist. Каждый из поддерживаемых платформой языков имеет свои экземпляры таких файлов с переводом, а непосредственно локализация в коде осуществляется с помощью функции NSLocalizedString. Ознакомиться со стандартным процессом локализации от Apple можно по ссылке.  Минусы такого подхода очевидны, если вспомнить наши требования к локализации: нет единственного источника правды между платформами, отсутствие проверки корректности используемых ключей на этапе компиляции, обновление ключей возможно только на стороне клиента, разработчики должны сами вносить локализации для разных языков. И если для решения последнего обозначенного минуса для экспорта локализации можно использовать формат XLIFF (стандарт обмена локализуемыми данными, основанный на языке разметки XML), то другие проблемы не получится решить стандартными средствами. Да и использование экспорта локализаций не особо поможет в большом проекте, потому что отсутствует централизованное хранение ключей локализации и необходимо будет как-то автоматизировать экспорт. Уже по первому и последнему требованиям к локализации становится понятно, что нам необходим некий бэкэнд для хранения и актуализации локализации, и чтобы не изобретать велосипед в виде собственного сервиса управления переводами, было принято решение воспользоваться уже существующими на рынке. Для себя мы выделили критерии, которые нам важны при использовании сервисов для локализации, и на их основании провели сравнение некоторых сервисов локализации, таблица по итогам которого представлена ниже:  Lokalise Phrase OneSky POEditor Разделение ключей по платформам + - - - Поддержка тегирования + + - + Поддержка plural и спецификаторов формата + + +/- + Мобильный SDK + + + - Удобство интерфейса + - - - Поиск/слияние дубликатов + - - - Поиск ошибок в тексте + - - - Поддержка машинного перевода + +/- - - Цены https://lokalise.com/pricing https://phrase.com/pricing/ https://www.oneskyapp.com/pricing/ https://poeditor.com/pricing/ Еще мы пытались разобраться с сервисом Crowdin, но безуспешно. На рынке в данный момент существует немало сервисов для решения проблемы локализации, но в большинстве своем они имеют более-менее идентичный базовый функционал, и мы в первую очередь обращали внимание на удобство использования и интуитивную понятность интерфейса. Исходя из этого было принято решение использовать Lokalise, так как он показался нам самым удобным из сравниваемых, что имело решающее значение. К тому же он имеет приятные бонусы, вроде проверки орфографии и встроенного машинного перевода от разных провайдеров, а это довольно удобно на этапе разработки фичи до появления актуальных переводов. В начале работы с lokalise необходимо создать новый проект на сервисе, затем загрузить уже имеющиеся файлы, либо добавить новые ключи вручную, и отправить приглашения членам команды. Для интеграции с проектом существуют следующие опции:  Скачать архив с локализациями с сервиса и добавить к себе в проект; Использовать скрипт Fastlane, предоставляемый сервисом; Использовать API/CLI; Использовать SDK.     Подробнее с интеграцией Lokalise можно ознакомиться здесь. Всегда удобнее пользоваться нативными инструментами, поэтому изначально мы хотели использовать SDK. Но у этого подхода имеется один неприятный момент: после актуализации переводов в сервисе бандл локализации необходимо генерировать и публиковать вручную, чтобы он стал доступен на клиенте, использующем SDK. Помимо этого, у нас появляется сильная связанность с конкретным сервисом без абстрагирования, что в будущем может усложнить переход на другой сервис локализации. Для решения двух этих проблем мы использовали прокси API, который скачивает локализации из Lokalise автоматически на каждое изменение ключей, а мобильные клиенты обращаются за бандлами уже к нему. Так же это позволило нам использовать платформенный процессинг и кастомную логику при формировании бандлов. Таким образом, мы не можем использовать Lokalise SDK, и у нас появляется необходимость в загрузке бандла локализаций вручную. Мы это делаем в двух случаях: во время сборки и при каждом запуске. К слову, Lokalise SDK делает это только при запуске, и существует вероятность, что загрузка прервется, и пользователь увидит названия ключей вместо локализованных строк. Одним из этапов сборки приложения является загрузка архива бандла с локализациями с параметризованным окружением (debug/release), в зависимости от которого формируется ссылка на загружаемый бандл. После загрузки бандла с локализациями мы проверяем .strings и .stringsdict файлы на валидность с помощью утилиты plutil (property list utility), которая проверяет синтаксис: сбалансированы ли кавычки и т.д. Далее приведен код данного скрипта на Ruby: Далее файлы локализации сохраняются в ресурсах главного бандла модуля локализации и запускается скрипт для генерации структуры Localisation, о котором будет рассказано чуть ниже. Во избежание необходимости пересборок проекта при обновлении файлов локализации, мы осуществляем загрузку вышеупомянутых бандлов вместе с  конфигурационным файлом приложения и некоторыми фундаментальными бизнес-сущностями при каждом запуске приложения. Класс LocalizationFetcher загружает метаданные о бандле: ссылку на сам бандл и номер версии. В случае, если версия кэшированных метаданных о локализации совпадает с только что полученной, мы ничего не предпринимаем - у нас актуальная версия локализации. Если же версия в кеше не совпала с полученной, то скачиваем архив с бандлом, разархивируем и сохраняем локализации в директорию Documents, сохраняем новые метаданные о локализации.  Для того, чтобы обновленные файлы локализации использовались сразу же, а не после перезапуска приложения, нам необходимо инвалидировать кеш для Localise.bundle. Делается это следующим образом: после сохранения бандла локализаций в директорию Documents мы проходим в цикле по всем директориям локализаций .lproj в ресурсах бандла и в каждой меняем названия файлов Localizable.strings и Localizable.stringsdict на Localizable.nocache.strings и Localizable.nocache.stringsdict, соответственно. Затем остается только указать “Localizable.nocache” в качестве параметра tableName при вызове метода localizedString(forKey:value:table:) на бандле локализаций, загруженном при запуске. Нам нередко приходилось сталкиваться со стандартными проблемами, которые возникают при работе с локализацией: об отсутствующих локализациях для ключей мы могли узнать уже в рантайме, и от ошибок в написании самих ключей тоже никто не был застрахован, поэтому для получения проверок во время компиляции, автокомплита и строгой типизации мы решили генерировать на основе бандла с локализациями иммутабельную структуру. R.swift для этих нужд нам не подошел, т.к., во-первых, умеет генерировать только то, что уже лежит в корне основного проекта (а мы скачиваем локализации при сборке и добавляем в бандл модуля локализации), во-вторых, размер файла при его использовании получается достаточно велик и имеет много ненужного нам сгенерированного содержимого. По этим причинам мы решили написать свой скрипт. Скрипт для генерации структуры с локализацией довольно прост: в качестве выхода определяется файл Localisation.swift в модуле локализации, после чего файлы локализации Localizable.strings и Localizable.stringsdict парсятся и записываются в выходной файл в виде статических констант и методов. Сгенерированные скриптом константы и методы имеют вид:  Вычисляемое свойство localized выглядит следующим образом: Сначала пытаемся сформировать локализованную строку для ключа из локализации в главном бандле модуля локализации; Затем локализованную строку из бандла локализаций, хранящегося в Documents, если таковой имеется; Возвращаем полученное значение, либо сам ключ, если он отсутствует и в локальном, и в серверном бандле. Метод, локализующий строку с аргументами (localized(with:)), представлен ниже: Здесь происходит следующее: Для plural форм просто возвращается локализованная строка; В ином случае спецификаторы формата, указанные для строки в Lokalise, заменяются на нативные; Происходит проверка на то, что строка не является пустой; И, наконец, возвращается строка после интерполяции. Стоит обратить внимание на некоторые детали реализации: во-первых, в файле Localizable.stringsdict уже присутствуют нативные спецификаторы формата, поэтому необходимости их заменять нет; во-вторых, что более важно, если попытаться это сделать в текущем виде, то plural локализация перестанет работать. Дело в том, что при вызове localizedString(forKey:value:table:) для plural формы, когда данные берутся из Localizable.stringsdict, возвращается объект внутреннего типа __NSLocalizedString. Это необходимое условие для корректной инициализации возвращаемой строки в случае plural форм. В свою очередь, использование опции .regularExpression при вызове replacingOccurencies(of:with:options:) приводит к созданию новой строки с типом String в процессе замены вхождений паттерна регулярного выражения, и метод вернет именно ее, вместо строки типа __NSLocalizedString. В итоге, общий флоу по работе с локализацией выглядит следующим образом:  Ставится задача на разработку фичи; Разработчики добавляют необходимые по макетам ключи в Lokalise с базовым переводом на английский язык, аналитики передают их на перевод на другие языки; Разработчик, взяв фичу в работу, запускает скрипт на обновление проекта, в ходе которого в том числе скачиваются файлы локализации для debug окружения, из которых генерируется файл Localisation.swift; Фича завершается, проходит код ревью, отдается на тестирование, найденные баги исправляются; Аналитики добавляют полученные переводы на другие языки в Lokalise; После того, как фича прошла тестирование и готова к релизу, происходит ручной деплой ключей на production окружение Lokalise аналитиками команды, ответственной за фичу; При сборке релиз версии приложения значения для ключей скачиваются уже с production окружения Lokalise; В случае последующего обновления имеющихся ключей скачивание бандла локализаций происходит при каждом запуске приложения. Для деплоя ключей на production окружение Lokalise в Gitlab репозитории проксирующего сервиса запускается кастомный пайплайн, в переменную которого можно передать команды добавления ключей либо списком названий, либо списком тэгов, по которым ключи сгруппированы, либо мигрировать все ключи, а также можно передать команду удаления ключей. Несмотря на все достоинства удаленной локализации, не обошлось и без проблем. Проблема заключается в том, что некоторые вещи, касающиеся локализации, в iOS и Android делаются по-разному. Например, в iOS плейсхолдер для строкового аргумента записывается как %@, тогда как в Android как %s. Для таких случаев, исходя из данных по предполагаемому распределению пользователей по платформам, было решено делать локализацию Android first в Lokalise, а в iOS на клиенте осуществлять замену. Нередко возникали ситуации, когда кто-то делал ошибки в локализованной строке. Например, использовал двойные кавычки без эскейпинга. В таком случае файл локализации становился невалидным и локализация не работала вовсе. Такой расклад нас не устраивал и было решено сделать валидацию файлов локализации после скачивания, код которой приводился выше. Это позволяет нам не только узнать о том, что локализация невалидная, но и определить файл и строку с ошибкой. Как уже было сказано ранее, у нас есть 2 “экземпляра” локализации - debug и production. В debug версию могут вносить изменения те, кто имеет к ней доступ, а в production локализация попадает путем миграции - специального процесса, который переносит нужные ключи из debug в production. Иногда бывают ситуации, когда мы делаем релизную сборку, но в production экземпляре еще нет некоторых ключей. Так как мы не хотим попасть в ситуацию с отсутствующей локализацией у пользователей, нам нужно как-то проверять наличие нужных ключей. В этом нам помогла кодогенерация из предыдущей главы: когда в проект скачивается локализация для релизной версии приложения, генерируется новый файл Localisation.swift, и во время сборки мы просто получаем ошибку компиляции со списком отсутствующих ключей. Возможно, решение спорное, но зато оно гарантирует надежность синхронизации используемых ключей и их значений из необходимого окружения. И так, в итоге для локализации мы выбрали сервис Lokalise, с использованием своего прокси-сервера для формирования и загрузки бандла локализации. Скачиваем мы его при сборке и на его основе генерируем иммутабельную структуру с ключами, и при необходимости скачиваем его также при каждом запуске приложения. Разумеется, не обошлось и без проблем, вроде платформозависимого синтаксиса плейсхолдеров, ошибок в файле локализации и синхронизации debug и production версий локализаций, но с ними нам удалось справиться. Возможно, это не последние проблемы, с которыми мы столкнемся в дальнейшем развитии проекта, но тем интереснее их решать. Спасибо за уделенное время, надеемся, данная статья была полезна и позволила почерпнуть что-то новое, либо натолкнула на новые мысли в решении задачи локализации. That’s all, Folks! iOS developer