類似之前的為什麼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.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.periodic中的Duration。如果設的太長,動畫就不會流暢,如果設得太短,就會消耗更多資源。如何拿捏這個平衡是很困難的,更何況我們還有無數效能各不相同的裝置要考慮,
再來就是,我們沒有辦法簡單地在Widget不可見時暫停它。除了Timer本身沒有提供pause/resume,讓我們必須cancel再重新建立之外,我們其實也沒辦法簡單地偵測Widget是否可見,因此才有visibility_detector這樣的套件出現。
最後一點,就是這讓動畫測試變得相當困難。想像一下如果我們是這種analog_clock widget的開發者,我們該怎麼測試這個widget?例如說我們想測試時鐘在14:37的時候,指針位置是正確的。一般來說我們會進行所謂的Golden Test,在Flutter中就是呼叫matchesGoldenFile
來比對Widget目前的畫面和一張我們事先準備好的正確圖檔。不過以時鐘來說,我們總不能每次等到14:37再測試吧。
是的,以上這些都是Ticker/TickerProvider可以輕易解決的問題。
首先是更新頻率。Ticker讓我們不用自尋煩惱,直接和裝置vsync同步,也就是最適合裝置的更新頻率。而且,和vsync同步也代表我們的
animation.addListener(() { setState(() {}); });
這件事情會發生在畫面渲染Pipeline的第一步,而我們的動畫更新就會發生在這一個frame。如果是以Timer來更新,很有可能會發生在Pipeline進行過程中,也就必須等到下一個frame才會被更新。
再來是動畫的暫停,這就是TickerProvider的兩個實作,SingleTickerProviderStateMixin
和TickerProviderStateMixin
存在的理由。
/// 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團隊幫我們做了那麼多處理。