Підвищуємо продуктивність Dart і Flutter-розробки з генерацією коду

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Привіт! Я Анна — Google Developer Expert у категоріях Dart та Flutter, Senior Staff Engineer у британській fintech-компанії Tide. Я регулярно ділюся своїм досвідом у сфері розробки мобільних застосунків на Flutter через технічні статті і виступи на конференціях.

Перед вами адаптований переклад моєї статті Code Generation with Dart and Flutter: The Ultimate Guide для блогу Code with Andrea. Рекомендую звернути увагу й на інші матеріали сайту, це чудове джерело корисної інформації.

Код демопроєкту на GitHub — на нього я час від часу посилатимусь у статті.

Dart-екосистема пропонує потужні інструменти, що забезпечують можливість швидкої та продуктивної розробки. Один із таких інструментів — це генерація коду. Цей посібник охоплює всі аспекти її застосування у повсякденній розробці.

Ми розглянемо механізм генерації коду з build_runner, обговоримо популярні корисні пакети, які спрощують деякі типові задачі, а головне — я надам практичні поради щодо ефективної підтримки проєктів, які активно використовують генерацію коду. Навіть якщо ви вже знайомі з цією темою, я переконана, ви все одно знайдете для себе щось нове.

Механізм генерації коду

Процес створення Dart і Flutter-застосунків для різноманітних сфер часто містить однакові типові завдання, як-от реалізація JSON-десеріалізації, отримання даних з бекенду, створення механізму інверсії залежностей, реалізація навігації та локалізації, використання картинок і шрифтів, написання тестів та багато інших.

Виконання більшості цих завдань передбачає написання повторюваного шаблонного коду, що вимагає значних затрат часу, до того ж в процесі можна легко помилитись та просто занудьгувати. На щастя, Dart-розробники можуть мінімізувати кількість коду, який їм потрібно писати вручну, і делегувати генерацію build_runner решти необхідного коду. Як зазначено в документації (на момент публікації цієї статі у квітні 2024 року):

Система генерації Dart-коду є хорошою альтернативою рефлексії (яка має проблеми з перформансом) та макросам (яких Dart-компілятори не підтримують).

Якщо коротко, build_runner — це інструмент, який надає гнучкий механізм для створення вихідних файлів на базі вхідних файлів. Розробники можуть реалізувати генератори коду, які працюють з цим механізмом, дозволяючи їм зчитувати будь-який вхідний файл, зазвичай Dart-код, і створювати відповідні вихідні файли, також зазвичай на Dart.

Це дозволяє користувачам цих генераторів вказувати лише мінімальну конфігурацію для майбутнього коду за допомогою спеціального синтаксису, і отримувати більше валідного Dart-коду після виконання генерації. У наступному розділі ми розглянемо кілька прикладів корисних генераторів, але перед цим давайте подивимося, як використовувати build_runner.

Спочатку його потрібно додати як dev-залежність у файл pubspec.yaml:

dev_dependencies:  
  build_runner: x.y.z

Тепер генерацію коду можна запустити за допомогою цієї команди:

dart run build_runner build -d

На цьому етапі build_runner запускає всі генератори коду, які додані у цей Dart або Flutter-пакет як залежності, надсилаючи їм всі релевантні файли пакета як вхідні дані, та дозволяючи їм створювати відповідні вихідні файли. Залежно від набору генераторів, результат виконання команди буде схожим на:

[INFO] Generating build script completed, took 636ms
[INFO] Precompiling build script... completed, took 12.8s
[INFO] Building new asset graph completed, took 1.8s
[INFO] Checking for unexpected pre-existing outputs. completed, took 7ms
[INFO] 22.6s elapsed, 224/228 actions completed.
[INFO] Running build completed, took 23.7s
[INFO] Caching finalized dependency graph completed, took 149ms
[INFO] Succeeded after 23.8s with 139 outputs (611 actions)

У вищезазначеному прикладі build_runner запускається в режимі build, який виконує одноразову генерацію. Цей режим стає у пригоді, коли проєкт запускають на новій машині, або коли одна з кодогенеруючих залежностей оновилась, і, отже, достатньо перегенерувати код один раз.

Крім того, build_runner підтримує режим watch, який відстежує файлову систему і миттєво оновлює вихідні файли, щойно змінюються вхідні файли. Це корисно, коли розробники активно працюють над пакетом.

Якщо під час генерації коду build_runner виявляє згенеровані файли, які, на його думку, він не створював, відображатиметься подібне повідомлення:

[INFO] Found 12 declared outputs which already exist on disk. This is likely because the`.dart_tool/build` folder was deleted, or you are submitting generated files to your source repository.
Delete these files?
1 - Delete
2 - Cancel build
3 - List conflicts

Параметр -d, що є скороченням від --delete-conflicting-outputs, попереджає такі запити та автоматично видаляє будь-які конфліктні згенеровані файли перед початком генерації коду. Зручніше, якщо цей параметр використовується з кожним запуском build_runner.

Важливо усвідомити, що build_runner сам по собі не генерує код; замість цього він виступає засобом запуску для генераторів, які цим займаються.

Корисні кодогенеруючі пакети

Розгляньмо деякі популярні корисні пакети для генерації коду, які працюють із механізмом build_runner. Кожен з цих пакетів полегшує вирішення одного типового завдання з розробки застосунків, дозволяючи інженерам сфокусуватися на «що», а не на «як». Коли ж вони використовуються одночасно, разом вони підсилюють цей ефект та максимізують ефективність розробників.

JSON-десеріалізація з json_serializable

Формат JSON є фактично стандартом для обміну даними, тому серіалізація і десеріалізація JSON є одним із найпоширеніших завдань у розробці застосунків.

Наприклад, застосунок, який відображає список новин, пов’язаних з космічними польотами, може отримувати деталі статей у подібному JSON:

{
  "id": 15870,
  "title": "Rocket Report: A heavy-lift rocket funded by crypto; Falcon 9 damaged in transport",
  "imageUrl": "https://cdn.arstechnica.net/wp-content/uploads/2022/07/F28-BW-Low2.jpg",
  "summary": "EcoRocket Heavy is an ecological, reusable, unprecedentedly low-cost rocket.",
  "publishedAt": "2022-07-22T11:00:53.000Z",
  "featured": false,
  "launches": [
    {
      "id": "f33d5ece-e825-4cd8-809f-1d4c72a2e0d3",
      "provider": "Launch Library 2"
    }
  ]
}

Аби відобразити ці дані у застосунку і скористатися перевагами строгої типізації мови Dart, їх потрібно перетворити в екземпляр Dart-класу:

class Article {
  const Article({
    required this.id,
    required this.title,
    this.image,
    this.summary,
    this.publishedAt,
    this.featured = false,
    this.launches = const <SpaceLaunch>[],
  });
    
