39 How to Handle Exception Catching and Information Collection When Issues Arise Online

39 How to Handle Exception Catching and Information Collection When Issues Arise Online #

Hello, I’m Chen Hang.

In the previous article, I shared how to write automated test cases for a Flutter project. The basic steps for designing a test case can be divided into three steps: defining, executing, and verifying. The unit testing and UI testing frameworks provided by Flutter can help simplify these steps.

With unit testing, we can easily verify the behavior of individual functions, methods, or classes. We can also use Mockito to customize the return of external dependencies, making the tests more controllable. UI testing provides the ability to interact with widgets, allowing us to simulate user behavior and perform corresponding interactive operations to confirm whether the application functions as expected.

Through automated testing, we can transform repetitive manual operations into automated verification steps, thus discovering problems more timely during the development phase. However, the fragmentation of terminal devices ultimately prevents us from completely simulating the real user’s operating environment during application development. Therefore, no matter how perfect our application is written or how comprehensive our testing is, it is always impossible to completely avoid exceptions occurring online.

These exceptions may be caused by insufficient device compatibility or poor network conditions. They could also be due to bugs in the Flutter framework, or even problems at the operating system level. Once these exceptions occur, the Flutter application will fail to respond to user interaction events. It can result in error messages, functional unavailability, or even crashes, which are all very unfriendly to users and something developers are unwilling to see.

Therefore, we need to find a way to capture user exception information, save the exception context, and upload it to the server. This way, we can analyze the exception context, locate the cause of the exception, and solve such problems. So today, let’s learn about capturing and collecting Flutter exceptions, as well as the corresponding data reporting processing.

Flutter Exceptions #

Flutter exceptions refer to unexpected error events that occur during the execution of Dart code in a Flutter application. We can catch these exceptions using the try-catch mechanism similar to Java. However, unlike Java, Dart programs do not enforce that we must handle exceptions.

This is because Dart uses an event loop mechanism to execute tasks, so the execution status of each task is independent of each other. In other words, even if an exception occurs in a task and we do not catch it, the Dart program will not exit. It will only cause the subsequent code of the current task to not be executed, and the user can continue to use other functionalities.

Dart exceptions can be further divided into App Exceptions and Framework Exceptions based on their sources. Flutter provides different ways to catch these two types of exceptions. Let’s take a look at them together.

Catching App Exceptions #

App exceptions refer to exceptions in the application code, usually caused by unhandled exceptions thrown by other modules at the application layer. Based on the execution order of the exception code, app exceptions can be divided into two types: synchronous exceptions and asynchronous exceptions. Synchronous exceptions can be caught using the try-catch mechanism, while asynchronous exceptions require using the catchError statement provided by Future to catch them.

The following code demonstrates these two ways of catching exceptions:

// Catching synchronous exceptions using try-catch
try {
  throw StateError('This is a Dart exception.');
} catch (e) {
  print(e);
}

// Catching asynchronous exceptions using catchError
Future.delayed(Duration(seconds: 1))
    .then((e) => throw StateError('This is a Dart exception in Future.'))
    .catchError((e) => print(e));

// Note that the following code cannot catch asynchronous exceptions
try {
  Future.delayed(Duration(seconds: 1))
      .then((e) => throw StateError('This is a Dart exception in Future.'));
} catch (e) {
  print("This line will never be executed. ");
}

It is important to note that these two approaches cannot be mixed. As shown in the above code, we cannot use try-catch to catch an exception thrown by an asynchronous call.

Synchronous try-catch and asynchronous catchError provide us with the ability to directly catch specific exceptions. If we want to centrally manage all exceptions in our code, Flutter also provides the Zone.runZoned method.

We can assign a Zone to the code execution object. In Dart, a Zone represents a scope of code execution and is similar to a sandbox. Sandboxes are isolated from each other. If we want to observe exceptions that occur during code execution in a sandbox, the sandbox provides an onError callback function to intercept any uncaught exceptions in the code execution object.

In the following code, we put statements that may throw exceptions inside a Zone. As you can see, without using try-catch or catchError, both synchronous and asynchronous exceptions can be directly caught through the Zone:

runZoned(() {
  // Throwing a synchronous exception
  throw StateError('This is a Dart exception.');
}, onError: (dynamic e, StackTrace stack) {
  print('Sync error caught by zone');
});

