20 | 关于跨组件传递数据,你只需要记住这三招

你好,我是陈航。

在上一篇文章中,我带你一起学习了在Flutter中如何响应用户交互事件(手势)。手势处理在Flutter中分为两种:原始的指针事件处理和高级的手势识别。

其中,指针事件以冒泡机制分发,通过Listener完成监听;而手势识别则通过Gesture处理。但需要注意的是,虽然Flutter可以同时支持多个手势(包括一个Widget监听多个手势,或是多个Widget监听同一个手势),但最终只会有一个Widget的手势能够响应用户行为。为了改变这一点,我们需要自定义手势,修改手势竞技场对于多手势优先级判断的默认行为。

除了需要响应外部的事件之外,UI框架的另一个重要任务是,处理好各个组件之间的数据同步关系。尤其对于Flutter这样大量依靠组合Widget的行为来实现用户界面的框架来说,如何确保数据的改变能够映射到最终的视觉效果上就显得更为重要。所以,在今天这篇文章中,我就与你介绍在Flutter中如何进行跨组件数据传递。

在之前的分享中,通过组合嵌套的方式,利用数据对基础Widget的样式进行视觉属性定制,我们已经实现了多种界面布局。所以,你应该已经体会到了,在Flutter中实现跨组件数据传递的标准方式是通过属性传值。

但是,对于稍微复杂一点的、尤其视图层级比较深的UI样式,一个属性可能需要跨越很多层才能传递给子组件,这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子Widget的数据,不仅繁琐而且冗余。

所以,对于数据的跨层传递,Flutter还提供了三种方案:InheritedWidget、Notification和EventBus。接下来,我将依次为你讲解这三种方案。

InheritedWidget

InheritedWidget是Flutter中的一个功能型Widget,适用于在Widget树中共享数据的场景。通过它,我们可以高效地将数据在Widget树中进行跨层传递。

在前面的第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”中,我与你介绍了如何通过Theme去访问当前界面的样式风格,从而进行样式复用的例子,比如Theme.of(context).primaryColor。

Theme类是通过InheritedWidget实现的典型案例。在子Widget中通过Theme.of方法找到上层Theme的Widget,获取到其属性的同时,建立子Widget和上层父Widget的观察者关系,当上层父Widget属性修改的时候,子Widget也会触发更新。

接下来,我就以Flutter工程模板中的计数器为例,与你说明InheritedWidget的使用方法。

  • 首先,为了使用InheritedWidget,我们定义了一个继承自它的新类CountContainer。
  • 然后,我们将计数器状态count属性放到CountContainer中,并提供了一个of方法方便其子Widget在Widget树中找到它。
  • 最后,我们重写了updateShouldNotify方法,这个方法会在Flutter判断InheritedWidget是否需要重建,从而通知下层观察者组件更新数据时被调用到。在这里,我们直接判断count是否相等即可。
class CountContainer extends InheritedWidget {
  //方便其子Widget在Widget树中找到它
  static CountContainer of(BuildContext context) => context.inheritFromWidgetOfExactType(CountContainer) as CountContainer;
  
  final int count;

  CountContainer({
    Key key,
    @required this.count,
    @required Widget child,
  }): super(key: key, child: child);

  // 判断是否需要更新
  @override
  bool updateShouldNotify(CountContainer oldWidget) => count != oldWidget.count;
}

然后,我们使用CountContainer作为根节点,并用0初始化count。随后在其子Widget Counter中,我们通过InheritedCountContainer.of方法找到它,获取计数状态count并展示:

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
   //将CountContainer作为根节点,并使用0作为初始化count
    return CountContainer(
      count: 0,
      child: Counter()
    );
  }
}

class Counter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //获取InheritedWidget节点
    CountContainer state = CountContainer.of(context);
    return Scaffold(
      appBar: AppBar(title: Text("InheritedWidget demo")),
      body: Text(
        'You have pushed the button this many times: ${state.count}',
      ),
    );
}

运行一下,效果如下图所示:

图1 InheritedWidget使用方法

可以看到InheritedWidget的使用方法还是比较简单的,无论Counter在CountContainer下层什么位置,都能获取到其父Widget的计数属性count,再也不用手动传递属性了。

