Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 5
×

Як ми українізували наш стартап. Локалізація застосунку на React

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Привіт. Я Леонід Шевцов — співзасновник сервісу Сінтра — це маленький український застосунок для ведення щоденного бюджету. Роблять його два хлопці з Дніпра — Олександр Зайцев — ідеолог та дизайнер, та я — програміст. Нашою задачею було зробити простий, але довершений сервіс, що гарно реалізує конкретний підхід до особистого бюджету. Більше про виникнення продукту можна почитати в статті Олександра, а про технічну реалізацію — в моїй. У 2022 році, після початку широкомасштабного вторгнення росії, нам стало очевидно — застосунок має бути перекладений українською мовою. Байдуже, що до того локалізація здавалась далеко за межами команди з двох людей, байдуже, що з війною вільного часу стало ще менше, ніж було раніше — переклад треба було робити. І нарешті у серпні Сінтра заговорила українською. На технічну реалізацію перекладу пішло 30 годин мого часу. До того ж, власне переклад рядків та тестування робив Олександр. У цій статті хочу поділитися технічними підходами та несподіваними додатковими задачами, які у нас виникали. Бо, як виявилося — переклад застосунку — це набагато більше, ніж просто переклад застосунку.

Локалізація вебзастосунку

Перше, що приходить на думку інженера — це локалізація коду застосунку. Бо з ним ми працюємо кожен день, та доволі очевидно, що всі вписані в код рядки мають бути перекладені. Застосунок у нас на React, тому для перекладу обрали бібліотеку i18next з додаванням react-i18next. Це, мабуть, найпоширеніша бібліотека для перекладів JavaScript. Доповнення react-i18next, по-перше, надає контекст, що стежить за поточною локаллю, а по-друге, уможливлює включення у переклад HTML-тегів, що ну дуже корисно для перекладу всіляких текстових повідомлень. Найпростіша частина — то перекладання статичних рядків в застосунку. Тут достатньо побудувати словник та позаміняти рядки на виклик функції t:

// словник
const uk = {
  welcome: "Ласкаво просимо, {{name}}",
  app_prompt: "Встановіть наш <0>додаток</0>",
};

// компонент додатка
function Welcome({ name }) {
  const { t } = useTranslation();
  return (
    
<div>      {/* Простий переклад з параметром */}       {t("welcome", { name })}       {/* Використання react-i18next для включення тегу */}       
<p>        ,           ]}         />       </p>
    </div>
<pre>  );
}

Визначальними інструментами для якісного перекладу були:

  • Плагін для ESLint eslint-plugin-i18next, який повідомляє про всі неперекладені рядки в застосунку. Так вдалося знайти майже кожний рядок, що потребував перекладу. Я кажу «майже», бо для здорового використання цього плагіну потрібно додати в список ігнорованих чимало виключень, та в них проскочило декілька рядків, які все ж таки треба було локалізувати (наприклад, URL).
  • Декларації для TypeScript від i18next. З ними є гарантія, що ключі перекладу дійсно існують в словнику. Без таких декларацій довелося б потім вручну шукати ключі з помилками, або просто такі, що забули додати.

Інколи локалізований рядок будується не в компоненті React. Найбільш поширений приклад — повідомлення валідації. Тут є два підходи:

  • Можна замінити рядки на ключі локалізації, а локалізувати вже при відображенні. Цей підхід мені здається найчистішим. Наприклад, результати валідації для форми дуже зручно генерувати у вигляді ключів: required, must_be_positive. Але це гарно працює тільки доки рядки не містять параметрів, бо повертати ключ та набір параметрів — це вже суттєве ускладнення, яке до того ж погано покривається типами.
  • Або треба передавати в бізнес-логіку поточну локаль, щоб згенерувати правильно локалізований рядок. А потім ще й переконатись, що при зміні локалі результат не буде закешований. Перевага цього підходу в тому, що він точно дасть локалізувати все, що завгодно. Наприклад, у нас так будується підпис до діапазону дат. Ми доклали багато зусиль, щоб він був стислий та гуманізований: «1-31 березня 2023», але «5 грудня 2023 — 5 січня 2024». Щоб досягти цього, ми складаємо рядок з декількох фрагментів за алгоритмом, тому одним ключем було не обійтись.

