iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 26
2

類似之前的為什麼build()在State裡?,這也是我剛開始學Flutter的時候,心裡曾經出現的小小疑問。如果你有在看這系列,應該會常常看到我使用Timer.periodic來更新狀態和畫面。如果你沒看過,我指的是像這樣的範例APP:

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

class Clock extends StatefulWidget {
  @override
  _ClockState createState() => _ClockState();
}

class _ClockState extends State<Clock> {
  String currentTime;

  @override
  void initState() {
    super.initState();
    currentTime = DateTime.now().toIso8601String();
    Timer.periodic(Duration(seconds: 1), (timer) {
      setState(() {
        currentTime = DateTime.now().toIso8601String();
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: RichText(
        textDirection: TextDirection.ltr,
        text: TextSpan(text: currentTime),
      ),
    );
  }
}

用Timer來呼叫setState,讓我可以用盡可能少的程式碼,來做一些和狀態更新相關的示範,畢竟StatefulWidget本身就已經很多boilerplate了,如果還要用一堆MaterialApp, Scaffold, FloatingActionButtion, onPress...來觸發狀態更新,這些多餘的程式碼常常會模糊焦點,讓人找不到範例的重點在哪。

Timer動畫

不過話說回來,既然我可以使用Timer.periodic來持續更新畫面,不就等於可以做動畫了嗎?如果我可以這樣做:

class _FadingHelloState extends State<FadingHello> {
  double opacity = 0;

  initState() {
    super.initState();
    Timer.periodic(Duration(milliseconds: 50), (timer) {
      setState(() { // update by Timer
        opacity += 0.02;
        if (opacity > 1) opacity -= 1;
      });
    });
  }

  Widget build(BuildContext context) {
    return Center(
      child: Opacity(
        opacity: opacity,
        child: RichText(
          text: TextSpan(text: "HELLO"),
          textDirection: TextDirection.ltr,
        ),
      ),
    );
  }
  
  dispose() {
    timer.cancel();
    super.dispose();
  }
}

為什麼還需要這樣做:

class _FadingHelloState extends State<FadingHello> with SingleTickerProviderStateMixin {
  AnimationController controller;
  Animation<double> animation;

  initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );
    animation = Tween(begin: 1.0, end: 0.0).animate(controller);
    animation.addListener(() {
      setState(() {}); // update by animation
    });
    controller.repeat(reverse: true);
  }

  Widget build(BuildContext context) {
    return Center(
      child: Opacity(
        opacity: animation.value,
        child: RichText(
          text: TextSpan(text: "HELLO"),
          textDirection: TextDirection.ltr,
        ),
      ),
    );
  }

  dispose() {
    controller.dispose();
    super.dispose();
  }
}

不但多了一堆AnimationController, Tween, Animation,讓動畫設置的邏輯複雜不少,最重要的是,SingleTickerProviderStateMixin到底是啥啊?AnimationController的vsync: this又是在做什麼?

如果我們要的真的就只是一個持續進行線性動畫,也就是說不需要AnimationController的播放控制,也不須要Animation和Curves,那我能不能乾脆就用Timer來做就好了?這還會有什麼問題?

Timer的問題

首先,我們必須自己控制更新頻率,也就是Timer.periodic中的Duration。如果設的太長,動畫就不會流暢,如果設得太短,就會消耗更多資源。如何拿捏這個平衡是很困難的,更何況我們還有無數效能各不相同的裝置要考慮,

再來就是,我們沒有辦法簡單地在Widget不可見時暫停它。除了Timer本身沒有提供pause/resume,讓我們必須cancel再重新建立之外,我們其實也沒辦法簡單地偵測Widget是否可見,因此才有visibility_detector這樣的套件出現。

最後一點,就是這讓動畫測試變得相當困難。想像一下如果我們是這種analog_clock widget的開發者,我們該怎麼測試這個widget?例如說我們想測試時鐘在14:37的時候,指針位置是正確的。一般來說我們會進行所謂的Golden Test,在Flutter中就是呼叫matchesGoldenFile來比對Widget目前的畫面和一張我們事先準備好的正確圖檔。不過以時鐘來說,我們總不能每次等到14:37再測試吧。

Ticker的功用

是的,以上這些都是Ticker/TickerProvider可以輕易解決的問題。

首先是更新頻率。Ticker讓我們不用自尋煩惱,直接和裝置vsync同步,也就是最適合裝置的更新頻率。而且,和vsync同步也代表我們的

animation.addListener(() { setState(() {}); });

這件事情會發生在畫面渲染Pipeline的第一步,而我們的動畫更新就會發生在這一個frame。如果是以Timer來更新,很有可能會發生在Pipeline進行過程中,也就必須等到下一個frame才會被更新。

再來是動畫的暫停,這就是TickerProvider的兩個實作,SingleTickerProviderStateMixinTickerProviderStateMixin存在的理由。

/// Provides a single [Ticker] that is configured to only tick while the current
/// tree is enabled, as defined by [TickerMode].
/// ....
@optionalTypeArgs
mixin SingleTickerProviderStateMixin<T extends StatefulWidget> on State<T> implements TickerProvider {
  Ticker _ticker;
  ....
  @override
  void didChangeDependencies() {
    if (_ticker != null)
      _ticker.muted = !TickerMode.of(context); // Here we pause/resume ticker, thus animation
    super.didChangeDependencies();
  }
}

這裡可以看到,實際上動畫的pause/resume是在didChangeDepndencies時,透過!TickerMode.of(context)來決定的。TickerMode.of又是什麼呢?

/// Enables or disables tickers (and thus animation controllers) in the widget
/// subtree.
///
/// This only works if [AnimationController] objects are created using
/// widget-aware ticker providers. For example, using a
/// [TickerProviderStateMixin] or a [SingleTickerProviderStateMixin].
class TickerMode extends StatelessWidget {
  static bool of(BuildContext context) {
    final _EffectiveTickerMode widget = context.dependOnInheritedWidgetOfExactType<_EffectiveTickerMode>();
    return widget?.enabled ?? true;
  }
}

可以看到它的of最終回傳了widget?.enable,也就是代表widget是否可見的資訊。

最後就是動畫的測試。Flutter提供了WidgetTester.pump(duration)這個神奇的函數:

  /// Triggers a frame after `duration` amount of time.
  ///
  /// This makes the framework act as if the application had janked (missed
  /// frames) for `duration` amount of time, and then received a "Vsync" signal
  /// to paint the application.
  /// ....
  @override
  Future<void> pump([
    Duration duration,
    EnginePhase phase = EnginePhase.sendSemanticsUpdate,
  ])

它讓我們在測試時可以使整個Flutter Framework像是跳過了這段期間所有的渲染一樣,並在最後發出一個vsync訊號,效果就像是坐時光機直接跳到未來一樣。當我們使用AnimationController並提供它vsync: this時,就是把它和Framework的vsync掛勾在一起,因此這時的動畫也會直接跳到我們所設定的時間。然而,如果我們使用Timer,因為它不是Framework的一部分,就不會受WidgetTester.pump影響。


以上幾點就是為什麼,即使是看似極為簡單,可以用Timer實作的動畫,我們還是應該乖乖使用Ticker/TickerProvider來觸發動畫更新。

雖然這對大部份人來說,可能不是什麼需要大書特書的問題,反正教學怎麼寫我們就怎麼做。但懶惰和叛逆如我,還是偶爾會有想要挑戰框架系統的念頭,看看我是不是能用什麼投機取巧的作法來省點功夫。

可惜這些嘗試往往都是失敗的,本來看似完美的解法,常常會在意想不到的地方出問題。而這些問題Flutter全部都幫你解決了。雖然有時候API並不是那麼簡潔直覺,但還是不得不感謝Flutter團隊幫我們做了那麼多處理。


上一篇
days[24] = "Flutter Web是怎麼運作的?(下)"
下一篇
days[26] = "為什麼Flutter的渲染樹這麼複雜?(上)"
系列文
Why Flutter why? 從表層到底層,從如何到為何。30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言