Локализация через Enum, неожиданный Дзен, быстрее только телепатия

Стандартные ARB, JSON и кодогенерация во Flutter ломают поток разработки, когда нужно быстро добавить одну строку. В этой статье я покажу, как архитектура на базе обычного Enum и switch-case решает проблему производительности, избавляет от хардкода и внедряется за 5 минут.

Обложка: Локализация через Enum, неожиданный Дзен, быстрее только телепатия

Решил давеча добавить локализацию в свое приложение на Flutter. Задачка-то простая: пара кнопок, пару десятков переменных. Казалось бы, делов на пять минут. Но Flutter «из коробки» сразу попытался всучить мне какую-то дичь в виде ARB и JSON файлов. Хмм, из прошлого с такими реализациями лишь печаль, так что…

Попытка №1. Путь в лоб: Классы и интерфейсы

Самый очевидный способ - создать родительский класс с переменными, а языки сделать его наследниками. Но это просто… фиаско. Даже если не писать from/toJson, процесс выглядит так:

  1. Объявил переменную в родителе.
  2. Прописал её в конструкторе.
  3. Повторил то же самое для всех дочерних классов (всех языков).

Если бы я работал на аутсорсе в Индии и мне платили за количество строк кода - это был бы идеальный вариант. Но я хотел, чтобы одной строки при объявлении было достаточно.

Попытка №2. Стандарт (ARB/JSON)

Я поплевался, но решил попробовать - «стандарт» всё-таки. Мало ли, может чего поменялось за годы. Вроде всё завелось, но сам процесс… это боль. Бегать по разным файлам, чтобы добавить одну строчку - так себе удовольствие.

Почему я от него окончательно отказался? Когда данных становится реально много (десятки языков, тысячи строк), ты попадаешь в ловушку: тебе нужно эту махину либо целиком держать в памяти, либо постоянно подгружать и парсить. Ради смены одного слова на кнопке заставлять девайс ворочать тяжелые JSON-ы в рантайме - так себе затея для производительности.

Попытка №3. Таблицы и костыли

Подумал: «Окей, почему бы не подтянуть старый добрый CSV или вообще закинуть всё в табличку?». И тут официальный пакет локализации сказал: «Извини, мужик, тут наши полномочия всё».

Ну, я тоже не пальцем деланный. Решил припахать нейросеть, чтобы она написала мне собственный генератор. Флоу получился такой:

  1. Добавляю строку в таблицу.
  2. Запускаю генератор.
  3. Он лепит «родительский» файл.
  4. После Freezed генерит toJson, fromJson…

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

Красный флаг для программиста

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

Что происходит на практике? Когда ты пишешь код и тебе нужно добавить одну несчастную строку, тебе лень (читать: нехочется выходить из потока творения) запускать весь этот цикл с генерацией. Ты просто её хардкодишь в надежде «потом скопом всё добавлю одним махом». А «потом» наступает тогда, когда уже весь проект завален хардкодом, и вычищать его - то еще удовольствие. Даже нейросетки с такими запросами помогают не с первого раза, и с сомнительной эффективностью.

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

Эволюция лени: почему Enum победил интерфейсы

Я начал мучить нейросеть разными вариантами реализации. Для меня в первую очередь был важен флоу работы: мне было тупо лень писать больше одной строки кода, чтобы добавить переменную.

Моя философия проста: строку захардкодить - моментально. Добавление даже одной строки в другом файле требует доп. действий. НО, если действий минимум, то кодер поймет, что выигрыш во времени сейчас мизерный против больших потерь в будущем, и исправно добавит строку в правильное место. Этого не произойдет, если для добавления строки нужно «отчитаться» в десяти местах.

Попытка №4. Рекорды (Records)

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

Идеальный костыль: Enum

В итоге я пришел к самому, казалось бы, «неправильному» способу, который оказался идеальным. Enum. Само название намекает на перечисления и цифры, но оказалось, что хранить в нем буквы и целые фразы - это лучший путь для локализации.

Знаете, мне моё решение так понравилось, что я пошел к нейросетям и проверил его со всех сторон. Все модели остались в полном восторге. Под это дело я даже подготовил монументальную «нейро-статью» на полтора часа внимательного чтения, со всеми графиками, бенчмарками и анализом производительности.

Но потом я заглянул в правила Хабра, прислушался к голосу разума и понял: никто не хочет читать полтора часа сухой статистики. Лучше я просто расскажу вам свою историю «от первого лица», как я докатился до такой жизни. Ну, а если вы совсем уж фанаты цифр - просто скормите этот текст любой нейронке, она вам перескажет всё в лучших академических тонах с графиками на любой вкус.

А здесь мы будем говорить по делу.

Ниже я выкатываю сам код. Код тут не для зубрежки, а лишь чтобы указать направление / идею. Самая большая прелесть этой системы в том, что она оставляет гигантское пространство для любого типа реализации. Это архитектурный каркас, который чертовски сложно сломать. Главное — уловить принцип.

Реализация

Принцип разделения:

  • enum Strings → типизированный контракт (что существует).
  • translator → источник данных (откуда берётся текст).

Структура файлов - минимальная. Никаких внешних зависимостей:

			localization/
  strings.dart               ← enum со всеми ключами
  languages_enum.dart        ← список языков с их свойствами
  localization_notifier.dart ← реактивность, любой стейт менеджер
  locale_builder_wrapper.dart← виджет-обёртка для UI
  i18n/
    russian.dart             ← функция-переводчик

		

Ядро - strings.dart

Весь контракт локализации в одном enum. Два режима - хардкод switch и серверный кеш - за одним и тем же

			call()
		