Але рядки — це не все, що потрібно локалізовувати. Дати та інші значення, наприклад, суми грошей, теж мають відповідати обраній локалі. Раджу робити це через i18next — можливо, написати власний хелпер:

// конфігурація
i18next.services.formatter?.add("date", (value, lng, options) =>
  dayjs(value).locale(lng).format(options.f)
);

// використання
const translations = {
  posted_on: "Опубліковано {{posted_on, date(f: 'D.MM')}}",
};

Локалізація мобільного застосунку

Оскільки мобільний застосунок у нас написаний на React Native, то його переклад робився буквально так само, як і вебзастосунку. Навіть словник у них спільний. Окрім коду на React Native, довелося перекласти буквально пару системних рядків в застосунку iOS. Наприклад, запрошення дозволити повідомлення — воно вказується у файлі Info.plist, і перекладається власними засобами Xcode. У Xcode перелік мов є властивістю проєкту, а для локалізації є дві опції. Або для файлу можна створити окремий варіант для кожної мови. Або ж додається так званий файл рядків, який схожий на словник i18next, але містить переклади з «головної мови», тобто мови вихідних файлів.

# uk.lproj/InfoPlist.strings
addExpenseShortcutTitle = "Додати витрату";

Локалізація бекенда

Бекенд, який у нас на Firebase Functions та теж на TypeScript, не займається інтерфейсом, тому ті рядки, що приходять з бекенду, ми також замінили на ключі локалізації. Але якщо потрібно, то можна туди теж під’єднати i18next. Єдине, що локаль на бекенді змінюється від запиту до запиту, тому її потрібно отримувати з профілю користувача.

Локалізація листів

Перед локалізацією зміст наших листів зберігався у шаблонах Mailgun. На той момент було зручно не мати шаблонізатора зі свого боку, а просто передавати в Mailgun ключ шаблону та змінні. Але ніякої підтримки локалізації тут немає — хіба що створювати копії шаблонів на кожну мову. Таке рішення не сподобалось. Тому спочатку шаблони перенесли в застосунок; Mailgun використовує шаблонізатор Handlebars та його нескладно було інтегрувати в наш бекенд. Потім постало питання, як же ж перекладати шаблони Handlebars. Після роздумів та вагань вирішили й цього не робити, а буквально замінити Handlebars на i18next, бо ніякої складної логіки в наших шаблонах не було. Тож остаточне рішення було таке — для листів є окремий словник i18next, в якому ключ — це назва листа, а значення — це його зміст. А ще є словник для тем листів, які теж треба перекладати. Цікаво, що переклади тіл листів ми пишемо в окремі файли, а потім вже завантажуємо в структуру словника. На бекенді цілком можливо під час запуску застосунку сканувати директорію з перекладами функцією fs.readDirSync.

Локалізація сайту

Сайт у нас статично згенерований на платформі Hugo. Це значить, що всі переклади він готує заздалегідь, у вигляді окремих сторінок. Hugo має вбудовану підтримку мультилінгвальності, проте треба розуміти, що це значить. В найпростішій реалізації сторінки мають варіанти для кожної мови. Це зручно зі звичайними текстовими сторінками, як-от умови використання.

# pages/tos.uk.md
---
url: /tos
title: Умови використання
---

# Умови використання

....

# pages/tos.en.md
---
url: /en/tos
title: Terms of Service
---

# Terms of Service

....

Для звичайних текстових сторінок це все, що потрібно. Зазначу, що завдяки атрибуту url можна маршрутизувати сторінки так, як це зручно, а не спиратись на якусь жорстку структуру шляхів. Окрім того, треба було перекласти навігацію. Тут працює підхід зі словником; така можливість вбудована в Hugo та дуже схожа на i18next. Також є функції для того, щоб отримати адреси сторінок в поточній локалі, або навпаки — перелік локалей для поточної сторінки.

