简话-Flutter异常处理

3,184 阅读5分钟
  1. 为什么写这篇文章
  2. Flutter异常分类
  3. Flutter异常的捕获方式和处理
  4. 针对特定业务的异常捕获如何处理?
  5. 为什么flutter触发dart异常的时候不会崩溃
  6. 在程序中内部用沙盒保护时,为什么部分异常捕捉不到?

为什么写这篇文章

flutter异常处理整体来说还是比较简单的,主要就是zone(沙盒),try/catch,Future的异步异常捕获,写这篇文章的原因是在开发flutter 动态化的时候,需要针对动态化的widget做特殊处理,比如单个widget降级、页面降级且不能影响其他业务等等。 所以梳理下关于flutter exception的知识,留个记录。

flutter异常分类

  • Native异常
    • plugin异常,比如Android端java,Ios端objectc触发的异常,这些异常都会导致程序闪退,和native的其他异常处理方式是一样的,这些不在本文的讨论范围之内
  • Dart异常
    • Zone,概念等同于沙盒,是一段代码执行的环境,不同的沙盒之间是相互隔离的,Zone可以同时捕获同步异常和异步异常,主要捕获flutter framework和应用代码未捕获的异常,这里说下同步异常和异步异常的区别
      • 同步异常:正常代码执行时发生的异常,可以被try/catch捕获,也可以被Zone捕获
      • 异步异常:try/catch是捕获不了的,但可以被Future的catchError捕获,Zone也可以捕获
  1. flutter framework异常,是指framework在运行中触发的异常,这部分异常已经被framework捕获

  2. widget build异常,其实也是属于flutter framework异常,由于发生在build阶段,framework在处理的时候为我们返回了ErrorWidget,我们在开发时接触最多的就是这类异常,回忆下红屏+黄色字体的报错吧~


看几个例子,分别是Zone,同步异常,异步异常

// 沙盒
runZonedGuarded<Future<Null>>(() async {
    runApp(MyApp());
  }, (error, stackTrace) async {
      //这里可以捕捉同步异常,也可以捕捉异步异常
    print("expectinTest zone error ${stackTrace.toString()}");
  });
  
  
String c;
Future.value(1).then((value) {
    // 异步异常,c是null
    print("app async error ${c.length}");
  }).catchError((error){
      // 输出异常,这里是捕捉异步异常
    print(error);
});


// 同步异常
try {
  print("expectinTest app error ${c.length}");
} catch (e) {
    print(e);
}

// 注意,这种情况try/catch是捕获不到的
try {
  Future.value(1).then((value) {
    // 异步异常,c是null
    print("app async error ${c.length}");
  });
} catch (e) {
    print(e);
}

flutter异常的捕获方式和处理

主要看以下三点

  1. flutter framework 是怎么捕获异常的
  2. widget build异常是如何用ErrorWidget处理成的
  3. Zone 是如何捕获异常的

flutter framework 是怎么捕获异常的,widget build异常是如何用ErrorWidget处理成的

说到这个,就不得不说flutter的三棵树,widget-element-render,日常开发中我们接触到最多的是widget,而在widget中,StatefulWidget和StateLessWidget是我们最熟悉的,他们对应的element分别是StatefulElement和StatelessElement,而他们的共同祖先是ComponentElement,注意ComponentElement的performRebuild方法(文章里的源码只保留部分逻辑,其他不重要的都会删掉)

// 对于StatefulWidget和StateLessWidget,他们执行的build方法都是这里发起的
@override
void performRebuild() {
  Widget built;
  try {
    // 这里执行的就是StatefulWidget和StateLessWidget的build方法
    built = build();
    // 这个debug保留着,之后会有一个比较有意思的小实验
    debugWidgetBuilderValue(widget, built);
  } catch (e, stack) {
    _debugDoingBuild = false;
    // 如果发生异常,就返回ErrorWidget
    built = ErrorWidget.builder(
        // 在_debugReportException方法里调用了FlutterError.reportError(details);
      _debugReportException(
        ErrorDescription('building $this'),
        e,
        stack,
        informationCollector: () sync* {
          yield DiagnosticsDebugCreator(DebugCreator(this));
        },
      ),
    );
  } finally {
   _dirty = false;
    assert(_debugSetAllowIgnoredCallsToMarkNeedsBuild(false));
  }
}

// _debugReportException方法里调用了FlutterError.reportError上报异常
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;
}