不过,InheritedWidget仅提供了数据读的能力,如果我们想要修改它的数据,则需要把它和StatefulWidget中的State配套使用。我们需要把InheritedWidget中的数据和相关的数据修改方法,全部移到StatefulWidget中的State上,而InheritedWidget只需要保留对它们的引用。

我们对上面的代码稍加修改,删掉CountContainer中持有的count属性,增加对数据持有者State,以及数据修改方法的引用:

class CountContainer extends InheritedWidget {
  ...
  final _MyHomePageState model;//直接使用MyHomePage中的State获取数据
  final Function() increment;

  CountContainer({
    Key key,
    @required this.model,
    @required this.increment,
    @required Widget child,
  }): super(key: key, child: child);
  ...
}

然后,我们将count数据和其对应的修改方法放在了State中,仍然使用CountContainer作为根节点,完成了数据和修改方法的初始化。

在其子Widget Counter中,我们还是通过InheritedCountContainer.of方法找到它,将计数状态count与UI展示同步,将按钮的点击事件与数据修改同步:

class _MyHomePageState extends State<MyHomePage> {
  int count = 0;
  void _incrementCounter() => setState(() {count++;});//修改计数器

  @override
  Widget build(BuildContext context) {
    return CountContainer(
      model: this,//将自身作为model交给CountContainer
      increment: _incrementCounter,//提供修改数据的方法
      child:Counter()
    );
  }
}

class Counter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //获取InheritedWidget节点
    CountContainer state = CountContainer.of(context);
    return Scaffold(
      ...
      body: Text(
        'You have pushed the button this many times: ${state.model.count}', //关联数据读方法
      ),
      floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法
    );
  }
}

运行一下,可以看到,我们已经实现InheritedWidget数据的读写了。

图2 InheritedWidget数据修改示例

Notification

Notification是Flutter中进行跨层数据共享的另一个重要的机制。如果说InheritedWidget的数据流动方式是从父Widget到子Widget逐层传递,那Notificaiton则恰恰相反,数据流动方式是从子Widget向上传递至父Widget。这样的数据传递机制适用于子Widget状态变更,发送通知上报的场景。

在前面的第13篇文章“经典控件(二):UITableView/ListView在Flutter中是什么?”中,我与你介绍了ScrollNotification的使用方法:ListView在滚动时会分发通知,我们可以在上层使用NotificationListener监听ScrollNotification,根据其状态做出相应的处理。

自定义通知的监听与ScrollNotification并无不同,而如果想要实现自定义通知,我们首先需要继承Notification类。Notification类提供了dispatch方法,可以让我们沿着context对应的Element节点树向上逐层发送通知。

接下来,我们一起看一个具体的案例吧。在下面的代码中,我们自定义了一个通知和子Widget。子Widget是一个按钮,在点击时会发送通知:

class CustomNotification extends Notification {
  CustomNotification(this.msg);
  final String msg;
}

//抽离出一个子Widget用来发通知
class CustomChild extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      //按钮点击时分发通知
      onPressed: () => CustomNotification("Hi").dispatch(context),
      child: Text("Fire Notification"),
    );
  }
}

而在子Widget的父Widget中,我们监听了这个通知,一旦收到通知,就会触发界面刷新,展示收到的通知信息:

class _MyHomePageState extends State<MyHomePage> {
  String _msg = "通知:";
  @override
  Widget build(BuildContext context) {
    //监听通知
    return NotificationListener<CustomNotification>(
        onNotification: (notification) {
          setState(() {_msg += notification.msg+"  ";});//收到子Widget通知,更新msg
        },
        child:Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[Text(_msg),CustomChild()],//将子Widget加入到视图树中
        )
    );
  }
}

运行一下代码,可以看到,我们每次点击按钮之后,界面上都会出现最新的通知信息:

图3 自定义Notification

EventBus

无论是InheritedWidget还是Notificaiton,它们的使用场景都需要依靠Widget树,也就意味着只能在有父子关系的Widget之间进行数据共享。但是,组件间数据传递还有一种常见场景:这些组件间不存在父子关系。这时,事件总线EventBus就登场了。

