Flutter异常监测与上报

Flutter异常

众所周知,软件项目的交付是一个复杂的过程,任何原因都有可能导致交付的失败。很多时候经常遇到的一个现象是,应用在开发测试时没有任何异常,但一旦上线就问题频出。出现这些异常,可能是因为不充分的机型适配或者用户糟糕的网络状况造成的,也可能是Flutter框架自身缺陷造成的,甚至是操作系统底层的问题。

而处理此类异常的最佳方式是捕获用户的异常信息,将异常现场保存起来并上传至服务器,然后通过分析异常上下文来定位引起异常的原因,并最终解决此类问题。

所谓Flutter异常,指的是Flutter程序中Dart代码运行时发生的错误。与Java和OC等多线程模型的编程语言不同,Dart是一门单线程的编程语言,采用事件循环机制来运行任务,所以各个任务的运行状态是互相独立的。也即是说,当程序运行过程中出现异常时,并不需要像Java那样使用try-catch机制来捕获异常,因为即便某个任务出现了异常,Dart程序也不会退出,只会导致当前任务后续的代码不会被执行,而其它功能仍然可以继续使用。

在Flutter开发中,根据异常来源的不同,可以将异常分为Framework异常和Dart异常。Flutter对这两种异常提供了不同的捕获方式,Framework异常是由Flutter框架引发的异常,通常是由于错误的应用代码造成Flutter框架底层的异常判断引起的,当出现Framework异常时,Flutter会自动弹出一个的红色错误界面。而对于Dart异常,则可以使用try-catch机制和catchError语句进行处理。

除此之外,Flutter还提供了集中处理框架异常的方案。集中处理框架异常需要使用Flutter提供的FlutterError类,此类的onError属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获异常逻辑,只需要为它提供一个自定义的错误处理回调函数即可。

异常捕获

在Flutter开发中,根据异常来源的不同,可以将异常分为Framework异常和Dart异常。所谓Dart异常,指的是应用代码引起的异常。根据异常代码的执行时序,Dart异常可以分为同步异常和异步异常两类。对于同步异常,可以使用try-catch机制来进行捕获,而异步异常的捕获则比较麻烦,需要使用Future提供的catchError语句来进行捕获,如下所示。

//使用try-catch捕获同步异常
try {
  throw StateError('This is a Dart exception');
}catch(e) {
  print(e);
}

//使用catchError捕获异步异常
Future.delayed(Duration(seconds: 1))
    .then((e) => throw StateError('This is a Dart exception in Future.'))
    .catchError((e)=>print(e));

需要说明的是,对于异步调用所抛出的异常是无法使用try-catch语句进行捕获的,因此下面的写法就是错误的。

//以下代码无法捕获异步异常
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");
}

因此,对于Dart中出现的异常,同步异常使用的是try-catch,异步异常则使用的是catchError。如果想集中管理代码中的所有异常,那么可以Flutter提供的Zone.runZoned()方法。在Dart语言中,Zone表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果想要处理沙盒中代码执行出现的异常,可以使用沙盒提供的onError回调函数来拦截那些在代码执行过程中未捕获的异常,如下所示。

//同步抛出异常
runZoned(() {
  throw StateError('This is a Dart exception.');
}, onError: (dynamic e, StackTrace stack) {
  print('Sync error caught by zone');
});

//异步抛出异常
runZoned(() {
  Future.delayed(Duration(seconds: 1))
      .then((e) => throw StateError('This is a Dart exception in Future.'));
}, onError: (dynamic e, StackTrace stack) {
  print('Async error aught by zone');
});

可以看到,在没有使用try-catch、catchError语句的情况下,无论是同步异常还是异步异常,都可以使用Zone直接捕获到。

同时,如果需要集中捕获Flutter应用中未处理的异常,那么可以把main函数中的runApp语句也放置在Zone中,这样就可以在检测到代码运行异常时对捕获的异常信息进行统一处理,如下所示。

runZoned<Future>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) async {
  //异常处理
});

除了Dart异常外,Flutter应用开发中另一个比较常见的异常是Framework异常。Framework异常指的是Flutter框架引起的异常,通常是由于执行错误的应用代码造成Flutter框架底层异常判断引起的,当出现Framework异常时,系统会自动弹出一个的红色错误界面,如下图所示。

