美文网首页
Flutter异常捕获

Flutter异常捕获

作者: 半心_忬 | 来源:发表于2020-12-16 10:16 被阅读0次

    无论我们的应用写得多么完美、测试得多么全面,总是无法完全避免线上的异常问题。

    这些异常,可能是因为不充分的机型适配、用户糟糕的网络状况;也可能是因为 Flutter 框架自身的 Bug,甚至是操作系统底层的问题。这些异常一旦发生,Flutter 应用会无法响应用户的交互事件,轻则报错,重则功能无法使用甚至闪退,这对用户来说都相当不友好,是开发者最不愿意看到的。

    所以,我们要想办法去捕获用户的异常信息,将异常现场保存起来,并上传至服务器,这样我们就可以分析异常上下文,定位引起异常的原因,去解决此类问题了。

    所以我们就来聊聊 Flutter 异常的捕获和信息采集,以及对应的数据上报处理。

    Flutter 异常

    Flutter 异常指的是,Flutter 程序中 Dart 代码运行时意外发生的错误事件。我们可以通过与 Swift 类似的 try-catch 机制来捕获它。但与 Swift 不同的是,Dart 程序不强制要求我们必须处理异常。

    这是因为,Dart 采用事件循环的机制来运行任务,所以各个任务的运行状态是互相独立的。也就是说,即便某个任务出现了异常我们没有捕获它,Dart 程序也不会退出,只会导致当前任务后续的代码不会被执行,用户仍可以继续使用其他功能。

    Dart 异常,根据来源又可以细分为 App 异常和 Framework 异常。Flutter 为这两种异常提供了不同的捕获方式。

    App 异常的捕获方式

    App 异常,就是应用代码的异常,通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序,App 异常可以分为两类,即同步异常和异步异常:同步异常可以通过 try-catch 机制捕获,异步异常则需要采用 Future 提供的 catchError 语句捕获。

    这两种异常的捕获方式,如下代码所示:

    // 使用 try-catch 捕获同步异常
    try {
        throw SYReportException('发生一个dart 同步异常');
    }
    catch(e) {
      print(e);
    }
     
    // 使用 catchError 捕获异步异常
    Future.delayed(Duration(seconds: 1)).then((e) {
      if (sendFlag) {
        print('异步异常发生之前 >>>>>>>>>>>');
        throw SYReportException('发生一个dart 异步异常');
      }
      print('异步异常后执行的代码 <<<<<<<<<<<');
    });
        
    // 注意,以下代码无法捕获异步异常
    try {
        Future.delayed(Duration(seconds: 1)).then((e) {
          if (sendFlag) {
            print('异步异常发生之前 >>>>>>>>>>>');
            throw SYReportException('发生一个dart 异步异常');
          }
          print('异步异常后执行的代码 <<<<<<<<<<<');
        });
    } catch (e) {
        print("这是不会执行的. ");
    }
    

    需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用 try-catch 去捕获一个异步调用所抛出的异常的。

    同步的 try-catch 和异步的 catchError,为我们提供了直接捕获特定异常的能力,而如果我们想集中管理代码中的所有异常,Flutter 也提供了 Zone.runZoned 方法。

    我们可以给代码执行对象指定一个 Zone,在 Dart 中,Zone 表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常,沙盒提供了 onError 回调函数,拦截那些在代码执行对象中的未捕获异常。

    在下面的代码中,我们将可能抛出异常的语句放置在了 Zone 里。可以看到,在没有使用 try-catch 和 catchError 的情况下,无论是同步异常还是异步异常,都可以通过 Zone 直接捕获到:

    runZoned(() {
      // 同步抛出异常
      throw SYReportException('发生一个dart 同步异常');
    }, onError: (dynamic e, StackTrace stack) {
      print('zone捕获到了同步异常');
    });
     
    runZoned(() {
      // 异步抛出异常
      Future.delayed(Duration(seconds: 1))
          .then((e) => throw SYReportException('发生一个dart 异步异常'));
    }, onError: (dynamic e, StackTrace stack) {
      print('zone捕获到了异步异常');
    });
    

    因此,如果我们想要集中捕获 Flutter 应用中的未处理异常,可以把 main 函数中的 runApp 语句也放置在 Zone 中。这样在检测到代码中运行异常时,我们就能根据获取到的异常上下文信息,进行统一处理了:

    runZonedGuarded(() {
        runApp(MyApp());
    }, (error, stackTrace) {
        // 这个闭包中发生的Exception是捕获不到的 @山竹
        SYExceptionReportChannel.reportException(error, stackTrace);
    }, zoneSpecification: ZoneSpecification(
        print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
      // 记录所有的打印日志
      parent.print(zone, "line是啥:$line");
    },
    ));
    

    接下来,我们再看看 Framework 异常应该如何捕获吧。

    Framework 异常的捕获方式

    Framework 异常,就是 Flutter 框架引发的异常,通常是由应用代码触发了 Flutter 框架底层的异常判断引起的。比如,当布局不合规范时,Flutter 就会自动弹出一个触目惊心的红色错误界面,如下所示:

    framework_error.png

    这其实是因为,Flutter 框架在调用 build 方法构建页面时进行了 try-catch 的处理,并提供了一个 ErrorWidget,用于在出现异常时进行信息提示:

    @override
    void performRebuild() {
      Widget built;
      try {
        // 创建页面
        built = build();
      } catch (e, stack) {
        // 使用 ErrorWidget 创建页面
        built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
        ...
      } 
      ...
    }
    

    这个页面反馈的信息比较丰富,适合开发期定位问题。但如果让用户看到这样一个页面,就很糟糕了。因此,我们通常会重写 ErrorWidget.builder 方法,将这样的错误提示页面替换成一个更加友好的页面。

    下面的代码演示了自定义错误页面的具体方法。在这个例子中,我们自定义了错误页面,显示导航栏和可滚动的错误信息:

    // 重写 ErrorWidget 的builder,显示地优雅一些
    ErrorWidget.builder = (FlutterErrorDetails details) {
      print('错误widget详细的错误信息为:' + details.toString());
    
      return MaterialApp(
        title: 'Error Widget',
        theme: ThemeData(
          primarySwatch: Colors.red,
        ),
        home: Scaffold(
          appBar: AppBar(
            title: Text('Widget渲染异常!!!'),
          ),
          body: _createBody(details),
        ),
      );
    };
    

    运行效果如下所示:

    custom_error_widget.png

    比起之前触目惊心的红色错误页面,自定义的看起来优雅一些,当然也可以找UI帮忙设计更友好的界面。需要注意的是,ErrorWidget.builder 方法提供了一个参数 details 用于表示当前的错误上下文,为避免用户直接看到错误信息,这里我们并没有将它展示到界面上。但是,我们不能丢弃掉这样的异常信息,需要提供统一的异常处理机制,用于后续分析异常原因。

    为了集中处理框架异常,Flutter 提供了 FlutterError 类,这个类的 onError 属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。

    在下面的代码中,我们使用 Zone 提供的 handleUncaughtError 语句,将 Flutter 框架的异常统一转发到当前的 Zone 中,这样我们就可以统一使用 Zone 去处理应用内的所有异常了:

    // framework异常捕获,转发到当前的 Zone
    FlutterError.onError = (FlutterErrorDetails details) async {
        Zone.current.handleUncaughtError(details.exception, details.stack);
    };
    
    异常上报

    到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。

    三方,我们一般都是用bugly。如果公司有自研的bug系统,那就更好了。

    这些异常上报,我们将使用MethodChannel推送给Native,由Native上报到bugly或自研的异常系统。

    这里只展示Dart的代码实现,至于Native怎么实现Channel,自行Google即可

    Dart实现

    代码如下:

    /// flutter exception channel
    class SYExceptionReportChannel {
      static const MethodChannel _channel =
          const MethodChannel('sy_exception_channel');
    
      // 上报异常
      static reportException(dynamic error, dynamic stack) {
        print('捕获的异常类型 >>> : ${error.runtimeType}');
        print('捕获的异常信息 >>> : $error');
        print('捕获的异常堆栈 >>> : $stack');
    
        Map reportMap = {
          'type': "${error.runtimeType}",
          'title': error.toString(),
          'description': stack.toString()
        };
    
        // 得使用这个
        print('这是通过convert转的json');
        print(jsonEncode(reportMap));
    
        _channel.invokeListMethod('reportException', reportMap);
      }
    }
    

    我们捕获到的异常后,由channel推送给Native,包含三个信息:

    • 异常的类型信息
    • 异常的简要说明信息(即error的toString的值)
    • 异常的堆栈信息
    优化、封装及问题点

    综合上述的阐述,我们将代码做一些封装和优化。

    • 优化:异常捕获后,在debug和release的模式下是不一样的处理,debug模式,直接打印到控制台是最直观的,release模式下,无法感知哪里出了问题,所以我们需要上报,然后分析问题。

    区分当前是debug还是release,有一个比较巧妙的方式,代码及注释如下:

    // 比较巧妙的一种方式判定是否是debug模式
    static bool get isInDebugMode {
        bool inDebugMode = false;
        // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
        assert(inDebugMode = true);
        return inDebugMode;
    }
    

    基于上述的思路,我们将未捕获的异常转发到zone做一个判断:

    // framework异常捕获,转发到当前的 Zone
        FlutterError.onError = (FlutterErrorDetails details) async {
          // debug模式
          if (ExceptionReportUtil.isInDebugMode) {
            // 打印到控制台
            FlutterError.dumpErrorToConsole(details);
    
            // release模式
          } else {
            // 转发到zone
            Zone.current.handleUncaughtError(details.exception, details.stack);
          }
        };
    
    • 封装:main函数中的代码,自然是越简练越好,但将未捕获的异常转发到zone及错误Widget重写必须放在main中,所以抽取一个工具类ExceptionReportUtil:
    /// 工具类
    class ExceptionReportUtil {
      // 比较巧妙的一种方式判定是否是debug模式
      static bool get isInDebugMode {
        bool inDebugMode = false;
        // 如果debug模式下会触发赋值,只有在debug模式下才会执行assert
        assert(inDebugMode = true);
        return inDebugMode;
      }
    
      // 初始化异常捕获配置
      static void initExceptionCatchConfig() {
        // framework异常捕获,转发到当前的 Zone
        FlutterError.onError = (FlutterErrorDetails details) async {
          // debug模式
          if (ExceptionReportUtil.isInDebugMode) {
            // 打印到控制台
            FlutterError.dumpErrorToConsole(details);
    
            // release模式
          } else {
            // 转发到zone
            Zone.current.handleUncaughtError(details.exception, details.stack);
          }
        };
    
        // 重写 ErrorWidget 的builder,显示地优雅一些
        ErrorWidget.builder = (FlutterErrorDetails details) {
          print('错误widget详细的错误信息为:' + details.toString());
    
          return MaterialApp(
            title: 'Error Widget',
            theme: ThemeData(
              primarySwatch: Colors.red,
            ),
            home: Scaffold(
              appBar: AppBar(
                title: Text('Widget渲染异常!!!'),
              ),
              body: _createBody(details),
            ),
          );
        };
      }
    
      // 创建错误widget body
      static Widget _createBody(dynamic details) {
        // 正确代码
        return Container(
          color: Colors.white,
          child: SingleChildScrollView(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Text(
                details.toString(),
                style: TextStyle(color: Colors.red),
              ),
            ),
          ),
        );
      }
    }
    
    • 问题点:在runZonedGuarded函数的闭包中接收未捕获的异常,然后上报,如果执行该闭包中的代码发生异常,是无法捕获的:

    代码及注释如下:

    main(List<String> args) {
      // 初始化Exception 捕获配置
      ExceptionReportUtil.initExceptionCatchConfig();
    
      runZonedGuarded(() {
        runApp(MyApp());
      }, (error, stackTrace) {
        // 这个闭包中发生的Exception是捕获不到的 @山竹
        SYExceptionReportChannel.reportException(error, stackTrace);
      }, zoneSpecification: ZoneSpecification(
        print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
          // 记录所有的打印日志
          parent.print(zone, "line是啥:$line");
        },
      ));
    }
    

    我们通过SYExceptionReportChannel.reportException(error, stackTrace)将错误上报给Native,但在Native如果没有实现channel的链接,那么必然会报MissingPluginException,这个异常是不在当前的zone中的,所以无法捕获。

    missingPluginException.png
    通过一个例子来验证我们的异常捕获

    写了一个例子,来演示这个功能的实现,以及具体的效果:

    demo_page.png

    在点击第三个按钮之前,前面两个按钮都是正常工作,不会发生异常,点击之后就会产生异常。

    通过打印信息,我们来看下每种异常具体捕获到了哪些信息:

    • Dart同步异常:
    dart同步异常.png
    • Dart异步异常:
    dart异步异常.png
    • flutter framework异常:
    flutter_framework异常.png

    通过异常类型、异常信息和异常的具体堆栈,对异常的定位将起到很大的帮助。

    总结

    对于 Flutter 应用的异常捕获,可以分为单个异常捕获和多异常统一拦截两种情况。

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

    在捕获到异常之后,我们需要上报异常信息,用于后续分析定位问题。

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

    后记

    上述代码的DEMO,传送门

    相关文章

      网友评论

          本文标题:Flutter异常捕获

          本文链接:https://www.haomeiwen.com/subject/klrngktx.html