  final String id;
  final String title;
  final Uri? image;
  final String? summary;
  final DateTime? publishedAt;
  final bool featured;
  final List<SpaceLaunch> launches;
}

Загальноприйнятою є практика реалізовувати JSON-серіалізацію та десеріалізацію у методах fromJson() та toJson() усередині класу, щоб ними можна було користуватися наступним чином:

assert(Article.fromJson(article.toJson()) == article);

Ось типова реалізація таких методів для наведеного вище прикладу:

class Article {
  // ...
    
  static Article fromJson(Map<String, dynamic> json) => Article(
        id: (json['id'] as int).toString(),
        title: json['title'] as String,
        image: json['imageUrl'] == null ? null : Uri.tryParse(json['imageUrl'] as String),
        summary: json['summary'] as String?,
        publishedAt: json['publishedAt'] == null ? null : DateTime.parse(json['publishedAt'] as String),
        featured: json['featured'] as bool? ?? false,
        launches: (json['launches'] as List<dynamic>?)
                ?.map((e) => SpaceLaunch.fromJson(e as Map<String, dynamic>))
                .toList() ?? const [],
      );
    
  Map<String, dynamic> toJson() => <String, dynamic>{
        'id': int.parse(id),
        'title': title,
        if (image != null) 'imageUrl': image!.toString(),
        if (summary != null) 'summary': summary,
        if (publishedAt != null) 'publishedAt': publishedAt!.toIso8601String(),
        'featured': featured,
        'launches': launches.map((e) => e.toJson()).toList(),
      };
}

Маємо статичний метод Article.fromJson(), що містить логіку парсингу для різних типів полів класу Article, таких як int, String, bool, DateTime, Uri, List, та власного типу SpaceLaunch, який також має статичний метод SpaceLaunch.fromJson(). Ба більше, не рідко методи, подібні до Article.fromJson(), містять логіку адаптації структури вхідних даних, аби краще відповідати потребам застосунку.

У вищенаведеному прикладі тип поля id змінено з int на String, назва поля класу image відрізняється від ключа imageUrl у JSON, а полям featured та launches надано значення за замовчуванням у випадку відсутності значення у JSON. Приклад також включає метод Article.toJson(), який виконує серіалізацію, додаючи у фінальну мапу лише поля зі значеннями не-null.

Пакет json_serializable дозволяє досягти того ж результату з набагато меншою кількістю коду. Він генерує методи, подібні до Article.fromJson(), які належним чином десеріалізують примітивні та користувацькі типи, та Article.toJson(), і надає механізми для реалізації всіх тих самих трансформацій формату даних, як у мануальній реалізації вище.

Щоб скористатися генерацією коду для JSON десеріалізації, додамо ці залежності у файл pubspec.yaml:

dependencies:
  json_annotation: x.y.z
    
dev_dependencies:
  build_runner: x.y.z
  json_serializable: x.y.z

Та замінимо реалізацію класу Article у файлі article.dart:

import 'package:json_annotation/json_annotation.dart';
    
part 'article.g.dart';
    
@JsonSerializable(explicitToJson: true, includeIfNull: false)
class Article {
  const Article({
    required this.id,
    required this.title,
    this.image,
    this.summary,
    this.publishedAt,
    this.featured = false,
    this.launches = const <SpaceLaunch>[],
  });
    
  @IntToStringConverter()
  final String id;
  final String title;
  @JsonKey(name: 'imageUrl')
  final Uri? image;
  final String? summary;
  final DateTime? publishedAt;
  final bool featured;
  final List<SpaceLaunch> launches;
    
  factory Article.fromJson(Map<String, dynamic> json) => _$ArticleFromJson(json);
    
  Map<String, dynamic> toJson() => _$ArticleToJson(this);
}
    
class IntToStringConverter implements JsonConverter<String, int> {
  const IntToStringConverter();
    
  @override
  String fromJson(int json) => '$json';
    
  @override
  int toJson(String object) => int.parse(object);
}

Анотація @JsonSerializable() над класом Article вказує, що він підлягає генерації коду, а декларація part 'article.g.dart' гарантує, що майбутній код буде частиною тієї ж бібліотеки article.dart.

Класи Article та SpaceLaunch із демо-проєкту містять приклад повноцінної імплементації.

Після виконання генерації коду буде створено новий файл article.g.dart із приватними методами _$ArticleFromJson() і _$ArticleToJson(), що забезпечують той самий результат, що й мануальна реалізація раніше. Завдяки анотації @IntToStringConverter() над полем id його тип коректно конвертується з int в String, анотація @JsonKey(name: 'imageUrl') гарантує, що поле image отримає значення з ключа imageUrl у JSON, а поля featured і launches отримують коректні значення за замовчуванням у випадку відсутності їх значення у JSON завдяки значенням за замовчуванням з їх конструктора.

Параметр includeIfNull: false означає, що при виклику Article.toJson(), лише поля не-null буде серіалізовано, а параметр explicitToJson: true необхідний для правильної серіалізації списків та власних типів. Така поведінка є бажаною у більшості випадків, тому було б ефективніше налаштувати її як поведінку за замовчуванням для всього пакету. Це можна зробити у спеціальному файлі build.yaml, розташованому поруч із файлом pubspec.yaml, де налаштовуються всі генератори коду.

targets:
  $default:
    builders:
      json_serializable:
        options:
          include_if_null: false
          explicit_to_json: true

Тепер параметри анотації @JsonSerializable() над класом Article можна видалити без зміни результату.

Порівняно з мануальною реалізацією JSON десеріалізації, використання генерації значно зменшує обсяг коду, який треба написати. Крім того, коли або формат даних у JSON, або структура Dart-класу оновлюється, мануальна реалізація потребує змін у декількох місцях. Це легко упустити, через що буде викинутий ексепшн серіалізації під час виконання програми. За допомогою ж генерації коду розробники можуть бути впевнені, що всі необхідні зміни застосовуються автоматично.

Цей матеріал описує лише базове використання пакета json_serializable. За більш докладною інформацією зверніться до офіційної документації.

Також читайте більше про використання пакетів json_serializable та freezed у моїй серії статей «Нетворкінг у Flutter-застосунках — про просте і складне»: Частина 1 та Частина 2.

Покращені Dart-класи з freezed

Є декілька операцій з екземплярами Dart-класів, які розробники виконують щодня, явним чи неявним чином: порівняння на основі значення полів, обчислення хеш-коду, перетворення на рядок, клонування з модифікацією частини даних. Реалізація деяких із цих операцій, таких як operator ==(), hashCode та toString(), вбудована в мову, але має незадовільний результат і тому потребує покращення, а інші, такі як метод copyWith(), необхідно писати з нуля.

Розгляньмо все той же клас Article, наведений вище. Два екземпляри з однаковими значеннями полів не вважаються рівними між собою, і їхні хеш-значення також є різними:

