Привет. В данной статье я хочу поделиться знаниями о том, как быстро локализовать приложение на flutter. Фундамент этих знаний был заложен при разработке продукта под названием Weather Today.

В качестве вступления хочу обратить внимание на разницу терминов локализации и интернационализации. Интернационализация (internationalization, i18n) - это процесс разработки приложения таким образом, чтобы его можно было адаптировать к различным языкам и регионам без инженерных изменений. Локализация (localization, L10n) же характеризуется как процесс адаптации интернационализированного программного обеспечения для конкретного региона или языка путем перевода текста и добавления специфических для данного региона компонентов. Разница заключается в том, что локализация выполняется несколько раз (например, при добавлении нового языка) и основана на инфраструктуре интернационализации, которая в хорошо спроектированном ПО должна выполняться один раз. Сейчас я покажу именно процесс локализации.

Данный материал входит в цикл статей о создании приложения Weather Today (Google Play) – лаконичного и бесплатного продукта для мониторинга погодных условий в вашем смартфоне.


Tак выглядит официальный подход к локализации flutter-приложений: «Internationalizing Flutter apps». В добавок предлагается ещё более подробный материал: «Flutter Internationalization User Guide». Официальный подход достаточно многословный и многого хочет от разработчика. Поэтому было решено подобрать что-то более кроткое. И, что немаловажно, функциональное.

Рассматриваемые варианты были следующими:

  • easy_localization link (v3.0.1 от May 13, 2022)

  • localization link (v2.1.0 от Feb 3, 2022)

  • fast_i18n link. Теперь это slang link(v3.12.0 от Feb 13, 2023).

Пакет localization слишком простой и малофункциональный. Нет, ну почти что ничего. Однако я замечу, что разработчик старался сделать пакет ещё лучше, предоставив приложение для настройки ключей (link):

We have an application to help you configure your translation keys. The project is also open-source, so be fine if you want to help it evolve!

The easy_localization, пожалуй, самый популярный пакет, однако из коробки не может в адекватную реактивность даже с использованием BuildContext см. issue 370. И это возмутительно для такого залайканного и распиаренного пакета. Тем не менее, поддерживает (судя по описанию) различные форматы хранимых переводов (JSON, CSV, XML, Yaml), умеет в плюрализацию, имеет некоторые полезные методы для flutter (сбросить локаль, получить локаль девайса и т.д.), есть кодогенерация и даже логгер. Использовать его достаточно просто (с кодогенерацией):

print(LocaleKeys.title.tr()); //String
//or
Text(LocaleKeys.title).tr(); //Widget

и без:

Text('title').tr(); //Text widget

print('title'.tr()); //String

var title = tr('title'); // Static function

Не забыв при этом:

  1. Выполнить кодогенерацию командой flutter pub run easy_localization:generate (кстати, не нашёл команды watch)

  2. Добавить в main пару строк

    WidgetsFlutterBinding.ensureInitialized();
    await EasyLocalization.ensureInitialized();
  3. Обернуть всё приложение в EasyLocalization с нужными параметрами

  4. Добавить в MaterialApp несколько строк:

    Widget build(BuildContext context) {
      return MaterialApp(
      ...
      localizationsDelegates: context.localizationDelegates,
      supportedLocales: context.supportedLocales,
      locale: context.locale,
      ...
      );
    }

Но всё это слабо комбинировалось с фантазиями автора сей статьи. В моем случае было важно, чтобы:

  1. использование пакета не было удручающим и способным захламить код

  2. было дружелюбным и кастомизированным в использовании

  3. с хорошей и (важно) подробной документаций

  4. поддерживалась реактивность. Это желание оторваться от BuildContext и использовать свой контроллер состояния на основе Riverpod

  5. была независимость от фреймворка flutter (only dart). Данная возможность пришлась бы сильно кстати, например, в консольных приложениях, чтобы не пришлось городить свои велосипеды с локализацией.

И всё померкло, когда я нашёл это чудо – fast_i18n, третий обозреваемый пакет в нашем списке. Вскоре разработчик переработал данный пакет, вобрав в него лучшие идеи; так появился на свет slang (structured language file generator).

Скажем так, обзор данного пакета далее – это одновременно рассказ о локализации моего приложения и публичная благодарность Tien Do Nam (github) (и контрибьюторам) за такой прекрасный пакет (ещё и под лицензией MIT).

Использование пакета slang

