As a software developer, you may encounter situations like writing code repeatedly for standard tasks. Imagine creating an application that needs integration with various APIs, managing JSON data, or performing tests. These tasks usually involve writing a lot of boilerplate code, which is time-consuming and increases the chance of mistakes.
This can be very frustrating, especially when deadlines are approaching or when you are working on a complex project with many dependencies. Instead of focusing on innovation and solving more creative problems, you get stuck in a seemingly endless routine of writing code. This wastes valuable time and reduces your enthusiasm as a developer.
This is where the importance of using code generators comes in. By utilizing code generation, you can reduce your workload in application development. You can automate repetitive processes, allowing you to focus on more strategic aspects and solve project-related problems. How can you use this in Flutter projects? Let's find out!
The Dart ecosystem provides developers with a variety of powerful tools, one of which is code generation techniques. Yes, that's right! As the name suggests, this tool helps programmers generate code so that we don't need to write the whole code, but only the parts we need.
Code Generation with build_runner
Creating applications with Flutter often involves many common tasks, such as:
- Deserializing JSON
- Using backend APIs
- Managing navigation
- Writing tests
Typically, completing these tasks involves writing repetitive code, which can be time-consuming and error-prone. However, we can reduce the amount of code we need to write manually by using build_runner to generate the required code.
In simple terms, build_runner is a tool for generating output files from input files. We can create a code generator that works with this mechanism, which allows us to read input files, usually written in Dart code, and generate the corresponding output files.
Using this generator, we only need to provide a minimal configuration for the desired code. After the build process is complete, we will receive a valid Dart code.
Here are the steps to use build_runner:
- Add it as a development dependency in the pubspec.yaml file:
dev_dependencies:
build_runner: x.y.z
- Run the code generation command:
flutter pub run build_runner build
After that, build_runner will execute all the code generators added as dependencies and generate output based on the input.
Watch Mode and Handling Existing Files
build_runner also has a watch mode that monitors the file system and automatically updates the output files when the input files change. If files are considered existing and not generated by build_runner, a reminder will appear to delete those files.
The -d option (short for --delete-conflicting-outputs) allows build_runner to delete conflicting files before starting code generation, making the process easier.
For a more detailed explanation, you can check here: build_runner documentation.
Implementation
There are many packages we can use, but this time we will look at some of the most popular ones. Some of these packages work with the build_runner tool. They make app development easier, so developers can focus more on what they want to create instead of how to create it. Here are some commonly used packages:
json_serializable
This package makes it easy to turn JSON data into Dart objects and vice versa.
By using these packages together, we can work faster and more efficiently when building Flutter apps.
For example, we can create an app that shows data from a JSON file:
"id": 1,
"user": {
"name": "Asep Saepul",
"avatar": "https://images.pexels.com/photos/27659838/pexels-photo-27659838/free-photo-of-portrait-of-a-man-on-a-white-background.jpeg?auto=compress&cs=tinysrgb&w=800",
"place": "Bandung, Indonesia"
},
"content": {
"isLike": false,
"image": "https://images.pexels.com/photos/3386600/pexels-photo-3386600.jpeg?auto=compress&cs=tinysrgb&w=600",
"likes": "1.000 likes",
"description": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor."
}
},
To be used further in the application and take advantage of the typed nature of Dart, this data must be transformed into an instance of a Dart class:
class Feed {
final int id;
final User user;
Content content;
Feed({
required this.id,
required this.user,
required this.content,
});
}
class Content {
final String image;
late final String likes;
final String description;
bool isLike;
Content({
required this.image,
required this.likes,
required this.description,
required this.isLike,
});
}
class User {
final String name;
final String avatar;
final String place;
User({
required this.name,
required this.avatar,
required this.place,
});
}
To implement JSON deserialization in the fromJson()
and toJson()
methods within a class to allow the following usage:
class Feed {
final int id;
final User user;
Content content;
Feed({
required this.id,
required this.user,
required this.content,
});
// Method to convert Feed object to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'user': user.toJson(),
'content': content.toJson(),
};
}
// Method to create Feed object from JSON
factory Feed.fromJson(Map<String, dynamic> json) {
return Feed(
id: json['id'],
user: User.fromJson(json['user']),
content: Content.fromJson(json['content']),
);
}
}
class Content {
final String image;
late final String likes;
final String description;
bool isLike;
Content({
required this.image,
required this.likes,
required this.description,
required this.isLike,
});
// Method to convert Content object to JSON
Map<String, dynamic> toJson() {
return {
'image': image,
'likes': likes,
'description': description,
'isLike': isLike,
};
}
// Method to create Content object from JSON
factory Content.fromJson(Map<String, dynamic> json) {
return Content(
image: json['image'],
likes: json['likes'],
description: json['description'],
isLike: json['isLike'],
);
}
}
class User {
final String name;
final String avatar;
final String place;
User({
required this.name,
required this.avatar,
required this.place,
});
// Method to convert User object to JSON
Map<String, dynamic> toJson() {
return {
'name': name,
'avatar': avatar,
'place': place,
};
}
// Method to create User object from JSON
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'],
avatar: json['avatar'],
place: json['place'],
);
}
}
Here, the static method Feed.fromJson()
handles the parsing logic for the various field types in Feed, such as int and the custom classes User and Content, which also have User.fromJson()
and Content.fromJson()
factories. Additionally, the example above also includes the Feed.toJson() method that performs the inverse transformation.
The json_serializable package allows for the same result with much less code. It can generate methods like Feed.fromJson()
and Feed.toJson()
and provides a mechanism to perform all the formatting adjustments as in the manual implementation above.
To take advantage of code generation for JSON deserialization, add the following dependency in your pubspec.yaml file:
dependencies:
json_annotation: x.y.z
dev_dependencies:
build_runner: x.y.z
json_serializable: x.y.z
Then an example of implementation by creating a Feed class as follows:
part feed.g.dart';
@JsonSerializable(explicitToJson: true, includeIfNull: false)
class Feed with _$Feed {
const factory Feed({
required int id,
required User user,
required Content content,
}) = _Feed;
factory Feed.fromJson(Map<String, dynamic> json) => _$FeedFromJson(json);
Map<String, dynamic> toJson() => _$FeedToJson(this);
}
The @JsonSerializable()
annotation above the Feed class indicates that this class is subject to code generation, and the declaration of the feed.g.dart section file ensures that the generated code is in the same feed.dart. Once the code is generated, a new feed.g.dart file is added with the private methods _$FeedFromJson()
and _$FeedToJson()
, allowing the same results as the previous manual implementation.
Using code generation significantly reduces the amount of code that needs to be written compared to a manual JSON deserialization implementation. Additionally, every time the JSON format or Dart class structure is updated, a manual implementation requires changes in several places, which are easy to miss until a serialization exception is raised at runtime. With code generation, we can ensure that all necessary changes are applied automatically.
This guide only shows the basic usage of the json_serializable package. Check the official documentation for more information.
Enhance Classes with Freezed
There are several operations that developers perform on Dart class instances explicitly or implicitly:
- value-based comparisons
- hash code calculations
- using string representations
- making copies with modifications
The implementations of some of these operations, such as the ==() operator, hashCode, and toString(), are already built into the language but do not provide the best development experience and therefore require improvement, such as the copyWith()
method, must be written from scratch.
Let’s consider the Feed class from the example above as an example. Two instances with the same field value are not equal, and their hash values are also different:
final feed1 = Feed(
id: 1,
user: User(
name: 'John Dhoe',
avatar:
'https://images.png’,
place: 'Bandung, Indonesia',
),
content: Content(
isLike: false,
image:
'https://images.png’,
likes: '1.000 likes',
description:
'Lorem ipsum dolor sit amet',
),
),
final feed2 = Feed(
id: 1,
user: User(
name: 'John Dhoe',
avatar:
'https://images.png’,
place: 'Bandung, Indonesia',
),
content: Content(
isLike: false,
image:
'https://images.png’,
likes: '1.000 likes',
description:
'Lorem ipsum dolor sit amet',
),
),
assert(feed1 == feed2); // false
assert(feed1.hashCode == feed2.hashCode); // false
This can cause problems when Feed instances are added to a Set, used as Map keys, or verified in tests.
At the same time, the string representations of two instances with different field values are the same and return an Instance of Feed:
assert(feed1.toString() == feed2.toString()); // true
To enhance the Feed class, we need to provide implementations of the following methods:
// Override == operator
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Feed &&
runtimeType == other.runtimeType &&
id == other.id &&
user == other.user &&
content == other.content;
// Override hashCode
@override
int get hashCode =>
id.hashCode ^ user.hashCode ^ content.hashCode;
But the problem doesn't stop there, because the user == other.user
check will likely fail most of the time even if both collections are empty or contain the same objects. To compare collections based on their contents, it should be replaced with:
const DeepCollectionEquality().equals(user, other.user)
The Freezed package allows us to not worry about this situation. To take advantage of Freezed in code generation to enhance Dart classes, add this dependency in the pubspec.yaml file:
dependencies:
freezed_annotation: x.y.z
dev_dependencies:
build_runner: x.y.z
freezed: x.y.z
And here is an example of its implementation:
@freezed
class Feed with _$Feed {
const factory Feed({
required int id,
required User user,
required Content content,
}) = _Feed;
factory Feed.fromJson(Map<String, dynamic> json) => _$FeedFromJson(json);
}
@freezed
class Content with _$Content {
const factory Content({
required String image,
required String likes,
required String description,
required bool isLike,
}) = _Content;
factory Content.fromJson(Map<String, dynamic> json) => _$ContentFromJson(json);
}
@freezed
class User with _$User {
const factory User({
required String name,
required String avatar,
required String place,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
The @freezed
annotation above the Feed class indicates that it is subject to code generation, and the declaration of the 'feed.freezed.dart' file section ensures that the generated code is part of the same as feed.dart.
Check out the Feed, Content, and User classes. You can check from the examples repository for the complete implementations. Once the code is generated, a new file feed.freezed.dart is added with implementations of the ==()
, hashCode
, toString()
, and copyWith()
operator methods, allowing for the same results as the manual implementation above.
As with JSON deserialization, using code generation to enhance Dart classes reduces the effort of creating and maintaining them, ensuring that any necessary changes are automatically applied when the class structure changes.
This guide only shows the basic usage of the freezed package. It has many useful features and can also be easily combined with json_serializable. Check the official documentation for more information.
There are still many packages that you can use for code generation, such as for RESTful API Consumption needs you can use Retrofit, Flutter Dependency Injection with Injectable, Documentation and Tests with mockito, and many more. Hopefully, in the next article, we can try to learn each of these packages to compare and learn how to implement them in our projects.
But for now, I think it's enough to know examples of using json_serializable and freezed as a first step to finding out the benefits of codegen and how we use it in a flutter project.
Drawback
Okay, we already know how many benefits we can get from code generators. But keep in mind, we often have to look at things from both sides. Yes, codegen also has disadvantages that we need to consider before using it. In my Flutter project, I found some things when using codegen:
- Time-consuming: Basic code generation can be time-consuming for complex data structures or requirements.
- Initial setup: Code generation requires some initial setup.
- Slows down the build process: The generated code can slow the build process.
- Clutters the project: The generated code can clutter your project with extra files.
- Requires team members to run the codegen step: If you don't commit the generated files to git, every team member must remember to run the codegen step.
- Requires a custom CI build step: If you don't commit the generated files to git, a custom CI build step is required to build the app.
Source Code
A simple Flutter demo project using Freezed package is available on GitHub.🚀
Conclusion
Code generation is a powerful tool that can make us more productive by facilitating many repetitive or typical tasks in Flutter application development.
In addition, those packages not only speed up the development process but also maintain the consistency and accuracy of the generated code. By utilizing these tools, developers can streamline their workflows, improve project maintenance, and ensure that structural changes in the code are automatically updated, which is an important step in maintaining the quality and sustainability of the application in the future.
However, besides the many benefits of using code generation, there are also things to consider. For example, parts of the code that have changed and impacted other parts must be re-generated, which may take more time than changing it manually. So, it is adjusted again to the needs of the project you are working on. If you have tried it, don't hesitate to share your experience in the comments; hopefully, it's useful!
References
https://docs.flutter.dev/
https://pub.dev/packages/build_runner
https://pub.dev/packages/freezed
Top comments (0)