Hi~ 我是 Eric!

Day 1 我先把 Flutter Learning Lab 的專案地圖看完,知道 README.mdmain.dartapp.dartrouter.darthome_page.dart 各自在做什麼。

今天進入 Day 2。

這一天的主題是 Flutter 初學者幾乎一定會聽到的一句話:

Everything is a Widget.

老實說,第一次看到這句話時,我的反應是:好,聽起來很厲害,然後呢?

因為如果只是把它當成口號,其實幫助不大。真正有幫助的是把它翻成一個可以拿來讀程式碼的心智模型:

Flutter UI 不是一堆畫面控制項,而是一棵用 Widget 描述出來的畫面樹。

Day 2 我想完成的事情,就是把「巢狀括號很多的 Flutter 程式碼」翻成「我看得懂的 Widget tree」。


Day 2 的目標:把括號看成樹

今天我對照的專案內容主要是:

  • docs/lessons/WIDGET_MENTAL_MODEL.md
  • docs/lessons/FLUTTER_UI_GUIDE.md
  • docs/lessons/UI_COMPONENT_LIBRARY.md
  • lib/views/home_page.dart
  • lib/01_basic_widgets.dart
  • lib/02_state_management.dart
  • lib/views/ui_kit_view.dart

Day 2 的目標不是一次學完所有 Flutter UI 元件。

我只想先做到這幾件事:

  1. 能用自己的話解釋 Everything is a Widget
  2. 能把一段 Flutter UI 程式碼翻成 Widget tree
  3. 能分辨 StatelessWidgetStatefulWidget
  4. 能知道 build() 不是畫一次畫面,而是描述目前狀態下的畫面
  5. 能判斷什麼時候該把一段 UI 拆成小 Widget

這一天的關鍵不是背元件,而是建立閱讀方式。


先從首頁開始,不要從抽象概念開始

我沒有先打開一大堆 Widget 文件。

我先回到 Day 1 看過的首頁:

lib/views/home_page.dart

這個檔案很適合當 Day 2 的入口,因為它不複雜,但又剛好包含 Flutter 畫面常見的結構:

  • Scaffold
  • AppBar
  • ListView
  • Column
  • Padding
  • Text
  • ListTile
  • Icon
  • Divider

如果只看程式碼,Flutter 很容易變成一串括號。

但如果把它翻成樹,就會變成這樣:

HomePage
  -> Scaffold
    -> AppBar
      -> Text
    -> ListView
      -> Column section
        -> Padding
          -> Text
        -> ListTile
          -> Text
          -> Icon
        -> Divider
      -> Column section
        -> CounterWidget
        -> ListTile
        -> ListTile
        -> Divider

這一刻我才比較能理解「Everything is a Widget」。

它不是在說 Flutter 很愛把東西都叫 Widget。

它是在說:畫面的骨架、文字、按鈕、間距、排列方式、捲動方式,全部都用 Widget 這個共同語言描述。


Widget 不是畫面本身,而是畫面的描述

這句話是 Day 2 我最想記住的概念。

在 Flutter 裡,Widget 比較像是「畫面應該長什麼樣子」的描述,而不是一個你手動操作的畫面物件。

例如:

Text('Flutter 學習路徑')

它不是叫你直接去螢幕上畫字。

它是在描述:「這裡應該有一段文字,內容是 Flutter 學習路徑。」

再例如:

ListView(
  children: [
    ListTile(title: Text('01 基礎元件')),
    ListTile(title: Text('UI 元件庫')),
  ],
)

它是在描述:「這裡應該有一個可以捲動的列表,裡面有兩個清單項目。」

這也是為什麼 Flutter 的 build() 很重要。

build() 不是「把畫面畫一次就結束」。

它比較像是在回答:

根據目前的資料與狀態,這個畫面現在應該被描述成什麼樣子?

這個理解一建立,後面看 state、API loading、表單錯誤、theme 切換時,就不會覺得畫面更新是一件很神祕的事。