事件总线是在Flutter中实现跨组件通信的机制。它遵循发布/订阅模式,允许订阅者订阅事件,当发布者触发事件时,订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系,甚至非Widget对象也可以发布/订阅。这些特点与其他平台的事件总线机制是类似的。

接下来,我们通过一个跨页面通信的例子,来看一下事件总线的具体使用方法。需要注意的是,EventBus是一个第三方插件,因此我们需要在pubspec.yaml文件中声明它:

dependencies:  
  event_bus: 1.1.0

EventBus的使用方式灵活,可以支持任意对象的传递。所以在这里,我们传输数据的载体就选择了一个有字符串属性的自定义事件类CustomEvent:

class CustomEvent {
  String msg;
  CustomEvent(this.msg);
}

然后,我们定义了一个全局的eventBus对象,并在第一个页面监听了CustomEvent事件,一旦收到事件,就会刷新UI。需要注意的是,千万别忘了在State被销毁时清理掉事件注册,否则你会发现State永远被EventBus持有着,无法释放,从而造成内存泄漏:

//建立公共的event bus
EventBus eventBus = new EventBus();
//第一个页面
class _FirstScreenState extends  State<FirstScreen>  {

  String msg = "通知:";
  StreamSubscription subscription;
  @override
  initState() {
   //监听CustomEvent事件,刷新UI
    subscription = eventBus.on<CustomEvent>().listen((event) {
      setState(() {msg+= event.msg;});//更新msg
    });
    super.initState();
  }
  dispose() {
    subscription.cancel();//State销毁时,清理注册
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body:Text(msg),
      ...
    );
  }
}

最后,我们在第二个页面以按钮点击回调的方式,触发了CustomEvent事件:

class SecondScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      ...
      body: RaisedButton(
          child: Text('Fire Event'),
          // 触发CustomEvent事件
          onPressed: ()=> eventBus.fire(CustomEvent("hello"))
      ),
    );
  }
}

运行一下,多点击几下第二个页面的按钮,然后返回查看第一个页面上的消息:

图4 EventBus示例

可以看到,EventBus的使用方法还是比较简单的,使用限制也相对最少。

这里我准备了一张表格,把属性传值、InheritedWidget、Notification与EventBus这四种数据共享方式的特点和使用场景做了简单总结,供你参考:

图5 属性传值、InheritedWidget、Notification与EventBus数据传递方式对比

总结

好了,今天的分享就到这里。我们来简单回顾下在Flutter中,如何实现跨组件的数据共享。

首先,我们认识了InheritedWidget。对于视图层级比较深的UI样式,直接通过属性传值的方式会导致很多中间层增加冗余属性,而使用InheritedWidget可以实现子Widget跨层共享父Widget的属性。需要注意的是,InheritedWidget中的属性在子Widget中只能读,如果有修改的场景,我们需要把它和StatefulWidget中的State配套使用。

然后,我们学习了Notification,这种由下到上传递数据的跨层共享机制。我们可以使用NotificationListener,在父Widget监听来自子Widget的事件。

最后,我与你介绍了EventBus,这种无需发布者与订阅者之间存在父子关系的数据同步机制。

我把今天分享所涉及到的三种跨组件的数据共享方式demo放到了GitHub,你可以下载下来自己运行,体会它们之间的共同点和差异。

思考题

最后,我来给你留下一个思考题吧。

请你分别概括属性传值、InheritedWidget、Notification与EventBus的优缺点。

欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

