V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
vifird
V2EX  ›  程序员

从 0 开始设计 Flutter 独立 APP | 第三篇: 一劳永逸解决全局 BuildContext 问题

  •  
  •   vifird · 2020-07-14 12:12:28 +08:00 · 1721 次点击
    这是一个创建于 1588 天前的主题,其中的信息可能已经有所发展或是发生改变。

    鉴于 Flutter 的高性能渲染、跨平台、多端一致性等优势,闪点清单在移动端 APP 上,使用了完整的 Flutter 框架来开发。既然是完整 APP,架构搭建完全不受历史 Native APP 的影响,没有历史包袱的沉淀,设计也能更灵活和健壮。

    全局BuildContext,几乎是所有 Flutter 开发者的一个痛点。这个痛点有多痛呢?我们来列举一下场景:

    1. 路由跳转、弹窗、媒体查询,全部依赖于 BuildContext,如果在 Service 层(或其他非 UI 层)做这些操作,必须要逐层传递正确的 BuildContext 实例。
    2. 依赖于 BuildContext 的逻辑,必须写在某一个页面的 Widget 初始化中,否则无法拿到正确的 BuildContext ;而一些全局初始化的逻辑必须要写在某一个页面里,而如果首次唤起的不是这个页面,需要手动保证初始化逻辑不出问题。
    3. 获取当前前台页面的路由,可以用 ModalRoute 对象,但必须拿到目标页面的 BuildContext 才可以,Navigator 的 BuildContext 是拿不到的。
    4. MediaQuery 、Navigator 、Overlays 的 BuildContext 不是一个,不能用错。
    5. Flutter 绝大部分第三方 UI 库是依赖于 BuildContext,意味着你必须要在 APP 初始化后才能使用这些库,即使是 toast 这样的工具 UI 。
    6. 等等等等......

    Flutter 全局 BuildContext 解决方案

    社区推荐方案

    在 Android 中,我们可以用getApplicationContext解决全局 context 问题,Flutter 官方并没有提供建议的方案,不过社区有一些推荐的解决方案,比如使用 GlobalKey 的方案:

    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        navigatorKey: globalNavigatorKey, // GlobalKey()
      )
    }
    
    globalNavigatorKey.currentState.push(
      MaterialPageRoute(builder: (context) => SomePage()),
    );
    

    首先我们定义一个GlobalKey,然后在初始化MaterialApp的时候传入navigatorKey,然后我们在需要使用路由跳转的地方,不使用原始的方式,而使用 navigatorKey 来调用:

    globalNavigatorKey.currentState.push(...)
    

    社区推荐方案的问题

    看起来上述方案好像可以解决问题,但是目前只能解决页面路由跳转问题,而如果使用 Overlays (比如 Dialog )、MediaQuery 等就会出现问题了,error 提示 context 不合法:

    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.
    

    而直接使用navigatorKey.currentState.context获取全局 context 也会出现同样的 error 。

    OneContext 解决方案

    在尝试众多方案都失败后,我们仍然在继续寻找更好的方案,最终找到了 OneContext 方案,仓库地址: one_context

    Flutter 全局 BuildContext 解决方案

    OneContext 是一个非常新的库,2020 年 5 月初才发第一个版本,目前还未发 1.0 版本。不过 API 的完成度还是很高的。

    使用方式

    使用 OneContext,首先我们需要在 MaterialApp 中配置 OneContext:

    MaterialApp(
      builder: (BuildContext context, Widget child) {
        return OneContext().builder(context, child, initialRoute: 'home');
      },
      /// builder: OneContext().builder, /// 如果不需要 initialRoute,可以使用这种方式
      navigatorKey: OneContext().key,
    )
    

    然后,需要使用 context 的地方,全部通过 OneContext 来调用:

    OneContext().pushNamed('calendar');
    
    OneContext().showModalBottomSheet(
      builder: (BuildContext context) {
        return Container();
      },
    );
    OneContext().showDialog(...);
    OneContext().addOverlay(...);
    

    路由跳转

    OneContext().pushNamed('/second');
    OneContext().push(MaterialPageRoute(builder: (_) => SecondPage()));
    OneContext().pop();
    

    Overlays 操作

    /// 展示 ModalBottomSheet
    OneContext().showModalBottomSheet(
      builder: (BuildContext context) {
        return Container();
      },
    );
    
    /// 添加移除覆盖物
    OneContext().addOverlay(
        overlayId: myCustomAndAwesomeOverlayId,
        builder: (_) => MyCustomAndAwesomeOverlay()
    );
    
    OneContext().removeOverlay(myCustomAndAwesomeOverlayId);
    
    /// 加载提示
    OneContext().showProgressIndicator();
    OneContext().showProgressIndicator(
        backgroundColor: Colors.blue.withOpacity(.3),
        circularProgressIndicatorColor: Colors.white
    );
    OneContext().hideProgressIndicator();
    

    主题和媒体查询

    print('Platform: ' + OneContext().theme.platform);
    print('Orientation: ' + OneContext().mediaQuery.orientation);
    

    主题模式修改

    OneContext().oneTheme.toggleMode();
    
    OneContext().oneTheme.changeDarkThemeData(
      ThemeData(
        primarySwatch: Colors.amber,
        brightness: Brightness.dark
     )
    );
    

    Flutter 全局 BuildContext 解决方案

    原理分析

    从 OneContext 配置中,可以看出来,OneContext 最关键的一句配置是OneContext().builder,我们点进去看源码:

    Widget builder(BuildContext context, Widget widget,
        {Key key,
        MediaQueryData mediaQueryData,
        String initialRoute,
        Route<dynamic> Function(RouteSettings) onGenerateRoute,
        Route<dynamic> Function(RouteSettings) onUnknownRoute,
        List<NavigatorObserver> observers = const <NavigatorObserver>[]}) =>
    ParentContextWidget(
      child: widget,
      mediaQueryData: mediaQueryData,
      initialRoute: initialRoute,
      onGenerateRoute: onGenerateRoute,
      onUnknownRoute: onUnknownRoute,
      observers: observers,
    );
    
    
    class ParentContextWidget extends StatelessWidget {
      /// ...
    
      @override
      Widget build(BuildContext context) {
        return MediaQuery(
          data: mediaQueryData ?? MediaQuery.of(context),
          child: Navigator(
            initialRoute: initialRoute,
            onUnknownRoute: onUnknownRoute,
            observers: observers,
            onGenerateRoute: onGenerateRoute ??
                (settings) => MaterialPageRoute(
                    builder: (context) => OneContextWidget(
                          child: child,
                        )),
          ),
        );
      }
    }
    

    从源码中我们可以看到:

    • 在 builder 函数中,OneContext 重写了 Widget 结构中的 MediaQuery 和 Navigator 的初始化配置,并在每个页面的 Widget 外层包了一层OneContextWidget,然后就可以在 OneContextWidget 拿到内层 context,这个 context 可以用于绝大部分场景。
    • 在 OneContextWidget 中,提供了Overlay的常用方法,并绑定了内部的 context 对象,从而解决 Overlay 的 context 获取问题。
    import 'package:flutter/material.dart';
    import 'package:one_context/src/controllers/one_context.dart';
    
    class OneContextWidget extends StatefulWidget {
      final Widget child;
      OneContextWidget({Key key, this.child}) : super(key: key);
      _OneContextWidgetState createState() => _OneContextWidgetState();
    }
    
    class _OneContextWidgetState extends State<OneContextWidget> {
      @override
      void initState() {
        super.initState();
        OneContext().registerDialogCallback(
            showDialog: _showDialog,
            showSnackBar: _showSnackBar,
            showModalBottomSheet: _showModalBottomSheet,
            showBottomSheet: _showBottomSheet);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Builder(
            builder: (innerContext) {
              OneContext().context = innerContext;
              return widget.child;
            },
          ),
        );
      }
    
      Future<T> _showDialog<T>(...){...}
    
      ScaffoldFeatureController<SnackBar, SnackBarClosedReason> _showSnackBar(...){ ... }
    
      Future<T> _showModalBottomSheet<T>(...){ ... }
    
      PersistentBottomSheetController<T> _showBottomSheet<T>(...) { ... }
    }
    
    • OneContextWidget在每次 build 时,会更新全局 context:
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Builder(
          builder: (innerContext) {
            OneContext().context = innerContext;
            return widget.child;
          },
        ),
      );
    }
    

    Flutter 全局 BuildContext 解决方案

    接入风险

    1. 接入 OneContext 后,务必对原有业务流程进行完成回归,尤其是页面返回逻辑(我们就被坑了一次,Navigator.pop无法正确关闭Dialog
    2. 页面返回逻辑,Overlay 的场景,需要使用OneContext().popDialog()代替Navigator.pop,切记切记。

    总结

    到目前我们解决了 Flutter 全局 BuildContext 的问题,但这其实并不应该是最终的方案,OneContext是一个侵入性比较高的方案,Flutter 官方应该提供更好的方案来解决这个问题。

    讲到这里,还并没有完成基础框架的搭建,后面我们会讲解更多的 Flutter 架构设计内容,比如:通知、分享、UI 设计等等。


    持续分享闪点清单在 Flutter 上的开发经验。闪点清单,一款悬浮清单软件:

    闪点清单,一款悬浮清单软件

    1 条回复    2020-07-14 17:52:28 +08:00
    RoyceLee
        1
    RoyceLee  
       2020-07-14 17:52:28 +08:00
    之前用一个 service locator 的包叫 getit 来解决全局 context 一点不好用,回头试下你这个。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3233 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 20ms · UTC 12:24 · PVG 20:24 · LAX 04:24 · JFK 07:24
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.