Краткий экскурс по данному пакету. Грубо скажем, что данный пакет умеет всё то, что умеет и easy_localization (грубо, потому что этот пакет делает многие вещи качественней, начиная с документации). Вдобавок:

  1. Не зависит от build_runner, но может работать и с ним

  2. Имеет кучу tools на все случаи жизни

  3. Глубокая кастомизация с помощью флагов

  4. Плюрализация, кардиналы и ординалы (количественные и порядковые числительные), гендерные формы

  5. Умеет работать с RichText

  6. Поддерживает списки, карты и динамические ключи, интерфейсы

  7. Динамическое переопределение переводов

  8. Может интегрироваться с различными менеджерами состояния

Далее разберем, каким образом я интегрировал данный пакет в приложение Weather Today.

Внедрение

Самый простой способ описан в документации здесь. Он достаточно подробный, и нет смысла его повторять. Мы же рассмотрим интеграцию данного пакета с flutter_riverpod.

Итак, используем:

# pubspec.yaml

dependencies:
  ...
  flutter_riverpod: ^2.0.2
  intl: ^0.17.0
  slang: ^3.5.0
  slang_flutter: ^3.5.0
  flutter_localizations:
    sdk: flutter
  ...

В нашем главном методе main напишем пару строк инициализации:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

	// используем контейнер, чтобы в нём асинхронно инициализировать состояние провайдеров
	final container = ProviderContainer();

	await container.read(AppLocalization.instance).init();
	
  runApp(
	  UncontrolledProviderScope(
			container: container,
      child: WeatherApp(),
      ),
    );
  );
}

AppLocalization - это наш класс, который содержит всю логику работы с локалью (locale) приложения и с пакетом slang. Вот что происходит в методе AppLocalization.init():

/// No rebuild after locale change.
TranslationsRu get tr => _tr;
late TranslationsRu _tr;

class AppLocalization {
  /// экземпляр класса
  static final instance = Provider<AppLocalization>(AppLocalization.new);

  /// Текущая локаль приложения.
  static final currentLocale = StateProvider<AppLocale>((ref) => AppLocale.ru);
  
  /// Текущий translation.
  static final currentTranslation = StateProvider<TranslationsRu>(
    (ref) {
      final AppLocale locale = ref.watch(currentLocale);
      // ignore: join_return_with_assignment
      _tr = locale.build(); // we need to assign
      return tr;
    }
  );

  Future<void> init() async {
    final AppLocale locale = AppLocaleUtils.parse(await _getUserStoredLocale());
    Intl.defaultLocale = locale.languageCode;
    _tr = locale.build();
    ref.read(currentLocale.notifier).update((_) => locale);
  }
}

Некоторые пояснения:

  • Как только мы обновляем currentLocale, автоматически обновляется currentTranslation и все другие провайдеры, которые следят с помощью метода watch.

  • instance необходим, чтобы получать доступ к методам класса AppLocalization.

  • Переменная tr нужна в некоторых случаях, когда доступ к currentLocale получить сложно (по большому счёту, из-за нежелания прокидывать Ref от Riverpod'а и усложнять код). Делать так нежелательно: если пользователь изменит язык и продолжит пользоваться приложением (без перезагрузки), то те объекты, которые имеют старый экземпляр tr, не будут обновлены. Однако в ситуациях, когда мы следим за жизненным циклом объектов, это хороший вариант использования. В моем случае есть удачный пример (чуть ниже).

  • Далее, в методе AppLocalization.init() в первой строке мы загружаем выбранную пользователем locale из базы данных. Изменения в Intl.defaultLocale необходимы, чтобы локализация пробралась глубоко в недра flutter, скажем так. Затем мы изменяем нашу локальную переменную _tr и состояние провайдера currentLocale.

Теперь, когда локаль 'прогружена', в WeatherApp() настраиваем MaterialApp:

class WeatherApp extends ConsumerWidget{

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    final AppLocalization appLocalization = ref.watch(AppLocalization.instance);
    final Locale locale = ref.watch(AppLocalization.currentLocale).flutterLocale;

    return MaterialApp(
      ...
      locale: locale,
      supportedLocales: appLocalization.supportedLocales,
      localizationsDelegates: appLocalization.localizationsDelegates,
      ...
    );
  }
}

Обратите внимание на supportedLocales и localizationsDelegates. Вернемся к ним чуть позже, когда будем обозревать оставшиеся методы класса AppLocalization.

Как получить перевод без BuildContext?

Тот самый удачный пример: в пакете, к которому я не имею доступ, есть такой файл (упрощено́):