final article1 = Article(id: '0', title: 'title');
final article2 = Article(id: '0', title: 'title');
assert(article1 == article2);                    // перевірка не проходить
assert(article1.hashCode == article2.hashCode);  // перевірка не проходить

Це може викликати проблеми, коли екземпляри Article додаються у Set, використовуються як ключі у Map або порівнюються в тестах.

Водночас перетворення на рядок двох екземплярів із різними значеннями полів дає однаковий результат і повертає Instance of 'Article':

final article1 = Article(id: '1', title: 'title 1');
final article2 = Article(id: '2', title: 'title 2');
assert(article1.toString() == article2.toString());  // перевірка проходить

Аби покращити клас Article, розробники мали б реалізувати наступні методи:

class Article {
  // ...
  
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Article &&
          runtimeType == other.runtimeType &&
          id == other.id &&
          title == other.title &&
          image == other.image &&
          summary == other.summary &&
          publishedAt == other.publishedAt &&
          featured == other.featured &&
          launches == other.launches;
    
  @override
  int get hashCode =>
      id.hashCode ^
      title.hashCode ^
      image.hashCode ^
      summary.hashCode ^
      publishedAt.hashCode ^
      featured.hashCode ^
      launches.hashCode;
      
  @override
  String toString() => 'Article(id: $id, title: $title, '
      'image: $image, summary: $summary, publishedAt: $publishedAt, '
      'featured: $featured, launches: $launches)';
}

Чи помітили ви тут помилку? У більшості випадків, перевірка launches == other.launches не спрацює, навіть якщо обидві колекції порожні або містять однакові об’єкти. Щоб порівняти колекції за їхнім вмістом, її слід замінити на:

const DeepCollectionEquality().equals(launches, other.launches);

Пакет freezed дозволяє розробникам не турбуватися про такі нюанси та мати всі ці методи коректно згенерованими. Аби використати генерацію коду для покращення Dart-класів, додамо ці залежності у файл pubspec.yaml:

dependencies:
  freezed_annotation: x.y.z
    
dev_dependencies:
  build_runner: x.y.z
  freezed: x.y.z

Та змінимо реалізацію класу Article у файлі article.dart:

import 'package:freezed_annotation/freezed_annotation.dart';
    
part 'article.freezed.dart';
    
@freezed
class Article with _$Article {
  const factory Article({
    required String id,
    required String title,
    Uri? image,
    String? summary,
    DateTime? publishedAt,
    @Default(false) bool featured,
    @Default([]) List<SpaceLaunch> launches,
  }) = _Article;
}

Анотація @freezed перед класом Article вказує, що він підлягає генерації коду, а декларація part 'article.freezed.dart' гарантує, що майбутній код буде частиною тієї ж бібліотеки article.dart.

Класи Article та SpaceLaunch із демопроєкту містять приклад повноцінної імплементації.

Після виконання генерації коду створиться новий файл article.freezed.dart із реалізаціями методів operator ==(), hashCode, toString() та copyWith(), що забезпечують той самий результат, що й мануальна реалізація вище.

Так само як і з реалізацією JSON-десеріалізації, використання генерації коду для покращення Dart-класів зменшує зусилля на їхнє створення та підтримку, забезпечуючи автоматичне оновлення всіх необхідних місць у разі зміни структури класу.

Цей матеріал описує лише базове використання пакета freezed. Він має багато інших корисних можливостей, таких як юніони та патерн-матчінг, а також може бути легко поєднаний з json_serializable. За більш докладною інформацією зверніться до офіційної документації.

Споживання RESTful API з retrofit

Багато Flutter-застосунків спілкуються зі своїм бекендом через RESTful API. У проєкті, що використовує dio для мережевої комунікації, отримання списку статей за допомогою GET-запиту до /articles може виглядати наступним чином:

class SpaceFlightNewsApi {
  const SpaceFlightNewsApi(this._dio);
    
  final Dio _dio;
    
  Future<List<Article>> getArticles() async {
    final response = await _dio.get<List<dynamic>>('/articles');
    final json = response.data!;
    final articles = json
        .map((dynamic i) => Article.fromJson(i as Map<String, dynamic>))
        .toList();
    return articles;
  }
}

При виклику метода getArticles(), Dio виконує GET-запит, після чого результат десеріалізується у список за допомогою методу Article.fromJson(), який ми реалізували вище. Подібні запити також можуть включати query- та path- параметри, body та headers.

Пакет retrofit дозволяє звести реалізацію методів, подібних до getArticles(), до одного рядка. Аби використати генерацію коду для оптимізації споживання REST API, додамо наступні залежності до файлу pubspec.yaml:

dependencies:
  retrofit: x.y.z
    
dev_dependencies:
  build_runner: x.y.z
  retrofit_generator: x.y.z

Та змінимо реалізацію класу SpaceFlightNewsApi у файлі api.dart:

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
    
part 'api.g.dart';
    
@RestApi()
abstract class SpaceFlightNewsApi {
  factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
    
  @GET('/articles')
  Future<List<Article>> getArticles();
}

Анотація @RestApi() над класом SpaceFlightNewsApi вказує, що він підлягає генерації коду, а декларація part 'api.g.dart' гарантує, що майбутній код буде частиною тієї ж бібліотеки api.dart.

Клас SpaceFlightNewsApi із демопроєкту містить приклад повноцінної імплементації.

Після виконання генерації коду створиться новий файл api.g.dart із реалізацією методу getArticles(), що забезпечує той самий результат, що й мануальна реалізація вище. Анотація @GET('/articles') над методом getArticles() призводить до виконання GET-запиту до /articles, і завдяки вказаному типу Future<List<Article>>, що повертає метод, згенерований код коректно перетворить дані з відповіді на список об’єктів Article за допомогою того ж методу Article.fromJson(). Хіба це не круто, що код, згенерований одним пакетом, використовує код, згенерований іншим!

Цей матеріал описує лише базове використання пакета retrofit. Він підтримує всі методи та атрибути для споживання повноцінного REST API. За більш докладною інформацією зверніться до офіційної документації.

Також читайте більше про використання пакету retrofit у моїй серії статей «Нетворкінг у Flutter-застосунках — про просте і складне»: Частина 5 та Частина 6.

Інверсія залежностей з injectable

Серед розробників вважається хорошою практикою дотримання принципів SOLID. При застосуванні в розробці Flutter-застосунків, Single Responsibility Principle передбачає розділення складної функціональності на декілька класів, кожен з яких відповідає за одну задачу, а Dependency Inversion Principle часто означає, що ці класи з’єднуються через впровадження залежностей у конструкторі.

У проєкті, який використовує багатошарову архітектуру, застосування цих принципів може виглядати наступним чином:

@RestApi()       
abstract class SpaceFlightNewsApi {
  factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
    
  // ...
}
    
class SpaceFlightNewsRepository {
  const SpaceFlightNewsRepository(this._api);
    
  final SpaceFlightNewsApi _api;
    
  // ...
}
    
class GetSpaceFlightNewsUseCase {
  const GetSpaceFlightNewsUseCase(this._repository);
  
  final SpaceFlightNewsRepository _repository;
        
  // ...
}

Якщо анотація @RestApi() над класом SpaceFlightNewsApi ні про що вам не каже, ознайомтесь з розділом «Споживання RESTful API з retrofit» вище.

У результаті, створення екземпляру GetSpaceFlightNewsUseCase виглядатиме наступним чином:

final dio = Dio(...);
final usecase = GetSpaceFlightNewsUseCase(
    SpaceFlightNewsRepository(
      SpaceFlightNewsApi(dio),
    ),
);

Що більше залежностей має GetSpaceFlightNewsUseCase, то довшим буде ланцюжок конструкторів. Одним зі способів спростити створення екземпляра юзкейсу є використання сервіс-локатора get_it. Спочатку його потрібно «навчити» створювати GetSpaceFlightNewsUseCase і всі його залежності, використовуючи:

GetIt getIt = GetIt.instance;
getIt.registerSingleton(Dio(...));
getIt.registerLazySingleton(() => SpaceFlightNewsApi(getIt<Dio>()));
getIt.registerLazySingleton(() => SpaceFlightNewsRepository(getIt<SpaceFlightNewsApi>()));
getIt.registerFactory(() => GetSpaceFlightNewsUseCase(getIt<SpaceFlightNewsRepository>()));

Наведена конфігурація показує різні способи реєстрації типів у GetIt.instance, що впливає на те, коли та як часто вони будуть створюватися. Виклик registerSingleton приймає вже створений екземпляр Dio, тоді як registerLazySingleton приймає фабрику, яка буде викликана вперше, коли хтось запитає екземпляр SpaceFlightNewsApi або SpaceFlightNewsRepository.

Зверніть увагу, що параметр конструктора SpaceFlightNewsRepository отримується від GetIt без передачі екземпляра Dio, оскільки GetIt вже «знає», де його взяти. registerFactory означає, що щоразу при запиті GetSpaceFlightNewsUseCase від GetIt буде створюватись новий екземпляр.

Тепер GetSpaceFlightNewsUseCase можна створити як:

final usecase = GetIt.instance<GetSpaceFlightNewsUseCase>();

У великих проєктах конфігурація GetIt може потребувати багато коду через велику кількість класів та їхніх залежностей.

Пакет injectable дозволяє скоротити конфігурацію GetIt до кількох рядків коду, незалежно від розміру пакета. Аби скористатися генерацією коду для спрощення впровадження залежностей, додамо наступні залежності у файл pubspec.yaml:

dependencies:
  get_it: x.y.z
  injectable: x.y.z
    
dev_dependencies:
  build_runner: x.y.z
  injectable_generator: x.y.z

У файлі di_initializer.dart оголосимо метод initDI(), що має бути викликаним при старті застосунка:

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:space_flight_news/di/di_initializer.config.dart';
        
@injectableInit
void initDI() => GetIt.instance.init();

Анотація @injectableInit над методом initDI вказує, що він підлягає генерації коду, а імпорт di_initializer.config.dart забезпечує доступ до майбутнього коду зі цього файлу.

І нарешті, анотуймо класи наступним чином:

@lazySingleton
@RestApi()       
abstract class SpaceFlightNewsApi {
  @factoryMethod
  factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
   
  // ...  
}
    
@lazySingleton
class SpaceFlightNewsRepository {
  const SpaceFlightNewsRepository(this._api);
    
  final SpaceFlightNewsApi _api;
    
  // ...
}
    
@injectable
class GetSpaceFlightNewsUseCase {
  const GetSpaceFlightNewsUseCase(this._repository);
  
  final SpaceFlightNewsRepository _repository;
        
  // ...
}

Після виконання генерації коду створиться новий файл di_initializer.config.dart з екстеншном init() для типу GetIt, який забезпечує такий же результат, як і попередня мануальна імплементація. Анотація @lazySingleton гарантує, що SpaceFlightNewsApi та SpaceFlightNewsRepository відкладено створюються лише один раз, тоді як анотація @injectable означає, що новий екземпляр GetSpaceFlightNewsUseCase створюватиметься щоразу, коли його запитують з GetIt. Анотація @factoryMethod над фабричним конструктором SpaceFlightNewsApi показує, як змусити GetIt використовувати фабричні або іменні конструктори замість конструкторів за замовчуванням.

Оскільки параметр Dio у SpaceFlightNewsApi походить з іншого пакета, його не можна легко анотувати з @singleton, щоб відтворити попередню поведінку. Однак його все ще можна зареєструвати в GetIt наступним чином:

import 'package:injectable/injectable.dart';
    
@module
abstract class DIModule {
  @singleton
  Dio createDio() => Dio(...);
}

Анотація @module гарантує, що всі анотовані гетери та методи класу DIModule також будуть зареєстровані в GetIt.

Метод initDI, а також класи DIApiModule та ArticlesListBloc із демопроєкту містять приклад повноцінної імплементації.

Цей матеріал описує лише базове використання пакета injectable. Він також дозволяє реєструвати примітивні типи та реалізації абстрактних класів, передавати параметри, і впроваджувати різну реалізацію абстракцій за типом збірки. За більш докладною інформацією зверніться до офіційної документації.

Документація та тести з bdd_widget_test

Кожен новий Flutter-проєкт, створений за допомогою команди flutter create, містить приклад застосунку Counter, що супроводжується віджет-тестом:

void main() {
  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    await tester.pumpWidget(const MyApp());
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();
    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });
}

Тут, після створення віджета MyApp, тест перевіряє наявність на екрані тексту «0», торкається іконки «+» та очікує, що текст зміниться на «1».

Хоч цей тест може здаватися тривіальним для будь-якого Flutter-розробника, іншим членам команди може бути важко його зрозуміти. Щобільше, тести зазвичай набагато складніші, ніж цей приклад. У разі виникнення питань в product owner або QA engineer щодо цього, їм доведеться звернутися до документації, що зберігається десь за межами кодової бази (що найчастіше означає, що вона стає застарілою досить швидко) або безпосередньо до розробників (що неефективно та може спричиняти затримки).

Найефективніший спосіб забезпечити постійну актуальність технічної документації та її відповідність фактичній реалізації — це генерувати її з кодової бази. Однак складність полягає в тому, щоб зробити її зрозумілою для нетехнічних членів команди.

