Привет. В данной статье я хочу поделиться знаниями о том, как быстро локализовать приложение на 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
Не забыв при этом:
-
Выполнить кодогенерацию командой
flutter pub run easy_localization:generate
(кстати, не нашёл командыwatch
) -
Добавить в
main
пару строкWidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized();
-
Обернуть всё приложение в
EasyLocalization
с нужными параметрами -
Добавить в
MaterialApp
несколько строк:Widget build(BuildContext context) { return MaterialApp( ... localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: context.locale, ... ); }
Но всё это слабо комбинировалось с фантазиями автора сей статьи. В моем случае было важно, чтобы:
-
использование пакета не было удручающим и способным захламить код
-
было дружелюбным и кастомизированным в использовании
-
с хорошей и (важно) подробной документаций
-
поддерживалась реактивность. Это желание оторваться от
BuildContext
и использовать свой контроллер состояния на основе Riverpod -
была независимость от фреймворка flutter (only dart). Данная возможность пришлась бы сильно кстати, например, в консольных приложениях, чтобы не пришлось городить свои велосипеды с локализацией.
И всё померкло, когда я нашёл это чудо – fast_i18n
, третий обозреваемый пакет в нашем списке. Вскоре разработчик переработал данный пакет, вобрав в него лучшие идеи; так появился на свет slang
(structured language file generator).
Скажем так, обзор данного пакета далее – это одновременно рассказ о локализации моего приложения и публичная благодарность Tien Do Nam (github) (и контрибьюторам) за такой прекрасный пакет (ещё и под лицензией MIT).
Использование пакета slang
Краткий экскурс по данному пакету. Грубо скажем, что данный пакет умеет всё то, что умеет и easy_localization
(грубо, потому что этот пакет делает многие вещи качественней, начиная с документации). Вдобавок:
-
Не зависит от
build_runner
, но может работать и с ним -
Имеет кучу tools на все случаи жизни
-
Глубокая кастомизация с помощью флагов
-
Плюрализация, кардиналы и ординалы (количественные и порядковые числительные), гендерные формы
-
Умеет работать с
RichText
-
Поддерживает списки, карты и динамические ключи, интерфейсы
-
Динамическое переопределение переводов
-
Может интегрироваться с различными менеджерами состояния
Далее разберем, каким образом я интегрировал данный пакет в приложение 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
ссылка на оригинал статьи https://habr.com/ru/post/718310/
Добавить комментарий