/// Represents units of pressure measurement.
enum Pressure {
  hectoPa('Hectopascal', 'hPa'), // Гектопаскали -- гПа
  mbar('Millibar ', 'mbar'), // МиллиБары -- мБар
  mmHg('Millimetre of mercury',
      'mmHg'), // Миллиметры ртутного столба -- мм. рт. ст.
  kPa('Kilopascal', 'kPa'), // Килопаскали -- кПа
  atm('Atmosphere', 'atm'), // Атмосферы -- атм
  inHg('Inch of mercury', 'inHg'); // Дюймы ртутного столба -- дюйм рт. ст.

  const Pressure(this.name, this.abbr);

  /// Full name.
  final String name;

  /// Abbreviation.
  final String abbr;
}

Наша задача - получить перевод полей name и abbr. Мы делаем следующее:

extension PressureTr on Pressure {
  String get abbrTr {
    switch (this) {
      case Pressure.hectoPa:
        return tr.units.pressure.abbr.hectoPa;
      case Pressure.mbar:
        return tr.units.pressure.abbr.mbar;
      case Pressure.mmHg:
        return tr.units.pressure.abbr.mmHg;
      case Pressure.kPa:
        return tr.units.pressure.abbr.kPa;
      case Pressure.atm:
        return tr.units.pressure.abbr.atm;
      case Pressure.inHg:
        return tr.units.pressure.abbr.inHg;
    }
  }

  String get nameTr {
    switch (this) {
      case Pressure.hectoPa:
        return tr.units.pressure.name.hectoPa;
      case Pressure.mbar:
        return tr.units.pressure.name.mbar;
      case Pressure.mmHg:
        return tr.units.pressure.name.mmHg;
      case Pressure.kPa:
        return tr.units.pressure.name.kPa;
      case Pressure.atm:
        return tr.units.pressure.name.atm;
      case Pressure.inHg:
        return tr.units.pressure.name.inHg;
    }
  }
}

И тем самым через getter получаем локализованные значения соответствующих полей. В противном случае необходимо использовать функцию с параметром TranslationsRu. А так как подобных PressureTr (SpeedTr, TempTr и т.д. ) предостаточно, передавать параметр каждый раз было бы нерационально.

В приложении это будет выглядеть так:

( Есть и другой способ, который сильнее привяжет нас к slang – использовать Custom Contexts / Enums. Он хорош тем, что теперь нам не нужно следить за глобальным состоянием tr , а также писать расширения вида PressureTr. Код получения актуальной локали в виджетах станет ещё короче )

Как получить доступ к актуальному переводу?

Далее мы можем использовать наш перевод так:

class _TilePressureUnitsWidget extends ConsumerWidget {
  const _TilePressureUnitsWidget();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
		// получаем перевод
    final t = ref.watch(AppLocalization.currentTranslation);

    // отслеживаем актуальные единицы измерения давления
    final Pressure units = ref.watch(SettingsPageController.pressureUnits);

    // то самое расширение PressureTr
    final String unitsTr = units.abbrTr;

    return ListTile(
      leading: AppIcons.pressureUnitsTile,
      title: t.settingsPage.pressureTile.tileTitle,
      subtitle: unitsTr,
      onTap: () {...},
    );
  }
}

Наш виджет _TilePressureUnitsWidget будет перестроен всякий раз, когда будет изменена текущая локаль AppLocalization.currentTranslation.

Где файлы переводов?

Что ж, теперь остается только сгенерировать эти самые переводы, а ещё написать их :) Создадим файл json вот с таким содержимым:

{
  "settings_page": {
      ...
      "pressure_tile": {
        "tile_title": "Единицы измерения давления",
        ...
      },
  },
  "units": {
  	"pressure": {
      "abbr": {
        "hecto_pa": "гПа",
        "mbar": "мБар",
        "mm_hg": "мм. рт. ст.",
        "k_pa": "кПа",
        "atm": "атм",
        "in_hg": "дюйм рт. ст."
      },
      "name": {
        "hecto_pa": "ГектоПаскали",
        "mbar": "МиллиБары",
        "mm_hg": "Миллиметры ртутного столба",
        "k_pa": "КилоПаскали",
        "atm": "Атмосферы",
        "in_hg": "Дюймы ртутного столба"
      }
    },
    ...
  }
  ...
}

Файл json для английской локализации:

{
  "settings_page": {
      ...
      "pressure_tile": {
        "tile_title": "Pressure units",
        ...
      },
  },
  "units": {
  	"pressure": {
      "abbr": {
        "hecto_pa": "hPa",
        "mbar": "mbar",
        "mm_hg": "mmHg",
        "k_pa": "kPa",
        "atm": "atm",
        "in_hg": "inHg"
      },
      "name": {
        "hecto_pa": "Hectopascal",
        "mbar": "Millibar",
        "mm_hg": "Millimetre of mercury",
        "k_pa": "Kilopascal",
        "atm": "Atmosphere",
        "in_hg": "Inch of mercury"
      }
    },
    ...
  }
  ...
}

Вот они лежат в папочке i18n:

Как получить переводы в виде кода на dart?

Теперь давайте сгенерируем из json --> dart файлы следующей командой

flutter pub run slang

Или же командой flutter pub run slang build.

В терминале видим следующее (прикрепил скрин, т.к. здесь видны некоторые настройки файла slang.yaml):

Обратите внимание, насколько это быстро! А теперь вспомните скорость генерации build_runner и заплачьте, благо автор пакета оставляет нам эту возможность, подключив следующие зависимости к проекту:

dev_dependencies:
  build_runner: <version>
  slang_build_runner: <version>

Что ж, теперь наши файлы переводов доступны:

В файле translation.g.dart есть ряд полезных методов. Используйте их при необходимости:

Как настроить конфигурационный файл slang.yaml?

Отлично, наши типобезопасные переводы готовы. А как же настроить файл slang.yaml? Ведь именно он отвечает за правильную генерацию кода. Полный список параметров доступен в богоподобной документации здесь. В нашем случае выглядит это так:

base_locale: ru # базовый язык
fallback_strategy: base_locale # в случае ошибки возвращаемся к базовой локали

input_directory: assets/i18n # путь хранения переводов
input_file_pattern: .i18n.json
output_directory: lib/i18n # путь генерации переводов
output_file_name: translations.g.dart
output_format: multiple_files

string_interpolation: braces # в json используем так: "Наш параметр {параметр}"

enumName: AppLocale  # название enum локали

key_case: camel # именование переменных в соответствии со спецификацией dart
key_map_case: null
param_case: camel

flat_map: false # нет необходимости в создании Map переводов

namespaces: false # удобство перевода постранично. Не используем.

locale_handling: false # remove unused t variable, LocaleSettings, etc.
translation_class_visibility: public

Сейчас пришло время вспомнить о некоторых дополнительных методах класса AppLocalization. Вот они:

class AppLocalization {
  AppLocalization(this.ref);

  final Ref ref;

	/// экземпляр класса
  static final instance = Provider<AppLocalization>(
    (ref) => AppLocalization(ref)
  );

  // доступ к базе данных
  IDataBase get _dbService => ref.read(dbService);

  // .....

  /// Текущая локаль девайса.
  AppLocale get deviceLocale => AppLocaleUtils.findDeviceLocale();

  /// Список поддерживаемых локалей.
  List<Locale> get supportedLocales =>
      AppLocale.values.map((locale) => locale.flutterLocale).toList();

  /// Делегаты.
  List<LocalizationsDelegate> get localizationsDelegates =>
      GlobalMaterialLocalizations.delegates;

  /// Установить новую локаль. (с сохранением в бд)
  Future<AppLocale> setLocale(AppLocale locale) async {
    await _saveLocale(locale.flutterLocale);
    Intl.defaultLocale = locale.languageCode;

    ref.read(currentLocale.notifier).update((_) => locale);
    return locale;
  }

  /// Сохранение локали в бд.
  Future<void> _saveLocale(Locale locale) async =>
      _dbService.save(DbStore.appLocale, locale.languageCode);
}

Возможно, вы не знакомы с tear-off (отрыв), поэтому я переписал наш instance провайдер более наглядно.

  • deviceLocale скрывает под собой WidgetsBinding.instance.window.locale

  • supportedLocales использует AppLocale, чтобы собрать весь список поддерживаемых локалей

  • localizationsDelegates содержатся здесь package:flutter_localizations/src/material_localizations.dart (мы указывали это в dependencies) Все эти параметры были указаны ранее в MaterialApp.

Как изменить язык в приложении?

Пожалуй, осталось только рассмотреть, как изменить локаль. Для этого необходимо вызвать метод AppLocalization.setLocale() из обратного вызова, например, в DropdownButton. В самом методе мы сохраняем локаль в базу данных, а затем обновляем провайдер currentLocale. Таким образом, все наши переводы будут обновлены немедленно.

Вот как выглядит этот виджет (упрощено́):

