Using Annotations and the build_runner Package to Automate Code Generation in Flutter

In Flutter development, code generation can significantly reduce boilerplate and improve productivity. Using annotations and the build_runner package allows you to automate repetitive tasks, ensuring cleaner and more maintainable code. This comprehensive guide explores how to leverage annotations and build_runner to automate code generation in your Flutter projects.

What is Code Generation?

Code generation involves writing programs that generate code. In the context of Flutter, this typically means creating Dart code based on predefined rules or annotations. Automation reduces manual effort and minimizes the risk of human error, especially for repetitive tasks.

Why Use Code Generation in Flutter?

  • Reduce Boilerplate: Automatically generate repetitive code like data serialization/deserialization, copyWith methods, etc.
  • Improve Productivity: Save time by automating common tasks, allowing developers to focus on more complex logic.
  • Maintainability: Keep code consistent and easier to update by modifying generation rules rather than countless individual files.
  • Type Safety: Code generation can enforce type safety and prevent runtime errors.

Key Tools: Annotations and build_runner

  • Annotations: Annotations (also known as metadata) provide information about the code. Code generators use these annotations as instructions for generating new code.
  • build_runner: This is a Dart package that simplifies the process of running code generators. It watches your files, triggers code generation based on annotations, and outputs the generated code.

Setting Up Your Project

Step 1: Add Dependencies

Add the necessary dependencies to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  # Add the base annotation dependency
  build_annotation: ^2.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  # Add build_runner and the actual code generator
  build_runner: ^2.4.6
  build_verify: ^3.1.0

Explanation:

  • build_annotation: Provides the base annotations you will use in your code.
  • build_runner: A tool for running code generators.
  • build_verify: (Optional but Recommended) – Ensures that your generated files are up-to-date. Helpful for CI/CD pipelines and preventing surprises.

Step 2: Create Annotation Classes

Create your own annotation classes to mark which code should be processed by the code generator. For example, let’s create an annotation called @GenerateDataClass:

Create a new file called lib/src/annotations.dart:

class GenerateDataClass {
  const GenerateDataClass();
}

This annotation will mark classes that should have a data class generated for them. Data classes usually include boilerplate like copyWith, ==, hashCode, and toString.

Step 3: Implement the Code Generator

Create a builder that uses the annotations to generate code. You’ll need a builder package to define how the code is generated. A common pattern is to create a separate package, let’s call it my_generator. Here’s how you can set it up:

a. Create a New Package

Create a new directory named my_generator in your project. This folder should not be inside your lib directory.

mkdir my_generator
cd my_generator
flutter create --template=package .
b. Add Dependencies to the Generator Package

In the my_generator/pubspec.yaml file, add the required dependencies:

dependencies:
  build: ^2.4.1
  analyzer: '>=5.0.0 <7.0.0' # Adjust version according to your Flutter SDK
  build_config: ^2.0.0
  code_builder: ^4.0.0
  dart_style: ^2.0.0

  #  Import the project itself
  flutter_code_generation_example: # Replace with your project name
    path: ../

dev_dependencies:
  build_runner: ^2.4.6
  test: ^1.16.0
c. Create the Builder

Create a Dart file (e.g., lib/src/my_generator_builder.dart) in the my_generator package:

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
import 'package:source_gen/source_gen.dart';

import 'package:flutter_code_generation_example/src/annotations.dart'; // Replace with your project import

class DataClassGenerator extends GeneratorForAnnotation {
  @override
  Future generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) async {
    if (element is! ClassElement) {
      throw InvalidGenerationSourceError(
          'Only classes can be annotated with @GenerateDataClass.',
          element: element);
    }

    final classElement = element as ClassElement;
    final className = classElement.name;
    final fields = classElement.fields.where((field) => !field.isStatic).toList();

    // Create the copyWith method
    final copyWithMethod = Method((b) {
      b
        ..name = 'copyWith'
        ..returns = refer(className)
        ..body = Code('''
          return $className(
            ${fields.map((field) => '${field.name}: ${field.name} ?? this.${field.name},').join('n')}
          );
        ''');
      for (final field in fields) {
        b.optionalParameters.add(Parameter((p) => p
          ..name = field.name
          ..type = refer('dynamic') // Use dynamic for nullable inference
          ..named = true));
      }
    });


    // Generate the class definition
    final classDefinition = Class((b) {
      b
        ..name = '${className}Generated' // Generated class name
        ..methods.add(copyWithMethod); // Add the copyWith method

      //Override == operator
      b.methods.add(Method((m) => m
        ..name = 'operator =='
        ..returns = refer('bool')
        ..requiredParameters.add(Parameter((p) => p
          ..name = 'other'
          ..type = refer('Object')))
        ..annotations.add(CodeExpression(Code('override')))
        ..body = Code('''
            return other is $className &&
              ${fields.map((field) => field.type.isNullable ? '${field.name} == other.${field.name}' : '${field.name}.runtimeType == other.${field.name}.runtimeType && ${field.name} == other.${field.name}').join('&&')};
        ''')));

      //Generate hashCode
      b.methods.add(Method((m) => m
        ..name = 'get hashCode'
        ..returns = refer('int')
        ..annotations.add(CodeExpression(Code('override')))
        ..getter = true
        ..body = Code('''
            return Object.hashAll([
              ${fields.map((field) => field.name).join(',')}
            ]);
        ''')));
    });

    // Convert the code to a string
    final emitter = DartEmitter();
    return DartFormatter().format('${classDefinition.accept(emitter)}');
  }
}

