Flutter 畫面落後邏輯狀態(log 正確、畫面不符)— 重繪訊號排查與心跳做法
現象
畫面上的內容明明該更新了,實際卻閃一下、跳過某個狀態、或凍在舊畫面。但程式的 log 完全正確:狀態、計時、順序都對。
也就是**「log 正確、畫面不符」**——邏輯狀態是對的,實體畫面卻沒跟上。
原因
Flutter 是按需重繪:有內容要求時才排下一個 frame 去 build → paint → 合成 → 呈現(排 frame 的底層原語是 scheduleFrame(),設計意義見 scheduleFrame():按需 render 的最底層原語)。會要求排 frame 的來源有好幾種——widget 被標記 dirty(setState、動畫、依賴變更)、animation ticker 的每個 vsync、以及外部 Texture 產生新影格時引擎自動排 frame(Android 端 SurfaceTexture 的 frame-available 會通知引擎 scheduleFrame)。所以正常情況下,影片 / texture 這類平台端內容更新,是會驅動重繪的。
畫面落後邏輯狀態,發生在「該更新」這個訊號沒有傳到 Flutter 的 frame 排程。實際根因通常是這兩類,而不是「平台內容一律不驅動重繪」:
- plugin / platform view 沒正確回報:某個 plugin 更新了自己的畫面,卻沒呼叫
markTextureFrameAvailable;或 hybrid-composition platform view(原生 view 直接進 Flutter 合成樹的那種嵌入模式)的邊界情況,讓引擎不知道要排 frame。 - 邏輯改了值、卻沒觸發 rebuild:你更新了 controller 或某個狀態,但顯示它的 widget 沒
setState、也沒監聽那個 controller,畫面就停在舊值。
這時實體畫面會停在最後一次重繪那一格,落後於邏輯狀態,表現成閃、跳、凍住。
一路在業務邏輯層修(計時、狀態切換時機)往往只是把同一個症狀換個樣子。當 log 正確、畫面卻不符,就該懷疑重繪/合成這一層——先查「更新訊號有沒有到 Flutter」(plugin 有沒有回報影格、你有沒有監聽 controller),再決定是接回訊號、還是定時強制刷新。
解法:幾種做法,依訊號斷點與可改動範圍選
畫面落後有幾種解法,選哪一種看「更新訊號斷在哪裡」以及「能不能改到出問題的那一層」。訊號接得回來就直接接回、改不動來源就定時強制刷新——以下每一種都可以試,心跳(定時強制排 frame)也是其中一種:
- controller-backed widget(video_player 等):這類 plugin 的 controller 是
ValueNotifier,用addListener或ValueListenableBuilder監聽,狀態變就 rebuild。這是內容落後最正規的解。(這裡落後的是 controller 暴露的狀態值——播放位置、播放旗標等,跟 texture 影格是兩條路。) - 真的要持續每幀刷新:用
Ticker/AnimationController(vsync:),每個 vsync 排一個 frame,是「每幀刷新」的慣用機制;偶爾推一個 frame 用WidgetsBinding.instance.scheduleFrame()。 RepaintBoundary隔離重繪範圍,避免無關區域一起重畫。
定時強制排 frame(重繪心跳)
定時強制排 frame 是不改動更新來源、直接讓合成層週期性刷新的做法:每隔一段時間重新 present 一次——composite 會把 texture 圖層的最新內容一起呈現。適合 platform 端不配合、又改不動那個 plugin 的情況。真正驅動的是「定時 setState/scheduleFrame」,跟畫面上顯示什麼無關(實測中一個可見的文字計數器、跟一個隱形的換色元件,效果一樣)。最小形式連 widget 都不用:
1Timer.periodic(const Duration(milliseconds: 200), (_) {
2 WidgetsBinding.instance.scheduleFrame();
3});如果偏好包成一個可以疊進畫面的隱形 widget,下面是一個版本:
1/// 重繪心跳:讓 Flutter 持續排 frame、畫面即時刷新。
2/// 每 200ms 重繪一次角落 1px、每次換一個肉眼看不出的顏色(內容必須有變才會觸發呈現)。
3/// 1px、近乎全透明,不留可見痕跡。
4class FramePump extends StatefulWidget {
5 const FramePump({super.key});
6
7 @override
8 State<FramePump> createState() => _FramePumpState();
9}
10
11class _FramePumpState extends State<FramePump> {
12 int _tick = 0;
13 Timer? _timer;
14
15 @override
16 void initState() {
17 super.initState();
18 _timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
19 if (mounted) setState(() => _tick++);
20 });
21 }
22
23 @override
24 void dispose() {
25 _timer?.cancel();
26 super.dispose();
27 }
28
29 @override
30 Widget build(BuildContext context) {
31 return IgnorePointer(
32 child: Align(
33 alignment: Alignment.topLeft,
34 child: SizedBox(
35 width: 1,
36 height: 1,
37 child: CustomPaint(painter: _FramePumpPainter(_tick)),
38 ),
39 ),
40 );
41 }
42}
43
44class _FramePumpPainter extends CustomPainter {
45 const _FramePumpPainter(this.tick);
46
47 final int tick;
48
49 @override
50 void paint(Canvas canvas, Size size) {
51 // 在兩個近乎全透明、肉眼看不出的顏色間交替;內容有變才會觸發重新呈現
52 final paint = Paint()
53 ..color = tick.isEven ? const Color(0x01000000) : const Color(0x01010000);
54 canvas.drawRect(const Rect.fromLTWH(0, 0, 1, 1), paint);
55 }
56
57 @override
58 bool shouldRepaint(_FramePumpPainter oldDelegate) => oldDelegate.tick != tick;
59}疊進畫面(放在 Stack 最上層即可):
1Stack(
2 fit: StackFit.expand,
3 children: [
4 yourContentLayer,
5 const FramePump(), // 重繪心跳
6 ],
7)更精簡:scheduleFrame 版包成 widget
不需要 CustomPaint 換色那套——用 timer 定時 scheduleFrame、widget 本身什麼都不畫,同時把 timer 的生命週期收在 initState/dispose:
1class FramePump extends StatefulWidget {
2 const FramePump({super.key});
3
4 @override
5 State<FramePump> createState() => _FramePumpState();
6}
7
8class _FramePumpState extends State<FramePump> {
9 Timer? _timer;
10
11 @override
12 void initState() {
13 super.initState();
14 _timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
15 WidgetsBinding.instance.scheduleFrame();
16 });
17 }
18
19 @override
20 void dispose() {
21 _timer?.cancel();
22 super.dispose();
23 }
24
25 @override
26 Widget build(BuildContext context) => const SizedBox.shrink();
27}不 setState、不重繪任何 widget,只是每 200ms 請引擎排一個 frame;比 setState + CustomPaint 版少了 rebuild 與 paint 的開銷,是實務上比較推薦的隱形 widget 形式。
幾個關鍵細節
- 驅動的是 tick、不是顏色:
setState(() => _tick++)讓 widget dirty、排一個 frame,shouldRepaint比對 tick(每次必變)觸發 paint。上面兩個const顏色交替其實可省——shouldRepaint根本沒看顏色,而且 present 出去的是整張重新合成的 frame,引擎不會因為某層像素相同就跳過呈現。要更精簡就用前面的scheduleFrame版、連 CustomPaint 都不需要。 - 要隱形:1px、alpha 只有
0x01(近乎全透明),肉眼看不出。 - 頻率:200ms(約 5fps)通常就夠讓內容變化即時呈現,不必 60fps 狂刷。
IgnorePointer:避免這層攔截觸控。- 背景不排 frame:
scheduleFrame受framesEnabled約束,app 進背景時不會排——前景播放的情境沒問題,若真要不論可見性都推 frame 才需scheduleForcedFrame()。
判斷是否是這個問題
- log 顯示邏輯正確(狀態、計時、順序都對),但實體畫面落後、閃、跳、凍住。
- 內容以靜態為主,或含 platform view / 外部 texture(影片播放器等),而畫面更新的訊號沒反映到 Flutter 的 frame 排程。
符合以上,先查「更新訊號有沒有到 Flutter」——plugin 有沒有正確回報 texture 影格、你有沒有監聽 controller。訊號接得回來就接回;platform 端不配合、又改不動時,就用定時強制排 frame 定時刷新,不必繼續在業務邏輯層打轉。