阅读 1651

Flutter 组件 | ValueListenableBuilder 局部刷新小能手

一、ValueListenableBuilder 的使用


1. ValueListenableBuilder 引言

我们对初始项目非常熟悉,在 _MyHomePageState 中,通过点击按钮将状态量 _counter 自加,在使用 setState 让当前 State 类持有的 Element 进行更新。作为初学者来说,这很方便,也很容易理解。但对于已入门的人来说,这样的 setState 显然是有失优雅的。

setState 会触发本类的 build 方法,我们想要修改的只是一个文字而已,但这样使得 Scaffold 及其之下的元素都被构建了一遍,这会导致 Build 过程出现不必要的逻辑。

解决这一问题方式是四个字:局部刷新。也就是控制 Build 的粒度,只构建刷新的部分。局部刷可以通过 provider 、flutter_bloc 等状态管理库实现。但相对较重,Flutter 框架内部提供了一个非常小巧精致的组件,专门用于局部组件的刷新,它就是 ValueListenableBuilder


2. ValueListenableBuilder 简单使用

现在来看如何使用 ValueListenableBuilder 来优化初始项目,使计数器刷新区域只是数字的范围ValueListenableBuilder 需要传入一个 ValueListenable<T> 对象,它继承自 Listenable<T> ,是一个可监听对象。 ValueListenable<T> 是一个抽象类,不能直接使用, ValueNotifier 是其实现类之一。接收一个泛型,这里需要的是数字,所以泛型用 int

class _MyHomePageState extends State<MyHomePage> {
  // 定义 ValueNotifier 对象 _counter
  final ValueNotifier<int> _counter = ValueNotifier<int>(0);