<!-- отримуємо посилання на сторінку в поточній локалі -->
{{with .Site.GetPage "/tos"}}

{{ i18n «tos» }}

{{end}}

Якщо деяка сторінка має більше розмітки, ніж тексту, ну як, наприклад, сторінка з цінами, то її теж варто перекладати за шаблоном, щоб не треба було вручну дублювати майбутні зміни розмітки. Використати шаблони в тексті сторінки Hugo заборонено. Тому для таких сторінок створюється виділений layout.

# pages/pricing.uk.md
---
url: /pricing
layout: pricing
title: Ціни
---

# layouts/pricing.html

<!-- тут можна використати шаблони -->
{{ i18n "pricing_title" }}

Локалізація сторінок App Store

Коли ви вже думали, що все перекладено, то виявляється, що всі елементи сторінки застосунку в App Store теж треба перекласти: опис, скриншоти, назви підписок та інше. Для цього в інтерфейсі App Store Connect є можливість створити локалізації для різних мов, а потім додавати переклади. При кожному оновленні застосунку також потрібно вказувати нотатки оновлення для кожної мови окремо.

Інше

А ще неперекладеною залишилась назва підписки у Paddle. Цей білінг-провайдер не дозволяє перекладати назви товарів. Технічно, мабуть, ми могли б створити окремі підписки для кожної мови. Але поки що просто вказали назву на інтернаціональній англійській. Цю назву клієнти бачать при оформленні підписки та в листах з рахунками. У інших місцях нашого сайту та застосунку назва локалізована, бо вона береться не з Paddle.

Визначення локалі

Як зрозуміти, яку мову показувати користувачу при першому відвідуванні? Є два фундаментальних підходи — можна дивитись на Accept-Language браузера, а можна на географічне розташування. Нам більше сподобався останній, бо нерідко люди ставлять системною мовою англійську. Виявилось, що Google Cloud та Firebase автоматично визначають геолокацію всіх відвідувачів та передають у спеціальних заголовках. Тобто нам залишилось просто дивитись на країну відвідувача і виставляти мову. Для цього ми написали на бекенді спеціальну функцію. Для застосунку вона просто повертає мову, тут все просто. А от якщо викликати її зі статичного сайту, то функція, завдяки технології JSONP, перенаправить вас на відповідний переклад поточної сторінки. При авторизації поточна мова зберігається у вашому профілі, і далі геолокація не має значення. Та, звісно, мову профілю можна змінити у будь-який час.

Автоматична перевірка (лінтери)

Щоб переконатись, що переклад зроблений повністю, ми додали декілька лінтерів.

  • eslint-plugin-i18next, про який я писав вище — допоможе в майбутньому помітити нові неперекладені рядки.
  • Самописний лінтер № 1 — перевіряє, що словники для кожної мови мають одну й ту саму структуру. Так можна уникнути десинхронізації словників при рефакторингу та поповненню словника.
  • Самописний лінтер № 2 — спеціально для пошти — перевіряє, що словники для листів мають однаковий набір ключів для тем та для змісту. Їх важко порівняти вручну, тому лінтер додає спокою.

Лінтери здатні покращити якість коду малої команди без витрат часу, якого в принципі немає, тому раджу не нехтувати цією можливістю

Плани

Тепер хочеться перекласти продукт на інші мови, принаймні, на англійську. Але поки що через війну та інші обставини на це немає часу. Якщо застосунок вже локалізований і має словник, то переклад можна доручити іншим особам, таким чином, отримати переклади хоч на всі мови світу. (А ще можна перекласти автоматично — бачу, що деякі автори так роблять, судячи з кумедних результатів). Після локалізації ми ще не додавали суттєвих фіч (хоча це плануємо, та раніше, ніж наступні переклади.) Як я передбачаю, то доповнення вже локалізованого застосунку — теж цікава проблема. Сподіваюсь, що лінтери нам в цьому допоможуть. Цей досвід навчив мене, що локалізація — не така непідйомна справа, як я її вважав раніше. Та зараз вона потрібна як ніколи. Тож беріть надійні інструменти, заручайтеся уважністю та терпінням, та гайда робити більше українського софту!

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

