Hi~ 我是 Eric!
Day 1 我拿到專案地圖,Day 2 看懂 Widget tree,Day 3 把單一表單畫面接回 App Shell。
今天進入 Day 4。
這一天開始,Flutter Learning Lab 終於比較像真實 app 了。
因為我們要看的是 Posts 列表。
列表頁看起來很普通:搜尋欄、篩選 chip、文章清單、載入更多、錯誤重試。
但它背後其實藏著很多 app 開發會一直遇到的問題:
- 畫面要不要直接呼叫 API?
- loading、data、error 要放在哪裡?
- 搜尋時要不要每打一個字就打 API?
- 分頁載入時,要不要讓整個畫面變回 loading?
- 測試時要不要真的連網路?
Day 4 的目標,就是從 PostListView 一路追到 ApiClient,把這些問題拆開。
Day 4 的目標:從畫面一路追到 API
今天對照的專案內容主要是:
docs/features/posts.mddocs/lessons/ADVANCED_STATE_NETWORK.mdlib/services/api_client.dartlib/features/posts/domain/post.dartlib/features/posts/domain/post_query.dartlib/features/posts/data/post_api_service.dartlib/features/posts/data/post_repository.dartlib/features/posts/presentation/post_list_state.dartlib/features/posts/presentation/post_list_view_model.dartlib/features/posts/presentation/post_list_view.dart
Day 4 我想看懂這條資料流:
PostListView
-> PostListViewModel
-> PostRepository
-> PostApiService
-> ApiClient
-> JSONPlaceholder API
如果 Day 3 是「一個畫面如何接回 app 骨架」,Day 4 就是「一個畫面如何接到資料來源」。
這也是我覺得 Flutter 開始變有趣的地方。
因為 UI 不再只是靜態元件,而是會跟非同步資料、錯誤、重新整理、分頁互動。
先看 feature-first 檔案地圖
Posts 是這個專案第一個完整 feature-first 範例。
檔案結構長這樣:
lib/features/posts/
domain/
post.dart
post_query.dart
data/
post_api_service.dart
post_repository.dart
presentation/
post_list_state.dart
post_list_view_model.dart
post_list_view.dart
這個分層一開始看起來檔案很多,但它其實是在回答三種問題:
| 層級 | 負責回答 |
|---|---|
domain/ | 這個 feature 操作什麼資料與查詢條件? |
data/ | 資料從哪裡來?怎麼轉成 app 能用的格式? |
presentation/ | 畫面狀態怎麼管理?使用者看到什麼? |
我一開始最容易犯的錯,是想從 ApiClient 開始看。
但這樣會太早掉進細節。
所以 Day 4 我反過來,從使用者看到的畫面開始:
PostListView
View:畫面只負責顯示和轉交事件
PostListView 是 ConsumerWidget。
它最重要的動作是監聽 ViewModel:
final postsAsync = ref.watch(postListViewModelProvider);
然後用 AsyncValue.when 分成三種畫面:
postsAsync.when(
data: (listState) => _PostListContent(state: listState),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _InitialErrorView(error: error),
)
這裡我看到 View 的責任邊界:
- loading 時顯示進度圈
- data 時顯示搜尋、篩選、列表、分頁
- error 時顯示錯誤與重試按鈕
- 使用者操作時,把事件交給 ViewModel
例如刷新按鈕:
onPressed: () => ref
.read(postListViewModelProvider.notifier)
.refreshPosts(),
搜尋欄位:
onChanged: viewModel.updateSearchTerm,
載入更多:
onPressed: viewModel.loadMore,
View 沒有自己呼叫 Dio,也沒有自己決定分頁邏輯。
這是 Day 4 第一個很重要的分工:
View 看得到使用者,但不應該直接碰資料來源。
ViewModel:列表頁真正的狀態中樞
接著看 post_list_view_model.dart。
它使用的是:
AsyncNotifierProvider<PostListViewModel, PostListState>
這裡其實有兩層狀態:
AsyncValue<PostListState>
-> 第一次載入的 loading / data / error
PostListState
-> 已進入列表後的搜尋、篩選、分頁、刷新、載入更多錯誤
這個設計一開始有點繞,但理解後很合理。
第一次進頁面時,使用者還沒有任何列表資料,所以可以用整頁 loading。
但如果使用者已經看到列表,只是點了「載入更多」,就不應該把整個畫面切回 loading。比較好的體驗是在底部顯示載入中,保留原本列表。
所以專案把狀態拆成:
- 初始狀態:交給
AsyncValue - 列表互動狀態:交給
PostListState
PostListState 裡有:
final List<Post> posts;
final PostQuery query;
final bool hasMore;
final bool isRefreshing;
final bool isLoadingMore;
final String? errorMessage;
final String? loadMoreErrorMessage;
這讓我開始理解,ViewModel 不是單純「拿資料」。
它更像是畫面的腦袋,負責把使用者事件轉成 UI state。
Repository:ViewModel 不需要知道資料怎麼來
ViewModel 取得資料時,讀的是:
ref.read(postRepositoryProvider).fetchPostPage(query)
這裡它依賴的是 PostRepository 抽象:
abstract class PostRepository {
Future<List<Post>> fetchPosts();
Future<PostPage> fetchPostPage(PostQuery query);
}
這個介面的價值是:ViewModel 不需要知道資料是從遠端 API、本地快取,還是測試用 fake repository 來。
現在實作是 RemotePostRepository:
PostRepository
-> RemotePostRepository
-> PostApiService
而且這個 repository 還做了一個教學用的折衷:JSONPlaceholder 的 /posts 回傳完整列表,專案在本地做 filter 和 pagination。
這不一定是真實產品最終寫法,但很適合學習。
因為它讓我可以先理解搜尋、篩選、分頁狀態,而不會被 server-side API 規格卡住。
Service:真正碰 HTTP 的地方
再往下追到 post_api_service.dart。
這一層才真的呼叫遠端 API:
final response = await _client.dio.get<List<dynamic>>('/posts');
然後把 JSON 轉成 Post:
return data
.whereType<Map<String, dynamic>>()
.map(Post.fromJson)
.toList();
這裡我學到一個很重要的邊界:
不要讓 Map<String, dynamic> 直接流進 UI。
post.dart 裡定義了 app 內部真正想操作的資料:
class Post {
const Post({
required this.id,
required this.title,
required this.body,
});
}
UI 和 ViewModel 應該使用 Post,而不是到處寫 json['title']。
這樣如果 API 欄位未來有變,至少變動會集中在 model / service 附近,而不是污染整個畫面層。
ApiClient:共用網路基礎設施
最底層是 lib/services/api_client.dart。
它封裝 Dio 的共用設定:
baseUrl- timeout
- headers
- log interceptor
- token reader
我覺得這個檔案很適合用來理解「基礎設施」。
ApiClient 不知道 Posts feature 是什麼。
它只負責提供一個可共用、可注入、可測試的 HTTP client。
例如 token 是用函式注入:
typedef TokenReader = String? Function();
沒有 token 時,就不送假的 Authorization header。
這比在 client 裡硬編碼假 token 好很多,因為正式環境、測試環境、未登入狀態都能有不同做法。
Search、Filter、Pagination:不要把互動邏輯塞進 Widget
Posts 最有趣的地方,是它不只載入列表。
它還有:
- search
- filter
- pagination
- debounce
- refresh
- retry
- local delete
這些都放在 ViewModel,而不是 View。
例如搜尋:
void updateSearchTerm(String value) {
final nextQuery = current.query.copyWith(
searchTerm: value,
page: 1,
);
state = AsyncValue.data(current.copyWith(query: nextQuery));
_searchDebounce?.cancel();
_searchDebounce = Timer(_debounceDuration, () {
_reloadFirstPage(query: nextQuery);
});
}
搜尋有 debounce,意思是使用者每打一個字,不會立刻重載列表。
它會等使用者停一下,再執行查詢。
這個邏輯如果寫在 Widget 裡,畫面很快就會變得又長又難測。
放在 ViewModel 後,我可以比較清楚地說:
TextField 只把文字變化通知出去。
ViewModel 決定什麼時候真的重新查詢。
Repository 負責依 query 回傳一頁資料。
Filter 則不同。
切 filter 是明確使用者操作,不需要 debounce,所以 changeFilter() 直接重載第一頁。
Pagination 也有自己的狀態:isLoadingMore 和 loadMoreErrorMessage。
這讓載入更多失敗時,畫面可以在底部顯示 inline error,而不是整頁炸掉。
我怎麼用 Codex 追資料流?
Day 4 我跟 Codex 的對話方式,是從畫面一路往下追。
我先問:
請只看 Posts feature。
從 PostListView 開始,用箭頭列出使用者第一次進入 /posts 時,
資料如何一路從 API 回到畫面。
接著追分工:
請用這個專案的實作說明:
View、ViewModel、Repository、PostApiService、ApiClient 各自負責什麼?
哪些事情不應該放在 View?
然後專門問狀態:
為什麼這裡同時有 AsyncValue<PostListState> 和 PostListState?
如果只用 AsyncValue<List<Post>>,會遇到什麼問題?
這一題很關鍵。
因為它讓我理解到:列表頁的狀態不是只有「有資料或沒資料」。
還有「正在刷新」、「正在載入更多」、「載入更多失敗」、「搜尋中但保留舊資料」這些細節。
最後我問 Codex:
如果我要新增一個依 title 排序的功能,
請先不要改程式碼。
請列出會牽動哪些檔案、狀態、測試。
這種問法讓我把 AI 從「幫我寫 code」轉成「幫我做修改前的影響分析」。
Day 4 開始,我越來越感覺到:學 app 架構時,會問問題比會複製範例更重要。
Day 4 常見卡點:資料流用問答才看得懂
Posts feature 的檔案很多,最容易發生的情況是:每個檔案都看得懂一點,但合起來不知道資料怎麼流。
所以我用 Q&A 把資料流拆開。
Q1:View 為什麼不能直接呼叫 Dio?
如果 PostListView 直接呼叫 Dio 取得 posts,
會有什麼問題?
為什麼要多一層 ViewModel 和 Repository?
Codex 幫我整理成三個原因:
View 會變得太肥。
測試會依賴真實網路。
資料來源改變時,UI 也要跟著改。
這讓我理解:View 只負責顯示和轉交事件,資料來源要被隔離出去。
Q2:AsyncValue<PostListState> 為什麼看起來這麼繞?
為什麼不是直接用 AsyncValue<List<Post>>?
PostListState 到底多處理了什麼?
Codex 的說法是:
AsyncValue 處理第一次載入。
PostListState 處理列表進入 data 後的互動狀態。
例如 isLoadingMore、isRefreshing、loadMoreErrorMessage 都不是單純 List<Post> 能表達的東西。
Q3:debounce 為什麼放在 ViewModel?
搜尋 debounce 可不可以直接寫在 TextField 的 onChanged 裡?
放在 ViewModel 的好處是什麼?
答案很清楚:
TextField 只負責回報輸入變化。
ViewModel 決定什麼時候重新查詢。
這樣 debounce 行為可以被 ViewModel test 驗證,不會藏在 UI 裡。
Q4:Repository 目前只是轉呼叫 API service,真的有必要嗎?
RemotePostRepository 現在看起來只是呼叫 PostApiService。
這一層是不是過度設計?
Codex 提醒我,Repository 目前已經不只是轉呼叫,它還負責本地 filter 和 pagination。
而且未來如果要加 cache、retry、離線資料,也會先放在 repository,不需要改 ViewModel。
今日最小練習:追一條資料流
Day 4 的最小練習不是新增功能。
我先要求自己把這條資料流寫清楚:
PostListView
-> ref.watch(postListViewModelProvider)
-> PostListViewModel.build()
-> _loadInitialPage()
-> postRepositoryProvider.fetchPostPage()
-> RemotePostRepository.fetchPostPage()
-> PostApiService.getPosts()
-> ApiClient.dio.get('/posts')
-> Post.fromJson
-> PostPage
-> PostListState
-> PostListView renders UI
然後回答四個問題:
- 哪一層真的呼叫 Dio?
- 哪一層把 JSON 轉成
Post? - 哪一層決定搜尋、篩選、分頁?
- 哪一層只負責顯示 UI 和轉交事件?
如果能回答這四題,表示我不是只看懂列表畫面,而是開始看懂資料怎麼進到畫面。
Day 4 結論:列表頁是一個小型架構實戰
Day 4 我學到最多的,不是 Dio 怎麼打 API。
而是資料流如何被分層。
我現在可以說出:
PostListView負責顯示 UI 和轉交事件PostListViewModel負責 loading、search、filter、pagination、retryPostRepository是資料入口,隔離 ViewModel 和資料來源PostApiService負責 HTTP request 與 JSON 轉換ApiClient負責 Dio 共用設定、timeout、headers、interceptorsPost是 UI 真正使用的 domain modelPostQuery把搜尋、篩選、分頁條件集中成一個 value objectAsyncValue<PostListState>可以區分初始載入和列表互動狀態- debounce 適合放在 ViewModel,而不是塞在 Widget
- fake repository 讓測試可以不依賴真實網路
這一天讓我開始理解,Flutter app 的難點不是「畫列表」。
真正的難點是:資料、狀態、錯誤、使用者操作和測試,要放在正確的位置。
下一篇,我會看 Settings feature,理解 app 如何記住使用者選擇,並讓本地偏好設定影響整個 App Shell。