Annotations (@) và Code Generation trong Dart
Trong ngôn ngữ Dart, Annotations (hay còn gọi là metadata) là một cách để đánh dấu hoặc cung cấp thông tin thêm cho code của bạn (ví dụ như class, phương thức, thư viện, hoặc biến). Annotations luôn bắt đầu bằng ký tự @.
Nếu bạn từng code Java, Kotlin (với Retrofit @Get(), @Post()) hay TypeScript, bạn sẽ thấy khái niệm này rất quen thuộc.
Dart cung cấp sẵn một số annotations tích hợp như @override, @deprecated, nhưng sức mạnh thực sự của nó nằm ở việc tạo ra các Custom Annotations kết hợp với Code Generation (sinh mã tự động), rất phổ biến trong các thư viện Flutter như json_serializable, freezed, hay retrofit.
1. Annotations cơ bản tích hợp sẵn
Hai annotations phổ biến nhất được tích hợp sẵn trong Dart:
@override: Cho trình biên dịch biết rằng phương thức này đang ghi đè một phương thức từ lớp cha. Nó giúp tránh lỗi typo (đánh máy sai) tên phương thức.@deprecated(hoặc@Deprecated('lý do')): Đánh dấu một đoạn code đã cũ và không nên dùng nữa, trình biên dịch sẽ cảnh báo (warning) nếu ai đó cố tình gọi nó.
class Animal {
void makeSound() {
print("Animal sound");
}
@deprecated
void oldMethod() {
print("Don't use this anymore");
}
}
class Dog extends Animal {
@override
void makeSound() {
print("Woof");
}
}2. Tạo Custom Annotation của riêng bạn
Trong Dart, mọi Annotation đơn giản chỉ là một object (đối tượng) mang trạng thái const (hằng số lúc biên dịch). Bạn có thể tạo nó bằng cách khai báo một lớp với hằng số constructor (const constructor).
// Định nghĩa một annotation
class Todo {
final String who;
final String what;
// Bắt buộc phải là const constructor
const Todo(this.who, this.what);
}
// Định nghĩa một annotation không chứa tham số (như @JsonSerializable)
class Route {
const Route();
}
const route = Route(); // Có thể khởi tạo sẵn một hằng số
// Sử dụng
@Todo('Bumbii', 'Cần tối ưu hàm này vào ngày mai')
void doSomething() {
print('Làm gì đó...');
}
@route // Dùng hằng số đã khởi tạo
class HomePage {}Bạn đã đặt thành công annotation lên code của mình. Nhưng tại sao chạy thử lại không có gì xảy ra? Đó là vì annotations tự nó không làm gì cả, nó chỉ đóng vai trò như một thẻ định danh (label/metadata). Bạn cần một công cụ nào đó để phân tích thông tin này và thực hiện hành động tương ứng.
3. Tại sao Dart/Flutter lại dùng Code Generation? (Vấn đề của Reflection)
Ở các ngôn ngữ như Java/C#, người ta dùng Reflection để đọc annotation trong lúc ứng dụng đang chạy (runtime). Dart cũng có thư viện reflection là dart:mirrors.
Tuy nhiên, Flutter vô hiệu hóa (disable) dart:mirrors. Nguyên nhân chính là vì Reflection cản trở quá trình tối ưu hóa code và Tree Shaking (loại bỏ code thừa). Nếu bật Reflection, trình biên dịch không thể biết chắc class/hàm nào có thể bị gọi bằng Reflection, nên nó phải giữ lại toàn bộ code -> dung lượng ứng dụng sau khi build qua lớn và chạy chậm.
Vậy nếu không dùng Reflection lúc runtime, làm sao chúng ta đọc được @Todo hay @JsonKey?
Giải pháp của Dart: Đọc nó ngay lúc viết code (compile-time) và Sinh ra code mới (Code Generation) tự động nhờ build_runner.
Đó là lý do bạn thường xuyên thấy các thư viện Flutter yêu cầu chạy dòng lệnh:
dart run build_runner build
# hoặc
flutter pub run build_runner build4. Ví dụ thực tế từ A đến Z: json_serializable
Hãy cùng xem Annotations làm việc như thế nào dựa trên thư viện cực kỳ nổi tiếng là json_serializable. Thư viện này giúp tự động tạo code chuyển đổi từ JSON thành Object Dart.
Bước 4.1: Cấu hình dependencies
Trong file pubspec.yaml, bạn cần khai báo các gói thư viện cần thiết:
json_annotation: Chứa các từ khóa (annotations) như@JsonSerializable,@JsonKey.build_runnervàjson_serializable: Là công cụ để đọc annotation ở file code của bạn và sinh mã (nằm ởdev_dependenciesvì sau khi tạo code xong thì ứng dụng khi chạy sẽ không phụ thuộc trực tiếp vào các package này nữa).
dependencies:
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.6
json_serializable: ^6.7.1Chạy dart pub get (hoặc flutter pub get).
Bước 4.2: Viết code với Annotations
Tạo file user.dart:
// user.dart
import 'package:json_annotation/json_annotation.dart';
// 1. Phải thêm dòng part 'tên_file.g.dart'
part 'user.g.dart';
// 2. Đánh dấu class này là cần sinh code map với JSON
@JsonSerializable()
class User {
final String name;
// 3. Sử dụng @JsonKey để xử lý trường hợp tên biến Dart
// khác với key trong chuỗi JSON (JSON dùng is_active, Dart dùng isActive)
@JsonKey(name: 'is_active')
final bool isActive;
User(this.name, this.isActive);
// 4. Khai báo 2 phương thức mặc định của thư viện sẽ tự sinh ra ở file .g.dart
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}Lúc này, trình soạn thảo của bạn (VS Code/Android Studio) có thể sẽ báo lỗi (Error) ở file này. Đừng lo lắng, nguyên nhân là do file user.g.dart và hàm _$UserFromJson chưa tồn tại.
Bước 4.3: Chạy Code Generation với build_runner
Mở Terminal và chạy lệnh:
dart run build_runner build(Nếu dùng trong dự án Flutter thì gõ flutter pub run build_runner build)
Điều gì xảy ra ẩn bên dưới khi chạy lệnh?
build_runnersẽ quét toàn bộ dự án của bạn.- Nó tìm thấy file
user.dartcó khai báopart 'user.g.dart'. - Phân tích file (công việc của thư viện
analyzer), công cụ này nhận diện classUsercó mang annotation@JsonSerializable. - Khi phân tích cấu trúc class, nó xác định biến
isActivechứa metadata@JsonKey(name: 'is_active'). - Nó đưa những thông tin này cho package
json_serializable. Package này sẽ dùng thông tin đó viết thành code Dart thuần túy và ghi vào fileuser.g.dart.
Bước 4.4: Chiêm ngưỡng kết quả (Code sinh ra)
Nhìn vào thư mục cùng cấp của file user.dart, bạn sẽ thấy file mới sinh ra user.g.dart:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
User _$UserFromJson(Map<String, dynamic> json) => User(
json['name'] as String,
json['is_active'] as bool, // Nó nhớ key JSON lấy từ @JsonKey
);
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'name': instance.name,
'is_active': instance.isActive,
};File này đã định nghĩa nội dung thực sự của 2 hàm mà hồi nãy chúng ta viết. Bây giờ lỗi ở file user.dart sẽ biến mất.
Bước 4.5: Sử dụng sau khi build xong
Giờ đây bạn sử dụng đối tượng User y như mọi code Dart bình thường khác:
import 'user.dart';
void main() {
String jsonStr = '{"name": "John Doe", "is_active": true}';
Map<String, dynamic> jsonMap = jsonDecode(jsonStr);
// Dùng hàm khởi tạo tự sinh
User user = User.fromJson(jsonMap);
print("User: ${user.name}, Active: ${user.isActive}");
}5. Tổng kết quy trình Annotations trong Dart
- Định nghĩa: Dùng Class có
constant constructor(@MyAnnotation). - Khai báo: Đặt nó lên class, thuộc tính hoặc method.
- Quét và Phân tích: Công cụ
build_runnercùng các thư viện generator sẽ phân tích những vị trí có gán annotation trong quá trình compile-time. - Sinh mã: Nó sẽ tạo file có đuôi
.g.dart(gviết tắt chogenerated) hoặc.freezed.dart. - Thực thi: Import file
.g.dartvào chính file code và sử dụng như code do chính tay mình viết.
Lưu ý: Nếu code trong class User thay đổi (thêm biến, xóa biến…), bạn bắt buộc phải chạy lại lệnh dart run build_runner build để file .g.dart được sinh (update) lại. Bạn cũng có thể dùng cờ --delete-conflicting-outputs để dọn dẹp file rác cũ.
dart run build_runner build --delete-conflicting-outputsTrong quá trình phát triển (development), nếu không muốn gõ lệnh build liên tục, có thể chuyển từ lệnh build sang watch. Trình build runner sẽ chạy ngầm, mỗi khi bạn lưu (Save) file thì nó tự động chạy sinh code lại ngay lập tức: dart run build_runner watch