коли буде Андроід версія? якраз шукав таку апку...

У нас достатньо хороша мобільна версія для вебу, але Андроїд поки поза можливостями. Пропоную спробувати просто вебзастосунок з телефону. 😅

застосунок на мультиплатформенному React Native. доля ринку Android — 70%.
CEO такий: ну значить так, робимо тільки на iOS... 🤦

дякую, непоганий чек-ліст. цікавить трохи продуктовий обвіс — чому обрали Рaddle?, там якась космічна комісія.
Інтерком? чи вистачає Plausible? Sentry безкоштовний план?

Paddle — бо не так багато систем підтримують українські рахунки (та при цьому відомі поза межами України).
Plausible вистачає, бо нам близькі їхні цінності: вони не відстежують персону користувача. А у нас немає підрозділу маркетологів, які будуть їх аналізувати. :) тож простої статистики достатньо.
Інтерком вже припинили; дуже дорого та не дуже корисно. Sentry — так, безкоштовний.

Докотились....
Писати статтю, про те як зробити локалізацію.
І що ви тут нового повідали, як заюзати

i18next

?

зате в тренді: майже медіум. спочатку мудрі малювали папірус богу, потім спеціалісти писали товсті книжки майбутнім спеціалістам. згодом: «я зварив перший раз борщ — поділюсь з вами досвідом». невдовзі: «я їм борщ — подивіться всі». і нарешті — «я зараз в туалеті...»
хіба ви не причетні до того, і завжди займались лише автоматизацією виробництва та спрощенням бізнес-процесів..?) шутка
стаття насправді не сама погана на цю тему, але.

На технічну реалізацію перекладу пішло 30 годин мого часу.

Я как разработчик Андроид слегка офигеваю от такого. При нативной разработке учат выносить весь текст в отдельный файл, а в коде использовать стрингресурсы. Мало того, сама Андроид Студия будет тебя матюкать, что ты так не делаешь.
Также учат делать отступы не слева и справа, а со старта и конца для того, локалей, где пишут справа налево.
И на самом деле ничего сложно в том, чтобы изначально так делать вообще нет. Это не то, чтобы нужно напрягаться.
И потом вся локализация заключается в переводе одного файла на нужный язык.

А з параметрами теж так працює? Ну оцими всілякими "Дякуємо %user за ваш %saleorder.
Чи ні?

Ніяких проблем.
у strings.xml додається:
<string name="user_thanks" formatted="true">Дякуємо %1s за ваш %2s</string>
Тут можна використовувати форматування як у html. Наприклад виділити частину болдом, або кольором.

Потім у коді
textView.text = getString(R.string.user_thanks, userName, saleOrder)
userName та saleOrder- строкові змінні у коді, які об’явлені раніше. Це рекомендований підхід

Є деякі проблеми з використанням такого підходу у вьюмоделі, але насправді воно вирішується без проблем.
І якщо треба щоб була англійська локалізація, то створюється strings.xml для потрібної локалі і там вже та ж сама строка буде виглядати так
<string name="user_thanks" formatted="true">Thanks %1s for your %2s</string>
Звичайно переклад параметрів треба робити заздалегідь у коді.

Я більше скажу, це не пишеться так, як я описав.
Коли я описую інтерфейс, а у розробці для Андроїду це робиться або у xml або у коді, воно само пропонує мені екстракцію строк у string resource. Я просто пишу текст у коді, або xml, потім натискаю Alt-Enter де воно мені підсвітило і далі воно само пропонує мені ім’я ресурсу і створює оце що я описав вище. Це займає приблизно 5 секунд.
Тобто я написал щось типу:
android:text = «Hello world»
Воно підсвітило, я натискую Alt-Enter. Воно мені показує діалог де вже написано запропоноване ім’я ресурсу hello_world і його значення тобто: «Hello world». Я натискаю Enter.
Воно мені створює ресурс
<string name="hello_world">"Hello world"</string>
А а те що я написав раніше само змінює на
android:text = «@string/hello_world»

