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 >>>
ссылка на оригинал статьи https://habr.com/ru/articles/851666/
Добавить комментарий