Пакет bdd_widget_test дозволяє писати технічну документацію прямо в кодовій базі схожою до розмовної мовою Gherkin, аби потім із неї отримати відповідні Flutter-віджет-тести для покриття описаної функціональності. Аби скористатися генерацією коду для підтримки документації та тестів, додамо ці залежності до файлу pubspec.yaml:

dev_dependencies:
  build_runner: x.y.z
  bdd_widget_test: x.y.z

Створімо counter.feature файл у теці test. Ось як він може виглядати для прикладу з Counter-застосунком:

Feature: Counter
    
  Scenario: Initial counter value is 0
    Given the Counter app is running
    Then I see {'0'} text
    
  Scenario: Tapping the Plus icon once increases the counter to 1
    Given the Counter app is running
    When I tap {Icons.add} icon
    Then I see {'1'} text

Після виконання генерації коду створюється супровідний файл counter_test.dart, який забезпечує той самий результат, що й віджет-тест, наведений вище. Тест-файл містить одну групу під назвою Counter і два віджет-тести, які відповідають сценаріям у feature-файлі:

// GENERATED CODE - DO NOT MODIFY BY HAND
    
void main() {
  group('''Counter''', () {
    testWidgets('''Initial counter value is 0''', (tester) async {
      await theCounterAppIsRunning(tester);
      await iSeeText(tester, '0');
    });
    testWidgets('''Tapping the Plus icon once increases the counter to 1''', (tester) async {
      await theCounterAppIsRunning(tester);
      await iTapIcon(tester, Icons.add);
      await iSeeText(tester, '1');
    });
  });
}

Методи iSeeText та iTapIcon, а також багато інших частих дій та перевірок, таких як iEnterText, iSeeWidget, iDontSeeWidget, надаються пакетом. Крім того, розробники можуть використовувати власні фрази, щоб об’єднати кілька примітивних дій або приховати складну бізнес-логіку під спрощеним формулюванням.

Для кожного такого нестандартного вислову в результаті генерації створюються заглушки методів, які можна реалізувати відповідно до потреб проєкту. Для кроку the Counter app is running у прикладі вище створиться файл step/the_counter_app_is_running.dart із заготовкою методу theCounterAppIsRunning, який розробники мають оновити на власний розсуд, наприклад:

Future<void> theCounterAppIsRunning(WidgetTester tester) async {
  await tester.pumpWidget(MyApp());
}

З таким підходом, проєкт має читабельні *.feature-файли, які можуть бути автоматично завантажені в систему для документації або доступні всій команді прямо в кодовій базі. Ці файли завжди містять актуальний опис функцій застосунку — це підтверджується проходженням тестів, які генеруються з цієї документації.

Файл articles_list.feature із демопроєкту містить приклад повноцінної імплементації.

Цей матеріал описує лише базове використання пакета bdd_widget_test. Він також підтримує тест-кейси, теги тощо, і може бути поєднаний із голден- та інтеграційними тестами. За більш докладною інформацією зверніться до офіційної документації, а також до серії коротких відео BDD in Flutter.

Автоматична інкапсуляція з barrel_files

Коли ви організуєте проєкт, розділивши його на кілька пакетів, або коли пишете опенсорс-пакет, він часто містить мікс із класів, до яких ви хочете дати доступ користувачам пакета, та класів, які краще приховати, аби публічний інтерфейс пакета лишався мінімалістичним і зрозумілим. У мові Dart весь код є публічним за замовчуванням, якщо тільки ім’я не починається з _ — в такому випадку цей елемент коду доступний лише в межах тієї ж Dart-бібліотеки/файлу.

Щоб зробити код доступним для іншого коду в тому ж пакеті, але не за його межами, широко прийнятою практикою є розміщення коду в теці lib/src. Якщо користувачі пакета спробують отримати доступ до будь-якого файлу в цій теці, вони отримають попередження про порушення правила implementation_imports: Don’t import implementation files from another package.

Аби відкрити доступ до обмеженого API-пакета, розробники можуть безпосередньо в теці lib створити один або кілька barrel-файлів з переліком export-директив, що вказують тільки на файли, які можуть бути доступні за межами пакета. Цей підхід гарантує, що користувачі мають доступ лише до публічного інтерфейсу пакета, дозволяючи авторам безпечно змінювати внутрішню реалізацію без ризику зворотно несумісних змін.

Для пакета space_flight_news, який складається з файлу usecase.dart:

class SpaceFlightNewsApi {
  // ...
}
    
class GetSpaceFlightNewsUseCase {
  // ...
}

та файлу model.dart:

class Article {
  // ...
}
    
class SpaceLaunch {
  // ...
}

barrel-файл lib/space_flight_news.dart може виглядати наступним чином:

export 'package:space_flight_news/src/model.dart';
export 'package:space_flight_news/src/usecase.dart';

В такому підході інформація про «видимість» коду відокремлена від самого коду і контролюється у barrel-файлі в іншому місці. Це легко може призвести до ситуації, коли barrel-файл не переглядається і не оновлюється достатньо регулярно, потенційно відкриваючи більше, ніж задумувалося.

У прикладі вище цілком можливою є ситуація, що клас SpaceFlightNewsApi був доданий до файлу usecase.dart не одразу, і відповідно він став відкритий випадково, хоча й не повинен бути частиною публічного API цього пакету.

Пакет barrel_files створює barrel-файли на основі анотацій, розміщених безпосередньо над елементами коду, які повинні бути доступними за межами пакета. Аби використати генерацію коду для автоматизації оновлення barrel-файлів, додамо ці залежності до файлу pubspec.yaml:

dependencies:
  barrel_files_annotation: x.y.z
    
dev_dependencies:
  barrel_files: x.y.z
  build_runner: x.y.z

Далі анотуймо класи, єнами, глобальні константи та інші елементи коду, які не є приватними для пакету, анотацією @includeInBarrelFile. Ось оновлений файл usecase.dart:

import 'package:barrel_files_annotation/barrel_files_annotation.dart';
    
class SpaceFlightNewsApi {
  // ...
}
    
@includeInBarrelFile
class GetSpaceFlightNewsUseCase {
  // ...
}

та оновлений файл model.dart:

import 'package:barrel_files_annotation/barrel_files_annotation.dart';
    
@includeInBarrelFile
class Article {
  // ...
}
    
@includeInBarrelFile
class SpaceLaunch {
  // ...
}

Після виконання генерації коду створюється новий barrel-файл lib/space_flight_news.dart, що забезпечує навіть кращий результат, аніж попередня мануальна імплементація, бо клас SpaceFlightNewsApi лишився прихованим:

// GENERATED CODE - DO NOT MODIFY BY HAND
    
export 'package:space_flight_news/src/model.dart' show Article, SpaceLaunch;
export 'package:space_flight_news/src/usecase.dart' show GetSpaceFlightNewsUseCase;

