
Многие разработчики хотят, чтобы их продукт был доступен максимально широкому кругу пользователей. И локализация на языки целевой аудитории может достаточно положительно сказаться на её росте. Вряд ли в данной статье будет что-то новое для людей, которые собаку съели на локализации, однако постараюсь поделиться максимально полезными советами по реализации для тех, кто только начинает свой путь.
Я являюсь выпускником МИП-15 2023. В свободное от работы время делаю фэнтезийное MMORPG в телеграме — Krezar Tavern. Не модные нынче миниаппы, а классический чат‑бот, без монетизации, блокчейнов и прочего, чисто пет‑проект для души. Все исходники лежат в открытом доступе на гитхабе.
Урок 1. Форматирование строк
Дано: два персонажа имеют возможность устроить дуэль. У текста начала дуэли может быть 1 и более вариаций для каждого языка. В каждом тексте упоминаются два участника в произвольном порядке.

Давайте напишем наивную реализацию и попробуем её улучшить:
public static String initDuel(Language language, Personage initiator, Personage acceptor) { final var templates = switch (language) { case RU -> List.of( initiator.badgeWithName() + " явно намеревается дать по щам " + acceptor.badgeWithName() + "!", "Гоп стоп! " + acceptor.badgeWithName() + " стопанули за углом таверны! " + initiator.badgeWithName() + " - серьезная персона и не собирается церемониться на дуэли!" ); case EN -> List.of(initiator.badgeWithName() + " starts a duel with " + acceptor.badgeWithName() + "!"); }; return RandomUtils.getRandomElement(templates); }
Какие тут недостатки?
-
Лишние конкатенации строк. По факту нам нужна одна готовая строка, а не все.
-
Легко ошибиться при копипасте. Субъективно, но по ощущением именно так.
-
Сложно читать
Форматирование в стиле СИ-строк
"%s явно намеревается дать по щам %s!".formatted(initiator.badgeWithName(), acceptor.badgeWithName())
Выглядит уже красивее, однако лишнее создание строк никуда не делось.
Форматирование с позиционными аргументами
"{0} явно намеревается дать по щам {1}!"; .... MessageFormat.format(RandomUtils.getRandomElement(templates), initiator.badgeWithName(), acceptor.badgeWithName())
Уже лучше, мы контролируем порядок слов, не создаем строки лишний раз, но страдает читаемость. Без контекста не понять, что такое 0, а что такое 1. На этом примере не так видно, но посмотрим сюда:
final var template = """ ?${0} ${1}${2} ${3}${4} (${5}) ${6}${7} (${8}) ${9}${10} (${11}) ${12}${13} (${14}) +${15}${16}""";
Страшно, правда?
Форматирование с именованными аргументами
На самом деле, всё что надо сделать в предыдущем пункте — добавить аргументам имена.
"${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!"
Выглядит сразу намного понятнее, особенно для неподготовленного человека (а разработчик на следующий день после написания кода уже является неподготовленным человеком).
Реализация в Java
Отдельная заметка как с этим работать. В Java встроенного инструмента нет. Я провёл несколько экспериментов в лоб из 4 решений: через replace, Regexp, StringSubstitutor из apache, и StringBuilder. По итогу выиграло решение через builder, конечная реализация — здесь.
Финальный код для старта дуэли будет выглядеть примерно так:
final var templates = switch (language) { case RU -> List.of( "${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!", """ Гоп стоп! ${acceptor_icon_with_name} стопанули за углом таверны! ${initiator_icon_with_name} \ - серьезная персона и не собирается церемониться на дуэли!""" ); case EN -> List.of("${initiator_icon_with_name} starts a duel with ${initiator_icon_with_name}!"); }; final var params = new HashMap<String, Object>(); params.put("initiator_icon_with_name", initiator.badgeWithName()); params.put("acceptor_icon_with_name", acceptor.badgeWithName()); return StringNamedTemplate.format( RandomUtils.getRandomElement(templates), params );
А бонусом, тот страшный пример с 17ью аргументами:
final var template = """ ?${personage_badge_with_name} ${health_icon}${remain_health} ${normal_attack_icon}${normal_damage_value} (${normal_damage_count}) ${crit_attack_icon}${crit_damage_value} (${crit_damage_count}) ${damage_blocked_icon}${damage_blocked_value} (${damage_blocked_count}) ${dodge_icon}${dodged_damage_value} (${dodged_damage_count}) +${reward_value}${money_icon}""";
Согласитесь, что стало понятнее, о чём здесь речь.
Урок 2. Частичная локализация

