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.md
  • docs/lessons/ADVANCED_STATE_NETWORK.md
  • lib/services/api_client.dart
  • lib/features/posts/domain/post.dart
  • lib/features/posts/domain/post_query.dart
  • lib/features/posts/data/post_api_service.dart
  • lib/features/posts/data/post_repository.dart
  • lib/features/posts/presentation/post_list_state.dart
  • lib/features/posts/presentation/post_list_view_model.dart
  • lib/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:畫面只負責顯示和轉交事件

PostListViewConsumerWidget

它最重要的動作是監聽 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 也有自己的狀態:isLoadingMoreloadMoreErrorMessage

這讓載入更多失敗時,畫面可以在底部顯示 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 後的互動狀態。

例如 isLoadingMoreisRefreshingloadMoreErrorMessage 都不是單純 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

然後回答四個問題:

  1. 哪一層真的呼叫 Dio?
  2. 哪一層把 JSON 轉成 Post
  3. 哪一層決定搜尋、篩選、分頁?
  4. 哪一層只負責顯示 UI 和轉交事件?

如果能回答這四題,表示我不是只看懂列表畫面,而是開始看懂資料怎麼進到畫面。


Day 4 結論:列表頁是一個小型架構實戰

Day 4 我學到最多的,不是 Dio 怎麼打 API。

而是資料流如何被分層。

我現在可以說出:

  • PostListView 負責顯示 UI 和轉交事件
  • PostListViewModel 負責 loading、search、filter、pagination、retry
  • PostRepository 是資料入口,隔離 ViewModel 和資料來源
  • PostApiService 負責 HTTP request 與 JSON 轉換
  • ApiClient 負責 Dio 共用設定、timeout、headers、interceptors
  • Post 是 UI 真正使用的 domain model
  • PostQuery 把搜尋、篩選、分頁條件集中成一個 value object
  • AsyncValue<PostListState> 可以區分初始載入和列表互動狀態
  • debounce 適合放在 ViewModel,而不是塞在 Widget
  • fake repository 讓測試可以不依賴真實網路

這一天讓我開始理解,Flutter app 的難點不是「畫列表」。

真正的難點是:資料、狀態、錯誤、使用者操作和測試,要放在正確的位置。

下一篇,我會看 Settings feature,理解 app 如何記住使用者選擇,並讓本地偏好設定影響整個 App Shell。