Build Scheduling and Frames
บทนี้เพิ่มอะไร
หัวข้อที่มีชื่อว่า “บทนี้เพิ่มอะไร”The Render Pipeline ระบุ 5 เฟสของหนึ่งเฟรมไปแล้ว (build, layout, paint, composite, raster) พร้อมงบเวลา ~16.6ms Async in Dart พิสูจน์ไปแล้วด้วย test การเรียงลำดับจริงว่า event loop ของ Dart มี microtask queue ที่ drain จนหมดก่อนที่จะไปแตะ event-queue task ถัดไป บทนี้ไม่ทำซ้ำการพิสูจน์ทั้งสองอย่าง แต่ตอบคำถามที่ทั้งสองบทยังไม่ตอบ: อะไรที่เปลี่ยน “widget เรียก setState” ให้กลายเป็น “เฟรมหนึ่งรัน” จริง ๆ และเฟรมนั้นทำงานตามลำดับที่แน่นอนแบบไหน? — ตรงจาก source ของ Flutter 3.47.5 ที่ติดตั้งไว้ (revision 6a19cca564) แล้วพิสูจน์ด้วย test จริงที่ผ่าน ว่าการเรียก setState สองครั้งใน turn ซิงโครนัสเดียวกันทำให้เกิด build แค่ หนึ่งครั้ง
SchedulerPhase: ห้าเฟส อ้างตรงตัว
หัวข้อที่มีชื่อว่า “SchedulerPhase: ห้าเฟส อ้างตรงตัว”SchedulerPhase คือ enum จริงใน source ที่ติดตั้งไว้ — ไม่ใช่คำอธิบายของโมเดลการ schedule ของ Flutter แต่เป็น type จริงที่ SchedulerBinding.schedulerPhase คืนค่า:
// scheduler/binding.dart:160 -- quoted verbatim from the installed 3.47.5 sourceenum SchedulerPhase { /// No frame is being processed. Tasks (scheduled by /// [SchedulerBinding.scheduleTask]), microtasks (scheduled by /// [scheduleMicrotask]), [Timer] callbacks, event handlers (e.g. from user /// input), and other callbacks (e.g. from [Future]s, [Stream]s, and the like) /// may be executing. idle,
/// The transient callbacks (scheduled by /// [SchedulerBinding.scheduleFrameCallback]) are currently executing. /// /// Typically, these callbacks handle updating objects to new animation /// states. transientCallbacks,
/// Microtasks scheduled during the processing of transient callbacks are /// current executing. midFrameMicrotasks,
/// The persistent callbacks (scheduled by /// [SchedulerBinding.addPersistentFrameCallback]) are currently executing. /// /// Typically, this is the build/layout/paint pipeline. persistentCallbacks,
/// The post-frame callbacks (scheduled by /// [SchedulerBinding.addPostFrameCallback]) are currently executing. /// /// Typically, these callbacks handle cleanup and scheduling of work for the /// next frame. postFrameCallbacks,}สองข้อเท็จจริงตกออกมาจาก enum นี้ตรง ๆ ซึ่งส่วนที่เหลือของบทนี้พิสูจน์ด้วย test จริง: transientCallbacks (ที่ Ticker/AnimationController อัปเดตค่า อยู่ด้านล่าง) รันมาก่อน persistentCallbacks (ที่ pipeline build/layout/paint ตัวจริงอยู่) — ดังนั้นค่าของ animation สำหรับเฟรมนี้จะลงตัวแล้วก่อนที่ widget ใด ๆ ที่อ่านมันใน build() จะรันสำหรับเฟรมเดียวกัน; และ postFrameCallbacks เป็นเฟสที่แยกออกมาจริง ๆ และมาทีหลัง persistentCallbacks — ซึ่งเป็นเหตุผลที่ addPostFrameCallback สามารถอ่านผล layout ที่ยังไม่มีตอน build() ได้อย่างปลอดภัย พิสูจน์ด้านล่าง
WidgetsBinding.drawFrame: ลำดับจริง อ้างตรงตัว
หัวข้อที่มีชื่อว่า “WidgetsBinding.drawFrame: ลำดับจริง อ้างตรงตัว”doc comment ของ WidgetsBinding.drawFrame เองระบุทุกขั้นของ persistentCallbacks สำหรับแอปที่ใช้ widgets layer — อ้างตรงตัว ไม่ได้สรุปความ:
- “The build phase: All the dirty
Elements in the widget tree are rebuilt (seeState.build).” - “The layout phase: All the dirty
RenderObjects in the system are laid out (seeRenderObject.performLayout).” - “The compositing bits phase: The compositing bits on any dirty
RenderObjectobjects are updated.” - “The paint phase: All the dirty
RenderObjects in the system are repainted (seeRenderObject.paint). This generates theLayertree.” - “The compositing phase: The layer tree is turned into a
Sceneand sent to the GPU.” - “The semantics phase: All the dirty
RenderObjects in the system have their semantics updated… This generates theSemanticsNodetree.” - “The finalization phase in the widgets layer: The widgets tree is finalized. This causes
State.disposeto be invoked on any objects that were removed from the widgets tree this frame.” - “The finalization phase in the scheduler layer: After
drawFramereturns,handleDrawFramethen invokes post-frame callbacks (registered withaddPostFrameCallback).”
นี่คือ 5 เฟสของ The Render Pipeline (build/layout/paint/composite/raster) อ่านตรงจากโค้ด scheduling จริงที่รันมัน พร้อมสามขั้นที่บทนั้นไม่จำเป็นต้องระบุ: compositing bits (การ pass dirty-flag แยกต่างหากจริง ๆ ระหว่าง layout กับ paint), semantics, และ finalization สองส่วน — การ dispose widget เกิดขึ้นข้างใน drawFrame เอง แต่ post-frame callback เป็นขั้นที่มาทีหลังจริง ๆ หลังจาก drawFrame return แล้วเท่านั้น ช่องว่างระหว่างขั้น 7 กับ 8 นี่แหละคือเหตุผลที่ addPostFrameCallback พิสูจน์ด้านล่างว่าเห็น layout ที่เสร็จจริงซึ่ง build() เห็นไม่ได้
setState → markNeedsBuild → scheduleBuildFor → scheduleFrame: สายโซ่จริง
หัวข้อที่มีชื่อว่า “setState → markNeedsBuild → scheduleBuildFor → scheduleFrame: สายโซ่จริง”สี่ขั้นจริงที่ตรวจสอบแยกกันแล้ว เชื่อมการเรียก setState() หนึ่งครั้งไปสู่เฟรมที่ถูก schedule:
flowchart LR s["State.setState(fn)"] --> m["Element.markNeedsBuild()"] m --> b["BuildOwner.scheduleBuildFor(element)"] b --> h["WidgetsBinding._handleBuildScheduled() (onBuildScheduled callback)"] h --> e["SchedulerBinding.ensureVisualUpdate()"] e --> f["SchedulerBinding.scheduleFrame()"] f --> p["PlatformDispatcher.scheduleFrame() (engine calls back onDrawFrame)"]
State.setState(VoidCallback fn)— doc comment ของมันเองระบุตรง ๆ ว่า: “In practice, thesetStatemethod’s implementation is trivial: it calls the provided callback synchronously, then callsElement.markNeedsBuild.”Element.markNeedsBuild()— guard การ coalesce ที่บทนี้พิสูจน์ด้านล่างคือสองบรรทัดจริงในเมธอดนี้:if (dirty) { return; }(บรรทัด 5399) ก่อน_dirty = true; owner!.scheduleBuildFor(this);การเรียกmarkNeedsBuild()ครั้งที่สองบน element ที่ dirty อยู่แล้วคือ no-opBuildOwner.scheduleBuildFor(Element element)— เพิ่ม element เข้า_dirtyElements(พิสูจน์ใน The Three Trees) และในครั้งแรกของ batch จะเรียกBuildOwner.onBuildScheduledซึ่งWidgetsBindingผูกไว้กับ_handleBuildScheduledของตัวเองตอน startup (buildOwner!.onBuildScheduled = _handleBuildScheduled;, binding.dart:477)SchedulerBinding.ensureVisualUpdate()ซึ่ง_handleBuildScheduledเรียก คือจุดที่SchedulerPhase(ข้างบน) ตัดสินว่าจะเกิดอะไรต่อ — อ้างตรงตัว: “This has no effect ifschedulerPhaseisSchedulerPhase.transientCallbacksorSchedulerPhase.midFrameMicrotasks… orSchedulerPhase.persistentCallbacks… It will schedule a frame if theschedulerPhaseisSchedulerPhase.idle… orSchedulerPhase.postFrameCallbacks.” นอกช่วงเฟรมที่กำลังทำงานอยู่ สิ่งนี้จะเรียกscheduleFrame()ซึ่งตั้งhasScheduledFrame = trueและขอให้PlatformDispatcherของ engine เรียกกลับonDrawFrameในที่สุด — จุดเข้าสู่drawFrameข้างบน
พิสูจน์แล้ว: setState สองครั้งใน turn เดียวกันทำให้เกิด build แค่หนึ่งครั้ง
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: setState สองครั้งใน turn เดียวกันทำให้เกิด build แค่หนึ่งครั้ง”doc comment ของ State.setState เองระบุเจตนาการออกแบบตรง ๆ: “The method is idempotent, there is no benefit to calling it more than once per State per frame.” นี่ไม่ใช่แค่คำแนะนำเรื่อง style — guard if (dirty) { return; } ของ Element.markNeedsBuild (อ้างข้างบน) ทำให้การเรียกครั้งที่สองในเฟรมเดียวกันเป็น no-op จริง ๆ ในระดับ scheduling พิสูจน์ได้ด้วย build counter จริง:
import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';
int buildCount = 0;
class CounterWidget extends StatefulWidget { const CounterWidget({super.key}); @override CounterWidgetState createState() => CounterWidgetState();}
class CounterWidgetState extends State<CounterWidget> { int value = 0;
// Calls setState() TWICE, synchronously, in the same call stack (the same // microtask/event-handler turn) -- exactly the scenario the coalescing // guard in Element.markNeedsBuild (`if (dirty) { return; }`) exists for. void bumpTwice() { setState(() => value++); setState(() => value++); }
@override Widget build(BuildContext context) { buildCount++; return Text('$value'); }}
double? sizeDuringBuild;bool sizeDuringBuildWasChecked = false;double? sizeInPostFrameCallback;
class SizeProbe extends StatefulWidget { const SizeProbe({super.key}); @override State<SizeProbe> createState() => _SizeProbeState();}
class _SizeProbeState extends State<SizeProbe> { final boxKey = GlobalKey();
@override Widget build(BuildContext context) { // Inside build(): the render tree for THIS frame has not been laid out // yet (layout is a LATER phase of the same frame -- see // WidgetsBinding.drawFrame, quoted above). The box for boxKey does not // exist yet on the very first build, so findRenderObject() is null. sizeDuringBuildWasChecked = true; final renderObject = boxKey.currentContext?.findRenderObject(); sizeDuringBuild = (renderObject is RenderBox && renderObject.hasSize) ? renderObject.size.width : null;
// addPostFrameCallback runs in SchedulerPhase.postFrameCallbacks, which // drawFrame's own doc comment (quoted above, step 8) places AFTER the // layout phase (step 2) and paint (step 4) -- by the time this callback // fires, layout has genuinely happened. SchedulerBinding.instance.addPostFrameCallback((_) { final box = boxKey.currentContext!.findRenderObject() as RenderBox; sizeInPostFrameCallback = box.size.width; });
return Center(child: SizedBox(key: boxKey, width: 123, height: 45, child: Container())); }}import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/frame_probe.dart';
void main() { testWidgets('two setState calls in the same synchronous turn produce ONE build (frame coalescing)', (tester) async { buildCount = 0; final key = GlobalKey<CounterWidgetState>(); await tester.pumpWidget(MaterialApp(home: CounterWidget(key: key))); expect(buildCount, 1, reason: 'the initial mount build');
key.currentState!.bumpTwice(); // calls setState() twice, synchronously await tester.pump(); // flush exactly one frame
expect(buildCount, 2, reason: 'ONE rebuild for both setState calls, not two -- Element.markNeedsBuild\'s "if (dirty) return;" guard'); expect(find.text('2'), findsOneWidget, reason: 'both increments were still applied -- coalescing merges the BUILD, not the state mutations'); });
testWidgets('addPostFrameCallback runs after layout: renderBox.size is available there, not during build', (tester) async { sizeDuringBuild = null; sizeDuringBuildWasChecked = false; sizeInPostFrameCallback = null;
await tester.pumpWidget(const MaterialApp(home: SizeProbe()));
expect(sizeDuringBuildWasChecked, isTrue); expect(sizeDuringBuild, isNull, reason: 'during build(), THIS frame\'s render object does not exist yet -- layout has not run'); expect(sizeInPostFrameCallback, 123.0, reason: 'by the time addPostFrameCallback fires, layout has genuinely happened -- the real, laid-out width is available'); });
testWidgets('WidgetsBinding.instance.scheduleFrame() sets hasScheduledFrame', (tester) async { // Drain any frame already scheduled by pumpWidget's own setup first. await tester.pump(); expect(SchedulerBinding.instance.hasScheduledFrame, isFalse, reason: 'no pending work -- nothing scheduled a frame');
SchedulerBinding.instance.scheduleFrame(); expect(SchedulerBinding.instance.hasScheduledFrame, isTrue, reason: 'scheduleFrame() flips the flag immediately, before the engine actually calls back');
await tester.pump(); expect(SchedulerBinding.instance.hasScheduledFrame, isFalse, reason: 'the flag resets once that frame has actually run'); });}00:00 +0: two setState calls in the same synchronous turn produce ONE build (frame coalescing)00:00 +1: addPostFrameCallback runs after layout: renderBox.size is available there, not during build00:00 +2: WidgetsBinding.instance.scheduleFrame() sets hasScheduledFrame00:00 +3: All tests passed!(ตัดทอนจากการรัน flutter test test/lessons/flutter-internals__build-scheduling-and-frames/ จริงบน Mac เครื่องนี้)
สามผลลัพธ์จริงแยกกัน: (1) buildCount เปลี่ยนจาก 1 เป็น 2 — ไม่ใช่ 3 — หลังจากเรียก setState แบบซิงโครนัสสองครั้ง คือหลักฐานตรงตัวของการ coalesce; find.text('2') ใน test เดียวกันยืนยันว่า state mutation ทั้งสองครั้งเกิดขึ้นจริง มีแค่ การ rebuild เท่านั้นที่ถูกรวมเป็นหนึ่ง (2) sizeDuringBuild นิ่งอยู่ที่ null ในขณะที่ sizeInPostFrameCallback ได้ค่าจริง 123.0 กลับมา พิสูจน์ว่า addPostFrameCallback รันหลัง layout จริง ๆ ไม่ใช่แค่ “ทีหลัง” แบบคลุมเครือ — build() ของ SizeProbe เองเห็นผล layout ของเฟรมตัวเองไม่ได้ เพราะ layout (drawFrame ขั้น 2) ยังไม่รันตอนที่ build() (ขั้น 1) กำลังทำงานอยู่ (3) hasScheduledFrame พลิกเป็น true ทันทีที่ scheduleFrame() แล้วกลับเป็น false เมื่อเฟรมนั้นรันจริงแล้ว คือพฤติกรรมที่มีเอกสารของ ensureVisualUpdate สังเกตได้ตรง ๆ ไม่ใช่แค่เชื่อตาม
Ticker กับ vsync: transientCallbacks รันก่อน พิสูจน์ด้วยลำดับของ enum
หัวข้อที่มีชื่อว่า “Ticker กับ vsync: transientCallbacks รันก่อน พิสูจน์ด้วยลำดับของ enum”AnimationController ทุกตัว (ครอบคลุมลึก ๆ พร้อมหลักฐานค่าระหว่างกลางจริงใน Animations) ถูกขับเคลื่อนโดย Ticker ซึ่ง doc comment ของมันเองระบุสิ่งที่ขับเคลื่อนมันตรง ๆ: “SchedulerBinding.scheduleFrameCallback, which drives tickers.” scheduleFrameCallback ลงทะเบียน callback แบบ transient — เฟส SchedulerPhase.transientCallbacks ที่อ้างข้างบน ซึ่งลำดับของ enum เองวางไว้ก่อน persistentCallbacks (pipeline build/layout/paint) นี่คือเหตุผลที่ค่าของ AnimationController สำหรับเฟรมหนึ่ง ๆ ลงตัวแล้วอย่างสมบูรณ์ — ขับเคลื่อนด้วย vsync หนึ่ง tick ต่อเฟรมจอแสดงผลจริง — ก่อนที่ widget ใด ๆ ที่อ่าน animation.value ใน build() จะรันสำหรับเฟรมเดียวกันนั้น รายงาน “แอนิเมชัน Flutter กระตุก” คือรายงานเกี่ยวกับ persistentCallbacks (หรือสิ่งที่มาก่อนมัน) ที่ใช้เวลานานเกินไป ไม่เคยเป็นเรื่องของ ticker เองที่พลาดจังหวะ
กระตุก (jank) อย่างแม่นยำ
หัวข้อที่มีชื่อว่า “กระตุก (jank) อย่างแม่นยำ”The Render Pipeline ระบุงบเวลาไปแล้ว (~16.6ms ที่ 60fps, ~8.3ms ที่ 120Hz) สิ่งที่บทนี้เพิ่มคือ ตำแหน่งที่แน่นอน ใน pipeline ที่งบเวลาที่พลาดมาจาก: ทุกเฟสที่พิสูจน์ข้างบน (transientCallbacks → midFrameMicrotasks → 8 ขั้นของ persistentCallbacks → postFrameCallbacks) รันบน thread เดียวกัน แบบซิงโครนัส ข้างในการเรียก handleDrawFrame ครั้งเดียว เฟรมจะมาช้าเมื่อผลรวมของงานนั้น — build() ของคุณ, performLayout(), paint(), และสิ่งที่ postFrameCallback ทำก่อนจะ yield — เกินงบเวลา ไม่มี thread แยกต่างหากที่คอยดูดซับ build() ที่ช้าอยู่เงียบ ๆ กลไกที่พิสูจน์ข้างบน คือ critical path ตัวจริง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ลองนึกภาพหนึ่งเฟรมเป็นการวิ่งผลัดที่มีลำดับเข้มงวดของนักวิ่งห้าคนที่ไม่เคยซ้อนกัน: transientCallbacks (ticker/animation ลงตัวค่าของเฟรมนี้), midFrameMicrotasks (continuation ของ Future/await ใด ๆ ที่ callback เหล่านั้นกระตุ้น), persistentCallbacks (pipeline 8 ขั้น build→layout→…→finalize พิสูจน์ข้างบนตรงตัวจาก doc comment ของ drawFrame เอง) แล้วตามด้วย postFrameCallbacks (cleanup และเป็นเฟสเดียว พิสูจน์ข้างบน ที่ widget สามารถตรวจ layout ที่เพิ่งเสร็จของตัวเองได้อย่างปลอดภัย) setState ไม่ได้รันสิ่งเหล่านี้โดยตรง — มันแค่ยกธง (Element._dirty) และในครั้งแรกของ batch จะแตะ ensureVisualUpdate() เพื่อให้แน่ใจว่ามีการ schedule การวิ่งผลัดจริง ๆ ทุก setState ถัดไปบน element ที่ dirty อยู่แล้ว (พิสูจน์ข้างบน) คือ no-op ในระดับ scheduling เพราะธงถูกยกไว้แล้ว
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| กลไก | ข้อดี | ต้นทุน |
|---|---|---|
การ coalesce ด้วย dirty-flag ของ markNeedsBuild | เรียก setState ซ้ำ ๆ (พิสูจน์ข้างบน) ไม่มีค่าใช้จ่ายเพิ่มในระดับเฟรม | การพึ่งพามันให้ “ทำงานเองได้” แทนที่จะเลี่ยงเรียก setState ซ้ำซ้อน ยังต้องจ่ายค่า closure allocation ที่ doc comment ของ setState เองเตือนไว้ |
addPostFrameCallback | ที่เดียวที่ปลอดภัยในการอ่านผล layout จริงของเฟรมนี้ (พิสูจน์ข้างบน) | รันหลังเฟรมอย่างเคร่งครัด — การอ่านและทำอะไรกับ size ทันทีตรงนี้ หมายความว่า action นั้นเกิดขึ้นช้าไปหนึ่งเฟรม ไม่ใช่ภายในเฟรมปัจจุบัน |
Ticker/transientCallbacks รันก่อน persistentCallbacks | ค่า animation ไม่มีทาง “อัปเดตครึ่ง ๆ” ตอนที่ build() ของคุณอ่านมัน | build() ที่อ่าน animation.value ต้องถูกเองด้วย — มันอยู่ตรง critical path ของ persistentCallbacks ที่พิสูจน์ข้างบนพอดี |
drawFrame แบบซิงโครนัสหนึ่งครั้งต่อเฟรม ไม่มี thread พื้นหลังสำหรับมัน | ลำดับที่กำหนดแน่นอน อ่านง่าย (พิสูจน์ข้างบนทีละขั้น) | build()/performLayout()/paint() ที่ช้าเพียงตัวเดียว บล็อกทั้งเฟรม — ไม่มี thread แยกต่างหากที่ดูดซับมัน ซึ่งเป็นเหตุผลที่งาน CPU จริงต้องใช้ Isolates & compute ไม่ใช่ pipeline นี้ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “เรียก
setStateสองครั้งเสมอทำให้เกิด rebuild สองครั้งแยกกัน” พิสูจน์แล้วว่าผิดข้างบน:setStateแบบซิงโครนัสสองครั้งทำให้เกิด rebuild แค่ครั้งเดียว (buildCountเปลี่ยนจาก 1 เป็น 2 ไม่ใช่ 3) — guardif (dirty) return;ของElement.markNeedsBuildเองที่อ้างข้างบนคือเหตุผล - “อ่าน size ที่เพิ่ง build เสร็จของ widget ได้ข้างใน
build()ของมันเอง” พิสูจน์แล้วว่าผิดข้างบน:sizeDuringBuildเป็นnull— render object ของเฟรมนั้นยังไม่มีอยู่ในขณะที่build()(ขั้น 1 ของdrawFrame) กำลังรันอยู่ มีแค่addPostFrameCallback(ขั้น 8 ที่มาหลัง layout จริง) เท่านั้นที่เห็นค่าจริง123.0 - “
addPostFrameCallbackแค่แปลว่า ‘ทีหลัง’ ประมาณเดียวกับFuture.delayedที่ duration เป็นศูนย์” มันคือเฟสที่มีชื่อเฉพาะเจาะจง (SchedulerPhase.postFrameCallbacksอ้างข้างบน) ที่ framework รับประกันว่ารันหลังจาก layout/paint/semantics เสร็จสำหรับเฟรมนั้นแล้ว — ไม่ใช่แค่ “สักพักหลัง microtask นี้” - “บางครั้ง animation อ่านค่าที่อัปเดตครึ่ง ๆ ถ้า widget rebuild กลางจังหวะ tick” พิสูจน์แล้วว่าผิดด้วยลำดับของ enum ข้างบน:
transientCallbacks(ที่ ticker อัปเดต) เป็นเฟสที่มาก่อนpersistentCallbacks(ที่ widget rebuild) อย่างเคร่งครัด — โดยโครงสร้างแล้วbuild()ของ widget ไม่มีทางรันขณะที่ ticker ของเฟรมเดียวกันยังอัปเดตอยู่
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โค้ด Flutter ที่ AI generate ที่ต้องการ size หรือตำแหน่งของ widget หลัง layout มักหยิบ Future.delayed(Duration.zero, ...) มาใช้แทน addPostFrameCallback — มัน “มักจะทำงานได้” ในการทดสอบทั่วไป (Future ที่ duration ศูนย์มักจะ resolve หลังเฟรมปัจจุบันในทางปฏิบัติ) แต่มันไม่ใช่การรับประกันที่บทนี้พิสูจน์ไว้: continuation ของ Future.delayed รันเป็น event-queue task ธรรมดา ไม่มีความสัมพันธ์ที่มีเอกสารรองรับกับ SchedulerPhase เลย
// @expect-error compiles clean -- "usually" reads a real post-layout size in casual testing, but has no documented guarantee about running after layout specifically, unlike addPostFrameCallback (proven above to be a named, ordered SchedulerPhase)class MeasureAfterBuild extends StatefulWidget { const MeasureAfterBuild({super.key}); @override State<MeasureAfterBuild> createState() => _MeasureAfterBuildState();}
class _MeasureAfterBuildState extends State<MeasureAfterBuild> { final key = GlobalKey();
@override void initState() { super.initState(); Future.delayed(Duration.zero, () { final size = (key.currentContext!.findRenderObject() as RenderBox).size; debugPrint('measured: $size'); }); }
@override Widget build(BuildContext context) => SizedBox(key: key, width: 100, height: 100);}ก่อนเชื่อโค้ดที่ generate มาซึ่งต้องการการวัดหลัง layout ตรวจสอบ:
- มันใช้
SchedulerBinding.instance.addPostFrameCallback(หรือWidgetsBinding.instance.addPostFrameCallbackเรียกเดียวกัน) แทนFuture.delayed/Timer/Future.microtaskหรือไม่? มีแค่ตัวแรกเท่านั้นที่พิสูจน์ข้างบนว่ารันในเฟสpostFrameCallbacksที่มีเอกสารรองรับ หลัง layout อย่างเคร่งครัด - ถ้าต้องวัดทุกเฟรม (ไม่ใช่แค่ครั้งเดียวหลัง state เปลี่ยน) มันใช้ hook ระดับ
LayoutBuilder/RenderObjectแทนการลงทะเบียน post-frame callback ใหม่ทุกเฟรมหรือไม่ ซึ่งเพิ่มความหน่วงหนึ่งเฟรมเต็มทุกครั้ง (พิสูจน์ข้างบน: callback รันหลังเฟรมที่มันถูกลงทะเบียน ไม่ใช่ภายในเฟรมนั้น)?
🐛 หา bug ในโค้ดนี้
class AutoScrollToBottom extends StatefulWidget {const AutoScrollToBottom({super.key, required this.controller, required this.child});final ScrollController controller;final Widget child;
@overrideState<AutoScrollToBottom> createState() => _AutoScrollToBottomState();}
class _AutoScrollToBottomState extends State<AutoScrollToBottom> {@overrideWidget build(BuildContext context) { if (widget.controller.hasClients) { widget.controller.jumpTo(widget.controller.position.maxScrollExtent); } return widget.child;}}เฉลย
การเรียก widget.controller.jumpTo(...) โดยตรงข้างใน build() อ่าน position.maxScrollExtent จาก ScrollPosition ที่ — ตามหลักฐานของบทนี้เองข้างบน — ยังไม่ถูก layout สำหรับเฟรมนี้ build() รันในขั้น 1 ของ drawFrame ก่อนที่ layout (ขั้น 2) จะคำนวณ content extent จริงของเฟรมนี้ ในทางปฏิบัติ สิ่งนี้จะ throw (ถ้ายังไม่เคย layout เลย) หรือไม่ก็ scroll ไปยัง maxScrollExtent เก่าของเฟรมที่แล้ว ช้าไปหนึ่งเฟรม ทางแก้คือกลไกที่พิสูจน์ข้างบนพอดี: ย้ายการเรียก jumpTo ไปไว้ใน WidgetsBinding.instance.addPostFrameCallback((_) { ... }) เรียกจาก build() (หรือดีกว่านั้นคือเรียกเฉพาะตอนมี content ใหม่จริง ๆ) เพื่อให้มันรันในเฟส postFrameCallbacks ที่มาทีหลังจริง ๆ หลังจาก layout ของเฟรมนี้คำนวณ maxScrollExtent จริงเสร็จแล้ว