Flutter (15): Flutter exception capture

Time: Column:Mobile & Frontend views:195

Before introducing Flutter's exception handling, it is essential to understand Dart's single-threaded model. Only by knowing Dart's code execution process can we determine where to capture exceptions.

15.1 Dart Single-Threaded Model

In Java and Objective-C (hereafter referred to as "OC"), if an exception occurs and is not caught, the program will terminate. However, this is not the case in Dart or JavaScript! The reason lies in their execution mechanisms. Both Java and OC are multi-threaded programming languages, where any unhandled exception in a thread causes the entire process to exit. But Dart and JavaScript don’t follow this model; they are single-threaded, with similar (but distinct) execution mechanisms. Below is a diagram provided by Dart to illustrate the basic workings of Dart:

Flutter (15): Flutter exception capture

Dart operates within a single thread using a message loop mechanism that contains two task queues: a "microtask queue" and an "event queue." As shown in the diagram, microtasks are prioritized over event tasks.

Now let’s look at how Dart threads run. As illustrated in the diagram, after the main() function completes, the message loop mechanism starts. Tasks in the microtask queue are executed one by one in a first-in-first-out order. Once all microtasks are processed, the event queue tasks are executed. However, during the execution of event tasks, new microtasks and event tasks can be added. In this way, the execution process of the entire thread is continuously looping without exiting. This is exactly how the main thread in Flutter operates—it never terminates.

In Dart, all external event tasks, such as I/O operations, timers, clicks, and rendering events, are placed in the event queue, while microtasks typically come from Dart's internal processes. The microtask queue must be kept short because too many microtasks will delay the event queue, leading to noticeable lag in GUI applications. You can add tasks to the microtask queue using the Future.microtask(...) method.

In the event loop, when an uncaught exception occurs, the program doesn’t exit. Instead, the current task’s subsequent code simply doesn’t execute. This means that an exception in one task won’t affect the execution of other tasks.

15.2 Exception Handling in Flutter

Dart allows you to catch exceptions using try/catch/finally, similar to other programming languages. For those unfamiliar with this, refer to the Dart language documentation. Here, we'll focus on exception handling in Flutter.

1. Exception Handling in the Flutter Framework

The Flutter framework has built-in exception handling for many critical methods. For example, when layout constraints are violated, Flutter automatically displays an error screen. This is because Flutter adds exception handling to the build method. The core code is as follows:

@Override
void performRebuild() {
  ...
  try {
    // Execute the build method
    built = build();
  } catch (e, stack) {
    // Show an error message when an exception occurs
    built = ErrorWidget.builder(_debugReportException('building $this', e, stack));
  } 
  ...
}

As you can see, Flutter’s default behavior is to display an ErrorWidget when an exception occurs. But what if you want to capture exceptions yourself and report them to an error-monitoring platform? Let’s explore the _debugReportException() method:

FlutterErrorDetails _debugReportException(
  String context,
  dynamic exception,
  StackTrace stack, {
  InformationCollector informationCollector
}) {
  // Construct an error details object
  final FlutterErrorDetails details = FlutterErrorDetails(
    exception: exception,
    stack: stack,
    library: 'widgets library',
    context: context,
    informationCollector: informationCollector,
  );
  // Report the error
  FlutterError.reportError(details);
  return details;
}

The error is reported using FlutterError.reportError. Let’s trace this further:

static void reportError(FlutterErrorDetails details) {
  ...
  if (onError != null)
    onError(details); // Calls the onError callback
}

We find that onError is a static property of FlutterError, which has a default error-handling method dumpErrorToConsole. Now it's clear—if we want to handle exceptions ourselves, we simply need to provide a custom error-handling callback, like this:

void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    reportError(details);
  };
  ...
}

This way, we can handle exceptions caught by Flutter. Now let's see how to catch other exceptions.

2. Catching Other Exceptions and Collecting Logs

In Flutter, there are still some exceptions that Flutter doesn’t catch, such as null method calls or exceptions within Future. In Dart, exceptions are classified as either synchronous or asynchronous. Synchronous exceptions can be caught using try/catch, but catching asynchronous exceptions is trickier. For example, the following code won’t catch exceptions in a Future:

try {
    Future.delayed(Duration(seconds: 1)).then((e) => Future.error("xxx"));
} catch (e) {
    print(e);
}

Dart provides the runZoned(...) method, which allows you to specify a Zone for code execution. A Zone acts as a sandbox, isolating different execution environments. Within a zone, you can intercept, modify, or capture certain behaviors, such as logging, timer creation, and microtask scheduling. Zones also allow you to capture all unhandled exceptions. Here’s how runZoned(...) is defined:

R runZoned<R>(R body(), {
    Map zoneValues, 
    ZoneSpecification zoneSpecification,
})

zoneValues represents private data within a zone, and zoneSpecification allows you to define zone-specific behavior. For example:

runZoned(
  () => runApp(MyApp()),
  zoneSpecification: ZoneSpecification(
    // Intercept print
    print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
      parent.print(zone, "Interceptor: $line");
    },
    // Intercept unhandled async errors
    handleUncaughtError: (Zone self, ZoneDelegate parent, Zone zone,
                          Object error, StackTrace stackTrace) {
      parent.print(zone, '${error.toString()} $stackTrace');
    },
  ),
);

This approach allows you to intercept all print statements for logging and capture unhandled asynchronous errors. By combining this with FlutterError.onError, you can now capture and report all exceptions in your Flutter app.

3. Final Error Reporting Code

Here is the final version of the error capturing and reporting code:

void collectLog(String line){
    ... // Collect logs
}
void reportErrorAndLog(FlutterErrorDetails details){
    ... // Report error and logs
}

FlutterErrorDetails makeDetails(Object obj, StackTrace stack){
    ...// Build error details
}

void main() {
  var onError = FlutterError.onError; // Save the original onError
  FlutterError.onError = (FlutterErrorDetails details) {
    onError?.call(details); // Call the original onError
    reportErrorAndLog(details); // Report error and logs
  };
  
  runZoned(
    () => runApp(MyApp()),
    zoneSpecification: ZoneSpecification(
      // Intercept print
      print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
        collectLog(line);
        parent.print(zone, "Interceptor: $line");
      },
      // Intercept unhandled async errors
      handleUncaughtError: (Zone self, ZoneDelegate parent, Zone zone,
                            Object error, StackTrace stackTrace) {
        reportErrorAndLog(details);
        parent.print(zone, '${error.toString()} $stackTrace');
      },
    ),
  );
}