针对以上源代码,我们发现了几个关键点,分别是build(),包裹在build()外的try/catch,ErrorWidget,FlutterError.reportError

  1. build(),日常编码中继承StatefulWidget和StateLessWidget的build方法就是这里发起调用的,很明显在调用build方法时,framework做了try/catch保护,这就是flutter framework的异常,当然保护远不止这里,但已经包含大多数错误了
  2. ErrorWidget,可以看到build方法发生异常时,系统会默认返回ErrorWidget,这个ErrorWidget就是我们日常开发中看到的红屏
  3. FlutterError.reportError,flutter framework异常都会通过这个方法上报,系统内的默认实现是把错误输出到控制台

flutter framework异常处理

FlutterError.reportError

看源码会发现,其默认实现就是个静态函数,最终会走到onError方法

/// _debugReportException调用了reportError方法
static void reportError(FlutterErrorDetails details) {
  assert(details != null);
  assert(details.exception != null);
  if (onError != null)
    onError(details);
}

//onError 默认实现是dumpErrorToConsole,dumpErrorToConsole就是把错误输出到控制台
static FlutterExceptionHandler onError = dumpErrorToConsole;

看完源码会发现,他们都是静态的,而且不是private的,那就好办了,自己实现个 FlutterExceptionHandler就行了,不过有一点要注意的是,赋值后应该要保留原有的onError,自己处理完异常后,也要把异常向上抛,类似于这样

final defaultOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
  // 自己先处理异常
  
  // 把异常向上透传,是否透传取决于业务
  defaultOnError(details);
};
ErrorWidget

这个其实是LeafRenderObjectWidget,这里顺便提下,Widget也分多种,例如常见的布局Widget,如StatelessWidget,StatefullWidget,带有布局-渲染属性的RenderObjectWidget,而LeafRenderObjectWidget就是RenderObjectWidget的子类,相似与ErrorWidget还有RawImage,这是图片的实际渲染类。 ErrorWidget的作用就是把错误堆栈渲染到屏幕上,所以系统的默认实现就是在build这个关键点上做了try/catch的保护,并在catch的保护中返回ErrorWidget,那我们实际开发中怎么处理呢?

// 看源码会发现,实际调用的是ErrorWidget.builder
built = ErrorWidget.builder(
  _debugReportException(
    ErrorDescription('building $this'),
    e,
    stack,
    informationCollector: () sync* {
      yield DiagnosticsDebugCreator(DebugCreator(this));
    },
  ),
);

// ErrorWidgetBuilder builder 也是个static function
static ErrorWidgetBuilder builder = _defaultErrorWidgetBuilder;

//_defaultErrorWidgetBuilder源码我不贴了,实现是构建返回了ErrorWidget

既然ErrorWidgetBuilder builder是个static function,那就好办了,自己实现个覆盖他就行了

var defaultErrorBuilder = ErrorWidget.builder;
ErrorWidget.builder = (FlutterErrorDetails details) {
  Widget errorBuilder;
  if (debug){
      // debug 建议走系统的,方便排查问题
    errorBuilder = defaultErrorBuilder(details);
  } else{
      // 线上环境 走业务兜底,避免影响线上用户
     errorBuilder = buildErrorWidget(details);      
  }
  return errorBuilder;
};
Zone 是如何捕获异常的

Zone能捕获代码中未处理的同步异常和异步异常,一般我们的写法都是把runApp包在里面,但有没有想过为什么flutter会推荐这样写,包裹一段其他的代码能不能行?

runZonedGuarded<Future<Null>>(() {
    runApp(MyApp());
  }, (error, stackTrace) {
    print("expectinTest zone error ${stackTrace.toString()}");
  });

接着看runZonedGuarded源码

@Since("2.8")
R runZonedGuarded<R>(R body(), void onError(Object error, StackTrace stack),
    {Map zoneValues, ZoneSpecification zoneSpecification}) {
    // 省略部分代码
  try {
      // 可以看到在实际运行的时候,用try/catch包了一层
    return _runZoned<R>(body, zoneValues, zoneSpecification);
  } catch (error, stackTrace) {
      // 如果触发异常,执行onError回调,这里就是同步异常
    onError(error, stackTrace);
  }
  return null;
}

通过源码可以看到在真实执行runZone时,还是用try/catch包了一层,并在触发同步异常后,执行onError回调,这是runZonedGuarded能捕获同步异常的原因。那异步异常是怎么捕获的呢?还得看Future源码。

// 这是上面的例子,运行后这个异常可以被Zone捕获到
Future.value(1).then((value) {
    // 异步异常,c是null
    print("app async error ${c.length}");
  });
  
 // 异步异常的回调代码在future_impl里
 static void _propagateToListeners(_Future source, _FutureListener listeners) {
  while (true) {
    assert(source._isComplete);
    bool hasError = source._hasError;
    if (listeners == null) {
        // 看这里
      if (hasError) {
        AsyncError asyncError = source._error;
        // 如果有错误,调用_Future的zone的handleUncaughtError方法,最终会回调到onError方法
        source._zone
            .handleUncaughtError(asyncError.error, asyncError.stackTrace);
      }
      return;
    }
    // 省略部分代码
 }
}

