阅读 1430
Flutter Dart 异常不讲武德

Flutter Dart 异常不讲武德

嗳,朋友们大家好,刚才项目经理问我 wandering 老师发生甚么事了,我说怎么回事,给我发了几张截图。我一看!嗷!塬濑氏佐田,有两个需求上线了。一个需求 PRD 九十多公斤,一个需求 PRD 八十多公斤。塔门说,诶...线上收到了三十个多异常警报,让我赶紧分析一下,我说可以。然后我一查异常日志,很快啊,发现一个空指针异常,一个类型转换异常,还有几个官方 issue,我全部 catch 住了啊,catch 住了以后,自然是按照传统的异常处理办法,打印了异常信息,我笑了一下准备合上电脑,因为传统的异常处理方法,我已经处理的非常合理了,我 catch 了没有上报服务器,这是我对异常最后的温柔。当我合上电脑准备下班,突然又来了一个异常告警,我大意了,这个异常没有 catch,还把我们后端服务搞垮了,但是没关系啊...

以上内容调侃归调侃,很多异常处理的细节是不是有点过于真实了?我们应当时时对异常处理保持敬畏。

前言

Flutter Dart 异常与传统原生平台异常很不一样,原生平台的任务采用多线程调度,当一个线程出现未捕获的异常时,会导致整个进程退出。而在 Dart 中是单线程的,任务采用事件循环调度,Dart 异常并不会导致应用程序崩溃,取而代之的是当前事件后续的代码不会被执行了。

这样带来的好处是一些无关紧要的异常不会闪退,用户还可以继续使用核心功能。

坏处是这些异常可能没有明显的提示和异常表现,导致问题容易被隐藏,如果此时恰好是核心流程上且链路较长的异常,可能导致问题排查极难下手。

本文将从异常的捕获、处理、提示、上报和稳定性指标等角度,系统的介绍异常处理的正确做法,帮助我们提升 APP 的稳定性,希望对你有所帮助。

局部异常捕获

同步异常

对于同步异常,我们只需要在可能出现异常的代码块包一层 try-catch 即可。

try {
  String abc;
  print("abc's length ${abc.length}");
} catch (error, stacktrace) {
  //todo catch all error
}
复制代码

catch 最多提供两个可选参数:

  • 第一个参数 error 类型为 Object,也就是异常是可以抛出任意对象。
  • 第二个参数 stacktrace,表示异常堆栈。

如果想捕获特定类型的异常可以,使用on关键字。

try {
  String abc;
  print("abc's length ${abc.length}");
}  on NoSuchMethodError catch (error, stacktrace) {
  //todo catch NoSuchMethodError
}
复制代码

在处理异常中,可以抛出新的异常,使用throw关键字。

try {
  String abc;
  print("abc's length ${abc.length}");
}  on NoSuchMethodError catch (error, stacktrace) {
  //exception handler
  ...
  throw 'abc';
}
复制代码

常见的同步异常包括,空指针异常(NoSuchMethodError)、类型转换异常(type xxx is not a subtype of type xxx)、格式转换异常(FormatException)等。

异步异常

使用 catchError 捕获异步异常,第一个参数为 Function error 类型,入参至多两个 分别为error 和 stackstace,均可选。

Future.delayed(Duration(seconds: 1), () {
  throw '123';
}).then((value) {
  print('value $value');
  return value;
}).catchError((error, stack) {
  print('error $error stack $stack');
});
复制代码

第二个参数为 {bool test(Object error)},是一个判断表达式,当此表达式返回值为 true 时,表示需要执行 catch 逻辑,如果返回 false,则不执行 catch 逻辑,即会成为未捕获的异常,默认不传时 认为是true。

这里的作用是可以精细化的处理异常,可以理解为同步异常中强化版的 on 关键字,例如:

Future.delayed(Duration(seconds: 1), () {
  throw 123;
}).then((value) {
  print('value $value');
  return value;
}).catchError(() {
  //todo exception handler
}, test: (error) => error is int);
复制代码

注意,try-catch 代码块不能捕获到异步异常,使用 await 关键字声明的同步调用,属于同步异常范围,可以通过 try-catch 捕获。