runZoned(() {
  // Throwing an asynchronous exception
  Future.delayed(Duration(seconds: 1))
      .then((e) => throw StateError('This is a Dart exception in Future.'));
}, onError: (dynamic e, StackTrace stack) {
  print('Async error caught by zone');
});

Therefore, if we want to centrally catch unhandled exceptions in a Flutter application, we can also place the runApp statement in the main function within a Zone. This way, when an abnormality occurs during the execution of the code, we can handle it uniformly based on the context information of the obtained exception:

runZoned<Future<Null>>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) async {
  // Do something for the error
});

Next, let’s take a look at how to catch framework exceptions.

Catching Framework Exceptions #

Framework exceptions refer to the exceptions raised by the Flutter framework. They are usually triggered by the application code causing underlying exception checks in the Flutter framework. For example, when the layout is not compliant, Flutter will automatically display a striking red error interface, as shown below:

Figure 1 Flutter layout error prompt

This is because the Flutter framework performs try-catch processing when calling the build method to construct the page and provides an ErrorWidget for displaying error information when an exception occurs:

@override
void performRebuild() {
  Widget built;
  try {
    // Create the page
    built = build();
  } catch (e, stack) {
    // Use the ErrorWidget to create the page
    built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
    ...
  } 
  ...
}

The information feedback provided by this page is quite rich and is suitable for locating problems during development. However, it would be terrible for users to see such a page. Therefore, we usually override the ErrorWidget.builder method and replace the default error page with a more user-friendly page.

The following code demonstrates the specific method of customizing the error page. In this example, we simply return a Text widget centered on the page:

ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
  return Scaffold(
    body: Center(
      child: Text("Custom Error Widget"),
    )
  );
};

The running effect is shown below:

Figure 2 Custom error prompt page

Compared to the previously striking red error page, the white-themed custom page looks a bit friendlier. It should be noted that the ErrorWidget.builder method provides a parameter details to represent the current error context. To avoid users directly seeing error information, we did not display it on the page. However, we should not discard such exception information and need to provide a unified exception handling mechanism for subsequent analysis of the cause of the exception.

To centrally handle framework exceptions, Flutter provides the FlutterError class, and its onError property will execute the corresponding callback when receiving framework exceptions. Therefore, to implement custom exception handling logic, we just need to provide a custom error callback for it.

In the following code, we use the handleUncaughtError statement provided by Zone to forward the Flutter framework’s exceptions to the current Zone, so that we can use Zone to handle all exceptions within the application uniformly:

FlutterError.onError = (FlutterErrorDetails details) async {
  // Forward to the current Zone
  Zone.current.handleUncaughtError(details.exception, details.stack);
};

runZoned<Future<Null>>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) async {
 // Do something for error
});

Exception Reporting #

So far, we have captured all the unhandled exceptions in the application. However, simply printing these exceptions to the console is not enough to solve the problem. We also need to report them to a place where the developers can see them, for further analysis, investigation, and problem-solving.

There are many excellent third-party SDK service providers in the market for developer data reporting, such as Umeng, Bugly, and open-source Sentry, and they provide similar functionality and integration processes. Considering the high level of activity in the Bugly community, I will use it as an example to demonstrate how to implement custom data reporting after capturing exceptions.

Dart Interface Implementation #

Currently, Bugly only provides SDKs for native Android/iOS, so we need to use the same plugin project as in the 31st article “How to implement native push capability?” to provide a Dart-level interface for Bugly’s data reporting.

Compared to integrating push capabilities, integrating data reporting is much simpler. We only need to complete some pre-application information binding and SDK initialization work to be able to use the pre-encapsulated data reporting interface at the Dart level to report exceptions. As you can see, for an application, the process of integrating data reporting services can be divided into two steps:

  1. Initialize the Bugly SDK
  2. Use the data reporting interface

These two steps correspond to two native interface calls that need to be encapsulated at the Dart level: setup and postException. Both of these calls invoke methods provided by the native code host on the method channel. Since data reporting is a capability shared by the entire application, we have encapsulated all the interfaces of the data reporting class, FlutterCrashPlugin, into singletons:

class FlutterCrashPlugin {
  // Initialize the method channel
  static const MethodChannel _channel =
      const MethodChannel('flutter_crash_plugin');

  static void setUp(appID) {
    // Use the app ID for SDK registration
    _channel.invokeMethod("setUp",{'app_id':appID});
  }
  static void postException(error, stack) {
    // Report the exception and stack trace to Bugly
    _channel.invokeMethod("postException",{'crash_message':error.toString(),'crash_detail':stack.toString()});
  }
}

