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