Решил давеча добавить локализацию в свое приложение на Flutter. Задачка-то простая: пара кнопок, пару десятков переменных. Казалось бы, делов на пять минут. Но Flutter «из коробки» сразу попытался всучить мне какую-то дичь в виде ARB и JSON файлов. Хмм, из прошлого с такими реализациями лишь печаль, так что…
Попытка №1. Путь в лоб: Классы и интерфейсы
Самый очевидный способ - создать родительский класс с переменными, а языки сделать его наследниками. Но это просто… фиаско. Даже если не писать from/toJson, процесс выглядит так:
- Объявил переменную в родителе.
- Прописал её в конструкторе.
- Повторил то же самое для всех дочерних классов (всех языков).
Если бы я работал на аутсорсе в Индии и мне платили за количество строк кода - это был бы идеальный вариант. Но я хотел, чтобы одной строки при объявлении было достаточно.
Попытка №2. Стандарт (ARB/JSON)
Я поплевался, но решил попробовать - «стандарт» всё-таки. Мало ли, может чего поменялось за годы. Вроде всё завелось, но сам процесс… это боль. Бегать по разным файлам, чтобы добавить одну строчку - так себе удовольствие.
Почему я от него окончательно отказался? Когда данных становится реально много (десятки языков, тысячи строк), ты попадаешь в ловушку: тебе нужно эту махину либо целиком держать в памяти, либо постоянно подгружать и парсить. Ради смены одного слова на кнопке заставлять девайс ворочать тяжелые JSON-ы в рантайме - так себе затея для производительности.
Попытка №3. Таблицы и костыли
Подумал: «Окей, почему бы не подтянуть старый добрый CSV или вообще закинуть всё в табличку?». И тут официальный пакет локализации сказал: «Извини, мужик, тут наши полномочия всё».
Ну, я тоже не пальцем деланный. Решил припахать нейросеть, чтобы она написала мне собственный генератор. Флоу получился такой:
- Добавляю строку в таблицу.
- Запускаю генератор.
- Он лепит «родительский» файл.
- После 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 языков не переведены. Оно компилируется и работает здесь и сейчас. А дальше в дело вступают гит-хуки: при комите нейросетка сама подхватывает изменения и заполняет недостающие поля в переводчиках.
Знаете, какое самое странное чувство? Мне сейчас реально проще и быстрее завести переменную в локализации, чем хардкодить строку в коде. Кажется, это и есть признак здоровой архитектуры - когда делать «правильно» становится физически удобнее, чем делать «быстро» и криво.
Собственно если в кратце это все о чем я хотел рассказать. Если тема зайдет или кто-то сам не сможет допереть, как прикрутить это к своей архитектуре - пишите в комментариях, разберемся.
Надеюсь, мой опыт сэкономит вам пару литров нервных клеток. Всех благ и чистого кода без «магических строк»!


