Flutter 绘制集录 | 秒表运动与Ticker

4,420 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 6天,点击查看活动详情


前言

如下所示,在上一篇中我们通过绘制,自定义了一个秒表盘的组件。本文将对该组件进行实际的应用,让其实现秒表运动的展示功能。


1. 等宽字体

在实现秒表运动之前,先来看个问题。下面通过点击 + 号,让当前的 Duration 对象增加 100 ms ,这里有一点小问题:由于目前字体不同数字的宽度存在差异,所以在变化过程中存在 “抖动” 的现象:

这是字体本身的问题,比如下面字体十个数字有 8 种不同的宽度。在像秒表这样有连续变化数字的场景,这种字体是不能用的。我们需要一种等宽字体 (Monospace),在编程时,为了便于对齐,IDE 中的字体一般都是等宽字体。


可以在 https://fonts.google.com/ 中搜索 Monospace 类型的字体:

如下是 IBMPlexMono 字体,由于每个字是等宽的,所以在变化时就不会出现抖动的问题。


2. 表盘更新的代码实现

上一篇说过 StopWatchWidget 需要展示什么由使用者决定,自身并不承担改变状态的责任。也就是说它是 不可变状态的 组件。我们如果想在点击时改变表盘显示的内容,就要由使用者来维护状态的变化,其实这本质上和 计数器 项目没有区别,只不过这里变化的是 Duration 对象而已。

如下,HomePageStatefulWidget ,在其状态类 _HomePageState 中维护 Duration 对象的变化。当点击按钮时,触发 updateDuration 方法,在当前 Duration 对象的基础上 + 100 ms 。之后,通过 setState 触发重新构建。

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  Duration duration = Duration(minutes: 0, seconds: 24, milliseconds: 850);

  void updateDuration(){
    int minus = duration.inMinutes % 60;
    int second = duration.inSeconds % 60;
    int milliseconds = duration.inMilliseconds % 1000;
    duration = Duration(minutes: minus,seconds: second,milliseconds: milliseconds+100);
    setState(() {

    });
  }
  
  Widget buildStopWatch(){
    return StopWatchWidget(
      duration: duration,
      radius: 120,
    );
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed:updateDuration,
      ),
      appBar: AppBar( title: Text('HomePage')),
      body: Center(child: buildStopWatch),
    );
  }
}

3.使用 ValueListenableBuilder 组件局部构建

频繁触发更新的情况下,靠考虑尽可能减少构建的范围。比如这里 _HomePageState 在触发 setState 时,其 build 方法会被触发,导致构建的范围较大,整个界面都会 重新构建


秒表运行需要频繁的更新,而且像标题、按钮并不需要跟随 Duration 对象而更新,所以没必要被频繁重新构建。有没有一种方式,可以只让 StopWatchWidget 组件根据 Duration 对象而更新?

最简单的一种实现方式就是 ValueListenableBuilder 组件。它的实现原理非常简单,就是组件抽离+监听更新而已。在 《Flutter 组件 | ValueListenableBuilder 局部刷新小能手》一文中有原理的详细说明,感兴趣的可以研究一下。这里主要说一下它的使用方式。

如下所示,buildStopWatch 方法中,使用 ValueListenableBuilder ,构造时需要提供一个 ValueListenable 类型的可监听对象 valueListenable。当该对象值发生变化,会触发 builder 回调方法,从而只更新 StopWatchWidget 组件,实现局部更新。

class _HomePageState extends State<HomePage> {
  ValueNotifier<Duration> duration = ValueNotifier(Duration.zero);

  void updateDuration(){
    int minus = duration.value.inMinutes % 60;
    int second = duration.value.inSeconds % 60;
    int milliseconds = duration.value.inMilliseconds % 1000;
    duration.value = Duration(minutes: minus,
                              seconds: second,milliseconds: milliseconds+100);
  }
  
  Widget buildStopWatch(){
    return ValueListenableBuilder<Duration>(
      valueListenable: duration,
      builder:(_,value,__) => StopWatchWidget(
        duration: value,
        radius: 120,
      ),
    );
  }


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

再强调一下,ValueListenableBuilder 组件的源码实现,内部也是通过 setState 触发更新的,不要对 setState 本身有任何偏见。工具没有好坏,只有场景的适不适合。


4.秒表的运动

之前有位朋友用 Flutter节拍器 时抱怨,Flutter 通过 Timer 计时有很大的误差。其实 Timer.periodic 方法上有很明确的注释,该方法并不能保证每次回调间隔的正确性,还有一些误差。所以像节拍器、秒表这种需要精确时间间隔的场景,不能使用 Timer.periodic"驱动"

当时我让这位朋友看一下 Ticker ,解决了他的问题。在 《Flutter 动画探索 - 流光幻影 · 十六章》中详细介绍了 Ticker 的源码,感兴趣的可以自己研究一下。这里主要介绍它的应用:可以通过构造方法直接构造 Ticker 对象,其中的入参是一个回调方法。当 Ticker 开启时,会不断触发回调,也就是下面的 _onTick 方法,回调的 Duration 对象就是 Ticker 运行的时间。这里说一下,动画的本质也是通过 Ticker 实现的。

late Ticker _ticker;

@override
void initState() {
  super.initState();
  _ticker = Ticker(_onTick);
}

void _onTick(Duration elapsed) {
  // TODO
}

@override
void dispose() {
  duration.dispose();
  _ticker.dispose();
  super.dispose();
}

所以只要开启 Ticker ,改变 StopWatchWidget 组件的 duration 值,就可以让秒表运动:

由于有暂停的需求,而 _ticker.stop 会让回调中的 Duration 对象重置。所以需要记录一下间隔时间 dt,和最后记录时间 lastDuration ,来维护 duration 的值。

Duration dt = Duration.zero;
Duration lastDuration = Duration.zero;

void _onTick(Duration elapsed) {
  dt = elapsed - lastDuration;
  duration.value += dt;
  lastDuration = elapsed;
}

void onTapIcon() {
  if (_ticker.isTicking) {
    _ticker.stop();
    lastDuration = Duration.zero;
  } else {
    _ticker.start();
  }
}

到这里,秒表的最核心功能就已经完成了。在 《Flutter 语法基础 - 梦始之地》 中,将对秒表基于此进行完善。那本文就到这里,谢谢观看 ~