The Render Pipeline
ห้า phase ต่อเฟรม
หัวข้อที่มีชื่อว่า “ห้า phase ต่อเฟรม”เมื่อ Flutter วาดเฟรม มันรัน pipeline ที่ตายตัว การรู้จัก phase บอกคุณว่าเวลาไปไหนและควรปรับปรุงตรงไหน
flowchart LR build["1. Build (run build methods)"] --> layout["2. Layout (constraints down, sizes up)"] layout --> paint["3. Paint (record draw commands)"] paint --> composite["4. Composite (assemble layers)"] composite --> raster["5. Raster (GPU draws pixels)"]
- Build — widget ที่ dirty rebuild สร้าง widget description ที่อัปเดตแล้ว
- Layout — pass constraints-ไหลลง/sizes-ไหลขึ้น (พิสูจน์อย่างลึกซึ้งใน บทเรียน Constraints & Layout) คำนวณ size และ position ของ render object ทุกตัว
- Paint — render object แต่ละตัวบันทึก drawing command (ยังไม่ใช่ pixel) ลงใน
Canvas/Picturerender object ตัวไหนได้ layer ของตัวเองตรงนี้ — พิสูจน์ตรง ๆ ข้างล่างด้วยRepaintBoundary— คือสิ่งที่ทำให้ repaint บางอันถูกในขณะที่บางอันแพง - Composite — layer ถูกประกอบเข้าเป็น scene graph สุดท้าย
- Raster — GPU วาด pixel จริงจาก scene นั้น นี่คือจุดที่ Impeller (ข้างล่าง) เปลี่ยนสิ่งที่เกิดขึ้น
budget ตึง: เพื่อให้ได้ 60fps คุณมี ~16.6ms ต่อเฟรม (~8.3ms ที่ 120Hz) Jank คือเฟรมที่พลาด budget นั้น และเกือบทุกครั้งมาจากงานมากเกินไปใน build, layout, หรือขั้น paint/composite ที่แพงเกินคาด (พิสูจน์ข้างล่างว่าเป็นต้นทุนจริงที่วัดได้ ไม่ใช่ความกังวลลอย ๆ)
RenderObject ทำงานจริง
หัวข้อที่มีชื่อว่า “RenderObject ทำงานจริง”Widget เป็น description ที่ immutable และเบา object ที่ layout และ paint จริง ๆ คือ RenderObject และมันอยู่ใน tree ที่สามของสาม tree ของ Flutter:
- Widget tree — configuration ที่ immutable ของคุณ (rebuild บ่อย ถูก)
- Element tree — กาวที่ mutable ที่ถือ state และเชื่อม widget กับ render object (พิสูจน์อย่างลึกซึ้ง ด้วยการ spy
createElement/didUpdateWidgetใน บทเรียน Widgets and the Tree) - Render tree —
RenderObjectที่คำนวณ layout และ paint (แพง; ถูกเก็บและ mutate ไม่ใช่ rebuild)
สำหรับกลไกเต็ม ๆ เบื้องหลังนี้ — markNeedsLayout, markNeedsPaint, และ render object ที่ dirty ถูก revisit อย่างไร — ดู Flutter Internals: Layout and Paint
Impeller: สิ่งที่มันแทนที่จริง ๆ ที่ขั้น raster
หัวข้อที่มีชื่อว่า “Impeller: สิ่งที่มันแทนที่จริง ๆ ที่ขั้น raster”Impeller precompile shader ทั้งหมดและสร้าง pipeline state object (PSO) ทั้งหมดล่วงหน้า ตอน engine-build time — นี่คือ quote ตรง ๆ จากคำอธิบายวัตถุประสงค์ของ Impeller เองที่ docs.flutter.dev/perf/impeller: “Predictable performance: Impeller compiles all shaders and reflection offline at build time. It builds all pipeline state objects upfront. The engine controls caching and caches explicitly.” นี่คือสิ่งเฉพาะเจาะจงที่มันแทนที่: Skia (renderer default เดิม) compile shader ตอน runtime แทน ครั้งแรกที่เจอ draw operation หนึ่ง ๆ — ซึ่งเป็นสาเหตุเป๊ะของ jank คลาสสิก “รอบแรกที่รัน animation ใหม่กระตุก แล้วค่อยลื่น” เพราะการ compile shader ครั้งแรกนั้นเกิดขึ้นสด ๆ บนเฟรมที่ต้องการมันพอดี
สถานะจริงต่อ platform ณ SDK Flutter 3.47 ของคอร์สนี้:
- iOS: ตาม
docs.flutter.dev/perf/impeller, “Impeller is the only supported rendering engine on iOS with no ability to switch to Skia.” - Android: Impeller กลายเป็น default บน device สมัยใหม่ (รองรับ Vulkan) ตั้งแต่ Flutter 3.27 — quote จาก release note ทางการ: “Impeller is now the default rendering engine on modern Android devices… On older Android devices and devices without Vulkan support, the Skia rendering engine will still be used as before.” opt out ด้วย
flutter run --no-enable-impeller(หรือ meta-data entry เทียบเท่าในAndroidManifest.xml) - macOS, Windows, Linux: Flutter 3.47 ทำให้ Impeller เป็น default ที่นี่ด้วย — บทเรียน Build and Release ของคอร์สนี้เองระบุไว้ตรง ๆ (ยืนยันกับ
docs.flutter.devระหว่าง Phase 3 review ของคอร์สนี้): “It became the default on iOS and Android earlier; Flutter 3.47 makes it the default on macOS, Windows, and Linux too.”
--enable-impeller/--no-enable-impeller คือ flag CLI จริง (ตัวเดียวกันที่ quote ใน release note ของ Flutter เองสำหรับ opt-in ช่วง preview และ opt-out วันนี้บน Android) — แต่หมายเหตุว่าบน iOS ไม่มีอะไรให้ opt out ไปหา เลย เพราะ Skia ไม่ได้ ship ที่นั่นอีกต่อไปแล้ว สำหรับสถาปัตยกรรม Impeller เต็ม ๆ (Aiks, Entities, pipeline การ precompile PSO จริง) ดู Flutter Internals: Impeller and the GPU
พิสูจน์แล้ว: RepaintBoundary แยก repaint ของ subtree หนึ่งจาก sibling ของมันได้จริง
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: RepaintBoundary แยก repaint ของ subtree หนึ่งจาก sibling ของมันได้จริง”โดย default เมื่อ render object ที่ไม่ใช่ boundary ต้อง repaint Flutter ต้อง re-record Picture ที่แชร์กันทั้งก้อนขึ้นไปถึง repaint boundary ที่ใกล้ที่สุด — หมายความว่าทุก sibling ภายใต้ boundary เดียวกันนั้นถูกเรียก paint() ใหม่ด้วย แม้จะไม่ได้เปลี่ยนอะไรเลยก็ตาม การห่อ subtree ที่เปลี่ยนบ่อยด้วย RepaintBoundary ของตัวเองให้ layer แยกต่างหาก ดังนั้น repaint ของมันจึงไม่บังคับให้ canvas ที่เหลือของ share ถูก re-record อีกต่อไป
import 'package:flutter/material.dart';
int staticPaintCount = 0;int spinnerPaintCount = 0;
class _CountingPainter extends CustomPainter { _CountingPainter(this.onPaint, this.color); final VoidCallback onPaint; final Color color; @override void paint(Canvas canvas, Size size) { onPaint(); canvas.drawRect(Offset.zero & size, Paint()..color = color); }
@override bool shouldRepaint(_CountingPainter oldDelegate) => color != oldDelegate.color;}
/// Never changes on its own — a good proxy for "unrelated sibling content".class StaticBox extends StatelessWidget { const StaticBox({super.key}); @override Widget build(BuildContext context) { return CustomPaint(size: const Size(40, 40), painter: _CountingPainter(() => staticPaintCount++, Colors.blue)); }}
/// Repaints every time tick changes — the "animating" widget.class SpinnerBox extends StatefulWidget { const SpinnerBox({super.key}); @override SpinnerBoxState createState() => SpinnerBoxState();}
class SpinnerBoxState extends State<SpinnerBox> { int tick = 0; void bump() => setState(() => tick++); @override Widget build(BuildContext context) { return CustomPaint( size: const Size(40, 40), painter: _CountingPainter(() => spinnerPaintCount++, Color.fromARGB(255, tick % 255, 0, 0)), ); }}
/// No RepaintBoundary around the spinner — both siblings share one canvas.class NoBoundaryScene extends StatelessWidget { const NoBoundaryScene({super.key, required this.spinnerKey}); final GlobalKey<SpinnerBoxState> spinnerKey; @override Widget build(BuildContext context) { return Column(children: [SpinnerBox(key: spinnerKey), const StaticBox()]); }}
/// The spinner gets its OWN layer — its repaints stop leaking into StaticBox.class WithBoundaryScene extends StatelessWidget { const WithBoundaryScene({super.key, required this.spinnerKey}); final GlobalKey<SpinnerBoxState> spinnerKey; @override Widget build(BuildContext context) { return Column(children: [ RepaintBoundary(child: SpinnerBox(key: spinnerKey)), const StaticBox(), ]); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/repaint_boundary_demo.dart';
void main() { testWidgets('WITHOUT a boundary, an unrelated sibling repaints too', (tester) async { staticPaintCount = 0; spinnerPaintCount = 0; final key = GlobalKey<SpinnerBoxState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: NoBoundaryScene(spinnerKey: key)))); expect(staticPaintCount, 1);
key.currentState!.bump(); await tester.pump();
expect(spinnerPaintCount, 2, reason: 'the spinner itself changed, so of course it repaints'); expect(staticPaintCount, 2, reason: 'StaticBox never changed, yet it was repainted too — same shared canvas'); });
testWidgets('WITH a RepaintBoundary, the sibling stops repainting', (tester) async { staticPaintCount = 0; spinnerPaintCount = 0; final key = GlobalKey<SpinnerBoxState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: WithBoundaryScene(spinnerKey: key)))); expect(staticPaintCount, 1);
key.currentState!.bump(); await tester.pump();
expect(spinnerPaintCount, 2, reason: 'the spinner still repaints — it genuinely changed'); expect(staticPaintCount, 1, reason: 'StaticBox is now on its own layer boundary — the spinner\'s repaint no longer touches it'); });}00:00 +0: WITHOUT a boundary, an unrelated sibling repaints too00:00 +1: WITH a RepaintBoundary, the sibling stops repainting00:00 +2: All tests passed!(ตัดจากผล flutter test test/lessons/layout-and-rendering__render-pipeline/ จริงบนเครื่องนี้)
staticPaintCount ไต่ไปที่ 2 ใน test แรก — สำหรับ widget ที่ painter ของตัวเองไม่เคยเปลี่ยนเลย — คือต้นทุนที่เป็นรูปธรรมที่ RepaintBoundary มีไว้เพื่อกำจัด; test ที่สองพิสูจน์ว่าวิธีแก้ทำงานจริง ไม่ใช่แค่ในทฤษฎี
พิสูจน์แล้ว: ผลจริงของ debugDumpRenderTree/debugDumpLayerTree
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: ผลจริงของ debugDumpRenderTree/debugDumpLayerTree”ทั้งคู่คือ diagnostic dump จริงของ tree ที่มีชีวิตจริง ไม่ใช่คำอธิบายของมัน ตัดจากผล flutter test จริงของ MaterialApp(home: Scaffold(body: Center(child: Text('Hi')))):
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';
void main() { testWidgets('dumps the real render and layer trees', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Center(child: Text('Hi'))))); debugDumpRenderTree(); debugDumpLayerTree(); });}00:00 +0: dumps the real render and layer trees00:00 +1: All tests passed!(ตัดจากผล flutter test test/lessons/layout-and-rendering__render-pipeline/ จริงบนเครื่องนี้ — dump จริงมีหลายร้อยบรรทัดสำหรับ MaterialApp เต็มก้อน; hash ของ object อย่าง #b98ff คือ memory address ต่อ run ไม่ใช่ identifier ที่คงที่)
debugDumpRenderTree() ตัดจริง (root ของ tree แล้ว RenderParagraph ของ Text leaf ตัดตรงกลาง):
_ReusableRenderView#26ce3 │ debug mode enabled - macos │ view size: Size(2400.0, 1800.0) (in physical pixels) │ device pixel ratio: 3.0 (physical pixels per logical pixel) │ configuration: BoxConstraints(w=800.0, h=600.0) at 3.0x (in │ logical pixels) … └─child: RenderParagraph#b98ff relayoutBoundary=up2 │ creator: RichText ← Text ← Center ← KeyedSubtree-[GlobalKey#7a44e] ← │ _BodyBuilder ← MediaQuery ← LayoutId-[<_ScaffoldSlot.body>] ← ⋯ │ parentData: offset=Offset(385.8, 290.0) (can use size) │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0) │ semantics node: SemanticsNode#4 │ size: Size(28.5, 20.0)debugDumpLayerTree() ตัดจริง (root transform layer ลงไปถึง PictureLayer จริงที่ถือ drawing command ที่บันทึกไว้):
TransformLayer#98ffa │ owner: _ReusableRenderView#6a1f8 │ engine layer: TransformEngineLayer#4685e │ transform: │ [0] 3.0,0.0,0.0,0.0 │ [1] 0.0,3.0,0.0,0.0 │ [2] 0.0,0.0,1.0,0.0 │ [3] 0.0,0.0,0.0,1.0 … │ └─child 1: OpacityLayer#7b400 │ │ creator: FadeTransition ← IgnorePointer ← FractionalTranslation ← │ │ SlideTransition ← FadeTransition ← DualTransitionBuilder ← ⋯ │ │ alpha: 255 │ … │ ├─child 1: PictureLayer#2517a │ │ paint bounds: Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) │ │ picture: _NativePicture#15e96สองอย่างที่น่าสังเกตตรง ๆ ในผลจริงนั้น: matrix ของ TransformLayer ที่ root เป็น scale 3.0 — device pixel ratio ของ test device ใส่ครั้งเดียวที่บนสุด ไม่ใช่ต่อ widget; และมี OpacityLayer อยู่แล้วในแอป Center(child: Text('Hi')) ธรรมดา ๆ จากกลไก route transition (FadeTransition) ก่อนที่โค้ดของคุณเองจะแตะ Opacity เลยด้วยซ้ำ — กลไกเป๊ะที่พิสูจน์ต่อไปนี้
พิสูจน์แล้ว: Opacity จอง compositing layer จริงแม้ที่ opacity: 1.0 — มีแค่ 0.0 เท่านั้นที่ฟรีจริง ๆ
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: Opacity จอง compositing layer จริงแม้ที่ opacity: 1.0 — มีแค่ 0.0 เท่านั้นที่ฟรีจริง ๆ”doc comment ของ widget Opacity เองบอกว่า “Values one and zero are painted with a fast path. Other values require painting the child into an intermediate buffer, which is expensive.” การอ่าน source จริงของ RenderOpacity (~/flutter/packages/flutter/lib/src/rendering/proxy_box.dart) แสดงกลไกที่แม่นยำกว่าจริง ๆ: alwaysNeedsCompositing => child != null && _alpha > 0 — จริงสำหรับ alpha ที่ไม่ใช่ศูนย์ ใด ๆ รวมถึง 255 (opacity 1.0) มีแค่ opacity: 0.0 (_alpha == 0) เท่านั้นที่ข้าม compositing และ painting ไปทั้งหมด พิสูจน์ได้ตรง ๆ ด้วยการตรวจ layer tree จริงที่แต่ละค่า:
import 'package:flutter/material.dart';
class OpacityDemo extends StatelessWidget { const OpacityDemo({super.key, required this.opacity}); final double opacity; @override Widget build(BuildContext context) { return RepaintBoundary( child: Opacity( opacity: opacity, child: Container(width: 40, height: 40, color: Colors.blue), ), ); }}import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/opacity_layer_demo.dart';
void main() { testWidgets('opacity: 1.0 still allocates its own OpacityLayer (alpha 255); only 0.0 omits it', (tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 1.0)))); final layersAtFull = tester.layers.whereType<OpacityLayer>().toList(); final ownLayerAtFull = layersAtFull.where((l) => l.alpha == 255).length;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 0.5)))); final layersAtHalf = tester.layers.whereType<OpacityLayer>().where((l) => l.alpha == 128).length;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 0.0)))); final totalLayersAtZero = tester.layers.whereType<OpacityLayer>().length;
expect(ownLayerAtFull, greaterThan(0), reason: 'opacity: 1.0 (alpha 255) DOES get its own OpacityLayer, contrary to "fully opaque = free"'); expect(layersAtHalf, 1, reason: 'opacity: 0.5 (alpha 128) gets the same kind of layer, just a different alpha value'); expect(totalLayersAtZero, lessThan(layersAtFull.length), reason: 'opacity: 0.0 is the one value that genuinely omits this widget\'s own layer'); });}00:00 +0: opacity: 1.0 still allocates its own OpacityLayer (alpha 255); only 0.0 omits it00:00 +1: All tests passed!(ตัดจากผล flutter test test/lessons/layout-and-rendering__render-pipeline/ จริงบนเครื่องนี้)
ใน run นี้ ตัวเลขที่แน่ชัดคือ: opacity: 1.0 → มี OpacityLayer ที่ alpha: 255 อยู่จริง; opacity: 0.5 → OpacityLayer ที่ alpha: 128; opacity: 0.0 → layer ของ widget นั้นหายไปทั้งหมด (เหลือแค่ layer ambient จาก route-transition ที่เห็นข้างบน) ความเชื่อที่พูดซ้ำ ๆ ว่า “opacity 1.0 ไม่มีต้นทุนเลย เหมือนกับไม่ใช้ Opacity เลย” ไม่ตรงกับสิ่งที่ SDK นี้ทำเป๊ะ — มันยังคงสร้าง compositing boundary และ layer object จริงที่ opacity เต็ม; ความต่างของต้นทุน GPU จริงระหว่าง 1.0 กับค่าเศษส่วนอยู่ที่ว่า layer นั้น composite ถูกแค่ไหน (layer alpha-255 เป็น passthrough ง่าย ๆ สำหรับ rasterizer) ไม่ใช่อยู่ที่ว่ามี layer หรือไม่
AnimatedOpacity/FadeTransition: กลไก compositing เดียวกัน ไม่ได้รับการยกเว้นจากต้นทุนของมัน
หัวข้อที่มีชื่อว่า “AnimatedOpacity/FadeTransition: กลไก compositing เดียวกัน ไม่ได้รับการยกเว้นจากต้นทุนของมัน”RenderAnimatedOpacityMixin ที่อยู่เบื้องหลัง AnimatedOpacity (~/flutter/packages/flutter/lib/src/rendering/proxy_box.dart) คำนวณ isRepaintBoundary แบบเดียวกับที่ RenderOpacity ทำ — มันผ่านกลไกการจอง layer เดียวกันเป๊ะที่พิสูจน์แล้วข้างบน แค่ค่า alpha เปลี่ยนทุก animation tick แทนที่จะเปลี่ยนครั้งเดียว สิ่งที่ AnimatedOpacity/FadeTransition เพิ่มมาเหนือกว่าการทำ setState + Opacity(opacity: value) เองด้วยมือคือ การจัดตารางเวลา ไม่ใช่ส่วนลดจากต้นทุน compositing ที่พิสูจน์แล้วข้างบน: framework ขับเคลื่อนค่าด้วย implicit animation แบบ ticker-based (ไม่ต้องมี setState boilerplate เอง, interpolation ที่ถูกต้อง, dispose อัตโนมัติ) แต่ทุกเฟรมระหว่างการ fade ยังต้องจ่ายค่าสำหรับการอัปเดต OpacityLayer แบบเดียวกันอยู่ดี
ClipRRect และต้นทุนจริงของ Clip.antiAliasWithSaveLayer
หัวข้อที่มีชื่อว่า “ClipRRect และต้นทุนจริงของ Clip.antiAliasWithSaveLayer”Clip — enum ที่ clipping widget ทุกตัว (ClipRRect, ClipRect, ClipPath, ClipOval, clipBehavior ของ Container, …) รับ — มี trade-off ทาง performance ที่ระบุไว้ตรง ๆ บนแต่ละค่า quote ตรงคำจาก source ของ dart:ui เอง (สะท้อนที่ api.flutter.dev/flutter/dart-ui/Clip.html):
Clip.none: “This is the default option for most widgets: if the content does not overflow the widget boundary, don’t pay any performance cost for clipping.”Clip.hardEdge: “Faster than other clipping modes, but slower than none” — clip โดยไม่ anti-alias ดังนั้นเส้นโค้ง/เส้นเฉียงจะขาด ๆClip.antiAlias(default ของClipRRectเอง): “Much faster than antiAliasWithSaveLayer, but slower than hardEdge… this clipping can have bleeding edge artifacts.”Clip.antiAliasWithSaveLayer: “This mode not only clips with anti-aliasing, but also allocates an offscreen buffer. All subsequent paints are carried out on that buffer before finally being clipped and composited back. This is very slow.”
ตัวสุดท้ายคือต้นทุนที่มีเอกสารจริงที่ section นี้พูดถึง: Clip.antiAliasWithSaveLayer คือตัวเลือกเดียวที่บังคับ offscreen buffer แบบ Canvas.saveLayer จริง ๆ — ต้นทุนคลาสประเภทเดียวกับที่พิสูจน์แล้วข้างบนสำหรับ Opacity แบบเศษส่วน — โดยเฉพาะเพื่อหลีกเลี่ยง bleeding-edge artifact ที่ Clip.antiAlias อาจมี ใช้มันเฉพาะเมื่อ artifact นั้นมองเห็นได้จริงและยอมรับไม่ได้; Clip.antiAlias (default) ถูกต้องสำหรับ rounded-corner clipping ส่วนใหญ่อย่างท่วมท้น
Frame budget
หัวข้อที่มีชื่อว่า “Frame budget”เพื่อให้ได้ 60fps คุณมี ~16.6ms ต่อเฟรม; ที่ 120Hz (โทรศัพท์สมัยใหม่หลายรุ่น) ลดลงครึ่งเป็น ~8.3ms ทุกเทคนิคที่พิสูจน์ในบทเรียนนี้ — RepaintBoundary รอบสิ่งที่เปลี่ยนจริง, เลือก Clip.antiAlias แทน Clip.antiAliasWithSaveLayer เว้นแต่จำเป็นจริง, รู้ว่า Opacity มีต้นทุน layer เสมอเมื่อไม่ใช่ศูนย์ — มีไว้เพื่อให้ pipeline build/layout/paint/composite/raster อยู่ในหน้าต่างนั้น พลาดครั้งเดียวมองไม่เห็น พลาดซ้ำ ๆ คือ jank ที่ user เห็นจริง สำหรับกลไก scheduler ที่เปลี่ยน “มีอะไรเปลี่ยนแปลง” ให้กลายเป็น “เฟรมรัน” ดู Flutter Internals: Layout and Paint และสำหรับสิ่งที่เปลี่ยนที่ขั้น raster โดยเฉพาะ ดู Flutter Internals: Impeller and the GPU
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ห้า phase คือ สายการผลิตของโรงพิมพ์: build คือบรรณาธิการที่ตัดสินว่าหน้ากระดาษควรพูดอะไร; layout คือช่างเรียงพิมพ์ที่หาตำแหน่งเป๊ะของทุกคำและทุกภาพ; paint คือการลงหมึกบนแผ่นพิมพ์ — หนึ่งแผ่นต่อ section ที่ไม่ต้องเปลี่ยนไปพร้อมกับอันอื่น (RepaintBoundary คือ section ที่ได้แผ่นพิมพ์ของตัวเอง ดังนั้นการลงหมึกใหม่ไม่เคยรบกวนแผ่นรอบข้าง พิสูจน์แล้วข้างบน); composite คือการเรียงซ้อนแผ่นที่ลงหมึกแล้วทั้งหมดตามลำดับที่ถูกต้อง; และ raster คือแท่นพิมพ์จริงที่รันกระดาษผ่านและปล่อยหมึกลง — ขั้นที่ Impeller ทำให้เร็วขึ้นด้วยการคำนวณสูตรหมึกของแผ่นพิมพ์ล่วงหน้า (PSO) แทนที่จะผสมสูตรหมึกใหม่สด ๆ กลางการพิมพ์ ครั้งแรกที่ต้องการสีนั้น (runtime shader compilation ของ Skia)
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
RepaintBoundary รอบ subtree ที่ repaint บ่อย | แยก repaint ของมันจาก sibling — พิสูจน์แล้วข้างบน | แต่ละ boundary คือ layer/memory allocation ของตัวเอง; ใช้มากเกินไปทุกที่เพิ่ม overhead โดยไม่มีประโยชน์ |
Opacity ที่ค่าเศษส่วน | fade แบบ declarative ง่าย ๆ | compositing layer จริงทุกเฟรมที่ไม่ใช่ศูนย์ พิสูจน์แล้วข้างบน — ไม่ฟรีแม้แค่มองผ่าน ๆ |
Clip.antiAlias (default) | เร็ว ขอบเรียบ ถูกต้องสำหรับเกือบทุกกรณี | อาจมี bleeding-edge artifact ในบางกรณีหายาก |
Clip.antiAliasWithSaveLayer | กำจัด bleeding artifact นั้นได้หมด | offscreen buffer จริงตาม doc ของ engine เอง — “very slow” ใช้อย่างตั้งใจ ไม่ใช่ default |
| Impeller (default ของ platform) | ลบ jank จาก shader-compile รอบแรก — กลไกพิสูจน์แล้วข้างบน | ไม่มีอะไรให้ควบคุมโดยตรง — มันเป็น default; --no-enable-impeller มีให้ใช้เฉพาะที่ยังมี Skia fallback ship อยู่ (Android) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “sibling widget ที่ไม่เกี่ยวข้องไม่เคย repaint แค่เพราะอย่างอื่นเปลี่ยน” พิสูจน์แล้วว่าผิดข้างบน: โดยไม่มี
RepaintBoundarystaticPaintCountไต่จาก1เป็น2เพียงเพราะCustomPainterของ sibling เปลี่ยน แชร์ canvas เดียวกัน - “Opacity ที่ 1.0 ฟรีสมบูรณ์ เหมือนกับไม่ได้ใช้ Opacity เลย” พิสูจน์แล้วว่าผิดข้างบนด้วยการตรวจ layer tree จริง:
OpacityLayerที่alpha: 255ถูกจองจริง มีแค่opacity: 0.0เท่านั้นที่เป็น fast path จริง - “AnimatedOpacity ถูกกว่าการ animate Opacity เอง” พิสูจน์แล้วข้างบนว่าแชร์กลไก compositing เดียวกันเป๊ะ — ประโยชน์จริงคือการจัดตารางเวลาแบบ ticker-driven ที่ถูกต้อง ไม่ใช่ส่วนลดจากต้นทุน layer ต่อเฟรม
- “Clip.antiAlias กับ Clip.antiAliasWithSaveLayer เหมือนกันโดยพื้นฐาน แค่ชื่อต่างกัน” doc comment ของ engine เอง quote ตรง ๆ ข้างบน ชัดเจน: มีแค่ตัวหลังเท่านั้นที่จอง offscreen buffer จริงและถูกอธิบายว่า “very slow” — ทั้งสองไม่ใช่ default ที่ใช้แทนกันได้
- “Impeller มีผลแค่กับ iOS” พิสูจน์แล้วข้างบนด้วยสถานะจริงต่อ platform: มันเป็น renderer เดียวบน iOS, default บน Android สมัยใหม่, และ — ตั้งแต่ 3.47 — default บน macOS, Windows, และ Linux ด้วย
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โค้ด Flutter ที่ AI generate มักใช้ Opacity เพื่อซ่อน-แสดง widget (Opacity(opacity: visible ? 1.0 : 0.0, child: ...)) — ถูกต้องในเชิงหน้าที่ แต่มันยังคง layout, paint (ที่ opacity: 1.0 พิสูจน์แล้วข้างบนว่ามีต้นทุน layer จริง) และ hit-test ได้เต็มที่สำหรับ widget ที่ซ่อนอยู่ ทั้งที่ Visibility หรือแค่ไม่ build widget เลยจะถูกกว่าและถูกต้องกว่า (widget Opacity: 0.0 ที่ “ซ่อน” ยังคงยึดพื้นที่ layout ของมัน และถ้าไม่มี alwaysIncludeSemantics/IgnorePointer อาจยังโต้ตอบได้ในบาง configuration)
// @expect-error compiles clean — the "hidden" widget still lays out, paints its own compositing layer at opacity 1.0 while visible, and stays in the tree; Visibility or a conditional build would be both cheaper and clearerclass ToggleableCard extends StatelessWidget { const ToggleableCard({super.key, required this.visible}); final bool visible;
@override Widget build(BuildContext context) { return Opacity( opacity: visible ? 1.0 : 0.0, child: const ExpensiveCard(), ); }}ก่อนเชื่อโค้ดที่ generate มาที่ใช้ Opacity ให้เช็ค:
- มันถูกใช้เพื่อ fade ระหว่างสองค่าตามเวลาจริง ๆ (use case จริง) หรือเพื่อซ่อน/แสดงแบบ binary (ที่
Visibility,ifในbuild(), หรือIgnorePointerมักชัดเจนกว่าและถูกกว่า)? ClipRRect/ClipPathถูกตั้งเป็นClip.antiAliasWithSaveLayerโดยไม่มี bleeding-edge artifact เฉพาะที่กำลังแก้อยู่หรือเปล่า? นั่นคือตัวเลือก “very slow” ที่มีเอกสารระบุไว้ ไม่ใช่ default ที่ปลอดภัย- widget ที่ animate บ่อย (spinner, progress indicator, live chart) นั่งเป็น sibling ตรง ๆ ของเนื้อหาคงที่โดยไม่มี
RepaintBoundaryคั่นหรือเปล่า? พิสูจน์แล้วข้างบนว่าเนื้อหาคงที่นั้นต้องจ่ายต้นทุน repaint จริงที่วัดได้โดยไม่ควรต้องจ่าย
🐛 หา bug ในโค้ดนี้
class LoadingOverlay extends StatelessWidget {const LoadingOverlay({super.key, required this.loading, required this.child});final bool loading;final Widget child;
@overrideWidget build(BuildContext context) { return Stack( children: [ child, Opacity( opacity: loading ? 1.0 : 0.0, child: const Center(child: CircularProgressIndicator()), ), ], );}}เฉลย
เมื่อ loading เป็น false CircularProgressIndicator มองไม่เห็น แต่ — พิสูจน์แล้วข้างบน — Opacity ที่ opacity: 0.0 เป็นกรณีเดียวจริง ๆ ที่ข้าม compositing layer ของตัวเองและการเรียก paint ของ child ไปทั้งหมด ดังนั้นโค้ดนี้เฉพาะจุดนี้ไม่ได้จ่ายต้นทุน layer ตอนซ่อนอยู่ บั๊กจริงที่ subtle กว่านั้น: spinner ยัง layout เต็มที่และยัง animate อยู่ (AnimationController ภายในของมันเองยัง tick ต่อไป schedule เฟรมต่อไป) แม้ตอนมองไม่เห็น เผาผลาญ CPU สำหรับ animation ที่ไม่มีใครเห็น วิธีแก้: gate มันด้วย conditional build (if (loading) Center(child: CircularProgressIndicator())) แทนที่จะเป็น Opacity เพื่อให้ spinner — และ animation ที่ tick อยู่ — ไม่มีอยู่จริง ๆ ตอนไม่ loading