Flutter 小心使用 ErrorWidget.builder

3,397 阅读3分钟

前言

最近组里童鞋,在写代码的时候,多次出现

Debug 都是好好的,Release 咋变成白屏了。

实际上 Debug 模式下,只是 ui 看起来好的,但其实上控制台已经有报错信息了,可能是因为 Debug 模式下,信息太多,没看到。

项目是重写过 ErrorWidget.builder ,按道理说应该能看到错误信息的,但实际上 却是白屏。

ErrorWidget.builder 我翻看了网络上大部分的文章,都表达的是用来重写发生错误的时候用于展示错误信息的 widget。但是实际上,并不是这么简单的。

To define a customized error widget that displays whenever the builder fails to build a widget

Handling errors in Flutter | Flutter

在 Flutter 里处理错误 - Flutter 中文文档 - Flutter 中文开发者网站 - Flutter

分析

错误代码

下面是几种错误代码演示:

    Stack(
      children: <Widget>[
        GestureDetector(
          child: Positioned(
            child: Container(
              height: 10,
              width: 10,
              color: Colors.red,
            ),
          ),
        )
      ],
    )
    Stack(
      children: <Widget>[
        Expanded(child: Container(),),
      ],
    )
    Column(
      children: <Widget>[
        Positioned(child: Container())
      ],
    )

报错的原因,应该很明白,没有弄清楚 Positionedparent 必须是 StackExpandedparent 必须是 Column,Row,Flex.

白屏原因

我们先看一下 ErrorWidget.builder 的注释

  /// The configurable factory for [ErrorWidget].
  ///
  /// When an error occurs while building a widget, the broken widget is
  /// replaced by the widget returned by this function. By default, an
  /// [ErrorWidget] is returned.
  ///

the broken widget is replaced by the widget returned by this function

大家注意这一句话,出错的 widget 会被这个方法创建的 widget 替换掉。如果你用 profile 模式跑上面的代码,你会发现 ErrorWidget.builder 会被不停的触发。白屏的原因找到了,你提供的 ErrorWidget.builder 在这种场景下,依然是错误的,造成了无限循环。

官方默认处理

我们再看看这段注释。

  /// The system is typically in an unstable state when this function is called.
  /// An exception has just been thrown in the middle of build (and possibly
  /// layout), so surrounding widgets and render objects may be in a rather
  /// fragile state. The framework itself (especially the [BuildOwner]) may also
  /// be confused, and additional exceptions are quite likely to be thrown.
  ///
  /// Because of this, it is highly recommended that the widget returned from
  /// this function perform the least amount of work possible. A
  /// [LeafRenderObjectWidget] is the best choice, especially one that
  /// corresponds to a [RenderBox] that can handle the most absurd of incoming
  /// constraints. The default constructor maps to a [RenderErrorBox].

因为调用这个方法的时候,整个结构已经不稳定,如果你继续使用复杂的 widget, 可能会造成额外的异常,所以推荐使用最小结构 LeafRenderObjectWidget,处理不合理的约束。

官方默认的是 ErrorWidget(RenderErrorBox)

错误文本

release 无错误信息,debugexception

    String message = '';
    assert(() {
      message = '${_stringify(details.exception)}\nSee also: https://flutter.dev/docs/testing/errors';
      return true;
    }());
    final Object exception = details.exception;

错误背景

release 灰色背景,debug 红色背景。

  static Color _initBackgroundColor() {
  // release 灰色
    Color result = const Color(0xF0C0C0C0);
    assert(() {
      // debug 红色
      result = const Color(0xF0900000);
      return true;
    }());
    return result;
  }

约束

宽高 (100000.0*100000.0)

  const double _kMaxWidth = 100000.0;
  const double _kMaxHeight = 100000.0;
  @override
  double computeMaxIntrinsicWidth(double height) {
    return _kMaxWidth;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    return _kMaxHeight;
  }

  @override
  bool get sizedByParent => true;

  @override
  bool hitTestSelf(Offset position) => true;

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return constraints.constrain(const Size(_kMaxWidth, _kMaxHeight));
  }

