Патчим freezed
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 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 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
. Уделить внимание нужно лишь трём файлам:
Начнём в обратном порядке, сначала код генератора
(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 {
@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 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 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 >>>