Creating Custom Code Generation Scripts in Flutter

Flutter’s ecosystem is rich with tools and techniques to enhance productivity and maintainability. One powerful feature is code generation, which allows you to automate the creation of repetitive or boilerplate code. By creating custom code generation scripts, you can streamline your development process, reduce errors, and enforce consistency across your Flutter projects.

What is Code Generation?

Code generation is the process of automatically creating source code based on a defined template or schema. It’s particularly useful for reducing boilerplate, ensuring consistency, and automating repetitive tasks. In Flutter, code generation is often used to create serializers, data models, and other infrastructure code.

Why Use Custom Code Generation Scripts?

  • Reduce Boilerplate: Automatically generate repetitive code, saving development time.
  • Ensure Consistency: Enforce coding standards and patterns across the project.
  • Improve Maintainability: Update code generation templates to propagate changes across multiple files.
  • Automate Complex Tasks: Handle complex code transformations or generation processes.

How to Create Custom Code Generation Scripts in Flutter

Creating custom code generation scripts in Flutter typically involves using tools like build_runner, analyzer, and custom scripts. Here’s a step-by-step guide:

Step 1: Set Up Your Flutter Project

Create a new Flutter project or navigate to an existing one.

flutter create my_code_gen_app
cd my_code_gen_app

Step 2: Add Dependencies

Add the necessary dependencies to your pubspec.yaml file:

dependencies:
  build_runner: ^2.4.6
  analyzer: ^6.3.0
  source_gen: ^1.4.1
  build: ^2.4.1
dev_dependencies:
  build_verify: ^3.1.0

Explanation of Dependencies:

  • build_runner: A tool for running code generators during development.
  • analyzer: Provides APIs for static analysis of Dart code.
  • source_gen: Provides utilities for creating source code generators.
  • build: Base package for defining build systems and build steps
  • build_verify:Ensures that the generated code matches the expected output

Run flutter pub get to install the dependencies.

Step 3: Create a Custom Annotation

Define a custom annotation that will be used to mark the classes or members that should be processed by the code generator. Create a new file named my_annotation.dart in the lib directory:

class MyAnnotation {
  const MyAnnotation();
}

const myAnnotation = MyAnnotation();

Step 4: Implement a Custom Generator

Create a custom generator that will read the annotated elements and generate the desired code. Create a new file named my_generator.dart in the lib directory:

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

class MyGenerator extends GeneratorForAnnotation {
  @override
  generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    if (element is ClassElement) {
      final className = element.name;
      return '''
        class $${className}Helper {
          String getHelperText() => 'Hello from $${className}Helper for class $className';
        }
      ''';
    } else {
      throw UnsupportedError(
          'MyAnnotation can only be applied to classes. Failed on $element');
    }
  }
}

Explanation:

  • MyGenerator: Extends GeneratorForAnnotation to process elements annotated with MyAnnotation.
  • generateForAnnotatedElement: This method is called for each annotated element. It checks if the element is a class, extracts the class name, and generates a helper class.
  • The generated helper class ($${className}Helper) contains a simple method that returns a text message.

Step 5: Create a Builder Definition

Create a builder definition to integrate the custom generator with build_runner. Create a new file named build.yaml in the root of your project:

targets:
  $default:
    sources:
      - lib/**
    builders:
      my_code_gen_app:my_generator:
        options:
          some_option: true

Explanation:

  • targets: Defines which source files should be processed. lib/** includes all files in the lib directory.
  • builders: Defines the builder to use.
    • my_code_gen_app:my_generator: Refers to the builder in your project (my_code_gen_app is the package name).
    • options: Allows passing options to the generator.

Step 6: Apply the Annotation

Apply the @MyAnnotation annotation to the classes that you want to process with the code generator. Create or modify a Dart file in the lib directory (e.g., lib/my_class.dart):

import 'my_annotation.dart';

@myAnnotation
class MyClass {
  String myProperty = 'Hello';
}

Step 7: Run the Code Generator

Run the code generator using build_runner:

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

This command will:

  • Scan your project for files annotated with @MyAnnotation.
  • Run the MyGenerator to generate the corresponding helper classes.
  • Create a new file (e.g., lib/my_class.g.dart) containing the generated code.

Step 8: Use the Generated Code

Import and use the generated code in your Flutter application. In your lib/main.dart or any other file, add:

import 'package:flutter/material.dart';
import 'my_class.dart';
import 'my_class.g.dart'; // Import the generated file

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final myClass = MyClass();
    final helper = $MyClassHelper(); // Use the generated helper class

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Code Generation Example'),
        ),
        body: Center(
          child: Text(helper.getHelperText()), // Access the generated method
        ),
      ),
    );
  }
}

Step 9: Verify the generated code

This step validates the generated code after each build. This prevents the check-in of generated code that doesn’t match what you’d get from a clean build.

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

flutter pub run build_verify

Example build_verify Workflow Action

Alternatively, you can use it directly in a GitHub Actions workflow:

name: build_verify

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  build_verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dart-lang/setup-dart@v1
      - run: dart pub get
      - run: dart pub global activate build_runner
      - run: dart run build_runner build --delete-conflicting-outputs
      - run: dart pub global activate build_verify
      - run: dart run build_verify

Advanced Customizations

Here are some advanced techniques to make your custom code generation even more powerful:

1. Handle Multiple Annotations

You can support multiple annotations by creating separate generators or by handling different annotations within a single generator:

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'my_annotation.dart';
import 'another_annotation.dart';
class MyMultiGenerator extends Generator {
  @override
  Future generate(LibraryElement library, BuildStep buildStep) async {
    final buffer = StringBuffer();
    for (final element in library.topLevelElements) {
      if (element is ClassElement) {
        final myAnnotation = TypeChecker.fromRuntime(MyAnnotation)
            .firstAnnotationOf(element);
        if (myAnnotation != null) {
          final className = element.name;
          buffer.writeln('''
          class $${className}Helper {
            String getHelperText() => 'Hello from MyAnnotation for class $className';
          }
          ''');
        }
        final anotherAnnotation = TypeChecker.fromRuntime(AnotherAnnotation)
            .firstAnnotationOf(element);
        if (anotherAnnotation != null) {
          final className = element.name;
          buffer.writeln('''
          class $${className}AnotherHelper {
            String getAnotherHelperText() => 'Hello from AnotherAnnotation for class $className';
          }
          ''');
        }
      }
    }
    return buffer.toString();
  }
}

Update the build.yaml file to include the new Generator.

targets:
  $default:
    sources:
      - lib/**
    builders:
      my_code_gen_app:my_multi_generator:
        options:
          some_option: true

2. Pass Options to Generators

Pass custom options to your generator via the build.yaml file:

In build.yaml:

targets:
  $default:
    sources:
      - lib/**
    builders:
      my_code_gen_app:my_generator:
        options:
          helper_prefix: CustomHelper

In my_generator.dart:

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'my_annotation.dart';
class MyGenerator extends GeneratorForAnnotation {
  final String helperPrefix;
  MyGenerator(this.helperPrefix);
  @override
  generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    if (element is ClassElement) {
      final className = element.name;
      return '''
        class ${helperPrefix}$${className} {
          String getHelperText() => 'Hello from ${helperPrefix}$${className} for class $className';
        }
      ''';
    } else {
      throw UnsupportedError(
          'MyAnnotation can only be applied to classes. Failed on $element');
    }
  }
}

Add a new file named builder.dart in the lib directory to handle the option parameters for the Generator Class:

import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'my_generator.dart';
Builder myGenerator(BuilderOptions options) =>
    PartBuilder([MyGenerator(options.config['helper_prefix'] as String)], '.g.dart',
        header: '''
// GENERATED CODE - DO NOT MODIFY BY HAND
''');

3. Generate Complex Data Structures

Use the analyzer package to inspect the annotated classes and generate more complex data structures, such as JSON serializers:

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'my_annotation.dart';
class MyJsonGenerator extends GeneratorForAnnotation {
  @override
  generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    if (element is ClassElement) {
      final className = element.name;
      final fields = element.fields.where((field) => !field.isStatic).toList();
      final buffer = StringBuffer();
      buffer.writeln('Map $${className}ToJson(${className} instance) => {');
      for (final field in fields) {
        buffer.writeln('  '${field.name}': instance.${field.name},');
      }
      buffer.writeln('};');
      return buffer.toString();
    } else {
      throw UnsupportedError(
          'MyAnnotation can only be applied to classes. Failed on $element');
    }
  }
}

Conclusion

Creating custom code generation scripts in Flutter allows you to automate repetitive tasks, ensure consistency, and improve the maintainability of your projects. By leveraging tools like build_runner, analyzer, and source_gen, you can create powerful generators that streamline your development workflow. Whether you’re generating simple helper classes or complex data structures, custom code generation is a valuable technique for any Flutter developer.