The Dart layer acts as a proxy for the native code host, and you can see that the interface design at this layer is relatively simple. Next, we will separately implement the corresponding implementation for the Android and iOS platforms for data reporting.

iOS Interface Implementation #

Considering that there is relatively little configuration work for data reporting on the iOS platform, we will first open the iOS project under the example folder in Xcode for plugin development. Please note that since the iOS subproject relies on the compiled artifacts of the Flutter project for running, you need to make sure that the entire project code has been built at least once before opening the iOS project for development, otherwise, the IDE will report an error.

Note: The following steps are based on the Bugly iOS SDK Integration Guide.

First, we need to import the Bugly SDK in the flutter_crash_plugin.podspec file of the plugin project, so that we can use Bugly’s data reporting functionality in the native project:

Pod::Spec.new do |s|
  ...
  s.dependency 'Bugly'
end

Then, in the native interface FlutterCrashPlugin class, initialize the plugin instance, bind the method channel, and provide implementation versions of Bugly’s iOS SDK for setup and postException in the method channel:

@implementation FlutterCrashPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    // Register method channel
    FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:@"flutter_crash_plugin"
            binaryMessenger:[registrar messenger]];
    // Initialize the plugin instance and bind the method channel
    FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init];
    // Register method channel callback function
    [registrar addMethodCallDelegate:instance channel:channel];
}

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if([@"setUp" isEqualToString:call.method]) {
        // Bugly SDK initialization method
        NSString *appID = call.arguments[@"app_id"];
        [Bugly startWithAppId:appID];
    } else if ([@"postException" isEqualToString:call.method]) {
      // Get the required parameters for Bugly data reporting
      NSString *message = call.arguments[@"crash_message"];
      NSString *detail = call.arguments[@"crash_detail"];

      NSArray *stack = [detail componentsSeparatedByString:@"\n"];
      // Call the Bugly data reporting interface
      [Bugly reportExceptionWithCategory:4 name:message reason:stack[0] callStack:stack extraInfo:@{} terminateApp:NO];
      result(@0);
  }
  else {
    // Method not implemented
    result(FlutterMethodNotImplemented);
  }
}

这一步我们已经完成了iOS平台的数据上报。

Dart层的Bugly数据上报封装类,包含了Bugly Android与iOS的数据上报接口调用。当Flutter代码向Dart层的接口发起调用时,便会触发原生代码的调用。关于具体的错误信息与错误堆栈的上报,由客户端进行异常上报处理,以帮助开发者更好地定位程序异常。

通过这篇文章的学习,你已经了解到了如何在Flutter应用中实现异常的捕获和上报。当应用出现未处理的异常时,可以将异常信息上报到开发者能看到的地方,以便后续分析定位并解决问题。 } }

@end

So far, after completing the interface encapsulation of the Bugly iOS SDK, the iOS part of the FlutterCrashPlugin plugin is also complete. Next, let’s take a look at how to implement the Android part.

Implementation of Android interface #

Similar to iOS, we need to use Android Studio to open the android project in the example directory for plugin development work. Similarly, before opening the Android project, you need to ensure that the entire project code has been built at least once, otherwise the IDE will report an error.

Note: The following operation steps are referenced from Bugly Exception Reporting Android SDK Integration Guide

Firstly, we need to import the Bugly SDK, namely crashreport and nativecrashreport, in the build.gradle file under the plugin project:

dependencies {
    implementation 'com.tencent.bugly:crashreport:latest.release' 
    implementation 'com.tencent.bugly:nativecrashreport:latest.release' 
}

Then, in the native interface FlutterCrashPlugin class, initialize the plugin instance, bind the method channel, and provide the implementation of Bugly Android SDK for the setup and postException methods in the method channel:

public class FlutterCrashPlugin implements MethodCallHandler {
  // Registrar, usually MainActivity
  public final Registrar registrar;
  // Register the plugin
  public static void registerWith(Registrar registrar) {
    // Register the method channel
    final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_crash_plugin");
    // Initialize the plugin instance, bind the method channel, and register the method channel callback function
    channel.setMethodCallHandler(new FlutterCrashPlugin(registrar));
  }

  private FlutterCrashPlugin(Registrar registrar) {
    this.registrar = registrar;
  }

