V2EX = way to explore
V2EX 是一个关于分享和探索的地方
Sign Up Now
For Existing Member  Sign In
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
vifird
V2EX  ›  程序员

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

  •  
  •   vifird · Jul 14, 2020 · 2356 views
    This topic created in 2126 days ago, the information mentioned may be changed or developed.

    鉴于 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 replies    2020-07-14 17:52:28 +08:00
    RoyceLee
        1
    RoyceLee  
       Jul 14, 2020
    之前用一个 service locator 的包叫 getit 来解决全局 context 一点不好用,回头试下你这个。
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   850 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 42ms · UTC 20:32 · PVG 04:32 · LAX 13:32 · JFK 16:32
    ♥ Do have faith in what you're doing.