ข้ามไปยังเนื้อหา

Widgets and the Tree

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 เอง

นี่คือ 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, Element และ Render tree ทำงานคู่ขนานกัน
  • 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 เดียวกัน)

lib/element_spy.dart
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}');
}
test/element_spy_test.dart
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 rebuild
00:00 +1: a different key forces a new element: createElement runs again
00: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 ไม่ใช่แค่ “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…

lib/const_rebuild.dart
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
],
);
}
}
test/const_rebuild_test.dart
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 time
00:00 +1: All tests passed!

(ตัดแบบเดียวกัน — run จริง ตัดบรรทัด loading <path> ออก)

เอาสองข้อพิสูจน์มารวมกัน “ทำไม rebuild ถึงถูก” ก็ไม่ใช่แค่คำขวัญอีกต่อไป: การใช้ type-เดิม-key-เดิมซ้ำ หลีกเลี่ยง element ใหม่ และ render object ใหม่ (พิสูจน์ด้วย createElementCount ที่ไม่ขยับ) และ const ไปไกลกว่านั้นอีกขั้นด้วยการข้าม rebuild call ทั้งหมด (พิสูจน์ด้วย constLeafBuildCount ที่ไม่ขยับ) Key (บทเรียนถัดไป) มีไว้ควบคุมการ match type/key เมื่อการ match ตามตำแหน่ง default ทำผิด — ที่พบบ่อยที่สุดคือการ reorder list ของ stateful children

คิดว่าสาม tree เป็น blueprint, contractor และ building widget tree คือ blueprint ใหม่ที่วาดขึ้นทุกครั้งที่ requirement เปลี่ยน — วาดง่าย ทิ้งทันทีหลังใช้ element tree คือ contractor ที่อ่าน blueprint ใหม่แต่ละใบแล้วตัดสิน ห้องต่อห้องว่า “ห้องเดิม จุดประสงค์เดิม → แก้เฉพาะที่เปลี่ยน” หรือ “ห้องนี้ไม่มีใน plan เก่า → รื้อแล้วสร้างใหม่” render tree คือตัวอาคารจริง — ผนัง สายไฟ ท่อน้ำ — และงานทั้งหมดของ contractor คือหลีกเลี่ยงการรื้ออาคารมากกว่าที่ blueprint ต้องการจริง ๆ const คือการยื่นรูปถ่ายให้ contractor แทน blueprint: มันไม่ใช่แม้แต่ห้องใหม่ที่ต้องเทียบ มันคือหลักฐานว่าไม่มีอะไรในห้องนั้นเปลี่ยนเลย

ทางเลือกข้อดีต้นทุน
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 ก่อน
  • “Widget คือ UI จริงบนจอ” มันคือ description ที่ immutable ที่ rebuild ตลอดและทิ้งไป — RenderObject ต่างหากที่อยู่บนจอจริง และเป็น layer ที่ Flutter พยายามรักษาความคงที่มากที่สุดข้าม rebuild
  • “Widget rebuild แปลว่ามี element ใหม่ถูกสร้าง” พิสูจน์แล้วว่าผิดข้างบน: createElementCount ค้างที่ 1 ตลอดสอง rebuild ของ SpyWidget type เดิม ไม่มี key — มีแค่ didUpdateWidgetCount ที่ขยับ
  • “const แค่ประหยัด allocation นิดหน่อย ไม่ค่อยมีผล” พิสูจน์แล้วข้างบน: build() ของ const child ไม่รันอีกเลย ไม่ใช่ “รันแต่เร็ว” แต่ข้ามจริง ๆ เพราะ identical(oldWidget, newWidget) short-circuit ก่อนที่ build จะถูกเรียกด้วยซ้ำ
  • “package:flutter/material.dart deprecated ไปแล้วตอนนี้ เลิกใช้เถอะ” ไม่ถูกสำหรับ 3.47: มัน frozen (ไม่รับ contribution ใหม่) และ กำหนดไว้ ว่าจะ deprecate แต่วันนี้ยังใช้งานได้ปกติ และคอร์สนี้ (เหมือนโค้ดส่วนใหญ่วันนี้) ก็ยังใช้อยู่

โค้ด 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 error
class 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 ฟรี ๆ โดยไม่จำเป็น ตามที่พิสูจน์ข้างบน
  • StatefulWidget build() สร้าง controller, subscribe stream หรือทำงานที่ควรเกิดครั้งเดียวหรือเปล่า? (บทถัดไปคุยเรื่องนี้)
  • callback closure ถูกสร้างใหม่เหมือนเดิมทุก build ในจุดที่ const หรือ reference ที่ hoist ไว้แล้วน่าจะพอหรือเปล่า?

🐛 หา bug ในโค้ดนี้

class PriceTag extends StatelessWidget {
const PriceTag({super.key, required this.price});
final double price;
@override
Widget 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 แทนที่จะแค่ “สร้างเร็วขึ้น”

Flutter เพิ่ม behavior อย่าง padding ให้ widget อย่างไร?
พิสูจน์ในบทนี้ด้วย createElement/didUpdateWidget spy เกิดอะไรขึ้นเมื่อ widget type เดิม rebuild ที่ตำแหน่งเดิมโดยไม่เปลี่ยน key?
ใน probe test เกิดอะไรกับ createElementCount เมื่อ key เปลี่ยนระหว่าง rebuild (type เดิม ตำแหน่งเดิม)?
พิสูจน์ด้วย build counter จริงในบทนี้ เกิดอะไรกับ build() ของ const widget เมื่อ parent rebuild?
สถานะจริงของ package:flutter/material.dart ใน Flutter 3.47 ตาม docs.flutter.dev คืออะไร?
layer ไหนที่ทำ layout และ paint จริง?