由于异步任务在默认另一个任务队列中,所以这部分异常不会影响 UI 渲染流程,不会在页面上有展示红屏,只会在控制台中输出。

全局异常捕获

全局异常可以分为全局同步异常和全局异步异常。

对于同步异常,大部分出现在 UI 渲染流程中,我们称之为全局 UI 异常。

注意这里说的全局 UI 异常,不是特指 UI 布局或绘制出现的异常,而泛指在整个流程中发生的异常。比如,在 initState 中出现的异常。

对于异步异常,上面讲到可以通过 catchError 手动捕获,那如果没有手动 catch,可不可以在全局集中捕获这些异常呢?还有上面讲到的非 UI 绘制流程中的同步异常,又能不能捕获到呢?

答案是肯定的,我们依次来看:

全局 UI 异常

在 Flutter 的世界一切皆 Widget,视图、业务逻辑由 UI 渲染驱动,你在开发 Flutter 页面时,时不时看见的红屏,实际上就是 Flutter Framework 对 UI 布局绘制中产生的异常捕获后展示的提示页面。

# framework.dart / ComponentElement
void performRebuild() {
    ...
    Widget built;
    try {
      built = build();
      ...
    } catch (e, stack) {
      built = ErrorWidget.builder(
        _debugReportException(
          ErrorDescription('building $this'),
          e, stack,
          ...
        ),
      );
    } finally {
      ...
    }
    try {
      _child = updateChild(_child, built, slot);
    } catch (e, stack) {
      built = ErrorWidget.builder(
        _debugReportException(
          ErrorDescription('building $this'),
          e, stack,
          ...
          },
        ),
      );
      _child = updateChild(null, built, slot);
    }
  }
复制代码

performRebuild 在 widget 重绘时调用,是 UI 渲染流程的必经之路,包括 State 生命周期,可以看到其内部对 build 方法和 updateChild 更新方法都做了 try-catch 处理。

ErrorWidget.builder 返回的 widget 将作为错误信息,展示在前台,默认也就是我们看到的红底黄字的错误页。

_debugReportException 方法内部会将错误封装成一个 _debugReportException 对象返回,并调用 FlutterError 对象的静态函数 onError ,默认是将错误信息、堆栈等信息打印在控制台。

# framework.dart
FlutterErrorDetails _debugReportException(
  DiagnosticsNode context,
  dynamic exception,
  StackTrace stack, {
  InformationCollector informationCollector,
}) {
  final FlutterErrorDetails details = FlutterErrorDetails(
    exception: exception,
    stack: stack,
    library: 'widgets library',
    context: context,
    informationCollector: informationCollector,
  );
  FlutterError.reportError(details);
  return details;
}

# FlutterError
static FlutterExceptionHandler onError = dumpErrorToConsole;

static void reportError(FlutterErrorDetails details) {
...
if (onError != null)
  onError(details);
}
复制代码

因此,我们可以在 main 函数入口,为 FlutterError 的 onError 重新赋值做自定义处理。

void main() {
    FlutterError.onError = (FlutterErrorDetails details) {
        //todo 自定义的异常处理
        print('catch a exception');
        FlutterError.dumpErrorToConsole(details);
  }; 
  runApp(MyApp());
}
复制代码

你如果实际测试一下会发现,这样做并不能捕获异常,这是因为 Flutter 的官方 issue#47447,在 release 模式下才能生效,或者使用船新的 Flutter 版本。

全局未捕获异常处理

Flutter 为我们提供 Zone 的概念,相当于沙盒,将我们的 runApp 调用包裹在一个 runZoned 下,可以全局捕获未 catch 的异常,包括异步异常,类似 Android 中的Thread.UncaughtExceptionHandler

# main.dart
runZoned(() {
    runApp(MyApp());
}, onError: _handleError);

void _handleError(Object obj, StackTrace stack) {
  // todo global uncaught exception handle
  print('zone _handleError $obj stack $stack');
}
复制代码

至此,我们就完成了 APP 内全部的 Dart 异常捕获的工作。

关于 Zone