  @override
  void dispose() {
    _counter.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar( title: Text(widget.title), ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text( 'You have pushed the button this many times:'),
            ValueListenableBuilder<int>(
              builder: _buildWithValue,
              valueListenable: _counter,
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}
复制代码

ValueListenableBuilder 还需要一个 builder ,对应的类型为 ValueWidgetBuilder<T>,它是 typedef,本质是一个方法,Widget Function(BuildContext context, T value, Widget child)。每当监听的对象值发生变化时,会触发builder 方法进行刷新。如下,在点击时只需要改变 _counter.value 的值,就会触发 _buildWithValue 从而将界面数字刷新。

void _incrementCounter() {
   _counter.value += 1;
}

Widget _buildWithValue(BuildContext context, int value, Widget child) {
   return Text(
     '$value',
     style: Theme.of(context).textTheme.headline4,
   );
}
复制代码

3. 局部刷新的思考

这样就实现了局部刷新,可以看出 Build 的时间少了很多,比起之前的全面刷新就会有所优化。注意,这里的很多帧是由于 FloatingActionButton 的水波纹效果。界面的变化是,帧的刷新是

我们反过来想想 FloatingActionButton 表象状态会自己变化,不然是不会出现水波纹的,那么在点击时,它底层实现的某处必然执行 setState,但 FloatingActionButton 是一个 StatelessWidget,为什么界面有变化的能力? 原因很简单 ,因为它内部使用了 RawMaterialButton ,它是 StatefulWidget。水波纹的效果也是在 RawMaterialButton 被点击时通过 setState 来刷新实现的。这也是另一种局部刷新实现的方式:组件分离,将状态变化的刷新封装在组件内部,向外界提供操作接口。这样一方面,用户不需要自己实现复杂的状态变化效果。另一方面,自己状态的变化仅在本组件状态内部,不会影响外界范围,即 局部刷新


二、ValueListenableBuilder 的 child 属性

可以说 ValueListenableBuilder 是一个非常好用的组件,它可以监听一个值的变化来构建组件,可以说是一把低耗狙击枪, 指哪打哪 。更强大的是一个 ValueListenable<T>对象,可以被多个 ValueListenableBuilder 监听,这样的话,就可以实现一些梦幻联动。比如下面滑动过程中,中间界面背景底部指示器背景颜色页码示数 都在变化。

左滑右滑

我们需要监听 PageView 的滑动,而这个滑动触发频率是非常高的,如果全局刷肯定不好,虽然视觉上体现不明显,但隐患往往就是一点点额外消耗所累加的结果,当最后一根稻草来临时,没有一片雪花是无辜的。通过这个案例,看一下如何局部更新特定的组件,你还会了解 ValueListenableBuilder 中 child 属性 的价值。


1. 主程序

这没什么好说的,主页面组件是 MyHomePage

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}
复制代码

2. 主页状态类

这里用了两个可监听对象 factorpage 分别处理滑动进度变化页码数变化。其实只用 factor 也可以算出当前页码,但是 factor 更新的频率很高,而页码的变化只在切页时变化,所以加一个 page 变量会更好。在 initState 中对 页面滑动控制器 进行初始化,并监听变化,为 factor 赋值。

class _MyHomePageState extends State<MyHomePage> {
  // 进度监听对象
  final ValueNotifier<double> factor = ValueNotifier<double>(1 / 5);
  // 页数监听对象
  final ValueNotifier<int> page = ValueNotifier<int>(1);
	// 页面滑动控制器
  PageController _ctrl;
  // 测试组件 色块
  final List<Widget> testWidgets =
      [Colors.red, Colors.yellow, Colors.blue, Colors.green, Colors.orange]
          .map((e) => Container(
              decoration: BoxDecoration(
                  color: e,
                  borderRadius: BorderRadius.all(
                    Radius.circular(20),
                  ))))
          .toList();

  Color get startColor => Colors.red; // 起点颜色
  Color get endColor => Colors.blue;  // 终点颜色
	
  //圆角装饰
  BoxDecoration get boxDecoration => const BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.only(
          topLeft: Radius.circular(40), topRight: Radius.circular(40)));
  
  // 初始化
  @override
  void initState() {
    super.initState();
    _ctrl = PageController(
      viewportFraction: 0.9,
    )..addListener(() {
        double value = (_ctrl.page + 1) % 5 / 5;
        factor.value = value == 0 ? 1 : value;
      });
  }
  
  // 释放对象
  @override
  void dispose() {
    _ctrl.dispose();
    page.dispose();
    factor.dispose();
    super.dispose();
  }
复制代码

3. 进度条触发刷新

先看一下底部的进度条,我们需要的就是在滑动到特定的分度值时,通知 LinearProgressIndicator 进行变化。这便是 ValueListenableBuilder 的长处,通过监听 factor ,每当滑动时 factor.value 改变时,就会 定点刷新这个进度条。这便是使用 ValueListenableBuilder 的妙处。另 外颜色可以通过 Color.lerp 来计算两个颜色之间对应分度值的颜色。

Widget _buildProgress() => Container(
      margin: EdgeInsets.only(bottom: 12, left: 48, right: 48, top: 10),
      height: 2,
      child: ValueListenableBuilder(
        valueListenable: factor,
        builder: (context, value, child) {
          return LinearProgressIndicator(
            value: factor.value,
            valueColor: AlwaysStoppedAnimation(
              Color.lerp(startColor, endColor, factor.value,),
            ),
          );
        },
      ),
    );
复制代码

4. 背景的刷新

关于背景的刷新,有点小门道。这里会体现出 ValueListenableBuilder中child 属性的作用。 主页内容放入 child 属性中,那么在触发 builder 时,会直接使用这个 child,不会再构建一遍 child。比如,现在当进度刷新时,不会触发 _buildTitle 方法,这说明 tag2 之下的组件没有被构建。如果将 tag2 的组件整体放到 tag1 的child 处时,那么伴随刷新, _buildTitle 方法会不断触发。这就是 child 属性的妙处。这点和 AnimatedBuilder 是一致的。当然你可以用 Stack 来叠放背景,不过这样感觉多此一举,还要额外搭上个 Stake 组件。

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: ValueListenableBuilder(
      valueListenable: factor,
      builder: (_, value, child) => Container(
        color: Color.lerp(startColor, endColor, value),
        child: child, //<--- tag1
      ),
      child: Container( //<--- tag2
        child: Column(
          children: [
            _buildTitle(context),
            Expanded( child: Container( child: _buildContent(),
              margin: const EdgeInsets.only(left: 8, right: 8),
              decoration: boxDecoration,
            ))
          ],
        ),
      ),
    ),
  );
}

Widget _buildTitle(BuildContext context) {
  print('---------_buildTitle------------');
  return Container(
    alignment: Alignment.center,
    height: MediaQuery.of(context).size.height * 0.25,
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(Icons.api, color: Colors.white, size: 45,), 
        SizedBox(width: 20,),
        ValueListenableBuilder(
          valueListenable: page,
          builder: _buildWithPageChange,
        ),
      ],
    ),
  );
}
复制代码

5. PageView 的使用及滑动变换动画

主题内容通过 _buildContent 进行构建。PageViewonPageChanged 中触发 page.value 的变化。这里的两点在于使用 AnimatedBuilder 对每个 item 在滑动过程中进行变换动画。AnimatedBuilder 的监听对象就是 页面滑动控制器 _ctrl,它也是一个可监听对象。注意这里将与变换无关的构建放在 AnimatedBuilder 的 child 属性中,和上面是异曲同工的。通过 _buildAnimOfItem 方法使用 Transform 组件,根据滑动进度,对子组件进行变换处理。随着滑动不断进行,不断地变换就形成了动画,即下所示:

左滑右滑
Widget _buildContent() {
  return Container(
      padding: EdgeInsets.only(bottom: 80, top: 40),
      child: Column(
        children: [
          Expanded(
            child: PageView.builder(
              onPageChanged: (index) => page.value = index + 1,
              controller: _ctrl,
              itemCount: testWidgets.length,
              itemBuilder: (_, index) => AnimatedBuilder(
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: testWidgets[index],
                  ),
                  animation: _ctrl,
                  builder: (context, child) => _buildAnimOfItem(context, child, index)),
            ),
          ),
          _buildProgress(),
        ],
      ));
}

Widget _buildAnimOfItem(BuildContext context, Widget child, int index) {
  double value;
  if (_ctrl.position.haveDimensions) {
    value = _ctrl.page - index;
  } else {
    value = index.toDouble();
  }
  value = (1 - ((value.abs()) * .5)).clamp(0, 1).toDouble();
  value = Curves.easeOut.transform(value);
  return Transform(
    transform: Matrix4.diagonal3Values(1.0, value, 1.0),
    alignment: Alignment.center,
    child: child,
  );
}
复制代码

顶部的页码标识,可以通过 ValueListenableBuilder 来监听 page,切页时 page 改变,会触发内部重建,从而局部更新页码信息。


Widget _buildTitle(BuildContext context) {
  return Container(
    alignment: Alignment.center,
    height: MediaQuery.of(context).size.height * 0.25,
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(Icons.api, color: Colors.white, size: 45,), 
        SizedBox(width: 20,),
        ValueListenableBuilder( <--- 
          valueListenable: page,
          builder: _buildWithPageChange,
        ),
      ],
    ),
  );
}

Widget _buildWithPageChange(BuildContext context, int value, Widget child) {
  return Text(
    "绘制集录 $value/5",
    style: TextStyle(fontSize: 30, color: Colors.white),
  );
}
复制代码

到这里,你应该对 ValueListenableBuilder 的价值有了很清楚的认识,它就是监听值的变化进行局部刷新ValueListenableBuilder 这么好用,源码应该非常复杂吧。其实它的核心代码不到 50 行。


三、ValueListenableBuilder 源码分析

1. ValueListenableBuilder 类的定义

继承自 StatefulWidget,定义 final 成员变量,通过 _ValueListenableBuilderState 实现构建。这些常规操作没什么难的,这样你就看完了 ValueListenableBuilder 一半的代码了。

class ValueListenableBuilder<T> extends StatefulWidget {
  const ValueListenableBuilder({
    Key key,
    @required this.valueListenable,
    @required this.builder,
    this.child,
  }) : assert(valueListenable != null),
       assert(builder != null),
       super(key: key);

  final ValueListenable<T> valueListenable;
  final ValueWidgetBuilder<T> builder;
  final Widget child;

  @override
  State<StatefulWidget> createState() => _ValueListenableBuilderState<T>();
}

typedef ValueWidgetBuilder<T> = Widget Function(BuildContext context, T value, Widget child);
复制代码

2. _ValueListenableBuilderState 类实现

对,你没看错,这就是这个组件所有的代码实现。在 initState 中对传入的可监听对象进行监听,执行 _valueChanged 方法,不出意料 _valueChanged 中进行了 setState 来触发当前状态的刷新。触发 build 方法,从而触发 widget.builder 回调,这样就实现了局部刷新。可以看到这里回调的 child 是组件传入的 child,所以直接使用,这就是对 child 的优化的根源。

class _ValueListenableBuilderState<T> extends State<ValueListenableBuilder<T>> {
  T value;

  @override
  void initState() {
    super.initState();
    value = widget.valueListenable.value;
    widget.valueListenable.addListener(_valueChanged);
  }

  @override
  void didUpdateWidget(ValueListenableBuilder<T> oldWidget) {
    if (oldWidget.valueListenable != widget.valueListenable) {
      oldWidget.valueListenable.removeListener(_valueChanged);
      value = widget.valueListenable.value;
      widget.valueListenable.addListener(_valueChanged);
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    widget.valueListenable.removeListener(_valueChanged);
    super.dispose();
  }

  void _valueChanged() {
    setState(() { value = widget.valueListenable.value; });
  }

  @override
  Widget build(BuildContext context) {
    return widget.builder(context, value, widget.child);
  }
}
复制代码

可以看到 ValueListenableBuilder 实现局部刷新的本质,也是进行组件的抽离,让组件状态的改变框定在状态内部,并通过 builder 回调控制局部刷新,暴露给用户使用,只能说一个字,妙。


@张风捷特烈 2020.12.30 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com -- 微信:zdl1994328
~ END ~