Widget build(BuildContext context, WidgetRef ref) {
  final locale = ref.watch(AppLocalization.currentLocale);
  
  return DropdownButton<AppLocale>(
    value: locale,
    alignment: Alignment.bottomCenter,
    isExpanded: true,
    items: AppLocale.values
        .map((e) => DropdownMenuItem<AppLocale>(
              value: e,
              onTap: () async =>
                  ref.read(AppLocalization.instance).setLocale(e),
              child: Text(
                e.nameTr,
                textAlign: TextAlign.center,
              ),
            ))
        .toList(),
    selectedItemBuilder: (_) {
      return AppLocale.values
          .map((e) => Center(
                child: Text(locale.nameTr),
              ))
          .toList();
    },
  );
}

В приложении это выглядит вот так:

О том, как сделать такой фон (на самом деле это анимация) на стартовом экране, я недавно рассказывал в статье «Почему анимированная погода – это код из конфигуратора или История одного грустного пакета»

Bonus при использовании slang

В качестве killer feature хочу вам показать замечательные инструменты командной строки (на всякий случай, версия slang: ^3.12.0) (в документации tools):

  • flutter pub run slang watch

    Действует согласно аналогичному методу из пакета build_runner. Запускает генерацию кода каждый раз, когда файл перевода, например translations.i18n.json, изменяется. Крайне удобная команда, когда перевод добавляется очень часто маленькими порциями. Запустил и забыл. Чтобы запустить генерацию однократно, уберите watch из команды.

  • flutter pub run slang migrate <type> <source> <destination>

    Инструмент миграции других i18n решений. На данный момент поддерживает преобразование ARB формата в JSON:

    flutter pub run slang migrate arb source.arb destination.json
  • flutter pub run slang analyze

    Очень удобная команда, чтобы найти отсутствующие и неиспользуемые переводы. Есть дополнительные флаги. Как это работает? Вы добавляете новый перевод, скажем, в translations.i18n.json. Запускаете данную команду и получаете файлы:

    В файле _unused_translations.json будут храниться неиспользуемые переводы во всём исходном коде (с флагом --full). Есть модификаторы ignoreMissing и ignoreUnused, которые позволяют игнорировать определенные ключи во время анализа.

    А в _missing_translations.json мы получаем отсутствующие переводы для конкретных локалей:

    {
      "@@info": [
        "Here are translations that exist in <ru> but not in secondary locales.",
        "After editing this file, you can run 'flutter pub run slang apply' to quickly apply the newly added translations."
      ],
      "en": {
        "settings_page": {
          "temp_page": {
            "tile_title": "Единицы измерения температуры",
            "dialog_sub": "Выбранный параметр будет применен во всех измерениях."
          }
        }
      }
    }
    

    При желании можно воспользоваться флагами --split-missing и --split-unused, чтобы разделить отсутствующие и неиспользуемые переводы для каждой локали. После остается заменить в данном файле перевод и выполнить следующую команду:

  • flutter pub run slang apply

    И ваш перевод будет добавлен в соответствующий файл; в нашем случае в translation_en.i18n.json (начиная с v3.12.0 переводы будут добавлены в соответствующие места, а не просто в конец файла). Это крайне удобно, ведь не нужно бегать с копией нового перевода по локальным файлам и вручную всё добавлять. Не забудьте после запустить команду flutter pub run slang build, чтобы сгенерировать dart-код.

  • flutter pub run slang stats

    Выводит в консоль некоторую статистику, например:


Пожалуй, это вся информация, которой я хотел поделиться о локализации приложения, написанного на flutter. Буду рад обсудить данную стратегию связки Riverpod + slang в комментариях.

© 2023 Ruble Pack

Комментарии (5)


  1. danial72
    00.00.0000 00:00

    Вот бы был пакет, с подробнейшим гайдом, что и как, без генераторов и смс, прямо из коробки, от создателей flutter. А точно, это же flutter_localization, который используют все остальные трешпакеты для локализации.


    1. PackRuble Автор
      00.00.0000 00:00
      +2

      Можете поподробнее раскрыть, в чём заключается "треш" пакета, который рассмотрен в статье?


    1. darkxanter
      00.00.0000 00:00
      +2

      Вот бы стандартная реализация решала бы все проблемы и не приходилось бы использовать стороннюю или делать собственную реализацию локализации.


    1. Kerrigan
      00.00.0000 00:00
      +1

      во флаттере отсутствует рефлексия, так что такие вещи как

      • автоматическая сериализация/десериализация

      • локализация

      • прочие действия, которые требуют лазить по ast и что-то там делать

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


  1. awaik
    00.00.0000 00:00
    +2

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