Flutter | 通过 ServiceLocator 实现无 context 导航

前言

最近在开发过程中看到很多同学问过这个问题。我想要在网络请求失败的时候弹出一个统一的处理页面告诉用户检查网络连接。由于这个行为可以发生在任何页面,我们当然不希望在每一个页面之中都要重新实现一遍这个逻辑,那样耦合就太高了,这时候我们的第一反应是在网络请求后某个部分统一处理这部分逻辑。

看上去没什么问题,但是如果你做过这个需求话,你就会发现:当我们实现跳转提示页面的时候,需要使用到 Navigator 这个组件。回想一下我们一般是如何进行跳转的。

Navigator.of(context).pushNamed('/errorPage');

我们发现,要实现跳转到 ErrorPage 这个操作,我们缺少了一个重要的元素 BuildContextNavigator.of(context) 操作其实是在祖先节点中寻找最近的一个 NavigatorState。而这里的 BuildContext 就是寻找的起点。 所以很多同学都卡在这里了,那我们就来解决这个问题。

在正式开始本文之前你需要已经理解下面几个概念:

理解导航原理

什么是Navigator,MaterialApp做了什么

我们经常会在应用中打开许多页面,当我们返回的时候,它会先后退到上一个打开的页面,然后一层一层后退,没错这就是一个堆栈。而在Flutter中,则是由Navigator来负责管理维护这些页面堆栈。

    压一个新的页面到屏幕上
    Navigator.of(context).push
    把路由顶层的页面移除
    Navigator.of(context).pop

通常我们我们在构建应用的时候并没有手动去创建一个 Navigator,也能进行页面导航,这又是为什么呢。

没错,这个 Navigator 正是 MaterialApp 为我们提供的。但是如果 home,routes,onGenerateRoute 和 onUnknownRoute 都为 null,并且 builder 不为 null,MaterialApp 则不会创建任何 Navigator。

既然我们的 Navigator.of(context) 实际上就是在获取 MaterialApp 提供的 NavigatorState 实例。而 BuildContext 跟当前 Element 有关,要统一控制实际上相当复杂。我们是否可以使用另外一种方式来获取 Navigator,这样就可以不再受 BuildContext 的约束了。

获取 Navigator 实例

要获取某个 Widget 我们在之前的文章中介绍了可以使用 GlobalKey 来实现。那我们应该如何获取到 Navigator 呢?

class _AppState extends State<App> {
  GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'navigate');

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: _navigatorKey,
      home: HomeScreen(),
    );
  }
}

由于 MaterialApp 封装了 Navigator,并且将 Navigator 的 key 属性作为 navigatorKey 暴露出来,我们只需要绑定一个 GlobalKey 就行了。

但是现在问题又来了,我们假如想要在外部使用这个 GlobalKey 好像还是不太方便。我们的 Navigator 可能在多处需要使用,假如直接依赖的话每一处都包含了用于创建、定位和管理依赖项的重复代码。假如我们现在仅仅只是想进行网络调试的测试,由于依赖了 Navigator 相关的代码,想要进行测试非常困难。

这时候就需要 ServiceLocator 来帮助我们进行解耦。

ServiceLocator

这是一种经典的设计模式,主要目的是将类与依赖解耦,让类在编译的时候并知道依赖相的具体实现。从而提升其隔离性和可测试性。

get_it

而今天我们要介绍的是一个来自 Flutter Community 和 Thomas Burkhart 制作的库 get_it。它是一个轻量级 ServiceLocator 库,仅仅用到了 99 行代码(包括注释)。建议有时间都去阅读一下。

简单上手

get_it 非常简单,使用就分两步。

  • 注册服务
  • 依赖注入

注册服务

首先创建出一个 GetIt 容器对象。

GetIt getIt = new GetIt();

然后把需要注册的服务在容器中注册。

getIt.registerSingleton<AppModel>(new AppModelImplementation());
getIt.registerLazySingleton<RESTAPI>(() =>new RestAPIImplementation());

依赖注入

在需要使用到这个依赖的地方我们还是通过这个容器来获取依赖。

var myAppModel = getIt<AppModel>();

你也可以使用 var myAppModel = getIt.get<AppModel>(); 这个方法,效果是一样的。

由于 dart 支持全局变量,我们就把容器直接写在一个 Dart 文件中就好了。是不是很简单呢?

这样我们的服务就是在容器中创建的,在实际依赖的时候,我们可以只依赖于接口,然后通过容器注入(DI)实现了该接口的实际对象,达到了解耦的效果。

实现 NavigateService

现在我们来看看该如何使用 get_it 实现一个 NavigateService。

添加依赖

image

创建全局 Locater

我们在项目中新建一个 service_locator.dart 文件。然后在这个文件中创建一个全局 GetIt 实例。

import 'package:get_it/get_it.dart';

    final GetIt getIt = GetIt();
    void setupLocator(){}

这里先写上 setupLocator 方法,之后会在这里进行服务注册。

创建 NavigateService

我们把导航相关的功能封装成 Service,方便之后使用。

import 'package:flutter/material.dart';

class NavigateService {
  final GlobalKey<NavigatorState> key = GlobalKey(debugLabel: 'navigate_key');