This builder does the following:

  • Extends GeneratorForAnnotation and specifies the GenerateDataClass annotation.
  • Checks if the annotated element is a class.
  • Extracts fields from the class.
  • Generates a copyWith method that allows easy modification of class instances.
  • Generates overridden == operator.
  • Generates overridden hashCode method.
d. Create a Builder Definition

Create a file my_generator/build.yaml:

targets:
  $default:
    sources:
      - lib/**
      - $package$

builders:
  data_class_generator:
    target: ":my_generator" # Replace with the name of your package
    import: "package:my_generator/src/my_generator_builder.dart" # Adjust to the correct import
    builder_factories: ["DataClassGenerator"]
    build_extensions: {".dart": [".generated.dart"]}
    auto_apply: dependents
    required_inputs: [".dart"]

Explanation:

  • targets: Specifies which files should be processed.
  • builders: Defines the builder and its configuration:
    • import: Path to the builder implementation.
    • builder_factories: Lists the builder factories.
    • build_extensions: Specifies the input and output file extensions.
    • auto_apply: Enables the builder automatically for dependents.
    • required_inputs: ensures that the generator is run only when .dart files are present

Step 4: Apply Annotations

Back in your main project, use the @GenerateDataClass annotation to mark the classes for which you want to generate data classes.

In lib/src/my_data_class.dart:

import 'package:flutter_code_generation_example/src/annotations.dart'; // Replace with your project path

@GenerateDataClass()
class MyDataClass {
  final String name;
  final int age;
  final String? address;

  MyDataClass({required this.name, required this.age, this.address});
}

Step 5: Run the Code Generator

Run the following command in your Flutter project’s root directory:

flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs

Explanation:

  • flutter pub get: Gets all the necessary dependencies.
  • flutter pub run build_runner build: Runs the code generator.
  • --delete-conflicting-outputs: Deletes any previously generated files to avoid conflicts.

After running this command, you should see a new file lib/src/my_data_class.generated.dart (or similar) with the generated code.


// GENERATED CODE - DO NOT MODIFY BY HAND

class MyDataClassGenerated {
  MyDataClassGenerated();

  MyDataClass copyWith({name, age, address}) {
    return MyDataClass(
      name: name ?? this.name,
      age: age ?? this.age,
      address: address ?? this.address,
    );
  }

  @override
  bool operator ==(Object other) =>
      other is MyDataClass &&
      name.runtimeType == other.name.runtimeType &&
      name == other.name &&
      age.runtimeType == other.age.runtimeType &&
      age == other.age &&
      address == other.address;

  @override
  int get hashCode => Object.hashAll([name, age, address]);
}

Step 6: Use the Generated Code

Now you can use the generated code in your Flutter application.


import 'package:flutter/material.dart';
import 'package:flutter_code_generation_example/src/my_data_class.dart';
import 'package:flutter_code_generation_example/src/my_data_class.generated.dart'; //Import the generated file

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final data = MyDataClass(name: 'John', age: 30, address: '123 Main St');
    final updatedData = data.copyWith(age: 31);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Code Generation Example')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Original Data: ${data.name}, ${data.age}, ${data.address}'),
              Text('Updated Data: ${updatedData.name}, ${updatedData.age}, ${updatedData.address}'),
              Text('Data Equality: ${data == updatedData}'), //Compare the data classes.  Returns false
            ],
          ),
        ),
      ),
    );
  }
}

Step 7: Keep Generated Files Updated

To automatically rebuild whenever changes are made, use the watch command:

flutter pub run build_runner watch --delete-conflicting-outputs

This command watches the file system and rebuilds the generated code whenever necessary.

Advanced Use Cases

Customizing Code Generation

You can pass parameters to your annotations to customize the code generation process. Modify your annotation class:

class GenerateDataClass {
  final String? suffix;

  const GenerateDataClass({this.suffix});
}

Update the generator to use the parameter:

class DataClassGenerator extends GeneratorForAnnotation {
  @override
  Future generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) async {
    //... (previous code)

    final suffix = annotation.read('suffix').stringValue ?? 'Generated'; //Get the suffix or default to 'Generated'
    final classDefinition = Class((b) {
      b
        ..name = '${className}${suffix}' // Generated class name
        ..methods.add(copyWithMethod); // Add the copyWith method
      //...
    });

  //...
  }
}

And then annotate the class:

@GenerateDataClass(suffix: 'Data')
class MyDataClass {
  //...
}

Conclusion

Annotations and the build_runner package provide a powerful mechanism for automating code generation in Flutter. By reducing boilerplate and improving productivity, you can focus on writing cleaner, more maintainable code. This guide covers the basics of setting up annotations, creating a custom generator, and applying it to your Flutter project. With these tools, you can significantly enhance your development workflow.