Navigator的正确打开方式

引言

在使用Flutter进行页面间跳转时,Flutter官方给的建议是使用NavigatorNavigator也很友好的提供了pushpushNamedpop等静态方法供我们选择使用。这些接口的使用方法都不算难,但是我们会经常碰到下面这个异常。

Navigator operation requested with a context that does not include a Navigator.

The context used to push or pop routes from the Navigator must be that of a widget that is a descendant of a Navigator widget.

翻译过来的意思是路由跳转功能所需的context没有包含Navigator。路由跳转功能所需的context对应的widget必须是Navigator这个widget的子类。

究竟是啥意思呢?让人看得是一头雾水啊。没有什么高深的知识是一个例子解决不了的,下面我们将通过一个例子来探究这个异常的前因后果。

一个例子

下面这个例子将通过点击搜索🔍按钮,实现跳转到搜索页的功能。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

/// 首页
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(    /// Scaffold start
        body: Center(
          child: IconButton(
            icon: Icon(
             Icons.search,
            ),
            onPressed: () {
              Navigator.push(context, MaterialPageRoute(builder: (context) {
                return SearchPage();
              }));
            },
          )
        ),
      ),   /// Scaffold end
    );
  }
}

/// 搜索页
class SearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("搜索"),
      ),
      body: Text("搜索页"),
    );
  }
}
image

上面这个例子是有问题的,当我们点击首页的搜索🔍按钮时,在控制台上会打印出上面所提到的异常信息。

我们将上面的例子稍微做一下转换。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

/// 首页
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: AppPage(),
    );
  }
}

/// 将第一个例子中的Scaffold包裹在AppPage里面
class AppPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
          child: IconButton(
            icon: Icon(
              Icons.search,
            ),
            onPressed: () {
              Navigator.push(context, MaterialPageRoute(builder: (context) {
                return SearchPage();
              }));
            },
          )
      ),
    );
  }
}

/// 搜索页
class SearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("搜索"),
      ),
      body: Text("搜索页"),
    );
  }
}

和第一个例子相比较,我们将MaterialApphome属性对应的widget(Scaffold)单独拎出来放到AppPage这个widget里面,然后让MaterialApphome属性引用改为AppPage。这个时候,让我们再次点击搜索🔍按钮,可以看到从首页正常的跳转到了搜索页面。

image

源码分析

异常问题解决了,但是解决的有点糊里糊涂,有点莫名其妙。下面我们将从源码入手,彻底搞清楚该问题的一个前因后果。

我们就从点击搜索🔍按钮这个动作开始分析。点击搜索🔍按钮时,调用了Navigatorpush方法。

static Future<T> push<T extends Object>(BuildContext context, Route<T> route) {
    return Navigator.of(context).push(route);
}

push方法调用了Navigatorof方法。

static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
  bool nullOk = false,
}) {
  final NavigatorState navigator = rootNavigator
      ? context.rootAncestorStateOfType(const TypeMatcher<NavigatorState>())
      : context.ancestorStateOfType(const TypeMatcher<NavigatorState>());
  assert(() {
    if (navigator == null && !nullOk) {
      throw FlutterError(
        'Navigator operation requested with a context that does not include a Navigator.\n'
        'The context used to push or pop routes from the Navigator must be that of a '
        'widget that is a descendant of a Navigator widget.'
      );
    }
    return true;
  }());
  return navigator;
}

of方法判断navigator为空,而且nullOkfalse时,就会抛出一个FlutterError的错误。看一下错误信息,这不正是我们要寻找的异常问题么?nullOk默认是false的,那也就是说当navigator为空时,就会抛出该异常。

那我们就找找看,为什么navigator会为空。继续往上看,navigator是由context执行不同的方法返回的。由于我们并没有主动赋值rootNavigator,因此navigator是由context执行ancestorStateOfType方法返回的。

BuildContext-1

上面所说的context是一个BuildContext类型对象,而BuildContext是一个接口类,其最终的实现类是Element。所以在BuildContext声明的ancestorStateOfType接口方法,在Element中可以找到其实现方法。

在讲解ElementancestorStateOfType方法前,我们要知道WidgetElement的对应关系,可以参考一下这篇文章 Flutter之Widget层级介绍。在这里可以简单的认为每一个Widget对应一个Element