Пакет barrel_files допомагає зберігати інформацію про «видимість» коду якнайближче до самого коду. Він гарантує актуальність barrel-файлів, і дотримується мінімалістичного підходу, надаючи доступ лише до явно анотованого коду, інкапсулюючи решту.

Інші застосування генерації коду

Як продемонстровано вище, генерація коду може значно полегшити багато типових завдань у розробці застосунків, таких як JSON-серіалізація, робота з REST API, впровадження залежностей, ведення документації тощо.

Сесія «Fun with code generation» з конференції Flutter Vikings демонструє використання більшості з наведених пакетів в одному Flutter проєкті.

Завдяки активній Flutter-спільноті існує ще багато пакетів для генерації коду вартих уваги:

  • dart_mappable надає достойну альтернативу комбінації freezed та json_serializable;
  • flutter_gen спрощує використання асетів у Flutter-пакетах;
  • auto_route надає популярний механізм навігації;
  • i69n спрощує локалізацію застосунків.

Дізнайтеся більше про локалізацію застосунків з пакетом i69n в моїй статті «Ще один підхід до локалізації Flutter-застосунків, якого вам бракувало».

Перелік можна продовжувати ще довго, отже застосування генерації коду може покращити багато аспектів розробки застосунків. Тепер обговорімо best practices для підтримки проєктів, які широко використовують усі ці пакети для генерації коду.

Ефективна підтримка проєктів

Активне використання генерації коду на Dart і Flutter-проєктах може значно підвищити продуктивність розробки, однак цей підхід потребує додаткових зусиль з підтримки. Тому важливо дотримуватися певних практик, аби продуктивність залишалась на високому рівні. Розгляньмо декілька технік, які допомагають ефективно підтримувати такі проєкти.

Оптимізуйте вхідні дані генераторів

Вище було згадано, що build_runner запускає всі генератори коду, додані до пакета, і, за замовчуванням, надає всі файли пакета як вхідні дані для кожного генератора. Тому не дивно, що розмір цих вхідних даних напряму впливає на час, необхідний для виконання генерації коду.

Зрештою, генератор коду — це просто скрипт, який перетворює певний вхідний рядок у вихідний рядок (який виявляється валідним Dart-кодом) та записує його на диск. Залежно від розміру проєкту та кількості генераторів, виконання генерації коду може зайняти від декількох секунд до години чи більше, тому важливо оптимізувати і прискорити цей процес, мінімізуючи розмір вхідних даних для кожного генератора.

Раніше ми вже ознайомились, як налаштовувати поведінку генераторів за допомогою конфігурації options, наданих у спеціальному файлі build.yaml. Останній також підтримує конфігурацію generate_for, яка приймає список файлів, які стануть вхідними даними для генератора. У наступному прикладі показано, як вказати окремі файли або включити всі файли в певних каталогах:

targets:
  $default:
    builders:
      json_serializable:
        generate_for:
          # єдиний файл `example.dart`
          - lib/src/example.dart
          # всі `.dart` файли у каталозі `foo`
          - lib/src/foo/*.dart
          # всі `.dart` файли у каталозі `bar` і його підкаталог
          - lib/src/bar**/*.dart

Після того як кількість вхідних файлів для генераторів коду буде зменшено (там де це має сенс), процес генерації коду виконуватиметься швидше.

Зверніть увагу, що коли в файлі build.yaml зконфігуровано generate_for, відповідний генератор коду ігноруватиме вхідні файли, які не відповідають тим, що вказані у списку. Внаслідок цього, для цих файлів цим конкретним генератором коду не буде створено жодного коду без жодного попередження або повідомлення про помилку.

Крім пришвидшення генерації коду, конфігурування генераторів за допомогою generate_for допомагає переконатися, що структура пакета відповідає наперед визначеним домовленостям.

Створюйте невеликі пакети

Ми вже бачили, що при запуску команди dart run build_runner відбувається генерація коду для всього пакету, на якому вона запущена. Таким чином, попри мінімізацію вхідних даних генераторів за допомогою техніки вище, генерація коду для всіх відповідних файлів у великому пакеті все одно може займати значний час. Природно, розділення проєкту на менші пакети додатково зменшує обʼєм вхідних даних генераторів, що призводить до швидшої генерації коду у кожному окремому пакеті.

Ця техніка не тільки спрямована на поліпшення швидкості генерації коду, але і має інші переваги, такі як покращена інкапсуляція, швидший запуск тестів та зменшення когнітивного навантаження.

Раніше ми бачили, що деякі генератори, наприклад, freezed або json_serializable, створюють вихідні файли для кожного вхідного файлу з пакета, тоді як інші, такі як injectable, створюють усього один вихідний файл на кожен пакет. Коли над пакетом працюють кілька розробників, файли з останньої категорії частіше стикаються з merge-конфліктами в більшому пакеті, ніж в меншому. Це підкреслює ще одну перевагу розбиття проєкту на менші пакети.

При такому підході розробники зазвичай виконують генерацію коду лише в пакетах, до яких вони внесли зміни. Проте, існують випадки, коли потрібно виконати генерацію коду в усіх пакетах проєкту, наприклад, після оновлення однієї з кодогенеруючих залежностей. Одним з ефективних способів розв’язання цієї задачі є використання інструменту Melos.

Melos — це консольна утіліта для підтримки Dart-проєктів з багатьма пакетами. Офіційний посібник з початку роботи демонструє, як налаштувати Melos для будь-якого проєкту Flutter.

Melos дозволяє розробникам створювати власні скрипти у спеціальному файлі melos.yaml і виконувати будь-які команди на відфільтрованому наборі пакетів проєкту. Наступний приклад показує, як створити скрипт generate, який запускає генерацію коду у всіх пакетах, що залежать від build_runner:

name: <project_name>
    
scripts:
  generate:
    run: melos exec -c 1 --depends-on="build_runner" -- \
      "dart run build_runner build --delete-conflicting-outputs"

Тепер його можна запустити командою:

melos run generate

Додавайте згенеровані файли до Git

Додавання згенерованих файлів до Git поруч із вручну створеними файлами має численні переваги.

Без згенерованих файлів проєкт не може бути скомпільований та запущений, оскільки вони містять важливі частини реалізації. Тому, якщо згенеровані файли не додані до Git, доведеться витрачати час і ресурси для відновлення проєкту в новому середовищі. Запуск генерації коду буде потрібний:

  • щоразу, коли проєкт завантажується на нову машину для розробки;
  • після завантаження останніх змін із віддаленого репозиторію, аби переконатися, що згенеровані локально файли все ще є актуальними;
  • на CI щоразу, коли він завантажує проєкт у чисте середовище для виконання перевірок PR або створення нового білда.