之所以会弹出一个错误提示页面,是由于系统在调用build()方法构建页面时会进行try-catch处理,如果出现任何错误就会调用ErrorWidget页面展示异常信息,并且Flutter框架在很多关键位置都自动进行了异常捕获处理。

通常,此页面反馈的错误信息对于开发环境的问题定位还是很有帮助的,但如果让线上用户也看到这样的错误页面,体验上就不是很友好比较了。对于Framework异常,最通用的处理方式就是重写ErrorWidget.builder()方法,然后将默认的错误提示页面替换成一个更加友好的自定义提示页面,如下所示。

ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
  //自定义错误提示页面
  return Scaffold(
    body: Center(
      child: Text("Custom Error Widget"),
    )
  );
};

应用示例

通常,只有当代码运行出现错误时,系统才会给出异常错误提示。为了说明Flutter捕获异常的工作流程,首先来看一个越界访问的示例。首先,新建一个Flutter项目,然后修改main.dart文件的代码,如下所示。

class MyHomePage extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   List numList = ['1', '2'];
   print(numList[5]);
   return Container();
 }
}

上面的代码模拟的是一个越界访问的异常场景。当运行上面的代码时,控制台会给出如下的错误信息。

RangeError (index): Invalid value: Not in range 0..2, inclusive: 5

对于程序中出现的异常,通常只需要在Flutter应用程序的入口main.dart文件中,使用Flutter提供的FlutterError类集中处理即可,如下所示。

Future main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    Zone.current.handleUncaughtError(details.exception, details.stack);
  };
  
  runZoned<Future>(() async {
    runApp(MyApp());
  },  onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

Future _reportError(dynamic error, dynamic stackTrace) async {
  print('catch error='+error);
}

同时,对于开发环境和线上环境还需要区别对待。因为,对于开发环境遇到的错误,一般是可以立即定位并修复问题的,而对于线上问题才需要对日志进行上报。因此,对于错误日志上报,需要对开发环境和线上环境进行区分对待,如下所示。

Future main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    if (isDebugMode) {
      FlutterError.dumpErrorToConsole(details);
    } else {
      Zone.current.handleUncaughtError(details.exception, details.stack);
    }
  };
   … //省略其他代码
}

bool get isDebugMode {
  bool inDebugMode = false;
  assert(inDebugMode = true);
  return inDebugMode;
}

异常上报

目前为止,我们已经对应用中出现的所有未处理异常进行了捕获,不过这些异常还只能被保存在移动设备中,如果想要将这些异常上报到服务器还需要做很多的工作。

目前,支持Flutter异常的日志上报的方案有Sentry、Crashlytics等。其中,Sentry是收费的且免费天数只有13天左右,不过它提供的Flutter插件可以帮助开发者快速接入日志上报功能。Crashlytics是Flutter官方支持的日志上报方案,开源且免费,缺点是没有公开的Flutter插件,而flutter_crashlytics插件接入起来也比较麻烦。

Sentry方案

Sentry是一个商业级的日志管理系统,支持自动上报和手动上报两种方方。在Flutter开发中,由于Sentry提供了Flutter插件,因此如果有日志上报的需求,Sentry是一个不错的选择。

使用Sentry之前,需要先在官方网站注册开发者账号。如果还没有Sentry账号,可以先注册一个,然后再创建一个App工程。等待工程创建完成之后,系统会自动生成一个DSN,可以依次点击【Project】→【Settings 】→【Client Keys】来打开DSN,如下图所示。

接下来,使用Android Studio打开Flutter工程,在pubspec.yaml文件中添加Sentry插件依赖,如下所示。

dependencies:
  sentry: ">=3.0.0 <4.0.0"

然后,使用flutter packages get命令将插件拉取到本地。使用Sentry之前,需要先创建一个SentryClient对象,如下所示。

const dsn='';
final SentryClient _sentry = new SentryClient(dsn: dsn);

为了方便对错误日志进行上传,可以提供一个日志的上报方法,然后在需要进行日志上报的地方调用日志上报方法即可,如下所示。

Future _reportError(dynamic error, dynamic stackTrace) async {
  _sentry.captureException(
      exception: error,
      stackTrace: stackTrace,
    );
}

runZoned<Future>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) {
  _reportError(error, stackTrace);         //上传异常日志
});

同时,开发环境遇到的异常通常是不需要上报的,因为可以立即定位并修复问题,线上遇到的问题才需要进行上报,因此在进行异常上报时还需要区分开发环境和线上环境。

const dsn='https://872ea62a55494a73b73ee139da1c1449@sentry.io/5189144';
final SentryClient _sentry = new SentryClient(dsn: dsn);

Future main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    if (isInDebugMode) {
      FlutterError.dumpErrorToConsole(details);
    } else {
      Zone.current.handleUncaughtError(details.exception, details.stack);
    }
  };

  runZoned<Future>(() async {
    runApp(MyApp());
  }, onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

Future _reportError(dynamic error, dynamic stackTrace) async {
  if (isInDebugMode) {
    print(stackTrace);
    return;
  }
  final SentryResponse response = await _sentry.captureException(
    exception: error,
    stackTrace: stackTrace,
  );

  //上报结果处理
  if (response.isSuccessful) {
    print('Success! Event ID: ${response.eventId}');
  } else {
    print('Failed to report to Sentry.io: ${response.error}');
  }
}

bool get isInDebugMode {
  bool inDebugMode = false;
  assert(inDebugMode = true);
  return inDebugMode;
}

在真机上运行Flutter应用,如果出现错误,就可以在Sentry服务器端看到对应的错误日志,如下图所示。

除此之外,目前市面上还有很多优秀的日志采集服务厂商,如Testin、Bugly和友盟等,不过它们大多还没有提供Flutter接入方案,因此需要开发者在原生平台进行接入。

Bugly方案

目前,Bugly还没有提供Flutter插件,那么,我们针对混合工程,可以采用下面的方案。接入Bugly时,只需要完成一些前置应用信息关联绑定和 SDK 初始化工作,就可以使用 Dart 层封装好的数据上报接口去上报异常了。可以看到,对于一个应用而言,接入数据上报服务的过程,总体上可以分为两个步骤:

  1. 初始化 Bugly SDK;
  2. 使用数据上报接口。

这两步对应着在 Dart 层需要封装的 2 个原生接口调用,即 setup 和 postException,它们都是在方法通道上调用原生代码宿主提供的方法。考虑到数据上报是整个应用共享的能力,因此我们将数据上报类 FlutterCrashPlugin 的接口都封装成了单例,如下所示。

class FlutterCrashPlugin {
  //初始化方法通道
  static const MethodChannel _channel =
      const MethodChannel('flutter_crash_plugin');

  static void setUp(appID) {
    //使用app_id进行SDK注册
    _channel.invokeMethod("setUp",{'app_id':appID});
  }
  static void postException(error, stack) {
    //将异常和堆栈上报至Bugly
    _channel.invokeMethod("postException",{'crash_message':error.toString(),'crash_detail':stack.toString()});
  }
}

Dart 层是原生代码宿主的代理,可以看到这一层的接口设计还是比较简单的。接下来,我们分别去接管数据上报的 Android 和 iOS 平台上完成相应的实现即可。

iOS 接口实现

考虑到 iOS 平台的数据上报配置工作相对较少,因此我们先用 Xcode 打开 example 下的 iOS 工程进行插件开发工作。需要注意的是,由于 iOS 子工程的运行依赖于 Flutter 工程编译构建产物,所以在打开 iOS 工程进行开发前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。以下是 Bugly 异常上报 iOS SDK 接入指南

首先,我们需要在插件工程下的 flutter_crash_plugin.podspec 文件中引入 Bugly SDK,即 Bugly,这样我们就可以在原生工程中使用 Bugly 提供的数据上报功能了。

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

然后,在原生接口 FlutterCrashPlugin 类中,依次初始化插件实例、绑定方法通道,并在方法通道中先后为 setup 与 postException 提供 Bugly iOS SDK 的实现版本,如下所示。

@implementation FlutterCrashPlugin
+ (void)registerWithRegistrar:(NSObject*)registrar {
    //注册方法通道
    FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:@"flutter_crash_plugin"
            binaryMessenger:[registrar messenger]];
    //初始化插件实例,绑定方法通道 
    FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init];
    //注册方法通道回调函数
    [registrar addMethodCallDelegate:instance channel:channel];
}

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if([@"setUp" isEqualToString:call.method]) {
        //Bugly SDK初始化方法
        NSString *appID = call.arguments[@"app_id"];
        [Bugly startWithAppId:appID];
    } else if ([@"postException" isEqualToString:call.method]) {
      //获取Bugly数据上报所需要的各个参数信息
      NSString *message = call.arguments[@"crash_message"];
      NSString *detail = call.arguments[@"crash_detail"];

      NSArray *stack = [detail componentsSeparatedByString:@"\n"];
      //调用Bugly数据上报接口
      [Bugly reportExceptionWithCategory:4 name:message reason:stack[0] callStack:stack extraInfo:@{} terminateApp:NO];
      result(@0);
  }
  else {
    //方法未实现
    result(FlutterMethodNotImplemented);
  }
}

@end

至此,在完成了 Bugly iOS SDK 的接口封装之后,FlutterCrashPlugin 插件的 iOS 部分也就搞定了。

Android 接口实现

与 iOS 类似,我们需要使用 Android Studio 打开 example 下的 android 工程进行插件开发工作。同样,在打开 android 工程前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。以下是 Bugly 异常上报 Android SDK 接入指南

首先,我们需要在插件工程下的 build.gradle 文件引入 Bugly SDK,即 crashreport 与 nativecrashreport,其中前者提供了 Java 和自定义异常的的数据上报能力,而后者则是 JNI 的异常上报封装,如下所示。

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

然后,在原生接口 FlutterCrashPlugin 类中,依次初始化插件实例、绑定方法通道,并在方法通道中先后为 setup 与 postException 提供 Bugly Android SDK 的实现版本,代码如下。

public class FlutterCrashPlugin implements MethodCallHandler {
  //注册器,通常为MainActivity
  public final Registrar registrar;
  //注册插件
  public static void registerWith(Registrar registrar) {
    //注册方法通道
    final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_crash_plugin");
    //初始化插件实例,绑定方法通道,并注册方法通道回调函数 
    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初始化方法
      String appID = call.argument("app_id");

      CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true);
      result.success(0);
    }
    else if(call.method.equals("postException")) {
      //获取Bugly数据上报所需要的各个参数信息
      String message = call.argument("crash_message");
      String detail = call.argument("crash_detail");
      //调用Bugly数据上报接口
      CrashReport.postException(4,"Flutter Exception",message,detail,null);
      result.success(0);
    }
    else {
      result.notImplemented();
    }
  }
}

在完成了 Bugly Android 接口的封装之后,由于 Android 系统的权限设置较细,考虑到 Bugly 还需要网络、日志读取等权限,因此我们还需要在插件工程的 AndroidManifest.xml 文件中,将这些权限信息显示地声明出来,如下所示。

     
    
     
    
     
    
     
    
     
    

至此,在完成了极光 Android SDK 的接口封装和权限配置之后,FlutterCrashPlugin 插件的 Android 部分也搞定了。FlutterCrashPlugin 插件为 Flutter 应用提供了数据上报的封装,不过要想 Flutter 工程能够真正地上报异常消息,我们还需要为 Flutter 工程关联 Bugly 的应用配置。

应用工程配置

在单独为 Android/iOS 应用进行数据上报配置之前,我们首先需要去Bugly 的官方网站,为应用注册唯一标识符(即 AppKey)。这里需要注意的是,在 Bugly 中,Android 应用与 iOS 应用被视为不同的产品,所以我们需要分别注册。

在得到了 AppKey 之后,我们需要依次进行 Android 与 iOS 的配置工作。iOS 的配置工作相对简单,整个配置过程完全是应用与 Bugly SDK 的关联工作,而这些关联工作仅需要通过 Dart 层调用 setUp 接口,访问原生代码宿主所封装的 Bugly API 就可以完成,因此无需额外操作。

而 Android 的配置工作则相对繁琐些。由于涉及 NDK 和 Android P 网络安全的适配,我们还需要分别在 build.gradle 和 AndroidManifest.xml 文件进行相应的配置工作。首先,由于 Bugly SDK 需要支持 NDK,因此我们需要在 App 的 build.gradle 文件中为其增加 NDK 的架构支持,如下所示。

defaultConfig {
    ndk {
        // 设置支持的SO库架构
        abiFilters 'armeabi' , 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
    }
}

然后,由于 Android P 默认限制 http 明文传输数据,因此我们需要为 Bugly 声明一项网络安全配置 network_security_config.xml,允许其使用 http 传输数据,并在 AndroidManifest.xml 中新增同名网络安全配置。

//res/xml/network_security_config.xml

 

      
    
         
        android.bugly.qq.com
    


//AndroidManifest/xml

至此,Flutter 工程所需的原生配置工作和接口实现,就全部搞定了。接下来,我们就可以在 Flutter 工程中的 main.dart 文件中,使用 FlutterCrashPlugin 插件来实现异常数据上报能力了。当然,我们首先还需要在 pubspec.yaml 文件中,将工程对它的依赖显示地声明出来,如下所示。

dependencies:
  flutter_push_plugin:
    git:
      url: xxx

在下面的代码中,我们在 main 函数里为应用的异常提供了统一的回调,并在回调函数内使用 postException 方法将异常上报至 Bugly。而在 SDK 的初始化方法里,由于 Bugly 视 iOS 和 Android 为两个独立的应用,因此我们判断了代码的运行宿主,分别使用两个不同的 App ID 对其进行了初始化工作。

此外,为了与你演示具体的异常拦截功能,我们还在两个按钮的点击事件处理中分别抛出了同步和异步两类异常,代码如下:

//上报数据至Bugly
Future _reportError(dynamic error, dynamic stackTrace) async {
  FlutterCrashPlugin.postException(error, stackTrace);
}

Future main() async {
  //注册Flutter框架的异常回调
  FlutterError.onError = (FlutterErrorDetails details) async {
    //转发至Zone的错误回调
    Zone.current.handleUncaughtError(details.exception, details.stack);
  };
  //自定义错误提示页面
  ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
    return Scaffold(
      body: Center(
        child: Text("Custom Error Widget"),
      )
    );
  };
  //使用runZone方法将runApp的运行放置在Zone中,并提供统一的异常回调
  runZoned<Future>(() async {
    runApp(MyApp());
  }, onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

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

class _MyAppState extends State {
  @override
  void initState() {
    //由于Bugly视iOS和Android为两个独立的应用,因此需要使用不同的App ID进行初始化
    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: [
            RaisedButton(
              child: Text('Dart exception'),
              onPressed: () {
                //触发同步异常
                throw StateError('This is a Dart exception.');
              },
            ),
            RaisedButton(
              child: Text('async Dart exception'),
              onPressed: () {
                //触发异步异常
                Future.delayed(Duration(seconds: 1))
                      .then((e) => throw StateError('This is a Dart exception in Future.'));
              },
            )
          ],
        ),
      ),
    );
  }
}

运行上面的代码,模拟异常上传,然后我们 打开Bugly 开发者后台 ,选择对应的 App,切换到错误分析选项查看对应的面板信息。可以看到,Bugly 已经成功接收到上报的异常上下文了,如下图所示。

总结

对于 Flutter 应用的异常捕获,可以分为单个异常捕获和多异常统一拦截两种情况。其中,单异常捕获,使用 Dart 提供的同步异常 try-catch,以及异步异常 catchError 机制即可实现。而对多个异常的统一拦截,可以细分为如下两种情况:一是 App 异常,我们可以将代码执行块放置到 Zone 中,通过 onError 回调进行统一处理;二是 Framework 异常,我们可以使用 FlutterError.onError 回调进行拦截。

需要注意的是,Flutter 提供的异常拦截只能拦截 Dart 层的异常,而无法拦截 Engine 层的异常。这是因为,Engine 层的实现大部分是 C++ 的代码,一旦出现异常,整个程序就直接 Crash 掉了。不过通常来说,这类异常出现的概率极低,一般都是 Flutter 底层的 Bug,与我们在应用层的实现没太大关系,所以我们也无需过度担心。

如果我们想要追踪 Engine 层的异常(比如给 Flutter 提 Issue),则需要借助于原生系统提供的 Crash 监听机制。不过,这方面的内容比较繁琐,具体可以参考: Flutter官方文档