Це суті не змінює. Просто я знаю програмний продукт, в якому основна проблема якраз переклади всіляких повідомлень. І їх дофіга. Тому так не працює. Чи не роблять... Чому й запитав.

Я так и не понял, автор накостылил или это так геморно реакт с локализацией работает?

в чем гемор? автор не накостылил; реакт с локализацией работает как все.
вопрос лишь в том, стоило ли обыденную задачу выносить очередной раз, и стоило ли тратить в последующем 30 часов на то, что делается с первых минут любого проекта.

і навіщо вам React Native, якщо апка тільки на iOS..?
сьогодні створювати застосунки з літералами, а потім (хто б знав), шукати рядки і вводити локалізацію — це ваш перший проект? будь-який джун вже на другому проекті знає, що це робиться одразу.

З мого досвіду, локалізацію треба закладати з самого початку проекту навіть якщо це буде одна мова.

Дякую! Можете поділитися перевагами такого підходу?

Я насправді дійшов зворотного висновку, що краще не перейматись, поки немає потреби. За виключенням проєктів, де 1) остаточні дизайни вже готові та 2) багатомовність є вимогою бізнесу, та має підтримку з боку команди маркетингу.

Спробував розповісти більш детально тут: leonid.shevtsov.me/stendap/2023-03-24

ну пишете t => t, або проксі, абощо, аби що, але не голі рядки.
буде потреба локалізувати — перепишете t (30 хвилин, а не годин!), не буде потреби (блін, а можна взагалі приклад, коли локалізувати на нашій планеті не треба?) — нічого не витрачаєте. чи ви анекдот про програміста та два стакани не чули..?

Якщо розробник вже має досвід з локалізацією, то використовувати ключі замість тексту входить у звичку. Тим більше на великому та середньому проекті це межа розділення відповідальності розробника і менеджера. Використання ключів можна розглядати як один з базових принципів не дублювати код. Так як вшитий текст у код теж стає частиною коду ).

Якщо розробник вже має досвід з локалізацією, то використовувати ключі замість тексту входить у звичку.

Це класична алгоритмічна проблема.
Робити по одному, або батчем.
Якщо батчем там роботи на 30 годин, то по одному десь на 90 годин. Просто цей час ліпити всюди локалізовані лейби буде розмитий по всьому життєвому циклу проекта, але він нікуди не дівся.
Проста арифметика.

Ви не праві. Наприклад якщо ми говоримо про Андроід, я при створенні візуального елемента Текст пишу текст для нього — «Hello world». Далі Андроід студія мені каже що я щось роблю не так. Я становлюся на текст, натискаю Ctrl-Enter і Extract string resource. Воно само пропонує мені ім’я ресурсу і створює його.
Це не займає «90 годин» і це правильний підхід на зважаючи на те буде локалізація чи ні.

Ти рахував ? Припустимо в проекті 500 лейб, ще по ходу п’єси той код тричі переписували.
Ітого
1500 разів смекнути про локалізацію в голові + 1500 разів натиснути ctrl + enter + 1500 разів набрати назву лейби + 1500 разів почекати поки студія відкриє окреме вікно + 1500 разів закрити окреме вікно.

Вас просто привчили так робити, але того ніхто не рахував. В складних системах взагалі можна написати парсер чи виділити єдине місце в системі що все що потрібно локалізує.

А ці лейби то є чисте monkey job

Ти рахував ?

Так. Це у мене займає 4 секунди. І це ніяк не залежить від довжини тексту. На 500 лейб — 2000 секунд. Менше години.