Совет актуален для тех разработчиков, кто в силах поддерживать один‑два перевода, а остальные делает сообщество. Очевидно, что сообщество будет отставать на некоторое время от выкатки фич. Многие используют следующую схему в таких случаях, и вряд ли есть что‑то лучше:
-
Есть локализация по‑умолчанию, которая покрывает 100%
-
Если по какой‑то локализации не хватает перевода, тогда берётся из дефолта.
Проще всего реализовать с помощью Map:
private static final Map<Language, List<String>> initDuelMap = new HashMap<>() {{ put(Language.RU, List.of(....)); put(Language.EN, List.of(....)); }}; public static String initDuel(Language language, Personage initiator, Personage acceptor) { var templates = initDuelMap.get(language); if (templates == null) { templates = initDuelMap.get(Language.DEFAULT); } final var params = new HashMap<String, Object>(); params.put("initiator_icon_with_name", initiator.badgeWithName()); params.put("acceptor_icon_with_name", acceptor.badgeWithName()); return StringNamedTemplate.format( RandomUtils.getRandomElement(templates), params ); }
Урок 3. Файлы Локализации
В примерах выше локализация лежит прямо рядом с кодом. Казалось бы, что в этом плохого?
-
Иногда локализацией занимаются отдельные люди, которые не разбираются в программировании, для них модифицировать исходники — сложно.
-
Много лишнего в исходниках — когда просматриваешь код, локализация будет просто отвлекать внимание от основной логики.
-
Сложно вносить изменения — даже если разработчик один, будет тратится лишнее время, чтобы найти нужный файл и конкретное место для правок.
Очевидное решение — вынести локализацию в отдельные файлы. Я выбрал формат TOML для этих целей.
Почему TOML
Небольшая табличка‑сравнение полуторагодовой давности, с разными форматами

Всем критериям удовлетворяли yaml и toml, но yaml мне категорически не нравится. И за время использования toml стал моим любимым языком конфигурации.
Итоговое решение, к которому я пришёл:
-
Файлы локализации разбиты на домены по языкам. Лучше много маленьких файлов, чем мало больших.
├── en │ ├── duel.toml └── ru ├── duel.toml
-
В момент старта приложения файлы парсятся в классы ресурсов и сохраняются в Map вида <Language, Resource>
ResourceUtils.doAction( LOCALIZATION_PATH + language.value() + DUEL_PATH, // DuelLocalization содержит внутри статический параметр для сохранения ресурсов it -> DuelLocalization.add(language, extractClass(mapper, it, DuelResource.class)) );
-
Дальше в рантайме достаём нужную локализацию из Map
public static String initDuel(Language language, PersonageMention initiatorMention, PersonageMention acceptorMention) { final var params = new HashMap<String, Object>(); params.put("mention_initiator_icon_with_name", initiatorMention.value()); params.put("mention_acceptor_icon_with_name", acceptorMention.value()); return StringNamedTemplate.format( /* resources - Это обертка над Map, в которой скрыт дублирующийся код по работе с массивами и объектами по умолчанию */ resources.getOrDefaultRandom(language, DuelResource::initDuel), params ); }
Подход не идеальный: храним локализацию в памяти, куча статик методов, но с такой структуры точно можно начать и модифицировать далее под свои нужды.
Урок 4. Словоформы
Во многих языках у одного слова может быть множество различных форм. В русском языке это в основном выражено падежами и родами. Рассмотрим на примере:
“This ${item} will be worth ${value} ${currency}”.
На английском нет проблем с подстановкой слов, а вот как может выглядеть итоговая строка на русском:
«Этот копьё будет стоить 10 золото» или «Это палица стоит 5 золото».
Как исправить?
-
Используем иконки вместо слов где можем, например «?» вместо «золото».
-
Оптимизируем предложения, чтобы наши подлежащие были независимы от окружения.
«Копьё будет стоить 10?» и «Spear will be worth 10?».
Если вдруг, всё же возникает потребность в разных преобразованиях для языков, то вполне возможно что придётся писать специфичный код для каждой локализации.
Вот так в моём проекте выглядит работа с формами слов:
private static String itemWithPrefixAndSuffixModifier(Language language, Item item, Modifier prefix, Modifier suffix) { final var params = new HashMap<String, Object>(); final var objectLocale = item.object().getLocaleOrDefault(language); params.put("object", objectLocale.text()); // Считаем, что у Modifier есть либо нужная форма либо форма WITHOUT, которая подходит всем params.put("prefix_modifier", prefix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form())); params.put("suffix_modifier", suffix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form())); return StringNamedTemplate.format( resources.getOrDefault(language, ItemResource::itemWithPrefixAndSuffixModifier), params ); }
Урок 5. Словарь
Если у вас есть большой проект, который изобилует большим количеством терминов, названий и так далее, задумайтесь о заведении словаря. Его не нужно использовать непосредственно для отображению игроку. Но с его помощью переводчик сможет сделать намного более согласованный текст и у ваших игроков будет более полный контекст.
Например, в моём проекте персонажи являются Искателями. В онлайн переводчиках предлагаются следующие варианты: Finder, Searcher, Seeker, Looker. Или это может касаться географических названий, когда не всегда понятны принципы транслитерации или адаптации, как легендарный Stormwind/Штормград. Поэтому важно фиксировать подобные моменты в словаре.
Заключение
Я надеюсь, что перечисленные выше советы помогут начинающим локализаторам избежать ряда граблей. На самом деле эта сфера намного глубже, существует специальное ПО для этих целей и даже целые студии. Однако, нужны такие сложные (а иногда и дорогие) вещи далеко не всем. Можно начать с использования перечисленного выше, а дальше дорабатывать решение под себя. Если я упустил что‑то важное из виду, дополните меня в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/832950/
Добавить комментарий