Одна стрінга, п’ять ARB-файлів і жодної перевірки

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

Я Flutter Staff Engineer і вже десять років роблю мобільні застосунки різної складності. За ці роки я працював над десятком різних застосунків, в основному комерційних, але маю і кілька власних. Один з них підтримує п’ять мов і лише дві з них я розумію.

Це працювало, поки я не випустив реліз, у якому в частині локалей бракувало ключів, а там, де переклад був, він місцями був неправильний. Усі відсутні стрінги відрендерились англійською, а білд, який їх зібрав, був зелений. Жодного ворнінга чи еррора. Я дізнався про це з відгуку від користувача.

Усе інше в тому релізі спрацювало без мене. Codemagic збирає ipa і aab, віддає їх у TestFlight і у внутрішній трек Play Store. Потім листом повідомляє, що все готово. Локалізація — це єдина дірка в цьому воркфлоу. І ця дірка досить конкретна. Якщо ти випускаєш застосунок більш ніж однією мовою, вона в тебе, скоріш за все, така сама.

Пишу це для тих, хто релізить Flutter-застосунок більш ніж однією мовою і тримає переклади в ARB. Якщо в тебе вже є перевірка локалізації в CI, нового тут буде мало.

Щоб додати одну стрінгу, треба відкрити п’ять файлів

Ось і вся проблема, і вона зовсім не складна.

ARB — це формат файлів перекладу, що його читає Flutter: JSON плюс домовленість щодо метаданих. gen_l10n хоче один файл на локаль. Додаю ключ в англійський файл:

{
  "checkoutButtonLabel": "Continue to checkout"
}

Потім відкриваю app_uk.arb і ручками дописую його туди. Потім app_pl.arb. Потім app_de.arb. Потім app_es.arb. Одна стрінга, п’ять файлів, і ніщо не перевіряє, чи я справді додав усі п’ять.

Ось це і є біль. Не якийсь едж кейс, не рідкісний збій. П’ять разів набираєш той самий ключ в однотипних файлах і сподіваєшся, що жодного не пропустив.

Це саме воркфлоу gen_l10n. Якщо тобі треба прибрати тільки цю рутину, з нею допомагає slang: файл на локаль лишається, але dart run slang analyze показує відсутні і невикористані переклади, а команда migrate перегоняє ARB у власний JSON slang, по одному файлу за раз. Це відповідь, яку на цю проблему зазвичай і дають. І вона справедлива.

Потім переклади. Вставляю нові стрінги в АІ, забираю переклади і кожен вставляю в потрібний файл. Додав три ключі — роблю це тричі. І в підсумку маю вивід моделі чотирма мовами, три з яких не розумію, і ніщо з цього не перевіряється.

Білд тебе тут не врятує

Здавалося б, відсутній переклад має зупинити білд. Не зупиняє.

Запускаєш flutter gen-l10n з локаллю, в якій бракує кількох ключів, і отримуєш ось це:

"de": 12 untranslated message(s).
To see a detailed report, use the untranslated-messages-file
option in the l10n.yaml file:
untranslated-messages-file: desiredFileName.txt
<other option>: <other selection>

This will generate a JSON format file containing all messages that
need to be translated.

Процес завершується успішно, білд іде далі.

Тут варто бути точним, бо зазвичай цю претензію формулюють неточно. Flutter таки повідомляє про пропуск — він виводить кількість пропущених ключів та підказує, як отримати детальну версію. І якщо цю пораду виконати, він запише JSON-звіт з усіма відсутніми ключами для кожної локалі. Інформація є.

Дві деталі про те, як ця кількість відображається у логах. Кількість пропущених ключів йде через logger.printStatus, а не printWarning і не printError, тобто приходить на тому ж рівні, що й кожен інший рядок білда. І цей виклик стоїть у гілці else: виставляєш untranslated-messages-file, і число в консолі зовсім перестає виводитись, замість нього пишеться файл.

Але білд не зупиняється. Ось як сам генератор у gen_l10n.dart вирішує, що робити з повідомленням, для якого немає перекладу:

var localeWithFallback = locale;
if (message.messages[locale] == null) {
  _addUnimplementedMessage(locale, message.resourceId);
  localeWithFallback = _templateArbLocale;
}

Відсутня стрінга фолбечиться на шаблонну локаль. Рендериться англійською. Застосунок компілюється і запускається. У німецькій локалі все виглядало нормально, бо я її не розумію, а шаблон писав сам.

Ось такий реліз я і випустив.

Кількість неперекладених ключів — це не перевірка

Скажімо, ти таки побачив те число в логах і виправив усі дванадцять. Ти все одно нічого не знаєш про:

  • Стрінгу, яка є, але неправильна. Вона у файлі, тому неперекладеною не вважається.
  • Загублений плейсхолдер. Переклад повертається без {count} взагалі. Метод усе одно приймає параметр, стрінга його не використовує, і genl10n про це не скаже. А перейменуй його ({count} на {Anzahl}), і отримаєш наступне: genl10n побачить незадекларований плейсхолдер, згенерує метод з обома іменами, і виклики перестануть компілюватись.
  • Відсутні форми множини. В англійській дві категорії множини, one і other. В українській чотири, і one тут не той, який можна вгадати: він бере 21 і 31, але не 11. Даєш моделі англійську множину, отримуєш дві форми, і ключ на місці, і все виглядає добре, але кожне число, якому потрібен few або many, провалюється в той єдиний other.

Тобто число відповідає на питання «чи хтось сюди щось надрукував». На питання «чи це правильно» воно не відповідає.

Ну і окремо: є стрінги, яких в ARB немає взагалі. Назва застосунку і кожен запит дозволу в iOS беруться з ios/Runner/Info.plist, а щоб їх локалізувати, файл треба створити руками: ios/Runner/<lang>.lproj/InfoPlist.strings. Це давно задокументований механізм Apple для локалізації ключів Info.plist. gen_l10n їх не відкриває. Тому можна мати ідеальний ARB-звіт і все одно показати користувачу запит доступу до камери англійською.

Три речі, які можна зробити сьогодні без жодного нового інструмента

Усі три працюють. Першими двома я користувався якийсь час.

1. Увімкнути звіт. У l10n.yaml:

untranslated-messages-file: l10n_missing.json

Тепер замість кількості отримуєш файл зі списком усіх відсутніх ключів за локалями. Ловить відсутні ключі. Більше нічого не ловить, і варто пам’ятати, що воно забирає число з консолі. Тобто або ти читаєш той файл, або ти бачиш кількість пропущених ключів у логах.

2. Продіфати ключі самому. ARB — це JSON, тому виходить коротко:

import json, pathlib, sys

l10n = pathlib.Path("lib/l10n")
template = json.loads((l10n / "app_en.arb").read_text(encoding="utf-8"))
keys = {k for k in template if not k.startswith("@")}
incomplete = False

for f in sorted(l10n.glob("app_*.arb")):
    if f.name == "app_en.arb":
        continue
    other = json.loads(f.read_text(encoding="utf-8"))
    missing = keys - {k for k in other if not k.startswith("@")}
    if missing:
        incomplete = True
        print(f.name, sorted(missing))

sys.exit(1 if incomplete else 0)

Запускати перед тим, як тегнути. encoding="utf-8" тут обов’язковий на Python 3.14+, інакше воно вмирає на першому ж українському файлі на віндовому раннері, і я з’ясовував це довше, ніж хотілося б.

3. Завалити білд у CI. Підключити звіт з пункту 1 до перевірки, яка падає, коли у файлі є щось, крім {}. Ось тут є деталь: коли нічого не бракує, gen_l10n пише не порожній файл, а порожній JSON-об’єкт. Тому [ -s l10n_missing.json ] буде істинним на кожному зеленому білді, і перевірка падатиме на кожному, поки ти її не видалиш. jq -e 'length == 0' l10n_missing.json — це вся перевірка.

У всіх трьох обмеження різні. Другий тільки порівнює набори ключів. Перший і третій читають власний звіт genl10n. І що б ти не вибрав, genl10n усе одно запускається на кожному білді, тому зламаний {count, plural, ...} завалить білд і без них. А от жоден з них не скаже, чи правильний переклад і чи є взагалі щось поза ARB.

