Доброго дня, в данной статье подробно рассмотрим как работает кодогенерация во Flutter. Главная цель, которая стояла перед мной при написании статьи - это разбор каждого этапа настройки кодогенерации, чтобы у разработчика, прочитавшего этого материал, сложилась полноценная картина всего процесса.

Для чего нужна кодогенерация?

Основная задача - это написание boilplate кода вместо разработчика. Один из примеров это генерация JSON из полей класса или наоборот. Пример может служить библиотека json_serializable и freezed.

Создадим свой генератор

При создании генератора реализуется следующий алгоритм (каждый пункт будет подробно описан ниже):

  1. Создание папки с файлом pubspec.yaml внутри.

  2. Создание файла annotations.dart с сущностями для аннотаций (необязательно, если не собираетесь использовать аннотации).

  3. Создание папки visitor.dart, в котором будет храниться обработчики полей, методов и классов.

  4. Создание папки generator.dart, в котором реализуется метод вызываемый build_runner’ом.

  5. Создание файла build.dart, в котором хранится объект типа builder, вызываемый .

  6. Создание файла build.yaml, в котором хранятся все конфигурации кодогенератора.

Основные пакеты

Для работы нам необходимо два основных пакета: build_runner и source_gen.

build_runner

Данный пакет непосредственно реализует генерацию кода. Порядок его работы заключается в следующем: build_runner проходится по всем папкам проекта и ищет файл build.yaml. Файл build.yaml является “триггером” для его срабатывания. Внутри этого файла хранятся все конфигурации для его дальнейшей работы. Подробнее о данном пакете можно прочитать здесь.

source_gen

source_gen это пакет, который даёт возможность использовать такие классы как Generator и GeneratorForAnnotation. Данные классы упрощают процесс работы с генератором, реализуя некоторый функционал внутри. Подробнее о данном пакете можно прочитать здесь.

Создание генератора

Генератор представляет из себя пакет, который подключется через pubspec.yaml.

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

Директория проека
Директория проека

1. pubspec.yaml

Для начала работы создадим дефолтный TODO проект. В папке проекта создадим папку generator с файлом pubspec.yaml внутри. Добавим в pubspec.yaml следующие зависимости.

name: generator 
description: Generator example.
version: 1.0.0

environment:
  sdk: ">=2.18.0-216 <3.0.0"
  flutter: ">=1.17.0"

dependencies:
  flutter:
    sdk: flutter
  build:
  analyzer:
  source_gen:

dev_dependencies:
  build_runner:

Нельзя начинать название пакета со слова test, так как в данном случае кодогенерация не сработает. Также при работе с yaml стоит использовать только пробел и перенос строки!

После заполнения pubspec.yaml в консоли необходимо выполнить следующую команду

$ flutter pub get

В директории появятся новые папки и она примет следующий вид

Все переменные pubspec.yaml

name - имя пакета

version - версия пакета (если версия не указана, то ставится версия 0.0.1)

description - краткое описание пакета (обязательно на латинице и не более 180 символов)

homepage - url на страницу, где находится полное описание пакета

repository - url на репозиторий, где хранятся исходники пакета

issue_tracker - url на ресурс, где поользователи могут оставлять свои сообщения о неисправностях

documentation - url на ресурс, где находится документация к проекту

dependencies - список пакетов, которые используются в проекте

dev_dependencies - список зависимостей, которые используются, только при разработке (эти пакеты не загружаются при при выпуске приложения для пользователя)

executables - список скриптов, доступных при активации пакета

platforms - список плтворм для которых доступен проект

publish_to - здесь указывается ресурс, кула публикуется проект

funding - список ресурсов, где пользователи могут поддержать авторов пакета/проекта

false-secrets - список файлов, где сервис, куда будут публиковаться пакеты, не будет искать утечки паролей или ключей

2. Аннотации

Во flutter для создания аннотции необходимо создать класс с константным конструктором. Создадим в папке generator другую папку lib.

Все файлы проекта, кроме конфигурационных - должны храниться в папке lib. Причиной тому служит то, что при получении доступа извне, видимыми будут только файлы в этой папке, а также файлы внутри lib не смогут ссылаться на файлы вне её.

Далее в lib создадим файл annotations.dart со следующим содержанием.

class PrintAnn{
  final String data;

  const PrintAnn(this.data);
}

class Sigma {
  const Sigma();
}

Sigma sigmaAnnotation = Sigma();

// возможные аннотации 
@PrintAnn("Hello")
@Sigma
@sigmaAnnotation

3. Visitor

Visitor необходим для обработки элементов класса, которые приходят от builder. Visitor реализует паттер визитёр, суть которого заключается в расширении функционала класс, без изменения кода самого класса. Данная библиотека visitor находится в analyzer package.

Для создания своего visitor необходимо наследовать один из следующих 4 классов:

  • GeneralizingElementVisitor<R> - во всех методах класса реализовано рекурсивное посещение element через метод visitElement(Element). Также класс даёт возможность переопределить метод visitElement(Element), позволяя тем самым кастомизировать процесс;

  • RecursiveElementVisitor<R> - во всех методах класса реализовано рекурсивное посещение element;

  • SimpleElementVisitor<R>- все методы пустые;

  • ThrowingElementVisitor<R>- для данного класса каждый метод должен быть переопределен. В случае, если какой-либо метод не реализован, то будет выброшена ошибка.

Мы унаследуем класс SimpleElementVisitor<R>, где R это тип данных, которые будут возвращаться методами класса. Создадим файл visitor.dart в папку ./lib.

import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/visitor.dart';
import 'package:source_gen/source_gen.dart';

import 'annotations.dart';

class Visitor extends SimpleElementVisitor<void> {
  String className = '';
  Map<String,String> printData = {};

  @override
  void visitConstructorElement(ConstructorElement element) {
    final elementReturnType = element.type.returnType.toString();
    className = elementReturnType.replaceFirst('*', '');
  }

  @override
  void visitFieldElement(FieldElement element) {
    var instanceName = element.name;
    var data = TypeChecker.fromRuntime(PrintAnn)
            .annotationsOf(element)
            .first
            .getField('data')
            ?.toString() ??
        '';

    printData[instanceName]= data;    
  }
}

Визитор работает по следующему принципу: Когда generator (будет описан далее) проходя по файлам проекта (кроме директории самого кодогенератора) находит класс, который имеет необходимую аннотацию, он вызывает метод generateForAnnotatedElement (на самом деле нет, происходит вызов метода generate, generateForAnnotatedElement вызывается далее, но так проще для понимания). Аннотированный класс передаётся в метод в виде объекта типа Element. Для того, чтобы visitor “обработал” класс, необходимо вызвать метод element.visitChildren(visitor).

visitChildren метод проходит по всем элементам класса, вызывая для каждого элемента свой обработчик в visitor. Element модель подразумевает, что конструктор, методы, поля и т.д. имеют свои собственные типы. Например у конструктора класса в element модели тип ConstructorElement, у поля - FieldElement. Весь список таких классов описан здесь.

Рассмотрим наш Visitor.

class Visitor extends SimpleElementVisitor<void> {

Здесь мы указываем, что созданный нами визитёр будет иметь тип Visitor и наследоваться от SimpleElementVisitor. Т.е. все методы будет реализованы, но без функционала. void - тип результата каждой функции.

String className = '';
  Map<String,String> printData = {};

Поля, которые нам будут необходимы для дальнейшей работы.

@override
  void visitConstructorElement(ConstructorElement element) {
    final elementReturnType = element.type.returnType.toString();
    className = elementReturnType.replaceFirst('*', '');
  }

Данный метод вызывается при обработке конструктора класса. Полю className присваивается имя класса, полученное из конструктора.

@override
  void visitFieldElement(FieldElement element) {
    var instanceName = element.name;
    var data = TypeChecker.fromRuntime(PrintAnn)
            .annotationsOf(element)
            .first
            .getField('data') // возвращаем поле с именем data
            ?.toStringValue() ??  // мы знаем, что поле имеет значение типа String и 
        '';                       // поэтому возвращаем String value

    printData[instanceName]= data;    
  }

Здесь обрабатываются поля класса, принимаемые в виде объекта типа FieldElement. Для определения, есть ли над переменной аннотация, используется метод TypeChecker.fromRuntime(PrintAnn).annotationsOf(element). TypeChecker это объект пакета source_gen, позволяющая работать с аннотациями. element - это элемент у которого мы ищем аннотацию.

 var data = TypeChecker.fromRuntime(PrintAnn) # PrintAnn - класс аннотации, 
											  # который мы хотим определить
            .annotationsOf(element)# element - элемент у которого 
							       # мы ищем аннотацию
            .first
            .getField('data')  # data - имя поля в классе аннотации PrintAnn
            ?.toStringValue() ??# возвращаемый объект типа DartObject 
        '';                     #мы конвертируем в String, если на каком-то из этапов

4. Генератор

Для реализации генератора необходимо создать класс, который наследуется от Generator или GeneratorForAnnotation<T>. Эти классы находятся в пакете source_gen. В нашем случае мы будем наследоваться от класса GeneratorForAnnotation<T>, где T класс аннотации для которой будет вызываться генератор. В случае, если аннотирование не используется - наследоваться надо от Generator.

class TestGenerator extends GeneratorForAnnotation<Sigma> {
// когда build_runner находит класс с аннотацией @sigma, 
// вызывется метод generateForAnnotatedElement и тело класса передаётся в аргумент
// в виде объекта Element
  @override
  String generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = Visitor();
    element.visitChildren(visitor);

    return '';
  }
// рузльтатом работы данной функции будет объект типа String, который будет 
// в сгенерированный файл .g.dart
}

В нашем случае сделаем следующее:

Из переменных, которые аннотированны классом PrintAnn, должны создаваться функции, которые будут в констоль отправлять аргументы аннотации.

import 'package:build/src/builder/build_step.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:generat/annotations.dart';
import 'package:generat/visitor.dart';
import 'package:source_gen/source_gen.dart';

class TestGenerator extends GeneratorForAnnotation<Sigma> {
  @override
  String generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = Visitor();
    element.visitChildren(visitor);

    var buffer = StringBuffer();

    buffer.writeln("extension \$${visitor.className} on ${visitor.className}{");
    visitor.printData.keys.forEach((element) {
      buffer.writeln(
      "void print_$element(){ print(\"Annotation ${visitor.printData[element]}\");}");
    });

    return buffer.toString();
  }
}

5. build.dart

В данном файле находится метод который возвращающает объект типа Builder. Метод вызывается build_rinner’ом на основе конфигурация build.yaml.

import 'package:generat/test_generator.dart';
import 'package:source_gen/source_gen.dart';
import 'package:build/build.dart';

Builder generate(BuilderOptions options) =>
    SharedPartBuilder([TestGenerator()], 'generator');

6. build.yaml

Данный файл является тригером для срабатывания build_runner и в нем хранятся все конфигурации для кодогенерации.

#targets предназначен для конфигурирования существующих builders
targets:
  $default: 
    builders: #здесь указывается какой генератор будет настраиваться
      test_generator|generator: #наименование генератора <имя пакета>|<имя генератора>
        enabled: true            #параметры
			source_gen|combining_builder:
				options:
          ignore_for_file:
            - type=lint

# здесь описываются содаваемые генераторы 
builders:
  generator:
    target: ":generator"
    import: "package:test_generator/builder.dart" #файл к котором описана функция generate
    builder_factories: ["generate"]    #название метода, который будет срабатывать при вызове 
    build_extensions: { ".dart": [".g.dart"] }
    auto_apply: dependents
    build_to: cache
    applies_builders: ["source_gen|combining_builder"]

Параметры

target - параметр берется из второго аргумента функции generate в builder.dart;

import - файл к котором описаны функции из builder_factories;

builder_factories - в данном параметре указывается массив имен функций из import, которые вызываются при срабатывании generator;

build_extensions - в параметре указывается расширение сгеренированного файла, для выбранного типа файла источника;

auto_apply - указывает на то, к каким модулям дополнительно будет применен данный builder(сомневаюсь в формулировке, по возможности поправьте в комментариях);

build_to - где будут храниться промежуточные данные;

applies_builders - builder, который будет вызван после окнчания работы текущего.

Для applies_builders необходимо поставить значение source_gen|combining_builder, так как этот builder складывает полученный результат в файлы в проекте.

Тестируем полученный генератор

В pubspec.yaml добавляем пакет

dependencies:
  flutter:
    sdk: flutter
  generator:
    path: ./generator/

Далее выполняем команду

$flutter pub get  

Далее создадим файл test.dart в папке lib. Запишем в файл следующее:

import "package:generator/annotation.dart"; // аннотации из нашего пакета
import 'dart:core';

part 'test.g.dart'; // ссылка на сгенерированный в будущем файл

@Sigma()
class TestClass {
  
  @PrintAnn("Hello")
  String message = "message";
}

Выполняем команду

$flutter pub run build_runner build

В результате получим в папке lib новый файл test.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'test.dart';

// **************************************************************************
// Test1Generator
// **************************************************************************

extension $TestClass on TestClass {
  
  void print_message() {
    print("Annotation Hello");
  }

Всё!

Исходники https://github.com/Majic97/habr_generator .

Комментарии (1)


  1. PackRuble
    00.00.0000 00:00

    Крутой материал, спасибо! Как думаете, dart 3.0 может нас отдалить от кодогенерации хоть ненадолго? :)