Widgets and the Tree
composition ไม่ใช่ inheritance
หัวข้อที่มีชื่อว่า “composition ไม่ใช่ inheritance”Flutter สร้าง UI ด้วยการ nest widget ไม่ใช่ subclass คุณไม่ extend Button เพื่อเพิ่ม padding — แต่ wrap ด้วย Padding หนึ่งหน้าจอคือ tree ที่ลึกของ widget เล็ก ๆ ที่ทำหน้าที่เดียว ประกอบเข้าด้วยกัน
Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ const Text('Hello'), ElevatedButton(onPressed: () {}, child: const Text('Tap')), ], ), ), );}นี่คือ composition over inheritance แบบสุดโต่ง: widget เล็ก ๆ จำนวนมาก แต่ละตัวทำสิ่งเดียว nest กันจนเป็นภาพรวม Center, Padding, Column ไม่ใช่ของประดับ — widget เหล่านี้ คือ layout ไม่มี “ภาษา layout” แยกต่างหากให้เรียน (เทียบกับ Flutter layout → React CSS mental map ถ้ามาจากฝั่งเว็บ): widget tree คือ stylesheet เอง
สาม tree
หัวข้อที่มีชื่อว่า “สาม tree”นี่คือ model ที่อธิบายเกือบทุกอย่างเกี่ยวกับ behavior และ performance ของ Flutter ไม่ได้มี tree เดียว — มี สาม tree ที่ถูก sync ให้ตรงกัน บทนี้พิสูจน์ claim หลักด้วย test จริง; ส่วนกลไกเต็ม ๆ — Element.update, BuildOwner.buildScope, RenderObject.markNeedsLayout ตรงจาก source flutter/flutter — ดูที่ Flutter Internals: the Three Trees
flowchart LR
subgraph W["Widget tree"]
w1["Padding"] --> w2["Text"]
end
subgraph E["Element tree"]
e1["PaddingElement
(อยู่ต่อเนื่อง)"] --> e2["TextElement
(ถือ state)"]
end
subgraph R["Render tree"]
r1["RenderPadding
(layout)"] --> r2["RenderParagraph
(paint)"]
end
W -.creates.-> E
E -.creates.-> R - Widget — description ที่ immutable ที่คุณเขียน rebuild ตลอดและทิ้งไป ถูก
createElement()คือ method เดียวของทุก widget ที่สร้างสิ่งที่อยู่ต่อเนื่องจริง ๆ - Element — living instance ของ widget ณ ตำแหน่งหนึ่งใน tree element อยู่ต่อเนื่องข้าม rebuild, ถือ
Stateของ stateful widget และตัดสินว่า widget ใหม่จะ update element นี้ในที่เดิมได้ (update/didUpdateWidget) หรือต้องรื้อแล้วสร้างใหม่ (unmount+createElementรอบใหม่) - RenderObject — ทำ layout, paint และ hit-testing จริง นี่คือ layer ที่แพง Flutter จึงพยายามอย่างหนักที่จะนำ render object กลับมาใช้ซ้ำ
พิสูจน์แล้ว: type เดิม + ไม่มี key → element (และ createElement) ถูกใช้ซ้ำ
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: type เดิม + ไม่มี key → element (และ createElement) ถูกใช้ซ้ำ”claim ข้างบน — “type และ key เดียวกันแปลว่า element ถูก update ในที่เดิม” — พิสูจน์ได้ตรง ๆ: spy createElement (เรียกครั้งเดียวตอนสร้าง element ครั้งแรก) และ didUpdateWidget (เรียกทุกครั้งที่ element เดิมได้รับ widget ใหม่ type เดียวกัน)
import 'package:flutter/material.dart';
int createElementCount = 0;int didUpdateWidgetCount = 0;
/// A StatefulWidget that counts every time Flutter actually creates a NEW/// element for it (via createElement) versus reuses the existing one and/// just hands it new data (via didUpdateWidget).class SpyWidget extends StatefulWidget { const SpyWidget({super.key, required this.value}); final int value;
@override StatefulElement createElement() { createElementCount++; return super.createElement(); }
@override State<SpyWidget> createState() => _SpyWidgetState();}
class _SpyWidgetState extends State<SpyWidget> { @override void didUpdateWidget(covariant SpyWidget oldWidget) { super.didUpdateWidget(oldWidget); didUpdateWidgetCount++; }
@override Widget build(BuildContext context) => Text('${widget.value}');}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/element_spy.dart';
void main() { testWidgets('same type, no key: createElement runs once, didUpdateWidget runs on every rebuild', (tester) async { createElementCount = 0; didUpdateWidgetCount = 0;
await tester.pumpWidget(const MaterialApp(home: SpyWidget(value: 1))); expect(createElementCount, 1, reason: 'first mount creates exactly one element'); expect(didUpdateWidgetCount, 0, reason: 'no update yet — this was a fresh mount');
// New SpyWidget instance, SAME type, SAME position, no key — Flutter // must reuse the existing element rather than create a new one. await tester.pumpWidget(const MaterialApp(home: SpyWidget(value: 2))); await tester.pumpWidget(const MaterialApp(home: SpyWidget(value: 3)));
expect(createElementCount, 1, reason: 'the element was REUSED — createElement never ran again'); expect(didUpdateWidgetCount, 2, reason: 'the reused element was updated twice, once per rebuild'); expect(find.text('3'), findsOneWidget); });
testWidgets('a different key forces a new element: createElement runs again', (tester) async { createElementCount = 0; didUpdateWidgetCount = 0;
await tester.pumpWidget(const MaterialApp(home: SpyWidget(key: ValueKey('a'), value: 1))); expect(createElementCount, 1);
// Same type, but a DIFFERENT key at the same position — Flutter treats // this as a different identity: tear down the old element, mount a new one. await tester.pumpWidget(const MaterialApp(home: SpyWidget(key: ValueKey('b'), value: 2)));
expect(createElementCount, 2, reason: 'different key at the same slot — old element torn down, new one created'); expect(didUpdateWidgetCount, 0, reason: 'no update happened because there was no reuse — it was a fresh mount'); });}00:00 +0: same type, no key: createElement runs once, didUpdateWidget runs on every rebuild00:00 +1: a different key forces a new element: createElement runs again00:00 +2: All tests passed!(ตัดจากผล flutter test test/lessons/widgets__widgets-and-the-tree/ จริงบนเครื่องนี้ — ผลเต็มมีบรรทัด loading <path> ต่อไฟล์ด้วย ตัดออกตรงนี้)
นี่คือเรื่องราวของ reconciliation ทั้งหมด ในเชิงกลไก: createElement คือต้นทุน แพง ตอนแรก (มันคือสิ่งที่สุดท้ายไปสร้าง RenderObject ด้วย) และเมื่อ element มีอยู่แล้ว การ rebuild แบบ type เดิม key เดิม จ่ายแค่ didUpdateWidget บวก build() — ไม่เคยต้อง element ใหม่หรือ render object ใหม่เลย
พิสูจน์แล้ว: const widget ข้าม rebuild ของตัวเองทั้งหมด
หัวข้อที่มีชื่อว่า “พิสูจน์แล้ว: const widget ข้าม rebuild ของตัวเองทั้งหมด”const ไม่ใช่แค่ “allocation น้อยลง” expression ของ const widget คือ object เดียวที่ถูก canonicalize — instance เดียวกัน ทุกครั้งที่บรรทัดโค้ดนั้นรัน เส้นทาง update element ของ Flutter short-circuit ที่ identical(oldWidget, newWidget) ดังนั้น const child จึงไม่ถูก diff เลยด้วยซ้ำ นับประสาอะไรกับ rebuild ในขณะที่ sibling ที่ไม่ใช่ const rebuild ทุกครั้งที่ parent rebuild
ลองรันจริง — คลาส ConstLeaf/DynamicLeaf/RebuildingParent ชุดเดียวกับที่ widget test ด้านล่างพิสูจน์ไว้ ห่อด้วยหน้าจอ demo เล็ก ๆ พร้อมปุ่มกด ให้คุณดู build count ที่เปลี่ยน (หรือไม่เปลี่ยน) ได้เอง:
const rebuild demo — tap the button, watch which build count moves
Loading DartPad…
import 'package:flutter/material.dart';
int constLeafBuildCount = 0;int dynamicLeafBuildCount = 0;
/// Always constructed as `const ConstLeaf()` by the parent below — every/// "rebuild" produces the identical object, not a new one.class ConstLeaf extends StatelessWidget { const ConstLeaf({super.key}); @override Widget build(BuildContext context) { constLeafBuildCount++; return const SizedBox(width: 10, height: 10); }}
/// Constructed fresh (non-const, because it carries a changing value) on/// every parent rebuild.class DynamicLeaf extends StatelessWidget { const DynamicLeaf({super.key, required this.tick}); final int tick; @override Widget build(BuildContext context) { dynamicLeafBuildCount++; return SizedBox(width: 10, height: 10 + tick.toDouble()); }}
class RebuildingParent extends StatefulWidget { const RebuildingParent({super.key}); @override RebuildingParentState createState() => RebuildingParentState();}
// Public (not private) so the test below can drive it via a typed GlobalKey.class RebuildingParentState extends State<RebuildingParent> { int tick = 0; void bump() => setState(() => tick++);
@override Widget build(BuildContext context) { return Column( children: [ const ConstLeaf(), // identical instance every build DynamicLeaf(tick: tick), // new instance every build ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/const_rebuild.dart';
void main() { testWidgets('const child never rebuilds; non-const sibling rebuilds every time', (tester) async { constLeafBuildCount = 0; dynamicLeafBuildCount = 0;
final key = GlobalKey<RebuildingParentState>(); await tester.pumpWidget(MaterialApp(home: RebuildingParent(key: key))); expect(constLeafBuildCount, 1); expect(dynamicLeafBuildCount, 1);
key.currentState!.bump(); await tester.pump(); key.currentState!.bump(); await tester.pump(); key.currentState!.bump(); await tester.pump();
expect(constLeafBuildCount, 1, reason: 'const ConstLeaf() is the identical instance every rebuild — build() never runs again'); expect(dynamicLeafBuildCount, 4, reason: 'DynamicLeaf is a new instance each build — its build() runs every time'); });}00:00 +0: const child never rebuilds; non-const sibling rebuilds every time00:00 +1: All tests passed!(ตัดแบบเดียวกัน — run จริง ตัดบรรทัด loading <path> ออก)
ทำไม rebuild ถึงยังถูก
หัวข้อที่มีชื่อว่า “ทำไม rebuild ถึงยังถูก”เอาสองข้อพิสูจน์มารวมกัน “ทำไม rebuild ถึงถูก” ก็ไม่ใช่แค่คำขวัญอีกต่อไป: การใช้ type-เดิม-key-เดิมซ้ำ หลีกเลี่ยง element ใหม่ และ render object ใหม่ (พิสูจน์ด้วย createElementCount ที่ไม่ขยับ) และ const ไปไกลกว่านั้นอีกขั้นด้วยการข้าม rebuild call ทั้งหมด (พิสูจน์ด้วย constLeafBuildCount ที่ไม่ขยับ) Key (บทเรียนถัดไป) มีไว้ควบคุมการ match type/key เมื่อการ match ตามตำแหน่ง default ทำผิด — ที่พบบ่อยที่สุดคือการ reorder list ของ stateful children
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”คิดว่าสาม tree เป็น blueprint, contractor และ building widget tree คือ blueprint ใหม่ที่วาดขึ้นทุกครั้งที่ requirement เปลี่ยน — วาดง่าย ทิ้งทันทีหลังใช้ element tree คือ contractor ที่อ่าน blueprint ใหม่แต่ละใบแล้วตัดสิน ห้องต่อห้องว่า “ห้องเดิม จุดประสงค์เดิม → แก้เฉพาะที่เปลี่ยน” หรือ “ห้องนี้ไม่มีใน plan เก่า → รื้อแล้วสร้างใหม่” render tree คือตัวอาคารจริง — ผนัง สายไฟ ท่อน้ำ — และงานทั้งหมดของ contractor คือหลีกเลี่ยงการรื้ออาคารมากกว่าที่ blueprint ต้องการจริง ๆ const คือการยื่นรูปถ่ายให้ contractor แทน blueprint: มันไม่ใช่แม้แต่ห้องใหม่ที่ต้องเทียบ มันคือหลักฐานว่าไม่มีอะไรในห้องนั้นเปลี่ยนเลย
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| Composition ลึก (widget เล็ก ๆ จำนวนมาก) | reuse ได้ ทดสอบได้ แต่ละชิ้นคิดเรื่องเดียว | class/file เยอะกว่า build() ก้อนเดียว — ปกติคุ้ม |
| พึ่ง type/key matching (default) | ไม่ต้องเขียนโค้ดเพิ่ม ถูกต้องเกือบทุกกรณีที่ไม่ใช่ list | พังเงียบ ๆ กับการ reorder list ของ stateful widget (บทถัดไป) |
ใส่ const ให้ widget ทุกที่ที่ทำได้ | ข้าม rebuild ทั้งหมด — แข็งแรงกว่า manual memoization ใด ๆ | ช่วยได้เฉพาะที่ input เป็น compile-time constant จริง ๆ widget ที่ถือ runtime value ทำ const ไม่ได้ |
ใช้ package:flutter/material.dart ต่อไป | ไม่มีต้นทุน migration ตรงกับโค้ด/tutorial ที่มีอยู่เกือบทั้งหมดวันนี้ | frozen ตั้งแต่ 3.44 — ฟีเจอร์ Material ใหม่ลง material_ui ก่อน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Widget คือ UI จริงบนจอ” มันคือ description ที่ immutable ที่ rebuild ตลอดและทิ้งไป —
RenderObjectต่างหากที่อยู่บนจอจริง และเป็น layer ที่ Flutter พยายามรักษาความคงที่มากที่สุดข้าม rebuild - “Widget rebuild แปลว่ามี element ใหม่ถูกสร้าง” พิสูจน์แล้วว่าผิดข้างบน:
createElementCountค้างที่1ตลอดสอง rebuild ของSpyWidgettype เดิม ไม่มี key — มีแค่didUpdateWidgetCountที่ขยับ - “
constแค่ประหยัด allocation นิดหน่อย ไม่ค่อยมีผล” พิสูจน์แล้วข้างบน:build()ของ const child ไม่รันอีกเลย ไม่ใช่ “รันแต่เร็ว” แต่ข้ามจริง ๆ เพราะidentical(oldWidget, newWidget)short-circuit ก่อนที่buildจะถูกเรียกด้วยซ้ำ - “
package:flutter/material.dartdeprecated ไปแล้วตอนนี้ เลิกใช้เถอะ” ไม่ถูกสำหรับ 3.47: มัน frozen (ไม่รับ contribution ใหม่) และ กำหนดไว้ ว่าจะ deprecate แต่วันนี้ยังใช้งานได้ปกติ และคอร์สนี้ (เหมือนโค้ดส่วนใหญ่วันนี้) ก็ยังใช้อยู่
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โค้ด Flutter ที่ AI generate มักใช้ const ไม่เต็มที่ — มันเป็น optional ทาง syntax ดังนั้น model ที่ train จากโค้ดผสมทั้งที่ const-correct และ const-สะเพร่า มักจะลืมใส่บน leaf ที่เป็น constant ชัด ๆ ทำให้เสียการข้าม rebuild ฟรี ๆ ที่พิสูจน์ไว้ข้างบนไปเงียบ ๆ
// @expect-error not a real analyzer error — a missed-const performance bug, not a compile errorclass StatusBadge extends StatelessWidget { const StatusBadge({super.key}); @override Widget build(BuildContext context) { return Row( children: [ Icon(Icons.check_circle, color: Colors.green), // missing const — rebuilds every time SizedBox(width: 4), // missing const too Text('Active'), // missing const too ], ); }}ก่อนเชื่อโค้ด widget ที่ generate มา ให้เช็ค:
- ทุก leaf ที่ argument ของ constructor เป็น compile-time constant ล้วน (ไม่มีตัวแปร runtime) — เขียนเป็น
const Icon(...),const SizedBox(...),const Text('...')หรือยัง? ถ้าไม่ มันจะ rebuild ทุกครั้งที่ parent rebuild ฟรี ๆ โดยไม่จำเป็น ตามที่พิสูจน์ข้างบน StatefulWidgetbuild()สร้าง controller, subscribe stream หรือทำงานที่ควรเกิดครั้งเดียวหรือเปล่า? (บทถัดไปคุยเรื่องนี้)- callback closure ถูกสร้างใหม่เหมือนเดิมทุก build ในจุดที่
constหรือ reference ที่ hoist ไว้แล้วน่าจะพอหรือเปล่า?
🐛 หา bug ในโค้ดนี้
class PriceTag extends StatelessWidget {const PriceTag({super.key, required this.price});final double price;
@overrideWidget build(BuildContext context) { return Row( children: [ Icon(Icons.attach_money, size: 16), Text(price.toStringAsFixed(2)), ], );}}เฉลย
Icon(Icons.attach_money, size: 16) มี argument ทุกตัวเป็น compile-time constant แต่ขาด const — ทุกครั้งที่ PriceTag rebuild (เช่น parent list rebuild ด้วยเหตุผลอื่นที่ไม่เกี่ยวกัน) icon นี้ถูกสร้างและ diff ใหม่โดยไม่จำเป็น Text(price.toStringAsFixed(2)) ถูกปล่อยเป็น non-const อย่างถูกต้อง เพราะ price เป็น field runtime และเป็น compile-time constant ไม่ได้ วิธีแก้: const Icon(Icons.attach_money, size: 16) — พิสูจน์แล้วข้างบนว่าจะทำให้ Flutter ข้าม rebuild ของ subtree นั้นผ่าน fast path ที่ identical instance แทนที่จะแค่ “สร้างเร็วขึ้น”