Getting to know build_runner — The Consequences of Convenience
build_runner
is a tool provided by the Dart ecosystem for generating code in a Dart or Flutter project. It automates the process of generating code based on annotations or configuration files. This generated code can be used to improve performance, reduce boilerplate code, or facilitate certain tasks like serialization or dependency injection.
A few packages that use build_runner
to generate code are:
In the realm of Flutter development, efficiency is paramount. Tools like build_runner
play a pivotal role in streamlining the development process, automating repetitive tasks, and enhancing developer productivity. But have you ever wondered how build_runner
actually works its magic behind the scenes?
At its core, build_runner
operates by analyzing every nook and cranny of your Flutter project, armed with a sophisticated understanding of Abstract Syntax Trees (ASTs). Let's peel back the curtain and delve into the inner workings of this essential tool.
When you invoke build_runner
, it journeys through every file within your project, parsing each one. Parsing involves dissecting the raw source code and constructing an Abstract Syntax Tree (AST), a structured representation that captures the syntactic essence of the code. This AST serves as a blueprint of the code’s structure, breaking it down into manageable components such as classes, functions, and variables.
Armed with ASTs, build_runner
begins on a systematic traversal of the tree, meticulously exploring each node. This traversal is akin to navigating a labyrinth, as it inspects every aspect of the code's structure and content. During this traversal, build_runner
keeps an eye out for special markers known as annotations or metadata. Annotations, denoted by symbols like @
, provide insights into how the code should be processed. Whether it's @JsonSerializable()
hinting at serialization requirements or @Injectable()
signaling the need for dependency injection, these annotations serve as beacons guiding build_runner
on its path.
“Why does
build_runner
seem to perform quickly initially, but then takes forever as the project scales?"
— Pretty much everyone
As you can tell, build_runner
has a lot of work to do. The larger the project, the larger volume of code it has to analyze, leading to increased complexity and longer processing times. Factors such as the number of files, complexity of annotations, and the intricacy of code dependencies all contribute to the slowdown.
Let’s learn how we can play nice with build_runner, shall we?
Generate For Explicit Paths
By specifying paths to include (and exclude), build_runner
automatically excludes all other directories. This approach reduces the amount of code analysis required and minimizes the number of directories build_runner
needs to traverse. As a result, the build process becomes more focused and efficient, leading to faster build times and improved overall performance.
To instruct build_runner
to operate on specific paths within your project, you'll utilize the build.yaml
configuration file. Once your build.yaml
file is configured, build_runner
will now adhere to the specified paths, disregarding any other files or directories.
targets:
$default:
builders:
json_serializable:
enabled: true
generate_for:
# "include:" can be omitted
- lib/domain/*.dart
# or
include:
- lib/domain/*.dart
exclude:
- lib/domain/some_file_to_exclude.dart
You can read more about these configurations in the build.yaml config docs.
Avoid Nested Files
The biggest factor I’ve found to slowness is nested files. Nested files can significantly slow down build_runner
due to the increased complexity and traversal required to analyze them. When files are deeply nested within multiple directories, build_runner
must traverse through numerous levels of directory structures to locate and process each file. This additional traversal adds overhead to the analysis process, leading to longer build times.
❌
lib
└── blocs
├── auth
│ ├── auth_bloc.dart
│ ├── auth_state.dart
│ └── auth_event.dart
└── user
├── user_bloc.dart
├── user_state.dart
└── user_event.dart
✅
lib
└── blocs
├── auth_bloc.dart
├── auth_state.dart
├── auth_event.dart
├── user_bloc.dart
├── user_state.dart
└── user_event.dart
If you wanna make your IDE (VS Code) look a little nicer, by making the list of files shorter, you can add the following setting to make your *_bloc.dart
file act like a folder, which will nest the *_state.dart
and *_event.dart
files.
{
"explorer.fileNesting.patterns": {
"*_bloc.dart": "${capture}_event.dart, ${capture}_state.dart, ${capture}_bloc*.dart"
}
}
Avoid Barrel Imports
Barrel files serve as both a blessing and a curse. While they effectively reduce the number of imports required within a file, they can significantly impede the performance of build_runner
, particularly during watch mode. Although the initial watch and build process are identical, watch mode dynamically tracks file changes and re-generates only affected files. This begs the question: How does build_runner
discern which files are affected? The answer lies in the import statements.
Consider a simple folder structure, where each file corresponds to an entity (The .g.dart
files are omitted):
lib
└── models
├── user.dart # imports address.dart
├── address.dart
├── location.dart # imports location_type.dart
├── location_type.dart
└── models.dart # barrel file
During build_runner
watch, imagine a change is made in location_type.dart
. Consequently, build_runner
regenerates location.dart
because it depends on location_type.dart
.
One might be tempted to optimize by adding the import ./models/models.dart
into user.dart
and location.dart
. This seemingly convenient shortcut saves a few lines of code and eliminates the need for future import statements. If a change occurs in location_type.dart
, build_runner
interprets the barrel file import as a signal to regenerate all files exported from models.dart
. This can lead to significant delays and frustration for developers as build_runner
churns through unnecessary regenerations.
Stop Generating So Much
Imagine you’re utilizing code generators like freezed, json_serializable, and copy_with_extension for a simple, single-membered class. How much of the generated code is actually needed? While these tools are undoubtedly powerful and can automate tedious tasks, such as serialization and copy methods, their usage should be approached with caution.
Firstly, consider whether the generated code is truly necessary for your project. If the generated code isn’t actively utilized in your application, it adds unnecessary bloat to your compiled project. This excess code not only increases the size of your application but can also impact the performance of your build process, slowing down build_runner and increasing development overhead.
Furthermore, each code generator introduces additional dependencies and complexity to your project. While these tools may solve specific problems efficiently, they also require maintenance and can complicate the debugging process if issues arise.
Instead of indiscriminately incorporating code generators, it’s essential to be intentional and selective about their usage. Focus on addressing existing pain points in your project rather than preemptively optimizing for hypothetical future scenarios. By adopting this approach, you can maintain a leaner, more efficient codebase while minimizing unnecessary overhead and ensuring optimal performance throughout your development workflow.
It’s clear that a proactive approach can go a long way in mitigating slowdowns. By leveraging strategies such as specifying explicit paths for build_runner
to operate on, simplifying directory structures to avoid nested files, and carefully considering the usage of code generators, developers can minimize unnecessary overhead. While build_runner
may present challenges, it ultimately remains a valuable ally in the world of development.
Okay, but its magic!
Ah, the allure of code generation — it truly does seem like magic. I’ve found myself oscillating between two extremes: staunchly avoiding code generation altogether and gleefully embracing its powers. But here’s the thing: the key to dispelling the magic lies in understanding its inner workings.
If you find yourself mystified by the code generation process, it might be a sign that you’re not quite ready to incorporate it into your workflow. After all, blindly relying on tools you don’t fully comprehend can lead to unforeseen complications down the road.
For me, the tipping point for adopting code generation is when I encounter a specific problem that it can elegantly solve. Take JSON serialization, for instance. If you’ve ever grappled with the tedium and error-prone nature of manual serialization, the allure of an automated solution is irresistible. However, even in these cases, it’s crucial to have a solid understanding of how code generation works and what it’s doing behind the scenes.
So, before succumbing to the allure of code generation, take a moment to assess whether it’s the right solution for your current needs. By approaching it with a clear understanding and purpose, you can harness its powers effectively while avoiding the pitfalls of blind reliance on magic.
Let’s celebrate build_runner
as our trusted ally in the world of development. With these strategies in our arsenal, we can optimize our projects and make build_runner
work for us. Here's to embracing build_runner
and its role in our coding journey!