Враховуючи потенційно значний час, потрібний на генерацію коду, особливо у великих проєктах, стає очевидним, що виключення згенерованих файлів з Git призводить до більших витрат. Додавання ж цих файлів до Git є абсолютно необхідним для підтримання кодової бази у стані готовності до запуску та релізу в будь-який момент, що є більш ефективним як з погляду часу, так і грошей.

Налаштовуйте статичний аналізатор

Статичний аналізатор Dart може бути індивідуально налаштований, аби задовольнити потреби різних команд, за допомогою спеціального файлу analysis_options.yaml, розташованого поруч із файлом pubspec.yaml. Розробники можуть використовувати один із наборів правил створених спільнотою або створити власний перелік, обираючи з усіх доступних правил. Така гнучкість становить виклик для генераторів коду, оскільки вони не можуть задовольнити всі правила аналізатора, налаштовані в конкретному проєкті. В результаті згенерований код може стати джерелом численних зауважень аналізатора, створюючи безлад і ускладнюючи виявлення реальних проблем.

Найпростіший спосіб усунути цю незручність — виключити згенеровані файли зі статичного аналізу. Для проєкту, що використовує всі перелічені вище пакети для генерування коду, файл analysis_options.yaml слід налаштувати наступним чином:

analyzer:
  exclude:
    - '**/*.g.dart'
    - '**/*.freezed.dart'
    - '**/*.config.dart'
    - '**/*.gen.dart'
    - '**/feature**/*_test.dart'

Аналіз цих файлів все ж може бути корисний в рідкісних випадках, але виключення згенерованих файлів зі статичного аналізу значно покращує продуктивність аналізатора, що є частою проблемою для великих проєктів.

Фіксуйте версії залежностей

Найчастіше залежності у файлі pubspec.yaml вказують із використанням синтаксису з кареткою: наприклад, build_runner: ^2.4.6. Це означає, що дозволяється використання діапазону версій build_runner від 2.4.6 (включно) до 3.0.0 (виключно).

Після виконання команди flutter pub get, файл pubspec.lock поруч із pubspec.yaml «фіксує» точну версію залежності, яка тепер використовуватиметься на цій машині, до тих пір, поки розробник явно її не оновить. Зафіксовано буде або останню версію з локального кешу pub, яка підходить, або, якщо такої нема, то остання, що підходить, з pub.dev.

Dart-команда радить не комітити файли pubspec.lock пакетів Dart та Flutter, «за винятком пакетів застосунків». Отже, у проєктах із пакетами, що не містять застосунки, а отже, їх файли pubspec.lock відсутні у Git, і де залежності, які генерують код, вказані з діапазонами версій, розробники можуть зіткнутися з ситуаціями, коли на різних девелоперських машинах використовуються різні версії генераторів коду.

У такому випадку, якщо нова версія генератора створює дещо інакший код, розробники можуть отримати зміни у згенерованих файлах без будь-яких явних модифікацій конфігурації з їх боку.

Щоб уникнути таких ситуацій, у файлі pubspec.yaml завжди вказуйте точні версії всіх залежностей, що генерують код:

dependencies:
  barrel_files_annotation: 0.1.1
  freezed_annotation: 2.4.1
  injectable: 2.3.2
  json_annotation: 4.8.1
  retrofit: 4.0.3
    
dev_dependencies:
  barrel_files: 0.1.1
  bdd_widget_test: 1.6.4
  build_runner: 2.4.6
  flutter_gen_runner: 5.3.2
  freezed: 2.4.5
  injectable_generator: 2.4.1
  json_serializable: 6.7.1
  retrofit_generator: 8.0.2

Також само собою, усі пакети у проєкті повинні використовувати однакові версії залежностей.

Спрощуйте запуск генерації

У проєктах, які інтенсивно використовують генерацію коду, розробники часто мають перезапускати цей процес. Тому варто інвестувати час в оптимізацію цієї задачі.

У системах на основі Unix, таких як macOS або Linux, можна перепризначати команди за допомогою аліасів. Традиційно вони визначаються в файлі .zshrc, розташованому у home каталозі користувача. Ось деякі зручні аліаси для спрощення процесу запуску генерації коду:

alias fpg="flutter pub get"
    
alias brb="dart run build_runner build --delete-conflicting-outputs"
alias brw="dart run build_runner watch --delete-conflicting-outputs"
    
alias fpgbrb="fpg && brb"
alias fpgbrw="fpg && brw"

Аліаси brb і brw запускають build_runner в режимі build або watch відповідно. fpg — це простий аліас для завантаження залежностей пакета. Це може бути обовʼязковим для виконання перед запуском генерації коду, тому аліаси fpgbrb і fpgbrw представляють комбінацію обох.

Створюйте шаблони коду

Написання конфігурувального коду для майбутнього згенерованого коду — це завдання, яке виконується щодня багатьма розробниками. Деякі пакети, такі як injectable, потребують лише додавання анотації, тоді як інші, наприклад, freezed, потребують запам’ятовування нетривіального синтаксису.

Усі популярні IDE підтримують code snippets, що надають зручний спосіб одноразової генерації простого коду, що особливо корисно для спрощення завдань, таких як оголошення freezed-класів.

Це коротке відео демонструє, як налаштувати Live Templates для Android Studio та IntelliJ IDEA. Ось текст шаблону, який використано у відео:

import 'package:freezed_annotation/freezed_annotation.dart';
    
part '$FILE_NAME$.freezed.dart';
part '$FILE_NAME$.g.dart';
    
@freezed
class $CLASS_NAME$ with _$$$CLASS_NAME$ {
  const factory $CLASS_NAME$ ({
    $END$
  }) = _$CLASS_NAME$;
  
  factory $CLASS_NAME$.fromJson(Map<String, dynamic> json) =>
    _$$$CLASS_NAME$FromJson(json);
}

Значення змінних шаблону:

FILE_NAME = fileNameWithoutExtension()
CLASS_NAME = capitalize(camelCase(fileNameWithoutExtension()))

Для конфігурації Code Snippets у VSCode, слідуйте інструкціям зі цього відео. Ось вміст файлу .code-snippets:

{
  "Serializable freezed model": {
    "prefix": "fmodel",
    "description": "Declare a serializable freezed model",
    "body": [
      "import 'package:freezed_annotation/freezed_annotation.dart';",
      "",
      "part '${TM_FILENAME_BASE}.freezed.dart';",
      "part '${TM_FILENAME_BASE}.g.dart';",
      "",
      "@freezed",
      "class ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} with _$${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} {",
      "  const factory ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}({",
      "    ${0}",
      "  }) = _${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g};",
      "",
      "  factory ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}.fromJson(Map<String, dynamic> json) => ",
      "      _$${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}FromJson(json);",
      "}"
    ]
  }
}

За допомогою цих шаблонів розробники можуть оголошувати freezed-класи за кілька секунд.

Згортайте згенеровані файли