// 那_Future的zone是怎么来的? 其实是初始化的时候就赋值了
_Future() : _zone = Zone.current;

//也可以指定zone
_Future.zoneValue(T value, this._zone) {
  _setValue(value);
}

思考题:Zone保护一段其他代码,如果这段代码里有一段异步代码,并且这部分异步代码抛出错误,Zone的异常捕获还能生效吗?这个问题放在问题6解答。 至此,问题1-3都回答完了。

特定业务的异常如何处理?

最近在做flutter动态化相关的需求,业务上有这样一个场景,需要hook某个特定Widget的build异常,让其不要影响到主业务的降级和错误上报。

解决方式是自定义ErrorWidgetBuilder builder,在build的回调中通过判断错误的堆栈,context等信息,如果需要hook,就返回自定义ErrorWidget,如果不需要,抛给上层处理

Future<void> _setErrorWidgetBuilder() async {
  var defaultErrorBuilder = ErrorWidget.builder;
  ErrorWidget.builder = (FlutterErrorDetails details) {
    if (buildErrorWidget == null) {
      return defaultErrorBuilder(details);
    }
    // 如果不是dynamic异常,直接抛给默认的处理
    if (!checkIfSpecialException(details)) {
      print("expectinTest normal build error");
      return defaultErrorBuilder(details);
    }
    Widget errorBuilder;
    errorBuilder = buildErrorWidget();// 构建自己错误的异常
    return errorBuilder;
  };
}

Set<String> _hookClassNameSet = Set<String>..add("a")..add("b");
bool checkIfHookException(FlutterErrorDetails details) {
  if (isEmpty(_hookClassNameSet)) return false;
  if (details == null) {
    return false;
  }
  
  // 判断上下文是否包含特殊类名
  if (details.context != null) {
    bool isHookError = false;
    _hookClassNameSet.forEach((element) {
      if (details?.context?.toString()?.contains(element) ?? false) {
        isHookError = true;
      }
    });
    if (isHookError) return true;
  }
  
   // 把堆栈处理成List,循环验证是否包含特殊类名
  Iterable<String> lines =
      details?.stack?.toString()?.trimRight()?.split('\n') ?? [];
  if (isEmpty(lines)) {
    return false;
  }
  final List<StackFrame> parsedFrames =
      StackFrame.fromStackString(lines.join('\n'));

  for (int index = 0; index < parsedFrames.length; index += 1) {
    final StackFrame frame = parsedFrames[index];
    final String className = '${frame.className}';
    if (_hookClassNameSet.contains(className)) {
      return true;
    }
  }
  return false;
}

为什么flutter触发异常的时候不会崩溃?

这个和flutter的消息循环机制有关,任务分两种,一个是微任务microtask,一个是事件event,他们有自己的队列,每个任务是相互独立的,一旦某个任务触发异常,也就是导致这个任务后续代码无法执行,并不会影响其他任务执行

在程序中内部用沙盒保护时,为什么部分异常捕捉不到?

这问题比较有意思,假设有以下场景,针对某个widget的build函数单独起一个Zone,但在build里运行Future并抛错时,发现这个Zone无法捕获这个异常错误。原因还是要看runZone的这段源码

String c;
 runZonedGuarded<Future<Null>>(() async {
   Future.value(1).then((value) {
    // 异步异常
    print("app async error ${c.length}");
      });
  }, (error, stackTrace) async {
    print("expectinTest zone error ${stackTrace.toString()}");
  });
 
 try {
    return _runZoned<R>(body, zoneValues, zoneSpecification);
  } catch (error, stackTrace) {
    onError(error, stackTrace);
  }
  
  /// Runs [body] in a new zone based on [zoneValues] and [specification].
R _runZoned<R>(R body(), Map zoneValues, ZoneSpecification specification) =>
    Zone.current
        .fork(specification: specification, zoneValues: zoneValues)
        .run<R>(body)

可以看到保护期只是在 _runZoned运行期间,也就是第一个参数body的执行范围内,而我们在针对特殊widget保护build的时候,build马上就执行完了,意味着保护就结束了,所以异步的异常也是同理,当前的自定义zone已经的保护期已经结束了,所以捕获不到。 这也是为什么我们看到的例子 runZone都是运行在main函数这里的原因。因为runApp()其实开启了Flutter主线程的运行,如果用户不退出APP,主线程会一直运行