1500 разів смекнути про локалізацію в голові + 1500 разів натиснути ctrl + enter + 1500 разів набрати назву лейби + 1500 разів почекати поки студія відкриє окреме вікно + 1500 разів закрити окреме вікно.

Одразу видно людину, яка ніколи не бачила Андроїд студії і гадки немає, як там створюються стрінгресурси.

Це у мене займає 4 секунди

Можешь десь викласти відео довжиною 4 секунди де ти вспіваєш локалізувати за цей час лейбу ?

www.youtube.com/watch?v=Y1dA2DcE3uc
Як приклад. І тут він мишею клацає. З клавіатури все швидше :)
Звичайно текст вже написаний. Але ж його у будь якому випадку сам текст треба набрати при усіх розкладах.

Так я так і не зрозумів. Яка різниця, зробити це зараз, чи зробити це потім, коли дійсно необхідно.
В чому економія ?

Одразу це робиться у процесі створення інтерфейсу. Робиться на автоматі тому що Студія виділяє це кольором як ворнінг.
Крім того, якщо це не зробити, при комміті воно буде писати, що там є проблема «hardcoded text».
Якщо робити це потім, треба пробігтися по всіх інтерфейсах. Знайти увесь текст, відкрити їх усі і виправити.
І потім це буде займати набагато більше часу.
Крім того, такий підхід дає і інші плюшки. Наприклад менше шансів помилитися якщо один і той самий текст використовується у різних лейблах.

потім це буде займати набагато більше часу

За рахунок чого воно буде займати набагато більше часу ? А якщо той код кілька разів переписували, то ти кожен раз лізеш і витрачаєш по 6 секунд бо встромив локалізацію в апп де нема локалізації ?

За рахунок чого воно буде займати набагато більше часу ?

Якщо захочеться локалізувати, то це буде займати набагато більше часу, чим якщо підготувати проєкт для цього одразу. Виправити 500 лейблів займе набагато більше часу, ніж ті самі 2000-2500 секунд одразу.
Більшість додатків у Андроїді так чи інакше локалізовані. Навіть якщо це не планувалося одразу.

Єдиний випадок коли мені треба буде це переписати, якщо дизайнер змінив дизайн.
Якщо при цьому мені треба або замінити текст, тоді я клацаю з контролом по назві ресурсу, потрапляю у файл strings.xml одразу на цей ресурс і виправляю текст, при чому він буде змінений одразу скрізь де я його використав.
Або якщо мені потрібно змінити тільки в цьому місці, я пишу текст замість ресурсу і роблю новий ресурс (ті самі 4 секунди)

Це питання не тільки локалізації. Як я вже казав раніше. Це питання того, що я розумію, що на усіх кнопках де «ОК» у мене написано «ОК», і якщо я захочу зробити «Ок», тому що так стукнуло дизайнеру, то я зроблю це у одному місці, а не буду шукати по всьому проєкту.

якщо я захочу зробити «Ок»,

Ctrl+Shift+H
Replace по всім файлам. І не потрібно собі голову лейбами морочити і код пишеться швидше і краще читається.

Основне правило програмування — не роби лишньої роботи, потім меньше переписувати.

Потім читаєш код і хоч ховайся. Одне чудо встромило локалізіцію в апп в якому нема локалізації, друге чудо встромило кеш де він не використовується, третє чудо встромило фабрику де створюється лише один обєкт. У всіх чудіків це зайняло «4 сєкунды», а код потім гівнокод якому KISS методологія тільки сниться.

Добре, я зрозумів твою позицію «усі тупі, я найрозумніший». Тому не бачу сенсу. Усього найкращого.

не так. я вище написав: не хочете робити локалізацію з самого початку — то робіть хоча б замість a="Hello" — a=t("Hello"), де t => t. Потім, коли буде потреба локалізувати — перепишете t.
це займе пару годин, а не неділю...
і це всього на три символи більше, тож не потрібно так рахувати години, як ви пишете

сложна как то, наверняка есть варики полехче

Підписатись на коментарі