绘制文本

最终 RenderErrorBoxpaint 中绘制出错误信息

  @override
  void paint(PaintingContext context, Offset offset) {
    try {
      context.canvas.drawRect(offset & size, Paint() .. color = backgroundColor);
      if (_paragraph != null) {
        double width = size.width;
        double left = 0.0;
        double top = 0.0;
        if (width > padding.left + minimumWidth + padding.right) {
          width -= padding.left + padding.right;
          left += padding.left;
        }
        _paragraph!.layout(ui.ParagraphConstraints(width: width));
        if (size.height > padding.top + _paragraph!.height + padding.bottom) {
          top += padding.top;
        }
        context.canvas.drawParagraph(_paragraph!, offset + Offset(left, top));
      }
    } catch (error) {
      // If an error happens here we're in a terrible state, so we really should
      // just forget about it and let the developer deal with the already-reported
      // errors. It's unlikely that these errors are going to help with that.
    }
  }

优化

从上面分析看来,ErrorWidget.builder 真的不是简简单单重写就行了。如果想在 release 环境下面显示出来错误信息,那我们应该怎么做呢?

方案1

还是用官方的 ErrorWidget,只是去掉对 message 只在 debug 下面赋值的限制。缺点,看不完全信息,没法滚动。

Widget _defaultErrorWidgetBuilder(FlutterErrorDetails details) {
  String message =
      '${details.exception}\nSee also: https://flutter.dev/docs/testing/errors';

  final Object exception = details.exception;
  return ErrorWidget.withDetails(
      message: message, error: exception is FlutterError ? exception : null);
}

void main() {
  ErrorWidget.builder = _defaultErrorWidgetBuilder;
}

方案2

发生错误的时候弹一个框来显示。

  • 优点,可以定制,显示信息的界面
  • 缺点,也不是百分百靠谱,毕竟你不知道发生错误情况是对哪一部分影响
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';

void main() {
  FlutterError.onError = _onError;

  runZonedGuarded<void>(() async {
    runApp(const MyApp());
  },
      ((error, stack) => _onError(FlutterErrorDetails(
            exception: error,
            stack: stack,
          ))));
}

void _onError(FlutterErrorDetails details) {
  // 根据自己情况上报异常
  // 
  // 显示异常
  WidgetsBinding.instance.addPostFrameCallback(
    (timeStamp) {
      showDialog(
        context: MyApp.navigatorKey.currentContext!,
        builder: (b) {
          return Padding(
            padding: const EdgeInsets.all(20.0),
            child: GestureDetector(
              onTap: () {
                Navigator.of(b).pop();
                exit(1);
              },
              child: Material(
                child: Container(
                  padding: EdgeInsets.all(10),
                  child: SingleChildScrollView(
                      child: Column(
                    children: <Widget>[
                      Text('exception:'),
                      Text('${details.exception}'),
                      Text('stack:'),
                      Text('${details.stack}'),
                    ],
                  )),
                ),
              ),
            ),
          );
        },
      );
    },
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      navigatorKey: navigatorKey,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(
        children: <Widget>[
          Expanded(
            child: Container(),
          ),
        ],
      ),
    );
  }
}

结语

我在 ErrorWidget.builder not working on profile/release · I… 中跟官方有相关的讨论。

When writing an ErrorWidget you have to take great care that it cannot fail to build as there is no back up.

希望官方能在文档中,着重提醒这个问题,毕竟不是每个开发者都能够 take great care

也希望官方能提供出更加安全,简单的方式来自定义展示错误信息。

最后再次强调,ErrorWidget.builder 不是用来给你自定义展示错误信息的,它是发生错误的时候,用来替换错误 widget 的备份。

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081

最最后放上 Flutter Candies 全家桶,真香。