  @Override
  public void onMethodCall(MethodCall call, Result result) {
    if(call.method.equals("setUp")) {
      // Bugly SDK initialization method
      String appID = call.argument("app_id");

      CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true);
      result.success(0);
    }
    else if(call.method.equals("postException")) {
      // Obtain the necessary parameters for Bugly exception reporting 
      String message = call.argument("crash_message");
      String detail = call.argument("crash_detail");
      // Call the Bugly exception reporting interface
      CrashReport.postException(4,"Flutter Exception",message,detail,null);
      result.success(0);
    }
    else {
      result.notImplemented();
    }
  }
}

After completing the encapsulation of the Bugly Android interface, due to the fine-grained permission settings in the Android system, considering that Bugly still requires permissions such as network and log reading, we also need to explicitly declare these permission information in the AndroidManifest.xml file of the plugin project to complete the registration with the system:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.hangchen.flutter_crash_plugin">
    <!-- Phone state read permission -->
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
    <!-- Internet permission -->
    <uses-permission android:name="android.permission.INTERNET" />
    <!-- Access network state permission -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <!-- Access wifi state permission -->
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <!-- Read logs permission -->
    <uses-permission android:name="android.permission.READ_LOGS" />
</manifest>

So far, after completing the encapsulation of the JPush Android SDK interface and the configuration of permissions, the Android part of the FlutterCrashPlugin plugin is also complete.

The FlutterCrashPlugin plugin provides encapsulation for reporting data for Flutter applications. However, in order for a Flutter project to truly report exception messages, we also need to associate Bugly’s application configuration with the Flutter project.

Application Engineering Configuration #

Before configuring the data reporting for Android/iOS applications separately, we first need to go to the official website of Bugly and register a unique identifier (AppKey) for the application. It is worth noting that in Bugly, Android applications and iOS applications are considered as different products, so we need to register them separately:

Figure 3 Android Application Demo Configuration

Figure 4 iOS Application Demo Configuration

After obtaining the AppKey, we need to configure Android and iOS in order.

The configuration process for iOS is relatively simple. The entire configuration process consists of associating the application with the Bugly SDK, and this association can be completed by calling the setUp interface through the Dart layer and accessing the Bugly API encapsulated by the native code host, so no additional operations are required.

The configuration process for Android is relatively more complicated. Because it involves adapting NDK and Android P network security, we need to perform corresponding configuration in the build.gradle and AndroidManifest.xml files.

First , since the Bugly SDK needs to support NDK, we need to add NDK architecture support in the App’s build.gradle file:

defaultConfig {
    ndk {
        // Set supported SO library architectures
        abiFilters 'armeabi' , 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
    }
}

Then , because Android P restricts plain text data transmission by default, we need to declare a network security configuration network_security_config.xml for Bugly to allow it to use http to transmit data and add the same name network security configuration in AndroidManifest.xml:

// res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- Network security configuration -->
<network-security-config>
    <!-- Allow plain text data transmission -->
    <domain-config cleartextTrafficPermitted="true">
        <!-- Add Bugly's domain to the whitelist -->
        <domain includeSubdomains="true">android.bugly.qq.com</domain>
    </domain-config>
</network-security-config>

// AndroidManifest/xml
<application
  ...
  android:networkSecurityConfig="@xml/network_security_config"
  ...>
</application>

With that, the native configuration work and interface implementation required for the Flutter project are all completed.

Next, we can use the FlutterCrashPlugin plugin in the main.dart file of the Flutter project to achieve the ability to report exception data. Of course, we need to declare the project’s dependency on it explicitly in the pubspec.yaml file:

dependencies:
  flutter_push_plugin:
    git:
      url: https://github.com/cyndibaby905/39_flutter_crash_plugin

In the following code, we provide a unified callback for the application’s exceptions in the main function, and use the postException method in the callback function to report the exception to Bugly.

In the initialization method of the SDK, because Bugly treats iOS and Android as two independent applications, we determine the running host of the code and initialize them with two different App IDs accordingly.

In addition, to demonstrate the specific exception interception function, we also throw synchronous and asynchronous exceptions in the click event handling of the two buttons:

// Report data to Bugly
Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
  FlutterCrashPlugin.postException(error, stackTrace);
}

