flutterdartarchitecture

How to catch async exception in one place (like main) and show it in AlertDialog?


Trouble

I build Flutter app + Dart. Now i am trying to catch all future exceptions in ONE place (class) AND showAlertDialog.

Flutter Docs proposes 3 solutions to catch async errors:

  1. runZonedGuarded
  2. ... async{ await future() }catch(e){ ... }
  3. Future.onError

But no one can achieve all of the goals (in its purest form):

First: can't run in widget's build (need to return Widget, but returns Widget?.

Second: works in build, but don't catch async errors, which were throwed by unawaited futures, and is"dirty" (forces to use WidgetBinding.instance.addPostFrameCallback. I can ensure awaiting futures (which adds to the hassle), but I can't check does ensures it third-part libraries. Thus, it is bad case.

Third: is similar to second. And looks monstrous.

My (bearable) solution

I get first solution and added some details. So,

I created ZonedCatcher, which shows AlertDialog with exception or accumulates exceptions if it doesn't know where to show AlertDialog (BuildContext has not been provided). AlertDialog requires MaterialLocalizations, so BuildContext is taken from MaterialApp's child MaterialChild.

void main() {
  ZonedCatcher().runZonedApp();
}

...

class ZonedCatcher {
  BuildContext? _materialContext;
  set materialContext(BuildContext context) {
    _materialContext = context;
    if (_exceptionsStack.isNotEmpty) _showStacked();
  }

  final List<Object> _exceptionsStack = [];

  void runZonedApp() {
    runZonedGuarded<void>(
      () => runApp(
        Application(
          MaterialChild(this),
        ),
      ),
      _onError,
    );
  }

  void _onError(Object exception, _) {
    if (_materialContext == null) {
      _exceptionsStack.add(exception);
    } else {
      _showException(exception);
    }
  }

  void _showException(Object exception) {
    print(exception);
    showDialog(
      context: _materialContext!,
      builder: (newContext) => ExceptionAlertDialog(newContext),
    );
  }

  void _showStacked() {
    for (var exception in _exceptionsStack) {
      _showException(exception);
    }
  }
}


...

class MaterialChild extends StatelessWidget {
  MaterialChild(this.zonedCatcher);

  final ZonedCatcher zonedCatcher;

  @override
  Widget build(BuildContext context) {
    zonedCatcher.materialContext = context; //!
    ...
  }
}

flaws

  1. At this moment I don't know how organize app with several pages. materialContext can be taken only from MaterialApp childs, but pages are set already at the MaterialApp widget. Maybe, I will inject ZonedCatcher in all pages and building pages will re-set materialContext. But I probably will face with GlobalKey's problems, like reseting materialContext by some pages at the same time on gestures.
  2. It is not common pattern, I have to thoroughly document this moment and this solution makes project harder to understand by others programmists.
  3. This solution is not foreseen by Flutter creators and it can break on new packages with breaking-changes.

Any ideas?


Solution

  • By default, if there is an uncaught exception in a Flutter application, it is passed to FlutterError.onError. This can be overridden with a void Function(FlutterErrorDetails) to provide custom error handling behaviour:

    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      FlutterError.onError = (details) {
        print(details.exception);  // the uncaught exception
        print(details.stack);  // the stack trace at the time
      }
      runApp(MyApp());
    }
    

    If you want to show a dialog in this code, you will need access to a BuildContext (or some equivalent mechanism to hook into the element tree).

    The standard way of doing this is with a GlobalKey. Here, for convenience (because you want to show a dialog) you can use a GlobalKey<NavigatorState>:

    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      final navigator = GlobalKey<NavigatorState>();
      FlutterError.onError = (details) {
        navigator.currentState!.push(MaterialPageRoute(builder: (context) {
          // standard build method, return your dialog widget
          return SimpleDialog(children: [Text(details.exception.toString())]);
        }));
      }
      runApp(MyApp());
    }
    

    Note that if you need a BuildContext inside your onError callback, you can also use navigator.currentContext!.

    You then need to pass your GlobalKey<NavigatorState> to MaterialApp (or Navigator if you create it manually):

    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        navigatorKey: navigatorKey,  // pass in your navigator key
        // other fields
      );
    }