Using Annotations and the build_runner Package in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, offers robust capabilities for code generation and metaprogramming through the use of annotations and the build_runner package. This approach can significantly reduce boilerplate code, improve maintainability, and enable more dynamic and flexible software design.

What are Annotations in Flutter?

Annotations, also known as metadata, are a form of syntactic metadata that can be added to your code to provide additional information about its properties. In Flutter and Dart, annotations are prefixed with an @ symbol and can be used to mark classes, methods, variables, or libraries.

Why Use Annotations and build_runner?

  • Code Generation: Automatically generate code based on annotations, reducing manual effort.
  • Maintainability: Improve code organization and reduce redundancy.
  • Extensibility: Easily extend the functionality of your code through automated processes.
  • Metaprogramming: Enable powerful metaprogramming capabilities for more dynamic and flexible applications.

How to Use Annotations and build_runner in Flutter

To effectively use annotations and build_runner, follow these steps:

Step 1: Add Dependencies

Add the necessary dependencies to your pubspec.yaml file. You will need:

  • The annotation package (e.g., json_annotation)
  • build_runner for generating code
  • The corresponding code generator (e.g., json_serializable)

dependencies:
  flutter:
    sdk: flutter
  json_annotation: ^4.8.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.6
  json_serializable: ^6.7.1

Step 2: Define Annotations

Create or use predefined annotations in your Dart code. For example, using json_annotation to define a class that can be serialized to and from JSON:


import 'package:json_annotation/json_annotation.dart';

part 'person.g.dart';

@JsonSerializable()
class Person {
  final String firstName;
  final String lastName;
  final int age;

  Person({required this.firstName, required this.lastName, required this.age});

  factory Person.fromJson(Map json) => _$PersonFromJson(json);

  Map toJson() => _$PersonToJson(this);
}

In this example:

  • The @JsonSerializable() annotation marks the Person class for JSON serialization.
  • The part 'person.g.dart'; directive tells Dart to include the generated file.

Step 3: Run build_runner

Execute the build_runner to generate the necessary code. Run the following command in your terminal:


flutter pub run build_runner build

Or, to continuously watch for changes and rebuild:


flutter pub run build_runner watch

This command generates the person.g.dart file, which includes the JSON serialization logic:


// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'person.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Person _$PersonFromJson(Map<String, dynamic> json) => Person(
      firstName: json['firstName'] as String,
      lastName: json['lastName'] as String,
      age: json['age'] as int,
    );

Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
      'firstName': instance.firstName,
      'lastName': instance.lastName,
      'age': instance.age,
    };

Step 4: Use the Generated Code

Now you can use the generated fromJson and toJson methods to serialize and deserialize Person objects:


import 'dart:convert';
import 'person.dart';

void main() {
  final person = Person(firstName: 'John', lastName: 'Doe', age: 30);

  // Serialize to JSON
  final json = person.toJson();
  print('JSON: ${jsonEncode(json)}');

  // Deserialize from JSON
  final decodedPerson = Person.fromJson(json);
  print('Decoded Person: ${decodedPerson.firstName} ${decodedPerson.lastName}, ${decodedPerson.age} years old');
}

Example: Custom Annotations and Code Generation

You can also create custom annotations to generate code tailored to your specific needs.

Step 1: Define Custom Annotation

Create a custom annotation. For example, @MyCustomAnnotation:


class MyCustomAnnotation {
  const MyCustomAnnotation();
}

Step 2: Create an Analyzer Plugin

To process custom annotations, you need to create an analyzer plugin using Dart’s analyzer package. This plugin will analyze your code and generate the desired output.

First, add the analyzer and build dependencies:


dependencies:
  analyzer: ^6.3.0

dev_dependencies:
  build_runner: ^2.4.6
  build_config: ^2.2.0

Step 3: Implement the Analyzer Plugin

Create a builder that looks for the @MyCustomAnnotation and generates corresponding code.


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

class MyCustomGenerator extends Generator {
  @override
  Future<String> generate(LibraryElement library, BuildStep buildStep) async {
    final buffer = StringBuffer();

    for (final element in library.topLevelElements) {
      if (element is ClassElement && element.metadata.any((annotation) => annotation.element?.name == 'MyCustomAnnotation')) {
        final className = element.name;
        buffer.writeln('// Code generated for class: $className');
        buffer.writeln('class ${className}Generated {');
        buffer.writeln('  void generatedMethod() {');
        buffer.writeln('    print("Generated method for $className");');
        buffer.writeln('  }');
        buffer.writeln('}');
      }
    }

    return buffer.toString();
  }
}

Builder myCustomBuilder(BuilderOptions options) => LibraryBuilder(
    MyCustomGenerator(),
    generatedExtension: '.custom.dart',
);

Step 4: Configure build.yaml

Add a build.yaml file in your project to configure the build_runner to use your custom builder.


builders:
  my_custom_builder:
    target: ":my_library"  # Replace with your library name
    sources:
      - "lib/**.dart"
    builder_factories: ["myCustomBuilder"]
    import: "package:your_package_name/builder.dart"  # Replace with the path to your builder
    build_to: source
    auto_apply: dependents

Step 5: Apply the Custom Annotation

Apply the @MyCustomAnnotation to your class:


import 'package:your_package_name/my_custom_annotation.dart'; // Replace with your package

part 'my_class.custom.dart';

@MyCustomAnnotation()
class MyClass {
  // Class content
}

Step 6: Run build_runner

Execute build_runner:


flutter pub run build_runner build

Step 7: Use the Generated Code

Use the generated code in your application:


import 'my_class.dart';
import 'my_class.custom.dart';

void main() {
  final myClass = MyClass();
  final myClassGenerated = MyClassGenerated();
  myClassGenerated.generatedMethod(); // Prints: Generated method for MyClass
}

Conclusion

Using annotations and the build_runner package in Flutter provides a powerful way to generate code, reduce boilerplate, and enhance maintainability. Whether you’re working with JSON serialization or creating custom code generation solutions, understanding how to leverage these tools can significantly improve your development workflow and the overall quality of your applications.