以上内容调侃归调侃,很多异常处理的细节是不是有点过于真实了?我们应当时时对异常处理保持敬畏。
前言
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);
由于异步任务在默认另一个任务队列中,所以这部分异常不会影响 UI 渲染流程,不会在页面上有展示红屏,只会在控制台中输出。
全局异常捕获
全局异常可以分为全局同步异常和全局异步异常。
对于同步异常,大部分出现在 UI 渲染流程中,我们称之为全局 UI 异常。
对于异步异常,上面讲到可以通过 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;
}());
}
最终效果是这样的:
这里有三个地方需要注意:
- 使用 assert 断言,只会在 debug 模式下执行,因此 release 版本不会弹出异常对话框。
- 由于异常可能在任何时刻出现,而视图有可能正在渲染 ,所以需要注册一个渲染结束的回调,在本帧渲染之后执行。对于渲染阶段的异常会在这一帧结束后立刻弹窗,对于异步异常如果在当前帧结束后才出现,则会在下帧渲染结束后弹出。
- 弹窗需要指定一个 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
这里提供几个工作中常见的异常情况,供你参考并在实际开发中多加留意。
- 多使用
?
和操作符??
来进行验空操作。 - 基本类型的操作注意验空,在 Dart 语言中一切皆为对象,常见的 int,bool 都是对象,且默认值是null,如果你稍不注意写出
if(isComplete)
或bool isExceed = count < 10
这样的代码,就可能出问题,正确的做法可以是这样,if(isComplete ?? false)
和bool isExceed = (count ?? 0) < 10
。 - setState() 保证在 mounted 条件下。
- 使用完的 ScrollController 注意调用 dispose 销毁,已经销毁的不要再次使用。
- 转数字的场景,可以使用 int.tryParse,而不是直接 int.parse,前者内部会处理异常情况。
- Text 指定的文本不能为 null,需做好验空处理。
- PlatformChannel 注意提前注册,并做好 method 实现。
- 做好核心流程和分支流程的隔离,比如首页同时请求三个网络接口,但核心接口是第一个,那么应当对第一个和后两个做单独的异常处理,而不能整体一起 catch。
- 尽量升级最新的 Flutter 版本,当真正拿到线上的异常数据时,你会发现很多异常都是 Flutter 官方 issue。
- 对于重度依赖 Webview 的场景,请尽快升级到 1.20 及以后版本,使用 HybridComposition 模式渲染 Webview,避免键盘、Resize、多指操作等奇葩问题。
- 对于在业务代码中已经手动捕获的异常,应该主动上报到独立的异常类型中,以和未捕获的异常区分。
相关文章
- Report errors to a service
- Dart Zones
欢迎关注公众号 wanderingTech ,获取更多深度好文,溜了~~~
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!