Dart 中的 Zone 表示指定代码执行的环境,主线程也是通过 _runMainZoned 方法启动一个新的沙盒环境。通过 runZoned 函数会基于当前 Zone Fork 一个新的沙盒环境,在这个沙盒环境中,我们可以做集中的异常处理,或者改变一些默认的系统行为。

# Zone.dart
R runZoned<R>(R body(),
    {Map zoneValues,
    ZoneSpecification zoneSpecification, 
    Function onError})
复制代码
  • zoneValues:指定 Zone 的私有数据,默认情况下父 Zone 节点设置的 zoneValues,在子 Zone 中同样可以访问到。我们可以通过 .parent 方法获得父 Zone 的引用Zone.current.parent。此外,这里设置 zoneValues 以后,在当前 zone 的任何位置,都可以通过Zone.current[#key]访问到值。
  • zoneSpecification:可以设置一些系统行为的拦截,比如全局事件回调、全局异常回调、Timer 创建拦截、print 打印拦截(可以做日志收集或美化打印)等等,详细参考 ZoneSpecification.class
  • onError: 即上文讲到的全局未捕获的异常,都会这里收到回调。对于已捕获的异常,我们也可以通过Zone.current.handleUncaughtError(exception, stack)方法发送到这个 onError 中集中处理。

比如上文提到的全局 UI 异常,是被 Flutter Framework 捕获了。那么我们可以通过下面的代码,收集到这部分异常。

FlutterError.onError = (FlutterErrorDetails details) async {
    Zone.current.handleUncaughtError(details.exception, details.stack);
};
复制代码

需要指出,Flutter 中的 Future 就是对 Zone 的封装。

异常提示和上报收集

异常捕获到了,仅仅完成了万里长征第一步,可以看到 UI 渲染的异常会有明显的视图提示,但是对于异步异常对研发和测试是无感知的,这不像是原生开发,出现异常 APP 会直接闪退。

这导致很容易忽略这部分异常,为了尽早的将问题暴露出来,我们必须要捕获到异常时给测试和开发 强提示

自定义异常提示视图

上文讲到,当 UI 渲染出现异常时,会展示一个红屏黄字的异常视图,这是 Flutter 的默认行为,我们可以在 main 函数中,自定义一个错误提醒页面,下面是一个例子:

# main.dart
ErrorWidget.builder = (FlutterErrorDetails detail) {
return Container(
    color: Colors.white,
    child: Column(children: <Widget>[
      Text(
        'error\n--------\n ${detail.exception}',
        style: TextStyle(fontSize: 12, color: Colors.green),
      ),
      SizedBox(height: 12),
      Text(
        'stacktrace\n--------\n ${detail.stack}',
        style: TextStyle(fontSize: 12, color:Colors.green),
      ),
    ]));
};
复制代码

效果如图,是不是看起来比红屏让人淡定多了呢?

自定义异常视图

全局提示弹窗

对于其他异常,默认是不会在页面上提示的,上文已经可以捕获到了这部分异常,我们可以将这些异常以弹窗的形式提醒开发者和测试人员。

具体可以这样做:

runZoned(() {
    runApp(MyApp());
}, onError: (exception, stackTrace) {
    _handleError(exception, stackTrace);
});

/// 异常处理
void _handleError(Object error, StackTrace stack, Map zoneValues) {
    debugPrint(error);//打印异常信息
    _showExceptionAlertDialog(error, stack);//提示弹窗
    _uploadException(error, stack);//上报异常
}
复制代码

弹窗代码如下:

void _showExceptionAlertDialog(Object error, StackTrace stack) {
  //①
  assert(() {
    GlobalKey key = globalKey;
    if (key == null || key.currentContext == null) {
      return true;
    }
    //②
    SchedulerBinding.instance.addPostFrameCallback(_) {
      showDialog(
        context: key.currentContext,//③
        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('$error'),
            content: SingleChildScrollView(
                child: Text("$stack"),
            ),
            actions: ...,
          );
        },
      );
    });
    return true;
  }());
}
复制代码

最终效果是这样的:

异常弹窗示例

