DEV Community

Ge Ji
Ge Ji

Posted on

Dart Lesson 19: Metadata and reflection — code "self-description"

In previous lessons, we learned about mixins, mastering advanced techniques for flexible code reuse in object-oriented programming. Today we'll explore another powerful feature in Dart — metadata and reflection. They give code the ability to "self-describe" and "dynamically analyze," playing important roles in framework development, code generation, serialization, and other scenarios.

I. Metadata Annotations: Adding "Extra Information" to Code

Metadata is additional information embedded in code that describes the code itself. It doesn't directly affect code execution logic but can be read by compilers, tools, or runtime environments for code checking, generation, or dynamic processing.

In Dart, metadata is represented by annotations, which start with the @ symbol and are typically placed before code elements (classes, methods, variables, etc.).

1. Built-in Metadata Annotations

Dart provides several commonly used built-in annotations:

(1) @override: Marking method overrides

Explicitly indicates that a method overrides a parent class method, helping the compiler check for correct overriding (e.g., matching method names and parameters):

class Animal {
  void makeSound() => print("Animal sound");
}

class Dog extends Animal {
  // Explicitly mark method override
  @override
  void makeSound() => print("Woof woof");
}

void main() {
  Dog().makeSound(); // Output: Woof woof
}
Enter fullscreen mode Exit fullscreen mode

If you accidentally misspell the method name (like writing makeSound2), the compiler will show an error, preventing simple mistakes.

(2) @deprecated: Marking obsolete code

Marks code that is no longer recommended for use. The compiler will issue warnings where this code is used:

class Tool {
  // Mark this method as obsolete
  @deprecated
  void oldMethod() => print("This is an old method, deprecated");

  void newMethod() => print("This is the new method, recommended for use");
}

void main() {
  final tool = Tool();
  tool.oldMethod(); // Compile-time warning: 'oldMethod' is deprecated
  tool.newMethod(); // No warning
}
Enter fullscreen mode Exit fullscreen mode

You can usually add a description after the annotation to tell users about alternatives:

@deprecated
/// Recommended: use newMethod() instead
void oldMethod() => print("This is an old method, deprecated");
Enter fullscreen mode Exit fullscreen mode
(3) @pragma: Passing specific instructions to the compiler

Used to pass platform-specific instructions to the Dart compiler, such as disabling specific warnings:

// Disable "unused variable" warning
@pragma('vm:unused')
int unusedVariable = 10;

void main() {
  // Variable is unused but won't trigger a warning
}
Enter fullscreen mode Exit fullscreen mode

2. The Nature of Metadata: Special Class Instances

All annotations in Dart are essentially class instances. Built-in annotations like @override and @deprecated are actually predefined classes. We can understand this through source code:

// Simplified implementation of @deprecated
class deprecated {
  final String? message;
  const deprecated([this.message]);
}

// When used, @deprecated is equivalent to @deprecated()
@deprecated // Equivalent to @deprecated()
void oldMethod() {}
Enter fullscreen mode Exit fullscreen mode

This is why annotations can take parameters (like @deprecated("Use newMethod instead")).


II. Custom Annotations: Creating Your Own Metadata

In addition to built-in annotations, we can define our own annotations to describe business-specific information (such as serialization configurations, permission checks, etc.).

1. Defining Custom Annotations

Custom annotations are essentially classes with const constructors (since annotations need to have determined values at compile time):

// Define a "logging annotation": marks methods that need logging
class Logged {
  final bool printParams; // Whether to print parameters
  final bool printResult; // Whether to print return value

  // Must be a const constructor (annotations are parsed at compile time)
  const Logged({this.printParams = false, this.printResult = false});
}

// Define a "permission annotation": marks methods that require permission checks
class RequirePermission {
  final String permission;
  const RequirePermission(this.permission);
}
Enter fullscreen mode Exit fullscreen mode

2. Using Custom Annotations

Apply annotations to elements like classes, methods, and variables:

class UserService {
  // Use @Logged annotation, specifying to print parameters and return value
  @Logged(printParams: true, printResult: true)
  String getUserInfo(int id) {
    return "Information for user $id";
  }

  // Use @RequirePermission annotation, specifying "admin" permission is needed
  @RequirePermission("admin")
  void deleteUser(int id) {
    print("Deleting user $id");
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point, annotations are just "markers" without actual functionality. They need to work with annotation processors to take effect.

3. Compile-time Annotation Processing: Code Generation

The value of custom annotations is typically realized through compile-time code generation: during the code compilation phase, tools read annotation information and automatically generate auxiliary code (such as serialization logic, logging code, etc.).

Take the json_serializable package as an example (it essentially implements automatic JSON serialization code generation through the custom @JsonSerializable annotation):

  1. Define a model class and add the annotation:
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart'; // Generated code file

@JsonSerializable() // Custom annotation
class User {
  final String name;
  final int age;

  User({required this.name, required this.age});

  // Generated serialization methods
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}
Enter fullscreen mode Exit fullscreen mode

2 . Run the code generation command:

dart run build_runner build
Enter fullscreen mode Exit fullscreen mode

3 . The tool reads the @JsonSerializable annotation and automatically generates the user.g.dart file containing serialization logic.

The advantage of this "compile-time processing" is that it doesn't affect runtime performance (code is generated in advance) and supports environments like Flutter where reflection is disabled.


III. Reflection: Allowing Code to "Understand Itself"

Reflection refers to a program's ability to dynamically obtain its own structure (such as class, method, and annotation information) and manipulate it at runtime. Simply put, it's code that can "observe" and "modify" itself.

Reflection in Dart is mainly implemented through the dart:mirrors library, but this library is disabled in Flutter (as it increases package size and affects performance). In actual development, the reflectable package is more commonly used (a lightweight reflection library that supports compile-time configuration).

1. Basic Concepts of Reflection

Reflection can achieve the following:

  • Obtain information about class names, methods, properties, etc.
  • Dynamically create class instances
  • Dynamically call methods or modify property values
  • Read and process annotation information

2. Implementing Reflection with the reflectable Package

Since dart:mirrors isn't available in Flutter, we'll demonstrate reflection using the reflectable package.

(1) Adding Dependencies

Add to pubspec.yaml:

dependencies:
  reflectable: ^4.0.13

dev_dependencies:
  build_runner: ^2.4.4
Enter fullscreen mode Exit fullscreen mode
(2) Defining a Reflection Configuration Class

Create a class inheriting from Reflectable, specifying reflection capabilities needed (such as obtaining methods, reading annotations, etc.):

// reflector.dart
import 'package:reflectable/reflectable.dart';

// Configure reflection capabilities: support instantiation, method invocation, annotation reading
class MyReflector extends Reflectable {
  const MyReflector()
    : super(
        instanceInvokeCapability, // Allow calling instance methods
        typeAnnotationQuantifyCapability, // Allow reading type annotations
        declarationsCapability, // Allow obtaining class declarations (methods, properties, etc.)
      );
}

// Create reflector instance
const myReflector = MyReflector();
Enter fullscreen mode Exit fullscreen mode
(3) Marking Classes for Reflection

Add the @myReflector annotation to classes that need reflection:

import 'reflector.dart';

// Mark this class with the reflector to enable reflection
@myReflector
class UserService {
  @Logged(printParams: true, printResult: true)
  String getUserInfo(int id) {
    return "Information for user $id";
  }

  @RequirePermission("admin")
  void deleteUser(int id) {
    print("Deleting user $id");
  }
}
Enter fullscreen mode Exit fullscreen mode
(4) Generating Reflection Code

Run the command to generate auxiliary code needed for reflection:

dart run build_runner build
Enter fullscreen mode Exit fullscreen mode

This will generate a reflectable.g.dart file containing metadata needed for reflection.

(5) Using Reflection to Obtain Class Information and Call Methods
import 'package:reflectable/reflectable.dart';
import 'reflector.dart';
import 'reflectable.g.dart'; // Import generated code

void main() {
  // Initialize reflection
  initializeReflectable();

  // Create UserService instance
  final userService = UserService();

  // Get reflection information for the class
  final instanceMirror = myReflector.reflect(userService);
  final classMirror = instanceMirror.type;

  // Print class name
  print(
    "Class name: ${classMirror.simpleName}",
  ); // Output: Class name: UserService

  // Iterate through class methods
  print("\nMethod list:");
  for (var method in classMirror.declarations.values) {
    if (method is MethodMirror && !method.isStatic) {
      print("- ${method.simpleName}");
      // Check if method has @Logged annotation
      for (var annotation in method.annotations) {
        if (annotation is Logged) {
          print(
            "  Has @Logged annotation: printParams=${annotation.printParams}",
          );
        }
      }
    }
  }

  // Dynamically call getUserInfo method
  print("\nDynamically calling method:");
  final result = instanceMirror.invoke("getUserInfo", [100]);
  print(
    "Call result: $result",
  ); // Output: Call result: Information for user 100
}
Enter fullscreen mode Exit fullscreen mode

Output result:

Class name: UserService

Method list:
- getUserInfo
  Has @Logged annotation: printParams=true
- deleteUser

Dynamically calling method:
Call result: Information for user 100
Enter fullscreen mode Exit fullscreen mode

3. Scenarios Where Reflection Should Be Used Cautiously

Although reflection is powerful, it should be used cautiously in actual development (especially in Flutter projects) for the following reasons:

  • Performance overhead: Reflection requires parsing class information at runtime and is much slower than direct method calls (usually 10-100 times slower).
  • Flutter incompatibility: The dart:mirrors library is disabled in Flutter. While reflectable can be used, it increases package size.
  • Poor code readability: Dynamic method calls make code logic more obscure and difficult to debug.
  • Broken type safety: Reflection can bypass compiler type checks, easily leading to runtime errors.

Alternative: Prefer compile-time code generation (like json_serializable, freezed), which generates code during compilation, balancing flexibility and performance.


IV. Practical Application Scenarios for Metadata and Reflection

1. Serialization/Deserialization

Packages like json_serializable use the @JsonSerializable annotation to generate JSON conversion code at compile time, avoiding the need to manually write tedious serialization logic.

2. Dependency Injection Frameworks

Dependency injection frameworks like get_it automatically create objects and inject dependencies through reflection (or code generation), simplifying object management.

3. Routing Management

Flutter routing frameworks (like auto_route) mark pages with the @MaterialRoute annotation and generate route tables at compile time, enabling type-safe route navigation.

4. Logging and Event Tracking

By marking methods that need event tracking with custom annotations (like @LogEvent), combined with compile-time generation or reflection, logging logic can be automatically added, reducing repetitive code.

Top comments (0)