Представьте, что у вас есть приложение для iOS. Оно полностью на русском, без единой локализованной строки. Часть строк вы выводите на экран в коде, часть зашита в Storyboard и xib-файлах. Приложение уже достаточно большое и опубликовано в App Store. У вас миллион пользователей в месяц. И тут возникает задача перевести приложение на другой язык. Не английский. Внезапно — на румынский. Что будете делать?

Напоминаю, ни одна строка не обёрнута в макрос NSLocalizedString(…).

Меня зовут Алексей, я iOS-разработчик в Dodo Engineering. В этой статье я расскажу о том, как мы локализовали приложение Додо Пиццы с помощью Crowdin на 9 языков и поделюсь практиками и инструментами, которые у нас появились.

Немного истории

Мобильное приложение Додо Пиццы появилось в 2017 году, ни о каких переводах в тот момент не было речи — нужно было его быстро разработать и выпустить. На локализацию тратить время не хотелось, ведь для этого нужно было бы каждую строку оборачивать в NSLocalizedString, а потом ещё и дописывать в Localized.strings-файл перевод с русского на русский.

В начале 2019 года мы запускали приложение в Румынии и надо было перевести приложение на румынский язык.

Сначала написали регулярное выражение, которое ищет все-все-все строки между кавычек в коде. Эту регулярку воткнули в find&replace в Xcode.

В качестве замены было NSLocalizedString("строка", comment: ""). Прошлись по всем строкам вручную, нажали Replace только там, где действительно требуется перевод.

Регулярное выражение: “(?:[а-яА-Я\s]+)"
Заменить на: NSLocalizedString($0, comment: "")
Регулярное выражение: “(?:[а-яА-Я\s]+)" Заменить на: NSLocalizedString($0, comment: "")

Некоторые места пришлось переписать. В основном те, где производилась склейка строк или использовалась интерполяция строки. Часть строк не нашлась регуляркой — доделали вручную.

Здесь хочу отметить, что строки, которые нужно локализовать, не рекомендуется конкатенировать. Лучше всегда использовать String(format: "", arguments). Поясню на примере, почему это важно.

Допустим, нужно локализовать на английский язык строку Активен промокод APPL, где APPL — значение промокода.

Вариант 1. Переводчик переведёт фразу так: Code is active. В итоге вы получите «грамматическую ерунду»: на русском — Активен промокод APPL, на английском — Code is active APPL, т.е. грамматически неверное предложение.

let promocode = "APPL"

// 1 вариант
let promocodeString1 = NSLocalizedString("Активен промокод", comment: "") + " " + promocode

// 2 вариант
let promocodeString2 = String(
format: NSLocalizedString("Активен промокод %@", comment: ""), 
promocode
)

Вариант 2. Переводчик уже понимает, что в тексте будет указан промокод, и может сделать перевод с учётом языковых особенностей. Фраза в переводах будет следующей: Code %@ is active.

В результате фраза будет читаться правильно на обоих языках. Это победа!

Активен промокод APPL

Code APPL is active

А вот так совсем нельзя:

let promocodeString3 = NSLocalizedString("Активен промокод 
\(promocode)", comment: "")

Когда мы закончили оборачивать все строки в NSLocalizedString, встал вопрос, как же теперь добавить ресурсные файлы с переводами. Это файлы Localized.strings. Да, название может отличаться, а вот расширение — нет. Тут решили использовать стандартную утилиту Apple genstrings.

Что делает genstrings

Она анализирует все исходники и генерирует в проекте Localized.strings файл со всеми строками из NSLocalizedStings-макроса. Плюс умеет ещё добавлять комментарий из макроса и объединяет дубликаты.

Строки у нас были на русском, поэтому мы генерировали .strings для русского языка. В итоге получили Localized.strings на русском. И нам нужно перевести его на румынский. Какие есть варианты?

В первую очередь, нужно найти приложение, которое умеет работать со .strings-файлами, дать его переводчикам вместе со .strings-файлами оригиналов. После перевода получать готовый .strings-файл, потом его добавлять в проект. Звучит сложно и много ручной работы. Можно ли её как-то автоматизировать?

Долго искать инструмент не пришлось. Мы используем Crowdin для переводов, поэтому решили подключить его в наш проект.

Что такое Crowdin

Crowdin — это облачная система менеджмента локализации контента (приложений, любого содержимого). Она позволяет организовать перевод ваших строк сторонними переводчиками на нужные языки. Чтобы строки на исходном языке быстро попадали к переводчикам, а результаты перевода так же быстро возвращались обратно к разработчикам, Crowdin позволяет настроить синхронизацию с популярными Git-сервисами (поддерживается GitHub, GitLab, Bitbucket). Таким образом, как только в коде появляется новая строка, переводчик сразу может её увидеть и перевести. После перевода и подтверждения строки редактором Crowdin создаёт Pull Request в вашу рабочую ветку с результатами перевода.

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

Эволюция работы с Crowdin

Шаг 1. Синхронизация с веткой для разработки

Мы хотели, чтобы переводы были сделаны перед отведением релизной ветки, поэтому настроили синхронизацию с dev-веткой. Таким образом, в релизе уже ничего переводить не нужно — на релизной ветке производится только регрессионное тестирование и починка багов.

Как это работает:

  • отдаём на перевод фразы;

  • дожидаемся, когда получим 100% перевода от переводчиков — статус отображается в Crowdin;

  • отводим релизную ветку.

Но получилось так, что после отведения релизной ветки мы не могли точно знать, все ли строки в релизе переведены, потому что пока у нас идёт регрессионное тестирование, параллельно разрабатываются фичи в dev-ветке. То есть в develop могли появиться новые фразы и получалось так, что в Crowdin могла для какого-то языка показывать, что нет 100% переводов.

Мы начинали проверять, что же не переведено. Оказывалось, что это новые фразы, которые не пойдут в релиз. Стали думать, как этого избежать. Ещё раз внимательно изучили документацию, которую предоставляет Crowdin, и нашли в ней рекомендацию, что нужно синхронизировать Crowdin с веткой main, а не develop.

Идея в том, что в процессе релиза код в ветке main меняться не будет, соответственно, и новые фразы не появятся. Откуда тогда взять новые фразы для перевода? Ответ простой: нужно настроить синхронизацию с релизной веткой. И Crowdin позволяет настроить синхронизацию с ветками по шаблону.

Шаг 2. Синхронизация с main-веткой

Мы настроили синхронизацию по шаблону release/* и договорились, что каждый релиз начинаем в ветке release/<номер релиза>, например release/8.12.0. Так как релизная ветка отводится от develop, то в ней будут все новые фразы, накопленные в процессе разработки новых фич. Именно то, что нам нужно. В итоге переводчики переводят новые фразы в релизной ветке, потом она сливается в main и мы получаем переведённый Source of Truth в ветке main.

Недостатки Crowdin, с которыми мы не смогли справиться

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

Итак, мы наладили работу с Crowdin и всё стало хорошо. Но в приложении стало появляться всё больше и больше языков. Сейчас их уже 9. И мы начали сталкиваться с новыми проблемами.

Проблема 1. Иногда стали появляться фразы на русском в нерусскоязычных странах. Например, это может стать блокером на пути клиента к заказу. Это происходило, если мы забывали перевести строку или обернуть в макрос NSLocalizedString.

Проблема 2. Выросло время релиза приложения. Чем больше в нём языков, тем дольше приходится ждать готовых переводов. В такой ситуации один язык может затормозить весь релиз. Т.к. основное количество пиццерий у нас в России, большинство фич также запускается в России, то переводы на другие языки таких фич не важны и нет смысла ждать, пока их переведут.

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

Решения

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

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

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

Итак, решили сделать переход на дефолтный английский язык в приложении. С чем мы подошли к этому переходу?

У нас все ключи в .strings файлах были на русском языке и, соответственно, все ключи в макросах NSLocalizedStrings тоже были на русском. Кроме того, в XCode-проекте стоял русский язык в качестве developer language. 

И нам нужно было это заменить на английский.

Мы написали утилиту, чтобы сделать все операции по переходу на дефолтный английский. Утилита взяла за основу файл с переводами en.strings в каждом фреймворке и в основном проекте.

Дальше решили, что переводы фраз на английском теперь будет ключами и мы сделаем их camelCase. Кроме того, сделали из фраз идентификаторы, т.е. удалили все пробелы и специальные символы из фраз, применили camelCase и получили новый ключ. 

Давайте рассмотрим на примере.

Есть фраза на русском Ваша электронная почта = Ваша электронная почта;

и на английском: Ваша электронная почта = Your email;

Тогда для такой связки ключом станет строка yourEmail, соответственно, после конвертации мы получим в .strings-файлах следующий набор:

на русском: "yourEmail" = "Ваша электронная почта";

и на английском "yourEmail" = "Your email";

Если фразы слишком длинные, то ограничиваем идентификаторы до пяти слов.

Если утилита обнаруживает совпадение идентификаторов, потому что фразы на английском совпадают, а исходные фразы — нет, то она выдаёт Warning и добавляет к идентификатору число. Например, идентификатор может превратиться в cancel1, если две разные фразы имеют перевод Cancel.

"Отменить" = "Cancel"

"Отмена" = "Cancel"

"cancel" = "Отменить"

"cancel1" = "Отмена" (WARNING: Possible duplicate key)

Руками сделали потом так:

"cancelVerb" = "Отменить"

"cancelNoun" = "Отмена"

Для фраз с параметром внутри мы добавляем постфикс "withParam", чтобы было понятно, что для фразы нужны параметры.

"Code %@ is active” превратилось в "codeIsActiveWithParam".

В итоге утилита берёт все .swift-исходники и меняет в них ключи на новые. Строка

NSLocalizedString("Корзина", comment: "Корзина") превращается в строку

NSLocalizedString("basket", comment: "Корзина").

Дальше утилита проходит по всем ресурсным файлам модуля и заменяет ключи в них. В ресурсных файлах получаем следующее:

ru:

"basket" = "Корзина";

en:

"basket" = "Basket";

de:

"basket" = "Warenkorb";

И так далее.

Так мы заменили все ключи в .strings-файлах. 

Но оставались ещё и .stringsdict-файлы. А т.к. у них совершенно другой формат, решили утилиту не модифицировать и заменить ключи в них и исходниках вручную, благо строк в этих файлах было немного.

Пример, как работает английский по умолчанию

Появилась новая строка "myNewString" = "New String" на английском и "myNewString" = "Новая строка" на русском. Что будет в этом случае на немецком? На экране будет отображаться ключ "myNewString". А нам нужно сделать так, чтобы отображалось "New String", то есть на английском, пока переводов на немецкий нет. В этом как раз помогает Crowdin. Он для всех языков проставляет переводы из языка по-умолчанию. А он теперь у нас английский.

Как добавлять новые строки в ресурсы

Теперь нужно было решить, что делать с утилитой genstrings. Мы не могли её больше использовать, потому что она заменяла переводы в нашем случае в ru.strings-файлах. Т.е. если в исходном коде появлялась строка

NSLocalizedString(“enterDeliveryAddress”, comment: “'Specify delivery address' button title”),

то после работы утилиты genstrings мы получали в ресурсных файлах следующее:

/* 'Specify delivery address' button title /
"enterDeliveryAddress" = "enterDeliveryAddress";

А это не то, что мы хотим. Мы хотим, чтобы здесь так и осталось

/ 'Specify delivery address' button title */
"enterDeliveryAddress" = "Enter delivery address”;

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

Утилита genstrings сама решала вопрос добавления фразы в ресурсы и разработчику ничего не требовалось, но если мы от неё отказываемся, то нужно как-то сделать так, чтобы разработчик не забыл добавить фразу в ресурсы, если он добавил NSLocalizedStings-макрос с новым ключом в код.

Другие инструменты

Линтер StringsLint

Сначала мы попробовали найти линтер, который бы валидировал, что все ключи, которые есть в .swift-файлах внутри макросов NSLocalizedStrings, присутствуют в ресурсных файлах. Мы нашли такой линтер — это библиотека StringsLint. Попробовали добавить её в фазу сборки проекта. Утилита сработала, но с ней была большая проблема — она существенно увеличивала время компиляции, а для монолита, где очень много фраз, мы даже не смогли дождаться завершения валидации всех исходников. Поэтому решили от неё отказаться.

R.Swift

Ещё мы попробовали внедрить R.Swift, чтобы писать в коде не макросы NSLocalizedString, а свифтовые скомпилированные идентификаторы. То есть разработчику нужно будет лишь добавить фразу в ресурсы, а дальше в коде использовать строку типа R.strings.blabla(), чтобы получить локализованную фразу.

Но получилось так, что после внедрения в проект R.Swift в Xcode стал плохо работать CodeCompletion. Из-за большого числа фраз CodeCompletion зависал и через некоторое время отказывался работать в редакторе кода. Нам это не понравилось и мы удалили R.Swift из проекта.

Утилита Linza

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

Мы решили, что нам обязательно нужны фразы на английском и русском языках. Соответственно, если утилита не находила ключ в ресурсах, то выдавала ошибку компиляции, показывая разработчику на строку в исходниках, чтобы было видно, какой строки не хватает в ресурсах. Работа утилиты была настроена в Build Phases в каждом фреймворке.

Каким стал флоу разработчика

Теперь, если требуется добавить в интерфейс строку, которая будет переводиться, то разработчик делает следующее:

  • добавляет в коде — не в сториборде, а именно в коде — строку, обёрнутую в макрос NSLocalizedStrings(“myNewStringKey”, …);

  • добавляет ключ myNewStringKey в .strings-файлы для русской и английской локализации (иначе проект не скомпилируется);

  • интегрирует код в dev-ветку.

Когда наступает время релиза:

  • разработчик отводит релизную ветку;

  • ждёт, когда Crowdin синхронизируется с релизной веткой;

  • Crowdin создаёт пулл-реквест с файлами ресурсов на всех языках, подставив туда значения из языка по умолчанию;

  • разработчик интегрирует этот пулл-реквест в релизную ветку. Релизная ветка готова к регрессу. Ветку с переводами можно удалить.

Дальше мы отдаём ветку на перевод. Обычно он занимает два-три дня. Стараемся добиться 100% перевода, но если процесс затягивается, то интегрируем пулл-реквест с переводами в релизную ветку, даже если не все языки полностью переведены. Как я уже писал выше, для непереведённых фраз будет отображаться английский вариант, и это нас устраивает.

Дальше происходит релиз и переводы попадают на продакшен.

Что ещё улучшили

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

Бывает так, что код переезжает из основного модуля приложения в фича-модуль, а за ним должны переехать и переводы. И в этом случае нужно перенести несколько фраз на 10+ языках из ресурсных файлов одного модуля в другой. Это рутинная и унылая работа, которую мы смогли автоматизировать — сделали утилиту. В ней нужно указать массив локализационных ключей и два пути: к модулю-источнику и модулю-приёмнику. И утилита перенесёт или скопирует нужные фразы.

Какие преимущества получили

  • Релизы не задерживаются из-за переводов.

  • Новые страны можно открывать без перевода (это приближает нас к глобальной цели по запуску страны без релиза приложения).

  • Не нужно лезть в код, чтобы поменять фразы на любом языке.

  • Если что-то не перевели, то клиент всё равно поймёт смысл.

  • Можно быстро выпускать фичи для тестирования только в одной стране, не дожидаясь перевода на все языки.

Куда движемся дальше

Мы хотим, чтобы переводы подгружались run-time из облака. Скорее всего, это не исключит полностью шаг с переводами из пайплайна релиза, но заметно увеличит скорость исправления ошибок в переводах, потому что не потребуется выпускать хотфиксы, если вдруг что-то перевели критически неправильно.

Кроме того, хотим прокачать наш инструментарий: добавить в утилиту возможность поиска в ресурсах неиспользуемых фраз с возможностью их удаления, если необходимо. Планируем решение выложить в опенсорс — следите за новостями в нашем канале Dodo Mobile.

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

Комментарии (11)


  1. xotta6bl4
    26.01.2022 12:52

    Ребят, а сделайте пожалуйста выбор языка независимо от языка страны. Для примера, живу в Эстонии и хотелось бы пользоваться приложением на русском / английском


    1. u_aleksei Автор
      26.01.2022 14:34

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


      1. drdead
        26.01.2022 14:42
        +1

        А почему такое поведение не заложено в приложениях \ сайтах изначально? Очень сильно мешает невозможность менять язык в приложениях и на сайтах, которые определяются по IP \ GPS \ языку системы \ формату даты \ броску кубика. Когда это самый первый запуск - без проблем, но когда приложение поддерживает разные языки, но не даёт их менять - это очень странное решение, имхо.


        1. u_aleksei Автор
          26.01.2022 15:20

          Основным блокером для нас являлось и является перевод как минимум на два языка всего меню, ингредиентов, всех изображений с промоакциями и прочего контента, который приходит на мобилку с бэка. Т.к система уже была запущена во многих странах, это оказалось непросто. Стоял выбор, запускать приложение, как сайт хотя бы на одном языке или не запускать вовсе. Решили идти по первому пути. А сейчас уже необходимость перевода на 2 языка становится всё острее и острее. Поэтому, следите за обновлениями, мы работаем над этим.


          1. xotta6bl4
            27.01.2022 00:49

            На самом деле прочитать ингридиенты на местном - это меньшая проблема. А вот всякие "Ой, что-то пошло не так" - это прям сложно.


    1. storoj
      26.01.2022 20:33

      А UI телефона у вас на каком языке? Может его и надо использовать? Причём это и так поведение по умолчанию.


      1. xotta6bl4
        27.01.2022 00:43

        Телефон на русском


        1. storoj
          27.01.2022 04:43

          А значит, что все остальные приложения тоже на русском (если поддерживают), и наверное dodo не должно бы быть исключением. Не логичнее ли ожидать просто локализацию на язык системы вместо настраиваемости?


    1. 2Grey
      27.01.2022 09:16

      Через системные настройки, в настройках конкретного приложения можно выставить предпочитаемый язык:

      Настройки ->" Название приложения" -> Язык


  1. j2602
    27.01.2022 09:16

    Привет, в Crowdin есть свой сдк, там уже доступна подгрузка локализации из облака - https://github.com/crowdin/mobile-sdk-ios, можете попробовать.


    1. u_aleksei Автор
      27.01.2022 15:35

      Спасибо! Да, мы пробовали интегрировать к себе их либу. Кажется делали подход в августе 2021. Но тогда было несколько моментов, которые работали не так, как нам было нужно. Мы созвонились с командой Crowdin, донесли до них наши хотелки и те проблемы, что мы нашли. Но после того, как они исправили, мы больше не делали подход к интеграции. Но думаю, мы ещё вернёмся к этому инструменту, да.