Якщо ти використовуєш тільки ARB і розумієш кожну мову у своєму застосунку, цього справді достатньо. Цього перестає вистачати там, де треба перевіряти самі значення, або де стрінги виходять за межі ARB: той самий InfoPlist.strings, маркетинговий сайт, веб-дашборд, який тягне ті самі тексти.

А якщо новий інструмент усе-таки не проблема: dart pub global activate rebellion, далі rebellion analyze lib/l10n/. Ловить відсутні ключі, відсутні форми множини для кожної локалі, загублені і зайві плейсхолдери. Це закриває дірки з плейсхолдерами і множинами вище, і писати нічого не треба. Він тільки для ARB і, за словами самого автора, тільки CLI. Остання версія — березень 2025 року.

Якщо зручніше мати це в редакторі, частину закривають два розширення. ARB Editor від Google перевіряє файл за схемою ARB, розуміє синтаксис ICU і пропонує quick fixes: 107 тисяч установок, останнє оновлення — грудень 2025 року. i18n-ally показує всі локалі для одного ключа одразу, установок у нього більше мільйона, але в репозиторії немає комітів з грудня 2024 року. Обидва працюють з тим файлом, який у тебе відкритий, а не проходять по всьому проєкту перед тегом.

Ну і якщо тобі простіше написати перевірку в CI, ніж читати далі, пиши перевірку в CI — це буде з користю проведений день.

Що я в підсумку зробив

Я написав StringLane, бо локалізація була єдиною частиною релізу, яку я досі робив ручками.

Він відкриває папку проєкту і показує всі мови. Ключ додається одразу у всі локалі, і проблема п’яти файлів зникає. Один проєкт — це один формат, тому у Flutter-проєкті він читає ARB. Решту форматів він теж підтримує: .xcstrings, iOS .strings, Android XML і i18next JSON. Але кожен відкривається окремим проєктом.

Переклад працює на твоєму API-ключі або на локальній моделі взагалі без ключа. Ніяких серверів. Змусити АІ зробити переклад сьогодні й так неважко. Бракувало саме можливості побачити, що воно наробило, до того як воно полетить користувачам.

І тут варто назвати межу, бо це саме те, на чому я обпікся — ніщо не перевіряє, чи переклад хороший. Софт може сказати, що ключ на місці, що плейсхолдери збігаються з шаблоном, що всі чотири категорії множини є і що ICU парситься. А чи німецький переклад саме правильний німецький переклад — це не те, що знає програма, і StringLane цього теж не знає.

Він також дає моделі те, про що питав би живий перекладач. Опис продукту, правила бренду, нотатки під конкретну локаль, ліміт довжини на ключ, опис самого ключа (для чого ця стрінга і де вона в інтерфейсі) і слова, які не можна чіпати. Частину цих метаданих він в ARB-проєкті генерує сам. Ідеальним переклад від цього не стає, але стає точнішим. А ключ, у якому є слово зі списку «недоторканних», він ще й підсвітить, якщо модель це слово змінила.

Правильного перекладу він не гарантує. Він прибирає ті помилки, які машина здатна побачити.

Як виглядає воркфлоу тепер

Ті три речі вище мені більше не потрібні. StringLane у мене відкритий, поки я працюю над проєктом, і новий ключ я додаю в ньому, а не у файлі: він одразу з’являється в усіх локалях. ⌘T перекладає активний ключ на всі інші мови. Якщо накопичилось — ⌘P, і «Translate all missing with AI» проходить по всьому проєкту за раз.

Проблеми видно на місці. Відсутні ключі, зламані плейсхолдери, неповні множини і помилки ICU підсвічуються прямо в таблиці, а ⌘J відкриває панель, де зібрані всі. Щоб дізнатись стан перекладів, я нічого не запускаю і не читаю логів. Його просто видно, весь час.

Тому й сам реліз виглядає інакше. Якщо в StringLane зелено, можна тегати.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
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

Мігрувавши проєкт на slang позбудешся всіх проблем, бо в його cli вже є все необхідне для перевірки ключів перекладів + його типізація дасть більше профіту на етапі генерації

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