StatelessWidget:資料進來,畫面畫出去

接著我用 lib/02_state_management.dart 來看 StatelessWidgetStatefulWidget 的差別。

專案裡有一個很小的範例:

class StaticText extends StatelessWidget {
  final String text;
  const StaticText(this.text, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(text);
  }
}

這段很適合用一句話理解:

資料從外面進來,Widget 負責把它顯示出去。

StaticText 自己不保存可變狀態。它收到 text,然後在 build() 裡回傳 Text(text)

像這種情境就很適合 StatelessWidget

  • 顯示標題
  • 顯示資訊卡
  • 顯示清單項目
  • 顯示從父層傳進來的資料
  • 拆出一小塊純展示 UI

回頭看 HomePage,它也是 StatelessWidget

首頁本身只是把學習項目列出來,點擊後交給 go_router 切頁。它沒有自己保存「目前選到哪一頁」這種狀態,所以用 StatelessWidget 很合理。


StatefulWidget:畫面需要保存短期狀態

同一個檔案裡也有 CounterWidget

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

真正保存狀態的是對應的 State

class _CounterWidgetState extends State<CounterWidget> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }
}

這裡的 _counter 就是畫面自己的短期狀態。

使用者點按鈕,_counter 改變,然後 setState() 告訴 Flutter:

這個 Widget 的狀態變了,請重新執行 build(),用新的狀態描述畫面。

所以 StatefulWidget 適合這種情境:

  • counter
  • 展開 / 收合
  • tab 當前選取狀態
  • animation controller
  • 表單 controller
  • 使用者正在輸入、還不一定要同步到全域的暫時狀態

但這裡也有一個重要提醒:不是所有狀態都該丟進 StatefulWidget

如果狀態需要跨頁共享、需要被測試、來自 API、會影響整個 app,就應該考慮 ViewModel、Riverpod 或 Repository。

這也是後面 Day 4、Day 5 會碰到的內容。


build() 不是生命週期儀式,而是畫面描述器

以前我看 Flutter 程式碼時,很容易把 build() 當成某種固定要覆寫的儀式。

每個 Widget 都有它,好像只是語法規定。

但 Day 2 我想換一個說法:

build() 是畫面描述器。

它做的不是「命令式地修改畫面」,而是回傳一棵 Widget tree。

例如 HomePagebuild() 回傳 Scaffold

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('Flutter 學習路徑')),
    body: ListView(...),
  );
}

這段的白話是:

這個頁面現在應該是一個 Scaffold。
上面有 AppBar。
內容是一個可以捲動的 ListView。
ListView 裡有學習區塊與清單項目。

當狀態改變時,Flutter 會重新呼叫 build(),重新拿到一份新的畫面描述。

這也是為什麼我們在 build() 裡要避免做太重的事情。它可能會被呼叫很多次,所以它應該專心描述 UI,而不是偷偷做 API request 或複雜副作用。


Row、Column、Stack:先理解排列方式

Day 2 我沒有急著把所有 Material 3 元件背起來。

我先把最常見的 layout Widget 分成三種:

Widget白話理解適合情境
Row橫向排一排icon + 文字、左右兩側資訊
Column直向疊起來表單欄位、卡片內容、區塊列表
Stack疊在同一個平面圖片上疊文字、badge、浮動元素

docs/lessons/FLUTTER_UI_GUIDE.md 裡也提醒了一個重要概念:Flutter layout 不是你想放哪就放哪,而是會受到 constraints 影響。

我先用很粗的方式記:

父層給限制
子層決定尺寸
父層決定位置

這句話 Day 2 不需要完全吃透,但先知道它存在很重要。

因為很多 Flutter UI 爆版,不是因為你不會用 Row,而是因為你還沒理解限制如何往下傳。

例如 Row 裡放太長的文字,如果沒有 Expanded 或換行策略,就很容易 overflow。

這就是 Day 2 我先想記下來的第一個 UI 雷點。


UI Kit:把常見元件集中看一次

接著我打開:

lib/views/ui_kit_view.dart

這個頁面不是業務功能,它比較像「常用元件展示區」。

對初學者來說,UI Kit 很有用,因為它可以把常見元件放在同一個畫面裡觀察:

  • buttons
  • inputs
  • cards
  • chips
  • dialogs
  • snackbars
  • lists

這裡我不是要背每個 Widget 的 API。

我比較想觀察的是:這些元件放在畫面上時,通常會搭配哪些外層 Widget?

例如:

  • button 常常需要 onPressed
  • card 通常需要 Padding
  • list item 常常用 ListTile
  • dialog / snackbar 是 feedback
  • input 會牽涉 controller、validator 或 state

這讓我開始把 Widget 分類,而不是只記名字。

Day 2 的重點不是「我知道很多 Widget」,而是「我知道看到一個 Widget 時,要問它負責哪一種 UI 角色」。


什麼時候該拆 Widget?

Flutter 初學時很容易出現一個狀況:整個頁面都塞在同一個 build() 裡。

短期看起來很快。

長期會變成括號地獄。

專案的 WIDGET_MENTAL_MODEL.md 給了一個很好用的判斷方式。當我看到這些訊號,就可以考慮拆 Widget:

  • build() 超過一個螢幕還看不完
  • 同一段 UI 重複出現
  • 某一小塊 UI 有清楚名稱
  • 想單獨測某一塊 UI
  • parent widget 同時處理 layout、資料、事件、樣式

拆 Widget 不是為了炫技。

它的目的很實際:讓畫面變得可讀、可維護、可測試。

例如首頁裡的 _LearningItem 目前只是一個小資料類別,但如果首頁清單項目之後變複雜,就可以考慮拆成 LearningItemTile

這樣 HomePage 就不需要知道每個 list item 的細節,只需要負責「首頁有哪些學習區塊」。


我怎麼用 AI 學 Widget?

Day 2 我覺得 AI 最有幫助的地方,不是叫它幫我產生更多 UI。

而是叫它幫我翻譯 Widget tree。

我可以直接問:

請把 lib/views/home_page.dart 的 build() 轉成樹狀結構,
並用初學者聽得懂的方式說明每一層 Widget 的責任。

也可以問:

這段 UI 什麼地方適合拆成小 Widget?
哪些 state 應該留在 StatefulWidget?
哪些 state 之後應該交給 Riverpod?

這種問法比「幫我寫一個漂亮 UI」更有學習價值。

因為我不是把 AI 當外包,而是把它當成一個能看著程式碼講解的 Flutter 導師。

它可以幫我把括號翻成樹,把樹翻成責任,再把責任翻成架構判斷。


我實際怎麼追問 Codex?

Day 2 的學習過程其實很像在跟 Codex 一起看 code review。

我不是一次問:「請教我 Flutter Widget。」

這種問題太大,AI 很容易回答成教科書。

我比較有效的問法是拿著一個具體檔案,請它先幫我拆最小單位。例如:

請先只看 lib/views/home_page.dart。
不要急著講全部 Flutter Widget。
請用初學者能理解的方式,告訴我這個頁面的 Widget tree 長什麼樣子。

這樣 Codex 的回答就會貼著專案走,而不是泛泛介紹 ScaffoldAppBarListView

接著我會繼續追問:

這個 HomePage 為什麼適合用 StatelessWidget?
它有沒有任何狀態需要自己保存?
如果未來清單項目變複雜,哪一段適合拆成新的 Widget?

這一輪問完,我才比較知道 StatelessWidget 不是「比較簡單的 Widget」,而是「自己不保存可變狀態的 Widget」。

然後我會換到 lib/02_state_management.dart

請比較 StaticText 和 CounterWidget。
不要只說一個是 StatelessWidget、一個是 StatefulWidget。
請用狀態責任的角度解釋,為什麼 CounterWidget 需要 State。

這裡 Codex 會把重點拉回 _countersetState()

我再追問:

如果這個 counter 的數字需要被其他頁面使用,
它還適合只放在 StatefulWidget 裡嗎?
什麼時候應該改用 Riverpod 或 ViewModel?

這個問題很重要,因為它把 Day 2 的 Widget 狀態,先接到後面 Day 4、Day 5 會學的 Riverpod 與 ViewModel。

我發現跟 Codex 學 Flutter 時,最有效的節奏是這樣:

  1. 先指定一個檔案,不要讓問題發散
  2. 請它把程式碼翻成樹狀結構
  3. 追問每一層 Widget 的責任
  4. 再問「這裡為什麼不用另一種寫法」
  5. 最後請它給我一個最小修改練習

例如 Day 2 我最後會問:

請根據目前的 HomePage,設計一個不會破壞架構的 Day 2 最小練習。
練習要能幫我確認自己真的理解 Widget tree、StatelessWidget 和 StatefulWidget。

這種對話讓我比較像是在被一位老師引導,而不是只是收到一份答案。

AI 生成答案很快,但真正讓我學會的,是我可以針對同一段程式碼一直問「為什麼」。


Day 2 常見卡點:我用問答把概念釐清

看完 Widget tree 之後,我發現自己還是會有一些很初學者的問題。

這些問題如果只自己悶著看,很容易越看越抽象。但丟給 Codex 之後,我可以用一問一答的方式,把每個卡點拆小。

Q1:Everything is a Widget 是不是代表所有東西都一樣?

我問 Codex:

Flutter 說 Everything is a Widget,
這是不是代表 Text、Padding、ListView、Scaffold 都是同一種東西?
那我怎麼知道每個 Widget 的責任?

Codex 幫我整理成一個比較好懂的說法:

它們都叫 Widget,但責任不同。
Scaffold 是頁面骨架。
AppBar 是頂部列。
ListView 是可捲動內容。
Padding 是空間規則。
Text 是內容。

所以我後來不再只問「這是不是 Widget」,而是改問:

這個 Widget 在這棵樹裡負責什麼?
它是在處理結構、排版、內容、互動,還是回饋?

這個問法比背 Widget 名稱有用很多。

Q2:StatelessWidget 真的完全不會變嗎?

這是我一開始很容易誤會的地方。

我問:

StatelessWidget 叫做 stateless,
那它是不是代表畫面永遠不會改變?
如果父層傳進來的資料變了,它會不會重建?

Codex 的解釋是:StatelessWidget 不是「畫面永遠不變」,而是「自己不保存可變狀態」。

父層如果用新的資料重建它,它仍然可以顯示不同內容。

差別在於它自己沒有 _counter、controller、animation 這種內部 state。

所以我改成這樣記:

StatelessWidget:
自己不記狀態,但可以根據外部傳入的資料重新畫出不同畫面。

這讓我比較能理解 HomePage 為什麼適合 StatelessWidget

首頁會顯示清單,也會讓我點擊切頁,但它自己不需要保存一份會變動的內部狀態。

Q3:什麼時候才需要 StatefulWidget?

我接著問:

如果 StatelessWidget 也會因為父層資料改變而重建,
那 StatefulWidget 到底什麼時候才需要?

Codex 叫我回去看 CounterWidget

因為 _counter 不是父層傳進來的資料,而是這個 widget 自己管理的短期 UI 狀態。

使用者點按鈕,_counter 改變,setState() 觸發重建。

這讓我把判斷標準改成:

這個狀態是不是只屬於這個畫面的一小段互動?
如果是,StatefulWidget 可能合理。

這個狀態是不是需要跨頁共享、被測試、來自 API、或影響整個 app?
如果是,就不要急著塞進 StatefulWidget。

這個問題也剛好銜接到後面 Day 4、Day 5 的 ViewModel 和 Riverpod。

Q4:build() 被重複執行會不會很可怕?

我以前看到 Flutter 一直 rebuild,會直覺覺得很危險。

於是我問:

如果 setState 會重新執行 build(),
那 build() 被呼叫很多次是不是代表效能很差?
我應該避免 rebuild 嗎?

Codex 的回答讓我比較安心:

build() 本來就可能被呼叫很多次。重點不是「完全避免 rebuild」,而是讓 build() 保持單純,專心回傳 Widget tree。

所以我記下兩個原則:

build() 裡適合描述 UI。
build() 裡不適合偷偷打 API、寫檔案、做很重的計算或副作用。

這也讓我比較能理解,為什麼後面 Posts API 載入會放到 ViewModel / Repository,而不是直接塞在 Widget 的 build() 裡。

Q5:什麼時候該拆 Widget?

我問 Codex:

如果我把 UI 拆成很多小 Widget,
會不會反而更難找?
什麼時候拆才合理?

Codex 給我的判斷很實用:

當一段 UI 有清楚名稱、會重複出現、需要單獨測試,
或讓 parent build() 變得太長時,就可以拆。

所以 Day 2 我先不追求「拆越多越好」。

我只問:

這一段 UI 能不能被命名?
拆出去後,原本的 build() 會不會更好讀?

如果答案是 yes,再拆就比較合理。

Q6:Row overflow 到底是誰的錯?

Day 2 還有一個很常見的卡點:Row 裡文字太長,畫面爆掉。

我問:

為什麼 Row 裡放長文字會 overflow?
我是不是只要包 Container 設寬度就好?

Codex 把問題拉回 layout constraints:

Row 會讓 children 橫向排列。
如果某個 child 想要的寬度太大,
又沒有 Expanded、Flexible 或換行策略,
就可能超出父層限制。

所以我先記一個簡單規則:

看到 overflow,不要先亂包固定寬高。
先問:父層給了什麼限制?子層有沒有彈性空間?

這讓我知道 Day 2 還不需要完全精通 layout,但至少要開始用 constraints 的角度看問題。

這些問答看起來都很小。

但它們讓我從「我知道一些名詞」變成「我知道遇到卡點時可以怎麼問」。


今日最小練習:畫出首頁 Widget Tree

Day 2 的最小練習很單純:

  1. 打開 lib/views/home_page.dart
  2. 找到 build()
  3. 先不要改程式碼
  4. 把整個畫面手動寫成 Widget tree
  5. 再挑一個清單項目,追它點擊後會去哪個 route
  6. 最後打開 lib/02_state_management.dart,比較 StaticTextCounterWidget

我會用這樣的格式寫筆記:

HomePage
  -> Scaffold:頁面骨架
  -> AppBar:頁面標題
  -> ListView:可捲動內容
  -> ListTile:單一學習入口
  -> context.push:交給 go_router 切頁

然後回答三個問題:

  1. 這個頁面哪些 Widget 只是顯示資料?
  2. 這個頁面有沒有自己保存狀態?
  3. 如果清單項目變複雜,我會拆出哪個小 Widget?

這個練習看起來很小,但它會直接訓練 Flutter 最重要的閱讀能力。


Day 2 結論:我開始看得懂 Flutter 的畫面語言

Day 2 我沒有寫出很華麗的 UI。

但我開始看懂 Flutter 的畫面語言。

我現在可以說出:

  • Everything is a Widget 代表 Flutter 用 Widget tree 描述 UI
  • Widget 不是畫面本身,而是畫面描述
  • build() 會根據目前狀態回傳 Widget tree
  • StatelessWidget 適合純展示或接收外部資料
  • StatefulWidget 適合保存畫面短期互動狀態
  • 不是所有狀態都該放在 StatefulWidget
  • RowColumnStack 先從排列方式理解
  • 拆 Widget 是為了可讀、可維護、可測試
  • AI 很適合幫我把巢狀 UI 轉成 Widget tree

這一天的成果不是「我會做漂亮畫面了」。

而是:「我終於不再只看到一堆括號。」

下一篇,我會把 Widget 接回 App Shell,繼續看 Form、Router、Theme 怎麼一起組成一個比較完整的 Flutter app。