Как быстро и легко локализовать приложение на flutter. Riverpod + slang

от автора

Привет. В данной статье я хочу поделиться знаниями о том, как быстро локализовать приложение на 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Стиль изложения позволяет понимать материал?
0% Да 0
0% Нет 0
Никто еще не голосовал. Воздержался 1 пользователь.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Попробуете ли в своих разработках данный вариант локализации?
0% Да, хорошо выглядит 0
0% Нет, плохой вариант 0
0% Использую другое решение (поделюсь в комментариях) 0
Никто еще не голосовал. Воздержался 1 пользователь.

ссылка на оригинал статьи https://habr.com/ru/post/718310/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *