Андрей Кураков,

Flutter-разработчик Mad Brains.

Всем привет! Сегодня хочу затронуть тему макросов на Dart, но перед этим уточню два момента. Первое: макросы, о которых я буду говорить ниже, находятся в стадии разработки (attention! В будущем материал может устареть). Второе: мы поговорим о первых впечатлениях от использования макросов и разберем краткие инструкции по работе с ними. 

Для начала немного терминов.

Метапрограммирование — это парадигма, в которой программы могут создавать другие программы или изменять себя во время выполнения.

Макросистема Dart — это новая важная языковая функция. Она добавляет поддержку статического метапрограммирования в язык Dart, но в настоящее время находится в разработке.

Макрос Dart — это определяемый пользователем фрагмент кода, который принимает другой код в качестве параметров и работает с ним в режиме реального времени для создания, изменения или добавления кода.

В отличие от генерации кода с помощью сторонних пакетов (например, build_runner) макросы полностью интегрированы в язык Dart и автоматически работают в фоновом режиме. Использование макросов становится более эффективным, чем взаимодействие через вспомогательные инструменты:

  1. Нет дублирования работы или постоянной перекомпиляции, снижающих производительность; все процессы сборки и генерации кода происходят автоматически.

  2. Файлы не записываются на диск. Макросы дополняют существующий класс.

  3. Нет запутанного тестирования: пользовательская диагностика выводится, как и любое другое сообщение от анализатора, непосредственно в IDE.

    Как Dart работает с макросами

    Давайте прям по полочкам, что делает макрос:

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

    • Проходит по циклам в топологическом порядке, компилируя библиотеки каждого и объединяя любые ручные дополнения библиотек.

    • В изоляте создает экземпляры макросов и выполняет их в фазовом порядке, собирая новые объявления и определения.

    • Создает библиотеки дополнений для каждой библиотеки, содержащей макросы, и добавляет в них новые объявления и определения.

    • Применяет сгенерированные библиотеки дополнений к основным библиотекам.

    Подробнее об этом можно почитать здесь

    Как работают макросы

    Чтобы разобраться, как работают макросы, нужно рассмотреть два ключевых слова: augment и external.

    Augment

    Ключевое слово augment используется в контексте библиотек дополнений (augmentation libraries). Оно позволяет добавлять новые члены (методы, поля, конструкторы) в уже существующие классы и расширять функциональность существующих библиотек.

    Пример использования augment:

// Основная библиотека
library main_lib;

class MyClass {
  void originalMethod() {
    print('Original Method');
  }
}

// Библиотека дополнений
library main_lib.augment;

augment class MyClass {
  void newMethod() {
    print('New Method');
  }
}

В этом примере augment class MyClass добавляет новый метод newMethod в существующий класс MyClass.

External

Ключевое слово external используется для объявления методов и функций, реализация которых предоставляется внешним образом, например, из сторонней библиотеки или платформо-специфичного кода.

Настройка окружения

После того как мы стали теоретически более подкованными, можно переходить к настройке окружения.  Я буду описывать его исключительно с точки зрения Flutter- проекта. 

Для включение макросов надо сделать следующее:

  1. Перейдите на master-канал, потому что на текущий момент макросы доступны только там.

flutter channel master 

2. Отредактируйте pubspec.yaml, изменив версию Dart SDK: ^3.5.0-152.
3. Включите эксперимент в analysis_options.yaml.

analyzer:
 enable-experiment:
   - macros

4. Запустите проект с экспериментальным флагом.

flutter run --enable-experiment=macros ...

Применение JsonCodable

Макрос JsonCodable — простое решение для сериализации и десериализации JSON в Dart. Он кодирует и декодирует пользовательские классы Dart в JSON-Map-ы типа Map<String, Object?>. Макрос генерирует два элемента: метод сериализации  toJson и конструктор десериализации FromJson

Для использования JsonCodable необходимо добавить пакет json в проект. После чего макрос можно будет применять на классах, для которых требуется генерация toJson и fromJson.

import 'package:json/json.dart';

@JsonCodable()
class User{
  final int id;
  final String name;
}

Сгенерированный код можно посмотреть через VS Code (для Android Studio поддержки пока нет). Достаточно нажать «Go to Augmentation» и мы увидим следующий код: 

augment library 'package:domain/src/models/enum/user.dart';

import 'dart:core' as prefix0;

augment class User {
  external User.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json);
  external prefix0.Map<prefix0.String, prefix0.Object?> toJson();
  augment User.fromJson(prefix0.Map<prefix0.String, prefix0.Object?> json, )
      : this.id = json['id'] as prefix0.int;
  augment prefix0.Map<prefix0.String, prefix0.Object?> toJson() {
    final json = <prefix0.String, prefix0.Object?>{};
    json['id'] = this.id;
    return json;
  }
}

Вот и все! Не надо сторонних библиотек и build_runner!

Пишем свой макрос: как я разработал ThemeColorsMacros

Код, представленный в этом разделе, не является идеальным, он может быть доработан. API макросов тоже может измениться, но основ, описанных здесь, будет достаточно, чтобы написать свой макрос в будущем. 

Да, действительно, вы можете использовать готовый макрос от разработчиков Dart, а можете создать его сами.

Те, кто хоть раз работал с анализатором, смогут быстро втянуться в эту работу, потому что, как мне показалось, API макросов на уровень выше, чем анализатора. И вообще, писать макросы — одно удовольствие.

Так вот. Я, например, не люблю добавлять новые цвета в тему. Это требует слишком много времени. Поэтому я решил написать макрос, который будет генерировать ThemeColors.raw конструктор и методы copyWith, lerp.

Итак, приступим. Первое, что нужно сделать, — это объявить сам макрос. Для этого необходимо использовать ключевое слово macro для своего класса. Мой макрос применим к классу и добавляет новые объявления к нему, поэтому он будет реализовывать интерфейсы ClassDeclarationsMacro и ClassDefinitionMacro:

import 'package:macros/macros.dart';

macro class ThemeColorsMacro implements ClassDeclarationsMacro, ClassDefinitionMacro{
  @override
  FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) {
    // TODO: implement buildDeclarationsForClass
    throw UnimplementedError();
  }

  @override
  FutureOr<void> buildDefinitionForClass(ClassDeclaration clazz, TypeDefinitionBuilder builder) {
    // TODO: implement buildDefinitionForClass
    throw UnimplementedError();
  }
}

Что по поводу других интерфейсов? На текущий момент API макросов особо не описано. Но если посмотреть код, можно найти  интерфейсы для макросов, применимым не только к классам, но и другим сущностям:EnumTypesMacro, MethodDeclarationsMacro, MixinDeclarationsMacro и т. д.

Далее все сводится к реализации методов buildDeclarationsForClass и buildDefinitionForClass. В первом случае мы будем объявлять конструктор и методы с использованием ключевого слова external. Во втором будет проходить непосредственная реализация логики объявленных методов.

Рассмотрим генерацию метода lerp, он ярче всего демонстрирует возможности и нюансы написания макросов.

Создадим mixin _LerpMethod и два метода для реализации логики, описанной выше:

mixin _LerpMethod {

  Future<void> _declareLerp(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {}
  Future<void> _buildDefinition(ClassDeclaration clazz, TypeDefinitionBuilder builder) async {}

}

Реализуем объявление метода. Первый этап — проверка на существование метода под названием lerp в классе. Если метод есть, то мы покажем соответствующую ошибку в IDE.

final List<MethodDeclaration> methods = await builder.methodsOf(clazz);
    if (methods.any((MethodDeclaration method) => method.identifier.name == 'lerp')) {
      builder.report(Diagnostic(
        DiagnosticMessage(
          'Cannot generate a lerp method due to this existing '
              'one.',
          target: clazz.asDiagnosticTarget,),
        Severity.error,),);
    }

Мы не можем передать тип данных просто строкой, потому что генерируемый код не знает никаких типов данных, их необходимо подгружать отдельно. Для lerp необходимо передавать параметр ThemeExtension<ThemeColor> и double t. Получение их типов происходит следующим образом:

    final Identifier themeExtIdentifier = await builder.resolveIdentifier(Uri.parse('package:flutter/src/material/theme_data.dart'), 'ThemeExtension',); //Получаем индетификатор класса
    final Identifier doubleIdentifier = await builder.resolveIdentifier(Uri.parse('dart:core'), 'double',);
    final NamedTypeAnnotationCode themeExtType = NamedTypeAnnotationCode(
      name: themeExtIdentifier,
      typeArguments: <TypeAnnotationCode>[NamedTypeAnnotationCode(name: clazz.identifier)],); // Генерируем на основе индетификатора новый тип с использование дженерика. В результате получится ThemeExtension<ThemeColors>

А дальше необходимо собрать весь код по кускам:

    builder.declareInType(DeclarationCode.fromParts(<Object>[
      '\texternal ',
      clazz.identifier.name,
      ' lerp(',
      themeExtType,
      ' other, ',
      ParameterCode(
        name: 't',
        type: NamedTypeAnnotationCode(name: doubleIdentifier),
      ),
      ');\n',
    ]),);

После выполнения данного кода получится следующее:

external ThemeColors lerp(prefix2.ThemeExtension<prefix0.ThemeColors> other, prefix3.double t);

Теперь создадим реализацию нашего метода: опять проверяем наличие метода lerp в классе. Если метода нет, то и генерировать ничего не будем.

final List<MethodDeclaration> methods = await builder.methodsOf(clazz);
    final MethodDeclaration? lerp =
    methods.firstWhereOrNull((MethodDeclaration method) => method.identifier.name == 'lerp');
    if (lerp == null) return;

Далее подготавливаем список параметров для их передачи в метод copyWith:

    final List<FieldDeclaration> allFields = await clazz.allFields(builder).toList(); // Получаем список всех полей класса
    final List<RawCode> args = <RawCode>[];

    final Identifier colorIdentifier = await builder.resolveIdentifier(Uri.parse('dart:ui'), 'Color',);
    final NamedTypeAnnotationCode colorType = NamedTypeAnnotationCode(name: colorIdentifier); // Получаем тип Color

    for (final FieldDeclaration field in allFields) {
      if ((field.type.code as NamedTypeAnnotationCode).name.name == colorType.name.name) { // Копируем только цвета
        args.add(RawCode.fromParts(<Object>[
          '\t\t\t${getFieldName(field)}: ',
          colorType.code.asNonNullable,
          '.lerp(',
          '${getFieldName(field)}, other.${getFieldName(field)}, t),',
        ],),); // Генерируем код для каждого поля класса
      }
    }

Аналогично тому, как мы делали с declaration, собираем остатки кода по частям:

    final FunctionDefinitionBuilder lerpBuilder = await builder.buildMethod(lerp.identifier);
    lerpBuilder.augment(FunctionBodyCode.fromParts(<Object>[
      '{\n',
      '\t\tif (other is! ThemeColors) return this;\n\n',
      '\t\treturn copyWith(\n',
      ...args.joinAsCode('\n'),
      '\n\t\t);\n\t}\n',
    ],),);

Здесь мы генерировали только тело функции, её объявление создастся автоматически из того, что мы сгенерировали выше.

На выходе получим следующий код:

  augment prefix0.ThemeColors lerp(prefix2.ThemeExtension<prefix0.ThemeColors> other, prefix3.double t, ) {
		if (other is! ThemeColors) return this;

		return copyWith(
			transparent: prefix1.Color.lerp(transparent, other.transparent, t),
			primary: prefix1.Color.lerp(primary, other.primary, t),
			secondary: prefix1.Color.lerp(secondary, other.secondary, t),
			background: prefix1.Color.lerp(background, other.background, t),
			primaryText: prefix1.Color.lerp(primaryText, other.primaryText, t),
			black10: prefix1.Color.lerp(black10, other.black10, t),
		);
	}
Полный код миксина
mixin _LerpMethod {

  Future<void> _declareLerp(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    final List<MethodDeclaration> methods = await builder.methodsOf(clazz);
    if (methods.any((MethodDeclaration method) => method.identifier.name == 'lerp')) {
      builder.report(Diagnostic(
        DiagnosticMessage(
          'Cannot generate a lerp method due to this existing '
              'one.',
          target: clazz.asDiagnosticTarget,),
        Severity.error,),);
    }

    final Identifier themeExtIdentifier = await builder.resolveIdentifier(
      Uri.parse('package:flutter/src/material/theme_data.dart'), 'ThemeExtension',);
    final Identifier doubleIdentifier = await builder.resolveIdentifier(Uri.parse('dart:core'), 'double',);
    final NamedTypeAnnotationCode themeExtType = NamedTypeAnnotationCode(
      name: themeExtIdentifier,
      typeArguments: <TypeAnnotationCode>[NamedTypeAnnotationCode(name: clazz.identifier)],);

    builder.declareInType(DeclarationCode.fromParts(<Object>[
      '\texternal ',
      clazz.identifier.name,
      ' lerp(',
      themeExtType,
      ' other, ',
      ParameterCode(
        name: 't',
        type: NamedTypeAnnotationCode(name: doubleIdentifier),
      ),
      ');\n',
    ]),);
  }

  Future<void> _buildDefinition(ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
    final List<MethodDeclaration> methods = await builder.methodsOf(clazz);
    final MethodDeclaration? lerp =
    methods.firstWhereOrNull((MethodDeclaration method) => method.identifier.name == 'lerp');
    if (lerp == null) return;

    String getFieldName(FieldDeclaration field) => field.identifier.name;

    final List<FieldDeclaration> allFields = await clazz.allFields(builder).toList();
    final List<RawCode> args = <RawCode>[];

    final Identifier colorIdentifier = await builder.resolveIdentifier(Uri.parse('dart:ui'), 'Color',);
    final NamedTypeAnnotationCode colorType = NamedTypeAnnotationCode(name: colorIdentifier);

    for (final FieldDeclaration field in allFields) {
      if ((field.type.code as NamedTypeAnnotationCode).name.name == colorType.name.name) {
        args.add(RawCode.fromParts(<Object>[
          '\t\t\t${getFieldName(field)}: ',
          colorType.code.asNonNullable,
          '.lerp(',
          '${getFieldName(field)}, other.${getFieldName(field)}, t),',
        ],),);
      }
    }
    final FunctionDefinitionBuilder lerpBuilder = await builder.buildMethod(lerp.identifier);
    lerpBuilder.augment(FunctionBodyCode.fromParts(<Object>[
      '{\n',
      '\t\tif (other is! ThemeColors) return this;\n\n',
      '\t\treturn copyWith(\n',
      ...args.joinAsCode('\n'),
      '\n\t\t);\n\t}\n',
    ],),);
  }
}

Теперь осталось обновить макрос:

macro class ThemeColorsMacro with _LerpMethod implements ClassDeclarationsMacro, ClassDefinitionMacro{
  @override
  FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    await _declareLerp(clazz, builder);
  }

  @override
  FutureOr<void> buildDefinitionForClass(ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
    await _buildDefinition(clazz, builder);
  }
}

Описывать остальные методы не буду, потому что фундаментально в реализации они ничем не отличаются от кейса выше. Исключение: вместо миксина для copyWith я написал отдельный макрос, потому что он может быть реиспользован в других частях кода.

Посмотрим, как изменился код?

Код до
class ThemeColors extends ThemeExtension<ThemeColors> {
  factory ThemeColors({
    required final Brightness brightness,
  }) {
    final bool isDark = brightness.isDark;

    return ThemeColors.raw(
      brightness: brightness,
      transparent: Colors.transparent,
      primary: Colors.red,
      secondary: const Color(0xFF2196F3),
      background: isDark ? const Color(0xFF373535) : Colors.white,
      primaryText: isDark ? const Color(0xFFD3D3DE) : const Color(0xFF232325),
      black10: const Color(0xFF101010),
    );
  }

  factory ThemeColors.light() =>ThemeColors(brightness: Brightness.light);

  factory ThemeColors.dark() => ThemeColors(brightness: Brightness.dark);

  ThemeColors.raw({
    required this.brightness,
    required this.transparent,
    required this.primary,
    required this.secondary,
    required this.background,
    required this.primaryText,
    required this.black10,
});

  final Brightness brightness;
  final Color transparent;
  final Color primary;
  final Color secondary;
  final Color background;
  final Color primaryText;
  final Color black10;

  @override
  ThemeColors copyWith({
    Brightness? brightness,
    Color? transparent,
    Color? primary,
    Color? secondary,
    Color? background,
    Color? primaryText,
    Color? grey55,
    Color? black10,
  }) {
    return ThemeColors.raw(
      brightness: brightness ?? this.brightness,
      transparent: transparent ?? this.transparent,
      primary: primary ?? this.primary,
      secondary: secondary ?? this.secondary,
      background: background ?? this.background,
      primaryText: primaryText ?? this.primaryText,
      black10: black10 ?? this.black10,
    );
  }


  @override
  ThemeColors lerp(ThemeExtension<ThemeColors>? other, double t) {
    if (other is! ThemeColors) {
      return this;
    }

    return copyWith(
      transparent: Color.lerp(transparent, other.transparent, t),
      primary: Color.lerp(primary, other.primary, t),
      secondary: Color.lerp(secondary, other.secondary, t),
      background: Color.lerp(background, other.background, t),
      primaryText: Color.lerp(primaryText, other.primaryText, t),
    );
  }

  @override
  String toString() {
	...
  }
}

Код после
@ThemeColorsMacro()
class ThemeColors extends ThemeExtension<ThemeColors> {
  factory ThemeColors({
    required final Brightness brightness,
  }) {
    final bool isDark = brightness.isDark;

    return ThemeColors.raw(
      brightness: brightness,
      transparent: Colors.transparent,
      primary: Colors.red,
      secondary: const Color(0xFF2196F3),
      background: isDark ? const Color(0xFF373535) : Colors.white,
      primaryText: isDark ? const Color(0xFFD3D3DE) : const Color(0xFF232325),
      black10: const Color(0xFF101010),
    );
  }

  factory ThemeColors.light() =>ThemeColors(brightness: Brightness.light);

  final Brightness brightness;
  final Color transparent;
  final Color primary;
  final Color secondary;
  final Color background;
  final Color primaryText;
  final Color black10;

  @override
  String toString() {
    return 'ThemeColors(brightness: $brightness, transparent: $transparent, primary: $primary, secondary: $secondary, background: $background, primaryText: $primaryText)';
  }
}

Теперь для добавления нового цвета достаточно нового поля в конструкторе. Мне это безумно нравится!

А что же там нагенерилось?

А вот что!
augment library 'package:app/src/base_app/theme/themes.dart';

import 'package:app/src/base_app/theme/themes.dart' as prefix0;
import 'dart:ui' as prefix1;
import 'package:flutter/src/material/theme_data.dart' as prefix2;
import 'dart:core' as prefix3;

augment class ThemeColors {
ThemeColors.raw({
		required this.brightness,
		required this.transparent,
		required this.primary,
		required this.secondary,
		required this.background,
		required this.primaryText,
		required this.black10,
	});

	external prefix0.ThemeColors copyWith({
		prefix1.Brightness?brightness,
		prefix1.Color?transparent,
		prefix1.Color?primary,
		prefix1.Color?secondary,
		prefix1.Color?background,
		prefix1.Color?primaryText,
		prefix1.Color?black10
	});

	external ThemeColors lerp(prefix2.ThemeExtension<prefix0.ThemeColors> other, prefix3.double t);

  augment prefix0.ThemeColors copyWith({prefix1.Brightness? brightness, prefix1.Color? transparent, prefix1.Color? primary, prefix1.Color? secondary, prefix1.Color? background, prefix1.Color? primaryText, prefix1.Color? black10, }) => ThemeColors.raw(
		brightness: brightness ?? this.brightness, 
		transparent: transparent ?? this.transparent, 
		primary: primary ?? this.primary, 
		secondary: secondary ?? this.secondary, 
		background: background ?? this.background, 
		primaryText: primaryText ?? this.primaryText, 
		black10: black10 ?? this.black10
	);
  augment prefix0.ThemeColors lerp(prefix2.ThemeExtension<prefix0.ThemeColors> other, prefix3.double t, ) {
		if (other is! ThemeColors) return this;

		return copyWith(
			transparent: prefix1.Color.lerp(transparent, other.transparent, t),
			primary: prefix1.Color.lerp(primary, other.primary, t),
			secondary: prefix1.Color.lerp(secondary, other.secondary, t),
			background: prefix1.Color.lerp(background, other.background, t),
			primaryText: prefix1.Color.lerp(primaryText, other.primaryText, t),
			black10: prefix1.Color.lerp(black10, other.black10, t),
		);
	}

}

С какими проблемами можно столкнуться

Не все так гладко, как кажется. Есть ряд сложностей, которые могут возникнуть при разработке макроса:

  1. Нечитабельный сгенерированный код
    Если вы заметили, то при формировании кода я вручную ставил '\t', '\n'. Без них код будет создан как одна сплошная строчка. Казалось бы, какая разница! Но дело в том, что при написании макроса без форматированного кода крайне неудобно проверять корректность его генерации.

  2. Умирающий анализатор
    Если внутри вашего сгенерированного кода будет найдена ошибка, есть вероятность того, что анализатор кода просто «отрубится» без признаков жизни. При этом вам сильно повезет, если после «реанимации» он покажет вам сгенерированный код и вы сможете найти место, где произошла ошибка.

  3. Кривое форматирование
    Плагины в IDE еще не до конца подготовлены для макросов, поэтому будьте готовы, что при автоформатировании ваш код «поплывет» в разные стороны.

  4. Отсутствие документации
    Если у анализатора нет документации, но есть много обсуждений на StackOverflow, по которым можно понять, как все работает, то с макросами пока полный ноль. Играем вслепую.

  5. Отсутствие возможности просматривать сгенерированный код в Android Studio
    Думаю, со временем эту возможность все же добавят.

Когда ждать релиз?

Дата выхода макросов в стабильном режиме пока неизвестна. Учитывая их применение в больших базах кода, где несколько макросов могут непрерывно анализировать и дополнять базу в разных местах, разработка порядка и этапов выполнения — сложная задача.

Однако разработчики обещают выпуск стабильной версии макроса JsonCodable к концу 2024 года, а стабильной версии для написания собственных макросов — в начале 2025-го.

Возможно, в следующем году на Google I/O нас встретит Dart 4 с метапрограммированием!

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


  1. Bardakan
    18.07.2024 09:31

    1)их можно использовать в нативном проекте на Swift?

    2)они как-то "светятся" в проекте или просто подменяют один код на другой? Например, для Swift макросов создается еще один таргет, который линкуется к основному проекту


  1. postflow
    18.07.2024 09:31

    Дизайнеры Dartа идут дорогой в бездну)