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)


  1. Vad344
    15.11.2024 05:09

    Странная картинка. Что за предмет одежды чинит этот мальчик?


    1. winsetuper Автор
      15.11.2024 05:09

      По задумке он решил подшить себе штаны для каламбура с названием. Штаны получились очень эластичными)


    1. strelkove
      15.11.2024 05:09

      Там ещё какой-то странный предмет на стене справа.


    1. ImagineTables
      15.11.2024 05:09

      Ногу.


      1. winsetuper Автор
        15.11.2024 05:09

        В неё стреляли)


  1. LazyLazyMeat
    15.11.2024 05:09

    Пугает простота, с которой Дали отрисовывает машинку, приштопывающую *заплатку* (ууу, отсылка к названию и теме статьи, ууу) не только к штанине, но и к ноге парнишки. В целом, лучше не вглядываться в обложку, потому что 'чем дольше всматриваешься в бездну, тем пристальнее она всматривается в тебя'.
    А по сабжу - красивое решение, но хотелось бы ещё хотя бы один пример.


  1. egorozh
    15.11.2024 05:09

    Мы у нас в проектах не смешиваем freezed с json. Для data - слоя используются json модельки, для domain-слоя - freezed. Думал, это общепринятая практика (clean architecture).


    1. winsetuper Автор
      15.11.2024 05:09

      Не вижу противоречий. Локализация - сущность domain слоя, тогда как патч - json моделька. Не стоит использовать класс локализации для транспортировки, так же как не стоит использовать в domain слое класс патча.

      Замечу, что формально ничего не мешает генерировать транспортировочные модельки с помощью freezed. Он отлично для этого подходит.


  1. egorozh
    15.11.2024 05:09

    «Удалено автором»


  1. dkfbm
    15.11.2024 05:09

    Ничего не понял. Зачем весь этот огород, если в дарт есть готовая, отличная система локализации?

    dart pub global activate intl

    Так не судьба?


    1. winsetuper Автор
      15.11.2024 05:09

      Данная статья является туториалом по написанию простейшего кодогенератора. Тема с локализацией рассматривается как пример.

      Пакет intl тоже имеет кодогенератор, однако он не позволит получить, например, arb файл из интернета во время работы приложения и использовать его для локализации. Более того, в одном из текущих проектов мы инициализируем локализацию на устройстве, используя пакет intl, уже после чего можем доносить себе патчи через наш сервер и firebase remote config.


      1. dkfbm
        15.11.2024 05:09

        Данная статья является туториалом по написанию простейшего кодогенератора. Тема с локализацией рассматривается как пример.

        Согласитесь, разумнее было бы в качестве примера взять более практически применимую задачу, чем дублирование intl.

        однако он не позволит получить, например, arb файл из интернета во время работы приложения и использовать его для локализации

        Не очень представляю себе такой сценарий использования. Чтобы использовать строки из .arb, код приложения должен знать, что такая строка в нём есть – добавить новую строку, не меняя код приложения не получится. Да и зачем менять строки в рантайме, я не знаю. Это же не контент, а UI. Опечатку разве что исправить – а как часто это нужно? Может, я ошибаюсь – тогда поясните.


        1. winsetuper Автор
          15.11.2024 05:09

          В качестве примеров половина интернета переписала туду списки и калькуляторы. ИМХО в материалах для обучения оригинальность не главный фактор.

          На второе ответ проще - бизнес хочет. Ну что тут можно поделать, менеджер сказал "хочу", пилим генератор. Лучше так, чем каждый раз писать руками.