  NavigatorState get navigator => key.currentState;

  get pushNamed => navigator.pushNamed;
  get push => navigator.push;
}

通过 key.currentState 获取到 NavigatorState 实例。

我这里简单暴露了导航的 push 和 pushName 功能,你可以根据自己的功能来进行扩展。

注册服务

现在就需要在容器中注册这个服务,回到 service_locator.dart。

void setupLocator(){
  getIt.registerSingleton(NavigateService());
}

通过调用 registerSingleton,我们在容器中注册了一个单例模式使用的 NavigateService。之后我们所有需要注册的 Service 都在这里注册一遍即可。

容器初始化

刚刚已经写好了注册函数,现在就需要在我们的 Flutter 应用运行时初始化一次,main 函数是一个不错的选择。

void main() {
  setupLocator();
  runApp(App());
}

这样在我们程序运行的时候就能够把服务都初始化到容器中。

依赖注入

刚才我们说了,要想获得 Navigator 需要在 MaterialApp 的 navigatorKey 绑定一个 GlobalKey。所以我们现在通过容器注入服务,来绑定这个 GlobalKey。

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: getIt<NavigateService>().key,
      routes: {'/ErrorScreen': (_) => ErrorScreen()},
      home: HomeScreen(),
    );
  }
}

上面通过 getIt<NavigateService>() 注入了 NavigateService 的依赖。这个 getIt 就是我们的全局实例。

然后添加了一个命名路由。这里我把 HomeScreen 和 ErrorScreen 的代码放在下面。

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(onPressed: () {
        getIt<NavigateService>().pushNamed('/ErrorScreen');
      }),
    );
  }
}

class ErrorScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      color: Colors.red,
      child: Text('Error'),
    );
  }
}

在 HomeScreen 中点击一下 FloatingActionButton 就会通过注入的 NavigateService 跳转到 ErrorScreen。

在进行跳转时,我们可以看到并没有使用 context。

getIt<NavigateService>().pushNamed('/ErrorScreen');

这样你就可以在你想要的地方恰当的处理一些全局导航操作了。它的一个巨大的好处在于你不仅可以在 Widget 中使用,而且可以在任何地方使用容器中的服务。

get_it 详解

不同的注册方式

GetIt 提供了多种注册方式,这将会影响这些对象的生命周期。目前有三种:

  • 工厂模式:void registerFactory<T>(FactoryFunc<T> func) 每次都会返回新的实例。
  • 单例模式:void registerSingleton<T>(T instance) 每次返回同一实例。 这种模式需要手动初始化,就像我们上面例子中那样。
  • 单例模式(懒加载): void registerLazySingleton<T>(FactoryFunc<T> func) 这种方式只有第一次注入依赖的时候,才会初始化服务,并且每次返回相同实例。

覆盖注册

如果你在容器中注册了两次同一服务的话,默认情况下会在调试模式中得到一个断言,就像下面这样。

void setupLocator(){
  getIt.registerSingleton(NavigateService());
  getIt.registerSingleton(NavigateService());
}

Failed assertion: line 53 pos 12: 'allowReassignment || !_factories.containsKey(T)': Type NavigateService is already registered

get_it 会认为你可能是写错了,所以提醒你这里注册了两次相同服务。如果你真的必须覆盖注册,那么你可以通过设置属性 allowReassignment == true 来关闭此断言。

重置容器

如果你想要重置所有容器,可以调用 reset() 方法。一般在做测试的时候会用到。

Q&A

ServiceLocator 与 Dependency Injection & Inversion of Control 的关系

我们在上面看到,当我们使用 ServiceLocator 之后,实现了控制反转(Ioc)。服务不再由使用者创建,而是通过容器注入。这样我们可以不再依赖于具体的实现,而是依赖于一层薄薄的的接口。这样调用者不再知道服务具体实现细节,可以很轻松的使用 mock 数据进行替换。ServiceLocator 其实就是一种特殊的控制反转。

Dependency Injection 实际上和 ServiceLocator 解决的是同样的问题。但是它又与DI的实现原理上有所不同。由于 Flutter 为了减少打包后应用体积禁用了 dart 的反射包,所以你不知道神奇注入对象的来源,这样一来大多数依赖于反射的 DI 包也就没法用了。

获取服务的性能

我们可以从 get_it 的源码中看到,这个 ServiceLocator 就是用一个 map 在储存数据。

final _factories = new Map<Type, _ServiceFactory<dynamic>>();

所以获取服务的性能是 O(1)。

写在最后

本文参考了以下资料:

感兴趣的同学可以去阅读一下大师的文章。

这次介绍的库非常轻量,你可以很快速的上手它。这里你可能会觉得它与 InheritWidget 有些相似。虽然都在解决模型依赖问题,get_it 不仅能够在 Widget tree 中进行使用,而且能够解决模型间的依赖问题。大家可以根据自己项目的情况来选择使用。

如果文章中还存在任何问题还请老师指正!欢迎在下方评论区以及我的邮箱1652219550a@gmail.com 一起讨论,我会及时回复!

题外话,我的个人博客也在同步连载中!欢迎各位光顾鸭 https://xinlei.dev/

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

推荐阅读更多精彩内容