再结合上面第一个例子,context就是MyAppbuild方法中的contextMyApp是一个StatelessWidget,而StatelessWidget对应着StatelessElement

在最初讲BuildContext的时候谈到,contextBuildContext类型,而其最终实现类是Element。所以,我们接着看ElementancestorStateOfType方法。

State ancestorStateOfType(TypeMatcher matcher) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    Element ancestor = _parent;
    while (ancestor != null) {
      if (ancestor is StatefulElement && matcher.check(ancestor.state)) /// 直到找到一个StatefuleElement对象并通过matcher的State校验
        break;
      ancestor = ancestor._parent;
    }
    final StatefulElement statefulAncestor = ancestor;
    return statefulAncestor?.state;
}

ancestorStateOfType做的事情并不复杂,主要是沿着其父类一直往上回溯,直到找到一个StatefulElement类型并且通过matcherState校验的一个Element对象,然后将该对象的State对象返回。

结合Navigatorof方法,这里的matcher对象为TypeMatcher<NavigatorState>()

问题:那么当前StatelessElement_parent是什么呢?这就要从入口方法main开始说起了。

main方法

我们知道main()方法是程序的入口方法。

void main() => runApp(MyApp());

main方法通过调用runApp方法接收一个widget

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

runApp方法中调用了attachRootWidget方法。这里的参数app就是MyApp这个widget

void attachRootWidget(Widget rootWidget) {
    _renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
      container: renderView,
      debugShortDescription: '[root]',
      child: rootWidget, ///这里的rootWidget是MyApp
    ).attachToRenderTree(buildOwner, renderViewElement);
}

attachRootWidget方法中又调用了RenderObjectToWidgetAdapterattachToRenderTree方法。这里的RenderObjectToWidgetAdapter实际上是一个Widget,而返回的_renderViewElementElement。也就是说这相当于App的顶部Widget和其对应的顶部Element

注意第一次调用时,attachToRenderTree方法的renderViewElement参数为null,而且rootWidget(MyApp)是作为RenderObjectToWidgetAdapter的子Widget传递进去。

RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [ RenderObjectToWidgetElement<T> element ]) {
    if (element == null) {
      owner.lockState(() {
        element = createElement();
        assert(element != null);
        element.assignOwner(owner);
      });
      owner.buildScope(element, () {
        element.mount(null, null);
      });
    } else {
      element._newWidget = this;
      element.markNeedsBuild();
    }
    return element;
}

elementnull,则通过调用createElement创建element对象。

RenderObjectToWidgetElement<T> createElement() => RenderObjectToWidgetElement<T>(this);

element对象类型为RenderObjectToWidgetElement,然后调用了mount方法,将两个空对象传递进去。也就是说RenderObjectToWidgetElement对象的父Elementnull。记住这一点,后面会用到这个结论。

说到这里,我们得出一个结论:

App的顶部Widget和其对应的顶部Element分别是RenderObjectToWidgetAdapterRenderObjectToWidgetElement,它的子WidgetMyApp

也就是说,MyApp这个Widget对应的Element,其父ElementRenderObjectToWidgetElement。这个结论回答了BuildContext-1这一小节最后提出的那个问题。

BuildContext-2

让我们再次回到BuildContextancestorStateOfType方法,也就是ElementancestorStateOfType方法。

State ancestorStateOfType(TypeMatcher matcher) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    Element ancestor = _parent;
    while (ancestor != null) {
      if (ancestor is StatefulElement && matcher.check(ancestor.state))
        break;
      ancestor = ancestor._parent;
    }
    final StatefulElement statefulAncestor = ancestor;
    return statefulAncestor?.state;
}

main方法这一小节的结论我们得知,由于当前的ElementMyApp对应的Element,那么_parent就是RenderObjectToWidgetElement,进入while循环,由于RenderObjectToWidgetElement并不是StatefulElement类型,则继续找到RenderObjectToWidgetElement的父Element。从main方法这一小节的分析可知,RenderObjectToWidgetElement的父Elementnull,从而推出while循环,继而ancestorStateOfType返回null

也就是说Navigatorof方法中的navigatornull

