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
GeneratorForAnnotationand specifies theGenerateDataClassannotation. - Checks if the annotated element is a class.
- Extracts fields from the class.
- Generates a
copyWithmethod that allows easy modification of class instances. - Generates overridden
==operator. - Generates overridden
hashCodemethod.
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.