freezed — один из популярнейших пакетов для генерации дата‑классов и перечислений в Dart. Но, к сожалению, он пока не генерирует дружественные классы‑патчи, чтобы можно было легко и быстро пропатчить дата‑класс в рантайме. Исправим же это!
Меня зовут Алексей Букин, я Flutter‑разработчик во FRESH. Давайте посмотрим, как сделать свой кодогенератор для Dart и подружить его с другими генераторами на примере.
Реальный кейс - локализация
Допустим, у нас есть подобный код для локализации с использованием freezed:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'locale.freezed.dart';
part 'locale.g.dart';
@freezed
class Locale with _$Locale {
const factory Locale({
required String cardTitle,
required String cardSubtitle,
required String cardButtonText,
// ...
}) = _Locale;
const Locale._();
factory Locale.fromJson(Map<String, dynamic> json) =>
_$LocaleFromJson(json);
}
Здесь по аннотации @freezed
генерируются два файла: locale.freezed.dart
и locale.g.dart
. Первый позволяет нам создать класс из конструктора, а второй из JSON.
Казалось бы, живи и радуйся, но из‑за типа required String
мы получаем двоякую ситуацию. С одной стороны, мы в любом месте в коде можем использовать любое поле из класса. С другой, если хочется иметь удалённую версию локализации для исправлений ошибок, то придется отдавать JSON со всеми полями сразу, пропусти одно поле и конструктор fromJson
упадёт с ошибкой.
Но ведь можно разбить такой дата‑класс на множество малых, с ними будет удобнее работать!
И всё равно придётся очень аккуратно следить за каждым! Одна ошибка — и не применится вся локализация. Более того, мы ограничены выбором одной из локализаций - с устройства либо с сервера.
Пишем патч руками
Так как мы хотим иметь устойчивую систему и потенциально несколько источников данных напишем патч-класс:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'locale_patch.freezed.dart';
part 'locale_patch.g.dart';
@freezed
class LocalePatch with _$LocalePatch {
const factory LocalePatch({
String? cardTitle,
String? cardSubtitle,
String? cardButtonText,
// ...
}) = _LocalePatch;
const LocalePatch._();
factory LocalePatch.fromJson(Map<String, dynamic> json) =>
_$LocaleFromJson(json);
}
Теперь, благодаря nullable типу String?
мы можем создать такой класс гарантированно из любого JSON! Теперь нужно всего лишь создать новую локализацию с применённым патчем:
// Внутри Locale
Locale patch(LocalePatch patch) => copyWith(
cardTitle: patch.cardTitle ?? cardTitle,
cardSubtitle: patch.cardSubtitle ?? cardSubtitle,
cardButtonText: patch.cardButtonText ?? cardButtonText,
// ...
);
Теперь чтобы добавить один параметр нам нужно:
добавить его в конструктор класса
Locale
добавить его в конструктор класса
LocalePatch
запустить кодогенерацию
обновить функцию
patch
в классеLocale
А если мы хотим создать отдельную локализацию, например, для нового экрана в приложении и там 10 текстовок? 100? 1000?
Пишем кодогенератор
Погодите, всё, чем пользуется freezed
есть в конструкторе класса и нам тоже хватит этих данных. Классы Locale
и LocalePatch
отличаются только типами, а функция patch
оперирует только полями этих классов!
Начнём с пакета с аннотацией — locale_gen_annotation
. В единственном файле пакета объявляем аннотацию:
class LocaleGen {}
// для красоты
const localeGen = LocaleGen();
Эта аннотация будет работать так же, как и freezed
:
@LocaleGen()
class TestLocale {}
// или
@localeGen
class TestLocale2 {}
Теперь интереснее, основной пакет locale_generator
. В нём надо импортировать пакеты: наш пакет locale_gen_annotation
, analyzer
, build
и source_gen
. Уделить внимание нужно лишь трём файлам:
build.yaml
lib/locale_generator.dart
lib/src/locale_generator.dart
Начнём в обратном порядке, сначала код генератора
(lib/src/locale_generator.dart
):
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:build/build.dart';
import 'package:locale_gen_annotation/locale_gen_annotation.dart';
import 'package:source_gen/source_gen.dart';
class LocaleGenerator extends GeneratorForAnnotation<LocaleGen> {
@override
String? generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
final definitions = element.children.where((c) =>
c.kind == ElementKind.CONSTRUCTOR &&
c is ConstructorElement &&
c.isConst);
if (definitions.length != 1) {
return null;
}
final constructor = definitions.first;
final patchContents = constructor.children.map((element) {
if (element.kind == ElementKind.PARAMETER &&
element is ParameterElement) {
final suffix =
element.type.nullabilitySuffix == NullabilitySuffix.question
? ''
: '?';
return '${element.type}$suffix ${element.name},';
}
return '';
}).join('\n');
final originalFile = element.librarySource!.shortName;
final filenameBase = originalFile.substring(0, originalFile.length - 5);
final copyWithEntries = constructor.children.map((element) {
if (element.kind == ElementKind.PARAMETER &&
element is ParameterElement) {
return '${element.name}: patch.${element.name} ?? ${element.name},';
}
return '';
}).join('\n');
return '''
import 'package:freezed_annotation/freezed_annotation.dart';
import '$originalFile';
part '$filenameBase.lg.freezed.dart';
part '$filenameBase.lg.g.dart';
@freezed
class ${element.name}Patch with _\$${element.name}Patch {
const factory ${element.name}Patch({
$patchContents
}) = _${element.name}Patch;
factory ${element.name}Patch.fromJson(Map<String, dynamic> json) => _\$${element.name}PatchFromJson(json);
}
extension ${element.name}PatchExtension on ${element.name} {
${element.name} patch(${element.name}Patch patch) => copyWith(
$copyWithEntries
);
}
''';
}
}
Расширяем класс GeneratorForAnnotation
с типом нашей аннотации в виде дженерика и переопределяем функцию generateForAnnotatedElement
. Из трёх аргументов здесь нам понадобится лишь один - element
. Это то, к чему прикреплена аннотация, в данном случае - класс локализации.
На выходе у нас получится единственный файл, содержащий класс-патч и расширение для оригинального класса с функцией patch
.
Сначала находим конструктор и убеждаемся что он один. Далее сохраняем его в переменную constructor
и теперь нам доступны параметры конструктора как constructor.children
.
Подставляем имя класса в текст как ${element.name}
. Переменные originalFile
и filenameBase
вычисляются тривиально.
Для patchContents
убеждаемся, что не сделаем тип nullable
второй раз nullable
c помощью строчки
// Чтобы не было `String??` и подобных типов
final suffix =
element.type.nullabilitySuffix == NullabilitySuffix.question
? ''
: '?';
Для copyWithEntries
даже этого делать не надо. В обоих случаях соотносим параметры конструктора в строчки сгенерированного файла с помощью map
и подставляем как $patchContents
и $copyWithEntries
.
Теперь надо заставить этот генератор сделать тыр тыр тыр запуститься. Для этого сначала объявим builder
. Это тот самый кирпичик, который встроится в общую систему генерации Dart
(lib/locale_generator.dart
):
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'src/locale_generator.dart';
Builder localeGenerator(BuilderOptions options) => LibraryBuilder(
LocaleGenerator(),
generatedExtension: '.lg.dart',
);
Осталось только описать наш генератор в декларативном виде в файле build.yaml
:
builders:
# Название генератора
locale_generator:
# Абсолютный путь с файлом билдера в Dart проекте
import: "package:locale_generator/locale_generator.dart"
# Название функции-билдера
builder_factories: ["localeGenerator"]
# Описание маппинга файлов-источников и файлов-артефактов
build_extensions: {".dart": [".lg.dart"]}
# Условие активности - при данном варианте достаточно
# будет импортировать пакет locale_generator
auto_apply: dependents
# Артефакты положить в кеш или в исходный код -
build_to: source
Та‑да! Наш генератор готов и можно попробовать его в действии.
Проверяем
В нашем изначальном проекте указываем целевые файлы для генерации и последовательность вызова генераторов. Так как наш пакет сгенерирует патч, который потом надо сгенерировать еще раз с помощью freezed, надо строго прогонять наш генератор ДО freezed. И то и другое достигается внесением соответствующих строчек в файл build.yaml
:
# Что еще умеет `build.yaml` можно
# почитать тут - https://pub.dev/packages/build_config
global_options:
locale_generator:locale_generator:
# Буквально - запускать до freezed
runs_before:
- freezed:freezed
targets:
$default:
builders:
# Из пакета locale_generator генератор locale_generator
# будет активен для всех файлов, заканчивающихся на `locale.dart`
locale_generator|locale_generator:
generate_for:
- lib/*/*locale.dart
И как в итоге выглядит код локализации:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:locale_gen_annotation/locale_gen_annotation.dart';
/// Экспортируем `.lg` файл для простоты использования метода `patch`
export 'simple_page_locale.lg.dart';
part 'simple_page_locale.freezed.dart';
part 'simple_page_locale.g.dart';
/// Сразу проходимся двумя генераторами по одному классу
@localeGen
@freezed
class SimplePageLocale with _$SimplePageLocale {
const factory SimplePageLocale({
required String title,
required String subtitle,
}) = _SimplePageLocale;
factory SimplePageLocale.fromJson(Map<String, dynamic> json) =>
_$SimplePageLocaleFromJson(json);
}
Запускаем команду (или любую аналогичную, которая запустит процесс генерации):
dart run build_runner build --delete-conflicting-outputs
Получим пять новых файлов:
simple_page_locale.freezed.dart
— содержит оригинальный конструктор иcopyWith
.simple_page_locale.g.dart
— позволит сериализовать локализацию, например, для логовsimple_page_locale.lg.dart
— наш сгенерированный файлsimple_page_locale.lg.freezed.dart
— содержит конструктор патчаsimple_page_locale.lg.g.dart
— позволит сериализовать патч, чтобы парсить его из JSON
И, наконец, момент ради которого мы тут собрались:
final defaultLocale = SimplePageLocale(
title: 'My title',
subtitle: 'My subtitle',
);
final remotePatch = SimplePageLocalePatch.fromJson(json);
final locale = defaultLocale.patch(remotePatch);
Экосистема языка Dart позволяет очень просто писать, настраивать и запускать множество кодогенераторов. Вы не поверите, сколько рутинных задач станут проще, если уделить этой области немного времени! А данного примера вполне хватит для начала.
Код с примером на GITHUB. Пакет на pub.dev. Иллюстрация за авторством DALL·E. Спасибо за внимание.
Хотите купить или продать авто? Ищите раздел на сайте FRESH >>>
Комментарии (15)
LazyLazyMeat
15.11.2024 05:09Пугает простота, с которой Дали отрисовывает машинку, приштопывающую *заплатку* (ууу, отсылка к названию и теме статьи, ууу) не только к штанине, но и к ноге парнишки. В целом, лучше не вглядываться в обложку, потому что 'чем дольше всматриваешься в бездну, тем пристальнее она всматривается в тебя'.
А по сабжу - красивое решение, но хотелось бы ещё хотя бы один пример.
egorozh
15.11.2024 05:09Мы у нас в проектах не смешиваем freezed с json. Для data - слоя используются json модельки, для domain-слоя - freezed. Думал, это общепринятая практика (clean architecture).
winsetuper Автор
15.11.2024 05:09Не вижу противоречий. Локализация - сущность domain слоя, тогда как патч - json моделька. Не стоит использовать класс локализации для транспортировки, так же как не стоит использовать в domain слое класс патча.
Замечу, что формально ничего не мешает генерировать транспортировочные модельки с помощью freezed. Он отлично для этого подходит.
dkfbm
15.11.2024 05:09Ничего не понял. Зачем весь этот огород, если в дарт есть готовая, отличная система локализации?
dart pub global activate intl
Так не судьба?
winsetuper Автор
15.11.2024 05:09Данная статья является туториалом по написанию простейшего кодогенератора. Тема с локализацией рассматривается как пример.
Пакет intl тоже имеет кодогенератор, однако он не позволит получить, например, arb файл из интернета во время работы приложения и использовать его для локализации. Более того, в одном из текущих проектов мы инициализируем локализацию на устройстве, используя пакет intl, уже после чего можем доносить себе патчи через наш сервер и firebase remote config.
dkfbm
15.11.2024 05:09Данная статья является туториалом по написанию простейшего кодогенератора. Тема с локализацией рассматривается как пример.
Согласитесь, разумнее было бы в качестве примера взять более практически применимую задачу, чем дублирование intl.
однако он не позволит получить, например, arb файл из интернета во время работы приложения и использовать его для локализации
Не очень представляю себе такой сценарий использования. Чтобы использовать строки из .arb, код приложения должен знать, что такая строка в нём есть – добавить новую строку, не меняя код приложения не получится. Да и зачем менять строки в рантайме, я не знаю. Это же не контент, а UI. Опечатку разве что исправить – а как часто это нужно? Может, я ошибаюсь – тогда поясните.
winsetuper Автор
15.11.2024 05:09В качестве примеров половина интернета переписала туду списки и калькуляторы. ИМХО в материалах для обучения оригинальность не главный фактор.
На второе ответ проще - бизнес хочет. Ну что тут можно поделать, менеджер сказал "хочу", пилим генератор. Лучше так, чем каждый раз писать руками.
Vad344
Странная картинка. Что за предмет одежды чинит этот мальчик?
winsetuper Автор
По задумке он решил подшить себе штаны для каламбура с названием. Штаны получились очень эластичными)
strelkove
Там ещё какой-то странный предмет на стене справа.
ImagineTables
Ногу.
winsetuper Автор
В неё стреляли)