static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
  bool nullOk = false,
}) {
  final NavigatorState navigator = rootNavigator
      ? context.rootAncestorStateOfType(const TypeMatcher<NavigatorState>())
      : context.ancestorStateOfType(const TypeMatcher<NavigatorState>());
  assert(() {
    if (navigator == null && !nullOk) {
      throw FlutterError(
        'Navigator operation requested with a context that does not include a Navigator.\n'
        'The context used to push or pop routes from the Navigator must be that of a '
        'widget that is a descendant of a Navigator widget.'
      );
    }
    return true;
  }());
  return navigator;
}

这样便满足了navigator == null && !nullOk这个条件,所以就抛出了FlutterError异常。

分析到了这里,我们算是回答了第一个例子为什么会抛出FlutterError异常的原因,接下来我们分析一下为什么修改后的例子不会抛出FluterError异常。

Navigator的正确打开方式

static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
  bool nullOk = false,
}) {
  final NavigatorState navigator = rootNavigator
      ? context.rootAncestorStateOfType(const TypeMatcher<NavigatorState>())
      : context.ancestorStateOfType(const TypeMatcher<NavigatorState>());
  assert(() {
    if (navigator == null && !nullOk) {
      throw FlutterError(
        'Navigator operation requested with a context that does not include a Navigator.\n'
        'The context used to push or pop routes from the Navigator must be that of a '
        'widget that is a descendant of a Navigator widget.'
      );
    }
    return true;
  }());
  return navigator;
}

在上面Navigatorof方法中,我们了解到在nullOk默认为false的情况下,为了保证不抛出FlutterError异常,必须保证navigator不为空。也就是说context.ancestorStateOfType必须返回一个NavigatorState类型的navigator

上面已经分析了MyApp这个Widget对应的Element,其父ElementRenderObjectToWidgetElement

那么我们从MyApp这个Widget出发,分析一下其子Widget树。

从修改后的例子可以看出,MyApp的子WidgetMaterialApp。而MaterialApp的子WidgetMaterialAppbuild方法决定。

Widget build(BuildContext context) {
    Widget result = WidgetsApp(
      key: GlobalObjectKey(this),
      navigatorKey: widget.navigatorKey,
      navigatorObservers: _navigatorObservers,
        pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
            MaterialPageRoute<T>(settings: settings, builder: builder),
      home: widget.home,
      routes: widget.routes,
      initialRoute: widget.initialRoute,
      onGenerateRoute: widget.onGenerateRoute,
      onUnknownRoute: widget.onUnknownRoute,
      builder: (BuildContext context, Widget child) {
        // Use a light theme, dark theme, or fallback theme.
        ThemeData theme;
        final ui.Brightness platformBrightness = MediaQuery.platformBrightnessOf(context);
        if (platformBrightness == ui.Brightness.dark && widget.darkTheme != null) {
          theme = widget.darkTheme;
        } else if (widget.theme != null) {
          theme = widget.theme;
        } else {
          theme = ThemeData.fallback();
        }

        return AnimatedTheme(
          data: theme,
          isMaterialAppTheme: true,
          child: widget.builder != null
              ? Builder(
                  builder: (BuildContext context) {
                    // Why are we surrounding a builder with a builder?
                    //
                    // The widget.builder may contain code that invokes
                    // Theme.of(), which should return the theme we selected
                    // above in AnimatedTheme. However, if we invoke
                    // widget.builder() directly as the child of AnimatedTheme
                    // then there is no Context separating them, and the
                    // widget.builder() will not find the theme. Therefore, we
                    // surround widget.builder with yet another builder so that
                    // a context separates them and Theme.of() correctly
                    // resolves to the theme we passed to AnimatedTheme.
                    return widget.builder(context, child);
                  },
                )
              : child,
        );
      },
      title: widget.title,
      onGenerateTitle: widget.onGenerateTitle,
      textStyle: _errorTextStyle,
      // The color property is always pulled from the light theme, even if dark
      // mode is activated. This was done to simplify the technical details
      // of switching themes and it was deemed acceptable because this color
      // property is only used on old Android OSes to color the app bar in
      // Android's switcher UI.
      //
      // blue is the primary color of the default theme
      color: widget.color ?? widget.theme?.primaryColor ?? Colors.blue,
      locale: widget.locale,
      localizationsDelegates: _localizationsDelegates,
      localeResolutionCallback: widget.localeResolutionCallback,
      localeListResolutionCallback: widget.localeListResolutionCallback,
      supportedLocales: widget.supportedLocales,
      showPerformanceOverlay: widget.showPerformanceOverlay,
      checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages,
      checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers,
      showSemanticsDebugger: widget.showSemanticsDebugger,
      debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
      inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) {
        return FloatingActionButton(
          child: const Icon(Icons.search),
          onPressed: onPressed,
          mini: true,
        );
      },
    );

    assert(() {
      if (widget.debugShowMaterialGrid) {
        result = GridPaper(
          color: const Color(0xE0F9BBE0),
          interval: 8.0,
          divisions: 2,
          subdivisions: 1,
          child: result,
        );
      }
      return true;
    }());

    return ScrollConfiguration(
      behavior: _MaterialScrollBehavior(),
      child: result,
    );
}