这里有三个地方需要注意:

  1. 使用 assert 断言,只会在 debug 模式下执行,因此 release 版本不会弹出异常对话框。
  2. 由于异常可能在任何时刻出现,而视图有可能正在渲染 ,所以需要注册一个渲染结束的回调,在本帧渲染之后执行。对于渲染阶段的异常会在这一帧结束后立刻弹窗,对于异步异常如果在当前帧结束后才出现,则会在下帧渲染结束后弹出。
  3. 弹窗需要指定一个 context,用于确定所属的导航栈,这里使用全局的 GlobalKey 就可以,而这个 GlobalKey 通常可以用 MaterialApp 中 home 指向的 Widget 声明的 GlobalKey。这么做的目的是,home 一定不会退出,可以保证这个 context 一定存在。

为了提高移植性,我将异常的回调提取出来,并提供一些开关,供业务层灵活使用,详细参考 Github flutter_exception_handler

对于异常的上报,如果没有自研的异常收集平台,可以使用 bugly 或者开源的 Sentry 服务。

至此,我们已经完成了异常的捕获和上报,有了这些异常数据,接下来我们应该如何评价项目的稳定性呢?

稳定性指标

对于原生开发而言,稳定性可以用崩溃率来衡量,崩溃率达到万分之一就是优秀的标准。

但是对于 Flutter 而言,崩溃只会出现在 Flutter 引擎或者一些 channel 中,Dart 侧的异常都不会导致应用程序的崩溃,大部分业务的异常都在 Dart 侧,所以传统的崩溃率统计一定是大幅下降的(不排除会出现一些奇奇怪的 Flutter Engine Crash,所以还是建议升级到最新的 Flutter 版本)。

与之相反,如果统计 Dart 异常率,这个指标有可能高的离谱,原因是一次启动可能导致多次异常,如果在一个定时任务中出现异常,那么这个指标就更高了。所以为了更合理的衡量 Flutter 侧的稳定性,我们可以统计 页面异常率

页面异常率 = 异常数 / 打开的页面数

对于打开的页面数,我们可以通过为 MaterialApp 注册 navigatorObservers,来监听全局的页面路由数据。在异常上报时同时附带当前路由信息,即可实现页面异常率的统计。

我们可以更精细化的统计某个页面的异常率,理论上这个值同样可能超过 100%,但相对于崩溃率的统计方式,已经大幅趋向合理。

对于重 Flutter 的业务场景,页面异常率应当同样以万分之一作为优秀标准。

常见的异常处理 Tips

这里提供几个工作中常见的异常情况,供你参考并在实际开发中多加留意。

  1. 多使用 ? 和操作符 ?? 来进行验空操作。
  2. 基本类型的操作注意验空,在 Dart 语言中一切皆为对象,常见的 int,bool 都是对象,且默认值是null,如果你稍不注意写出 if(isComplete)bool isExceed = count < 10 这样的代码,就可能出问题,正确的做法可以是这样,if(isComplete ?? false)bool isExceed = (count ?? 0) < 10
  3. setState() 保证在 mounted 条件下。
  4. 使用完的 ScrollController 注意调用 dispose 销毁,已经销毁的不要再次使用。
  5. 转数字的场景,可以使用 int.tryParse,而不是直接 int.parse,前者内部会处理异常情况。
  6. Text 指定的文本不能为 null,需做好验空处理。
  7. PlatformChannel 注意提前注册,并做好 method 实现。
  8. 做好核心流程和分支流程的隔离,比如首页同时请求三个网络接口,但核心接口是第一个,那么应当对第一个和后两个做单独的异常处理,而不能整体一起 catch。
  9. 尽量升级最新的 Flutter 版本,当真正拿到线上的异常数据时,你会发现很多异常都是 Flutter 官方 issue。
  10. 对于重度依赖 Webview 的场景,请尽快升级到 1.20 及以后版本,使用 HybridComposition 模式渲染 Webview,避免键盘、Resize、多指操作等奇葩问题。
  11. 对于在业务代码中已经手动捕获的异常,应该主动上报到独立的异常类型中,以和未捕获的异常区分。

相关文章

欢迎关注公众号 wanderingTech ,获取更多深度好文,溜了~~~

wx公众号

文章分类
前端
文章标签