精选留言

  • Mr.J

    2019-08-14 00:18:45

    请你分别概括属性传值、InheritedWidget、Notification 与 EventBus 的优缺点。
    ·属性传值:单页面同一个视图树中使用,或者通过构造方法将值传递过去,有点直接将值带过去,不需要过多的操作,缺点是多层级的Widget需要一层层的传值,效率很低;中间一层忘了传整个下游都中断,而且中间某一个层级修改了数据,上层无法及时更新;
    ·InheritedWidget:主要体现是下层Widget主动去向上层拿数据,实现相对复杂,(有个疑问,多层的视图树,在最下层直接使用of可以获取到最上层的数据吗?),缺点传值方向的单一;
    ·Notification:与InheritedWidget相反,主要体现推数据,针对性强,具体通知给哪个Widget明确,不需要跨多层实现,缺点实现起来相对繁琐点,传值方向单一;
    ·EventBus:订阅关系,针对性强,全局使用,缺点是不同的事件需要定义不同的实体,传递时要区分哪个事件传递给哪个控件,销毁Widget时不能忘记取消订阅;
    作者回复


    InheritedWidget 无论跨多少层都可以的

    2019-08-14 18:52:16

  • 许童童

    2019-08-13 11:59:30

    老师能讲下闲鱼的Redux吗?
    作者回复

    更推荐Provider

    2019-08-13 20:32:57

  • 许童童

    2019-08-13 12:02:13

    请你分别概括属性传值、InheritedWidget、Notification 与 EventBus 的优缺点。
    属性传值:简单,但跨多个父子节点不方便
    InheritedWidget:跨多个父子节点方便,但修改数据麻烦
    Notification :传递事件方便,读取不方便
    EventBus :通过事件总线,全局,方便,但要记住事件名称,全局容易冲突,组件消除要清理事件。
    作者回复

    2019-08-14 18:52:35

  • 淡~

    2019-08-14 08:57:15

    Bloc和scopedmodel等一些状态管理方案会讲吗,更富推荐那些做状态管理啊
    作者回复

    推荐Provider

    2019-08-14 11:24:59

  • 寂寞不点烟

    2019-12-17 15:56:32

    subscription = eventBus.on().listen((event) { setState(() {msg+= event.msg;});//更新msg });event这样接收会出现事件混乱。应该加一层判断
    if(event.runtimeType == CustomEvent){
    setState(() {
    msg += event.msg;
    }); //更新msg
    }
    作者回复

    2019-12-27 17:42:58

  • 福杯满溢

    2022-06-23 21:04:17

    为什么把属性和修改属性的方法放在InheritedWidget里不可以呢?子组件获取父组件,同样可以调用父组件里的方法修改属性呀。
  • J

    2021-08-13 16:13:03

    老师您好,我带着 MVVM 和 MVC 的想法学习本节内容,您将到的 InheritedWidget 这个跨层功能 widget 的 demo ,看到了一些 数据驱动 页面的影子,但是整体的数据流转和页面展示似乎不大清晰,可以指点一下 flutter 的 MVC 或者 MVVM 的代码组织是怎样的吗?
  • Geek_061196

    2021-08-10 18:07:58

    随着项目的迭代, Eventbus 会越来越不方便维护。有没有类似阿里的Arouter组件,替代Eventbus实现组件间通信呢?
  • 毛成方

    2021-02-01 09:49:33

    在实际项目中遇到问题的时候 翻阅这里的文章 真的很有帮助 绝对是干货
  • Chvjs

    2020-08-11 19:54:38

    现在 StreamSubscription 是 undefined 了,API 更新了吗?
  • Geek_6b80e0

    2020-04-22 10:56:11

    属性传值,是只能通过层层传递,容易丢失和不好使用。
    InheritedWidget 可以解决跨层传递,灵活性比较大,需要根据不同的需求实现of逻辑。只能从父级往子级进行传递。
    Notification 其实也是通过订阅模式,进行传递,需要子级触发,父级订阅。
    EventBus 全局共享,没有父子限制。
  • JimLai

    2019-12-30 11:37:40

    老师您好,请问flutter可以达到类似安卓ContentProvider实现的跨进程数据通讯的效果吗?例如:一个由flutter开发的app部署到安卓设备上,与另一个原生安卓app进行数据通讯。
  • 🌝

    2019-11-13 18:52:45

    我通过子widget类直接修改传入的父widget中的变量可以吗?实验过,如果是对象的变量可以直接修改掉。

    class Obj {
    int a;
    String b;

    Obj({this.a, this.b});
    }
    class OneWidget extends StatefulWidget {
    OneWidget({Key key}) : super(key: key);
    @override
    _OneWidgetState createState() => _OneWidgetState();
    }
    class _OneWidgetState extends State<OneWidget> {
    Obj obj;
    @override
    Widget build(BuildContext context) {
    return TwoWidget(obj: obj);
    }
    }

    class TwoWidget extends StatefulWidget {
    final Obj obj;
    TwoWidget({Key key, this.obj}) : super(key: key);
    @override
    _TwoWidgetState createState() => _TwoWidgetState();
    }
    class _TwoWidgetState extends State<TwoWidget> {
    @override
    Widget build(BuildContext context) {
    return FlatButton(
    onPressed: () {
    widget.obj.a = 123;
    },
    child: Text('点击'),
    );
    }
    }
    作者回复

    可以啊,对象是引用传递就没问题

    2019-11-26 16:05:58

  • 和小胖

    2019-09-10 20:30:36

    老师,dispose() { subscription.cancel();} 这里的 subscription 哪里来的呢?不是应该是 eventbus.destroy() 吗?
    作者回复

    注册通知(调用listen)的时候会返回一个监听对象,用于后续取消事件通知

    2019-09-26 19:27:07

  • 和小胖

    2019-09-10 17:28:16

    老师,上面提的问题似乎找到答案了。

    把 _incrementCounter 传入到 CountContainer 里面或许是为了类似于 java 里面的多态,子类可以有很多,同时子类可以自定义很多自己的方法,但是在调用的时候都统一调用父类的同名方法。

    而我用 state.increment 之所以不行,是因为我是 onPressed: () => state.increment 这样写的,如果改成 onPressed: () => state.increment() 其实也是可以的,或者按照老师那种 onPressed 的写法也是可以的。

    onPressed: () => state.increment() 是否可以认为是在 onPressed 的回调响应里面调用了 state 的 increment() 方法而已,而 onPressed: state.increment 是不是可以看成是在拿 state 的 increment 属性在给 onPressed 这个属性赋值呢?
    作者回复

    赞,你已经得出正确结论了

    2019-09-26 19:24:29

  • 和小胖

    2019-09-10 16:50:28

    老师,请问下第一种父传子的方式,为啥要把 _incrementCounter 传入到CountContainer里面呢?在按钮的点击事件里面直接使用 state.model._incrementCounter() 不是也可以吗?

    另外我发现我用state.model._incrementCounter()是可以,但是使用 state.increment 却无法让 数字变化,这是为什么呢?
    作者回复

    1.直接使用State当然可以呀,只是这个例子是演示如何通过InheritedWidget进行读写数据。InheritedWidget只能读不能写,要写数据得通过State中转一层
    2.看评论你已经得出结论啦

    2019-09-26 19:23:45

  • 咖啡凉了

    2019-09-09 17:31:01


    class Counter extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    // 获取 InheritedWidget 节点
    CountContainer state = CountContainer.of(context);
    return Scaffold(

    body: Text(
    'You have pushed the button this many times: ${state.model.count}', // 关联数据读方法
    ),
    floatingActionButton: FloatingActionButton(onPressed: state.increment), // 关联数据修改方法
    );
    }
    }
    我在尝试的时候遇到了问题,这段代码中的 state.increment ,没有反应,改成state.increment()才能响应。这是什么问题
    作者回复

    这段代码应该是没问题的,如果是用箭头函数才会有问题

    2019-09-26 19:25:11

  • 历史课代表

    2019-09-04 20:39:13

    可以讲一下EventBus在Flutter中怎么实现的吗
    作者回复

    EventBus原理比较简单,通过一个可以被多人共享(订阅)的数据流就可以实现事件的分发。Dart的event_bus实际上就是通过一个可以被多个人订阅的Stream实现的,而其底层的StreamController就是专门用来给Steam制造数据的。

    2019-09-05 18:01:18

  • 看不懂的你

    2019-08-29 10:07:41

    老师具体讲一下provider的实现与思想,还有bloc的sixiabg
    作者回复

    Provider会讲的

    2019-08-29 13:57:02

  • ptlCoder

    2019-08-27 22:17:47

    系统没有提供缓存相关API嘛?
    作者回复

    有啊。但是对于跨组件传递数据,存取方式并不重要,重要的是存好了怎么通知其他组件来取

    2019-08-29 19:24:13