直接看到最后的return,返回了ScrollConfiguration。也就是说MaterialApp的子WidgetScrollConfiguration。而ScrollConfigurationchild赋值为result对象,这里的resultWidgetsApp,从而得到ScrollConfiguration的子WidgetWidgetsApp

以此类推分析下去,得到下面一条树干(前一个Widget是后一个Widget的父Widget):

MyApp->MaterialApp->ScrollConfiguration->WidgetsApp->DefaultfocusTraversal->MediaQuery->Localizations->Builder->Title->AnimatedTheme

而这里的AnimatedTheme就是上面MaterialAppbuild方法中定义的AnimatedTheme。那么它的子Widget(child属性)就是WidgetsAppbuilder属性传递进来的。而builder属性是在WidgetsApp对应的WidgetsAppStatebuild方法用到。

Widget build(BuildContext context) {
    Widget navigator;
    if (_navigator != null) {
      navigator = Navigator(
        key: _navigator,
        // If window.defaultRouteName isn't '/', we should assume it was set
        // intentionally via `setInitialRoute`, and should override whatever
        // is in [widget.initialRoute].
        initialRoute: WidgetsBinding.instance.window.defaultRouteName != Navigator.defaultRouteName
            ? WidgetsBinding.instance.window.defaultRouteName
            : widget.initialRoute ?? WidgetsBinding.instance.window.defaultRouteName,
        onGenerateRoute: _onGenerateRoute,
        onUnknownRoute: _onUnknownRoute,
        observers: widget.navigatorObservers,
      );
    }

    Widget result;
    if (widget.builder != null) {
      result = Builder(
        builder: (BuildContext context) {
          return widget.builder(context, navigator);
        },
      );
    } else {
      assert(navigator != null);
      result = navigator;
    }

    ...省略

    return DefaultFocusTraversal(
      policy: ReadingOrderTraversalPolicy(),
      child: MediaQuery(
        data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
        child: Localizations(
          locale: appLocale,
          delegates: _localizationsDelegates.toList(),
          child: title,
        ),
      ),
    );
}

可以看到,在WidgetsAppStatebuild方法中调用了widget.builder属性,我们重点关注第二个参数,它是一个Navigator类型的Widget,正是这个参数传递过去并作为了AnimatedTheme的子Widget。结合上面Navigatorof方法逻辑,我们知道必须找到一个NavigatorState类型的对象。这里的Navigator就是一个StatefulWidget类型,并且对应着一个NavigatorState类型对象。

如果我们继续往下分析,就能看到这样的一条完整树干:

MyApp->MaterialApp->ScrollConfiguration->WidgetsApp->DefaultfocusTraversal->MediaQuery->Localizations->Builder->Title->AnimatedTheme->Navigator->......->AppPage

大家也可以通过调试的方法来验证上述的结论,如下图所示。

image

image

image

由于这条树干太长,因此只截取了部分。可以看到上部分的顶端是AppPage,下部分的底端是MyApp,而中间是Navigator

由于MaterialApp的子Widget必定包含Navigator,而MaterialApphome属性返回的Widget必定是Navigator的子Widget

所以由上述的分析得出如下结论:

如果在Widget中需要使用Navigator导航,则必须将该Widget必须作为MaterialApp的子Widget,并且context(实际上是Element)也必须是MaterialApp对应的context的子context

参考文章

Flutter | 深入理解BuildContext

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,277评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,689评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,624评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,356评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,402评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,292评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,135评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,992评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,429评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,636评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,785评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,492评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,092评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,723评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,858评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,891评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,713评论 2 354

推荐阅读更多精彩内容