Зазвичай, в проєктах з інтенсивним використанням генерації коду, кількість згенерованих файлів досить велика. За замовчуванням ці файли відображаються в дереві проєкту поруч зі звичайними файлами, і заважають сфокусуватися. На щастя, всі популярні IDE дозволяють приховувати згенеровані файли, згортаючи їх під основним файлом з такою ж назвою.

Щоб налаштувати Android Studio або IntelliJ IDEA на згортання згенерованих файлів, у вікні Project перейдіть до Options → File nesting. Знайдіть .dart у стовпчику Parent File Suffix і додайте .g.dart; .freezed.dart; .config.dart до значення у стовпчику Child File Suffix.

Для налаштування VSCode, відкрийте Settings → Features → Explorer, увімкніть File nesting і налаштуйте .dart запис під File Nesting: Patterns, аби той містив ${capture}.g.dart, ${capture}.freezed.dart, ${capture}.config.dart.

За посиланнями VSCode Shortcuts, Extensions & Settings for Flutter Development та IntelliJ / Android Studio Shortcuts for Flutter Development ви знайдете додаткові корисні поради щодо налаштування IDE.

Модифікуйте code-coverage звіт

Test coverage є однією з метрик, які часто використовуються для оцінки якості коду. Flutter-розробники можуть легко згенерувати звіт про покриття коду тестами, виконавши цю команду:

flutter test --coverage

Файл звіту lcov.info буде створено у теці з назвою coverage. За замовчуванням згенеровані файли включено до звіту, і оскільки не весь код у цих файлах завжди покритий тестами, це призводить до зменшення загального відсотка покриття. Однак повністю покрити згенерований код тестами складно та часто є зайвим.

Найпростіший спосіб розв’язання цієї проблеми — виключити згенеровані файли зі звіту про покриття коду тестами. Це можна зробити за допомогою інструменту remove_from_coverage, який дозволяє маніпулювати звітом у lcov.info, щоб ігнорувати файли, що відповідають заданим шаблонам.

Після встановлення інструмента за допомогою команди:

dart pub global activate remove_from_coverage

Файл coverage/lcov.info зі звітом про покриття коду тестами можна змінити наступним чином, аби виключити з нього згенеровані файли:

dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info \
    -r '.g.dart$' -r '.freezed.dart$' -r '.config.dart$'

Перевикористовуйте налаштовані анотації

Більшість кодогенеруючих пакетів підтримують можливість конфігурації згенерованого коду за допомогою параметрів анотацій. Наприклад, використання анотації @JsonSerializable() порівняно з @JsonSerializable(includeIfNull: false, explicitToJson: true) призводить до генерації різної логіки серіалізації.

Коли в різних частинах проєкту потрібні схожі конфігурації, це призводить до дублювання коду. Цього можна уникнути, виокремивши часто використовувані конфігурації анотацій у реюзабельні константи, як показано нижче:

const serializableModel = JsonSerializable(
  includeIfNull: false, 
  explicitToJson: true,
);
      
@serializableModel
class ExampleModel {...}

Контролюйте порядок запуску генераторів

У деяких випадках може стати у пригоді можливість контролювати послідовність виконання генераторів коду. Наприклад, генератор retrofit вирішує, чи генерувати виклик .toJson() для моделі, яка є частиною body REST-запиту, виходячи з того, чи клас моделі має декларацію методу .toJson().

Якщо для серіалізації такої моделі використовується json_serializable у поєднанні з freezed, метод .toJson() не буде декларовано явно, допоки не будуть виконані обидва цих генератори. Таким чином, їх необхідно запустити перед генератором retrofit, аби останній зміг згенерувати валідний код.

Послідовність виконання генераторів можна контролювати у файлі build.yaml наступним чином:

global_options:
  freezed:
    runs_before:
      - json_serializable
  json_serializable:
    runs_before:
      - retrofit_generator

У цьому випадку генератор freezed виконується першим, json_serializable виконується наступним, і retrofit_generator виконується останнім.

Похідний код демопроєкту

Репозиторій із Flutter-проєктом, що демонструє використовує всіх згаданих пакетів генерації коду, доступний на GitHub.

Цей проєкт містить похідний код застосунка для новин про космічні польоти:

Цей застосунок реалізований двічі: без використання генерації коду і з її використанням.

На завершення

Генерація коду є потужним інструментом, що може підвищити нашу продуктивність, полегшуючи виконання багатьох типових завдань у розробці застосунків на Flutter.

У цьому посібнику ми дослідили механізм генерації коду за допомогою build_runner, використали декілька кодогенеруючих пакетів для полегшення таких завдань, як серіалізація JSON, споживання REST API, впровадження залежностей, документація та інші, і обговорили найкращі практики для ефективної підтримки проєктів, що активно використовують генерацію коду.

Безсумнівно, генерація коду приносить багато переваг, і я сподіваюся, що інструменти та техніки з цього посібника стануть вам у пригоді. Happy Fluttering! 💙

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

дякую за статтю, не те щоб щось нове, але приємно побачити що хтось думає так само як ти і застосовує все те саме )
Про «Додавайте згенеровані файли до Git» працюю на замовника де за таке розстрілюють на місці, але я особисто це підтримую :)

Дякую за відгук!

Перелік складала на основі власного досвіду і вподобань 🙂

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

Або для підтримки великих проєктів, навіть нових. Бо механізм макросів покладається на Dart аналізатор, перформанс якого вже й так викликає багато зауважень на більш-менш великих проєктах.
До того ж деякі з цих порад, а також ідеї щодо того, який саме код можна делегувати механізму генерації, можна застосувати й до макросів.

Я бы не стал на новых проектах применять.Перфоманс починят — тут сомнений нет.А вот фризеды это явные «костыли» Читать такой код уже не хочется ибо Дарт развивается в другом направление . А нам надо бежать за этим направлением. Через 1-2 года уверяю Вас генерация будет смотреться как php 3 при том что есть php 8 ))

На практиці може мало чого змінитися, той же фрізд вже переписується на макроси. Тобто через пів року ми просто побачимо мажорні оновлення цих пакетів)

Дякую за цікаву та пізнавальну статтю! Чекатиму на нові статті!

Дякую за відгук! 💙

Цього липня я виступатиму на найбільшій триденній Flutter конференції у Європі — FlutterCon в Берліні з темою «Your ultimate guide to code generation productivity for Dart and Flutter», де розповідатиму саме про ці практики з підтримки ефективності розробки в проєктах, що інтенсивно використовують кодогенерацію.
Радо ділюся з вами кодом на знижку 50% на квитки: FTCON24EU_FRIENDS_ANNA834. Якщо скористалися — поставте «+» в коментарях, мені буде приємно. (Код має обмежену кількість використань, то ж якщо він не спрацював, значить хтось був швидшим за вас).

клас, до зустрічі на конфі!

Підписатись на коментарі