Future<Null> main() async {
  // Register the exception callback for the Flutter framework
  FlutterError.onError = (FlutterErrorDetails details) async {
    // Forward the error callback to Zone
    Zone.current.handleUncaughtError(details.exception, details.stack);
  };
  // Custom error prompt page
  ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
    return Scaffold(
      body: Center(
        child: Text("Custom Error Widget"),
      )
    );
  };
  // Use the runZone method to place the runApp execution in a Zone and provide a unified exception callback
  runZoned<Future<Null>>(() async {
    runApp(MyApp());
  }, onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    // Since Bugly treats iOS and Android as two independent applications, different App IDs are used for initialization
    if(Platform.isAndroid){
      FlutterCrashPlugin.setUp('43eed8b173');
    }else if(Platform.isIOS){
      FlutterCrashPlugin.setUp('088aebe0d5');
    }
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Crashy'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            RaisedButton(
              child: Text('Dart exception'),
              onPressed: () {
                // Trigger synchronous exception
                throw StateError('This is a Dart exception.');
              },
            ),
            RaisedButton(
              child: Text('async Dart exception'),
              onPressed: () {
                // Trigger asynchronous exception
                Future.delayed(Duration(seconds: 1))
                      .then((e) => throw StateError('This is a Dart exception in Future.'));
              },
            )
          ],
        ),
      ),
    );
  }
}

Running this code and clicking the Dart exception and async Dart exception buttons several times, you can see that our application and console do not prompt any exception message.

Figure 5 Exception interception demonstration example (iOS)

Figure 6 Exception interception demonstration example (Android)

Then , we open the Bugly developer console, select the corresponding App, switch to the Error Analysis option to view the corresponding panel information. We can see that Bugly has successfully received the reported exception context.

Figure 7 Bugly iOS error analysis report data view

Figure 8 Bugly Android error analysis report data view

Summary #

Well, that’s all for today’s sharing. Let’s summarize.

For the exception handling of a Flutter application, it can be divided into two cases: catching a single exception and intercepting multiple exceptions uniformly.

For catching a single exception, it can be achieved using the synchronous exception try-catch provided by Dart, as well as the asynchronous exception catchError mechanism. For intercepting multiple exceptions uniformly, it can be further divided into the following two cases: firstly, for app exceptions, we can put the code execution block into a Zone, and handle them uniformly through the onError callback; secondly, for framework exceptions, we can intercept them using the FlutterError.onError callback.

After catching an exception, we need to report the exception information for subsequent analysis and problem locating. Considering the high activity of the Bugly community, I used Bugly as an example to explain how to report exception information in the form of a native plugin package.

It should be noted that the exception interception provided by Flutter can only intercept exceptions at the Dart layer, and cannot intercept exceptions at the Engine layer. This is because most of the implementation at the Engine layer is written in C++, and once an exception occurs, the entire program crashes directly. However, generally speaking, the probability of such exceptions is very low, and they are usually bugs in the Flutter underlying layer, which have little to do with our implementation at the application layer, so we don’t need to worry too much.

If we want to trace exceptions at the Engine layer (e.g., when submitting an issue to Flutter), we need to rely on the crash monitoring mechanism provided by the native system. This is a cumbersome task.

Fortunately, the Bugly data reporting SDK that we use provides this capability, which can automatically collect native code crashes. After Bugly collects the corresponding crashes, what we need to do is to download the symbol table corresponding to the Flutter Engine layer and use the ndk-stack provided by Android, symbolicatecrash provided by iOS, or the atos command to analyze the corresponding crash stack trace, thereby obtaining the specific code causing the Engine layer crash.

For detailed instructions on these steps, you can refer to the Flutter official documentation.

I have packaged the knowledge points involved in today’s sharing on GitHub. You can download it and run it several times to deepen your understanding and memory.

Discussion Questions #

Finally, I’ll leave you with two discussion questions.

First question, please expand the implementation of _reportError and custom error page. In the Debug environment, print exception data to the console, while keeping the original system error page implementation.

// Report data to Bugly
Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
  FlutterCrashPlugin.postException(error, stackTrace);
}

// Custom error page
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
  return Scaffold(
    body: Center(
      child: Text("Custom Error Widget"),
    )
  );
};

Second question, can we intercept exceptions from concurrent Isolates using the capture mechanism introduced in today’s sharing? If not, what should we do?

// Concurrent Isolate
doSth(msg) => throw ConcurrentModificationError('This is a Dart exception.');

// Main Isolate
Isolate.spawn(doSth, "Hi");

Feel free to leave your thoughts in the comments section. I’ll be waiting for you in the next article! Thank you for listening, and feel free to share this article with more friends to read together.