. Даже сообщения об ошибках локализации — сами локализованы:

			enum Strings {
  welcome('Welcome'),
  save('Save'),
  delete('Delete'),
  networkError('Network error'),
  subscriptionDays('{days} days remaining'),
  greeting('Hello, {name}!'),
  // Ошибки локализатора тоже через Strings — рекурсивная элегантность
  errorLocalizationMissing('Missing key: {key} in {lang}'),
  // ... остальные ключи

  const Strings(this._def);

  final String _def;

  static bool _isDefault = true;
  static String Function()? _languageName;
  static String? Function(Strings, {num? count})? _translator;
  static Map<Strings, String> _cache = {}; // Map<Strings,String> — опечатки в ключах невозможны

  static Future<void> _load({
    String? Function(Strings, {num? count})? translator,
    Future<Map<Strings, String>?> Function()? loader,
    String Function()? languageName,
  }) async {
    _translator = translator;
    _languageName = languageName;
    _cache = {};
    if (loader != null) _cache = await loader() ?? {};
    _isDefault = translator == null && loader == null;
  }

  // Для тестов — сброс состояния между тест-кейсами
  static void reset() {
    _translator = null;
    _cache = {};
    _isDefault = true;
  }

  String call({num? count, Map<String, String>? params}) {
    String? translated;
    try {
      // 1. Кеш (серверные языки) — O(1) по типизированному ключу
      // 2. Switch (хардкод языки) — jump table в AOT
      // 3. Дефолт — приложение никогда не сломается
      translated = _cache[this] ?? _translator?.call(this, count: count);
    } catch (e) {
      Logger.error(Strings.errorLocalizationMissing(
        params: {'key': name, 'lang': _languageName?.call() ?? 'unknown'},
      ));
    }

    if (translated == null && !_isDefault) {
      Logger.warn(Strings.errorLocalizationMissing(
        params: {'key': name, 'lang': _languageName?.call() ?? 'unknown'},
      ));
    }

    String text = translated ?? _def;
    if (count != null) text = text.replaceAll('{count}', count.toString());
    params?.forEach((key, value) => text = text.replaceAll('{$key}', value));
    return text;
  }
}

		

Список языков - languages_enum.dart

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

			enum _LanguagesEnum {
  english('en', 'English', null, null, false),
  russian('ru', 'Русский', _translateToRu, null, false),
  arabic('ar', 'العربية', null, _loadAr, true); // RTL — одна константа

  const _LanguagesEnum(this.code,
      this.nativeName,
      this.translator,
      this.loader, // без сервака ненужно, но добавить пару минут
      this.isRtl, // ← const поле, не getter — задаётся раз и навсегда
      );

  final String code;
  final String nativeName;
  final String? Function(Strings, {num? count})? translator; // хардкод switch
  final Future<Map<Strings, String>?> Function()? loader; // сервер/JSON
  final bool isRtl;

  Future<void> apply() =>
      Strings._load(
        translator: translator,
        loader: loader,
        languageName: () => nativeName,
      );
}

		

Хотите грузить с сервера? Одна строка + loader. И

			fromJson
		

- тоже одна строка, потому что

			Strings.values
		

уже является registry:

			Future<Map<Strings, String>?> _loadEs() async {
  final json = await fetchTranslations('es');
  return {
    for (final s in Strings.values)
      if (json[s.name] != null) s: json[s.name] as String,
    // Невалидные ключи из JSON физически не попадут в Map<Strings, String>
  };
}

		

Переводчик - i18n/russian.dart

			String? _translateToRu(Strings s, {num? count}) {
  return switch (s) {
    Strings.welcome => 'Добро пожаловать',
    Strings.save => 'Сохранить',
    Strings.delete => 'Удалить',
    Strings.networkError => 'Ошибка сети',

  // Плюрализация — читается как русский язык, не как ICU-иероглифы
    Strings.subscriptionDays =>
    switch (count) {
      num n when n % 10 == 1 && n % 100 != 11 => '{days} день',
      num n when n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) => '{days} дня',
      _ => '{days} дней',
    },
  // Сравните с ICU-синтаксисом ARB:
  // "{days, plural, one{{days} день} few{{days} дня} other{{days} дней}}"

    _ => null, // null = используй дефолтный английский
  // Пока _ => null подчёркнут серым — все кейсы покрыты явно.
  // Уберите его — компилятор укажет на каждый непереведённый ключ.
  };
}
		

Как это выглядит в жизни (и почему я перестал хардкодить) Я намеренно не привожу здесь код оберток виджетов или конкретных стейт-менеджеров. Всё это - чистая «вкусовщина». Для работы системы нужен лишь элементарный вещатель событий (Notifier), повешенный на метод смены языка.

Но главное - это мой ежедневный флоу. Сейчас, чтобы добавить строку в UI, я просто вызываю нужный мне ключ: Strings.someKey(). Без контекста, везде.

А если ключа еще нет? Я тупо иду в Strings и добавляю одну строчку в Enum. Всё.

Благодаря дефолтному конструктору, приложению абсолютно плевать, что у меня там еще 100 языков не переведены. Оно компилируется и работает здесь и сейчас. А дальше в дело вступают гит-хуки: при комите нейросетка сама подхватывает изменения и заполняет недостающие поля в переводчиках.

Знаете, какое самое странное чувство? Мне сейчас реально проще и быстрее завести переменную в локализации, чем хардкодить строку в коде. Кажется, это и есть признак здоровой архитектуры - когда делать «правильно» становится физически удобнее, чем делать «быстро» и криво.

Собственно если в кратце это все о чем я хотел рассказать. Если тема зайдет или кто-то сам не сможет допереть, как прикрутить это к своей архитектуре - пишите в комментариях, разберемся.

Надеюсь, мой опыт сэкономит вам пару литров нервных клеток. Всех благ и чистого кода без «магических строк»!

Рекомендуем