MVVM ViewModel 開發方法論
MVVM ViewModel 開發方法論
前言
為了提升AI開發前端的穩定性,我想依賴MVVM確實定義前端的狀態跟模型,以及責任分層,這樣可以降低除錯的複雜度
核心概念
ViewModel 定位
ViewModel 是 MVVM 架構的核心層,負責:
- Domain → UI 轉換:將 Domain 模型轉為 UI 需要的格式
- UI 狀態管理:管理 Widget 狀態和互動邏輯
- Provider 定義:定義 Riverpod Provider 供 Widget 使用
- UI 專用計算邏輯:提供顏色、圖標、格式化文字等 UI 屬性
MVVM 分層原則
1┌─────────────────────────────────────────┐
2│ Presentation Layer (UI 層) │
3├─────────────────────────────────────────┤
4│ Widget (Page/Extensions) │
5│ - 純 UI 組裝,無業務邏輯 │
6│ - 使用 ViewModel Provider 取得資料 │
7│ - 顯示 ViewModel 提供的 UI 屬性 │
8├─────────────────────────────────────────┤
9│ ViewModel │
10│ - Domain → UI 轉換 │
11│ - UI 狀態管理 │
12│ - UI 專用計算邏輯 │
13│ - Provider 定義 │
14├─────────────────────────────────────────┤
15│ Mapper │
16│ - Domain 模型 → ViewModel 轉換邏輯 │
17├─────────────────────────────────────────┤
18│ Domain Layer (領域層) │
19│ - 業務邏輯 │
20│ - Domain 模型 │
21│ - Domain 服務 │
22└─────────────────────────────────────────┘ViewModel 命名規範
命名格式
格式:[Feature]ViewModel
範例:
EnrichmentProgressViewModel- 補充進度顯示ChromeExtensionImportViewModel- Chrome Extension 匯入LibraryDisplayViewModel- 書庫展示AdvancedSearchViewModel- 進階搜尋
檔案位置
標準路徑:lib/presentation/[feature]/[feature]_viewmodel.dart
範例:
1lib/presentation/
2├── import/
3│ └── chrome_extension_import_viewmodel.dart
4├── library/
5│ ├── library_viewmodel.dart
6│ └── library_display_page.dart
7└── search/
8 └── advanced_search_viewmodel.dartViewModel 職責定義
包含的職責
1. Domain → UI 轉換
將 Domain 模型轉換為 UI 需要的格式:
1/// Domain 來源
2final EnrichmentProgress domainProgress;
3
4/// UI 專用欄位(計算屬性)
5String get displayStatus => _mapStatus();
6IconData get statusIcon => _mapIcon();
7Color get progressColor => _mapColor();2. UI 狀態管理
管理 Widget 需要的狀態:
1class EnrichmentProgressViewModel {
2 // 狀態欄位
3 final EnrichmentProgress domainProgress;
4 final List<Book> failedBooks;
5
6 // UI 控制狀態
7 bool get showFailedBooks => failedBooks.isNotEmpty;
8 bool get canRetry => domainProgress.isComplete && failedBooks.isNotEmpty;
9}3. Provider 定義
定義 Riverpod Provider 供 Widget 使用:
1final enrichmentProgressViewModelProvider =
2 StreamProvider.family<EnrichmentProgressViewModel, String>(
3 (ref, operationId) {
4 // Provider 邏輯
5 },
6 );4. UI 專用計算邏輯
提供 UI 需要的格式化資料:
1/// 格式化的摘要文字
2String get summaryText =>
3 '已處理 ${domainProgress.processedBooks}/${domainProgress.totalBooks} 本';
4
5/// 進度顏色(根據狀態決定)
6Color get progressColor {
7 if (domainProgress.failedEnrichments > 0) return Colors.orange;
8 if (domainProgress.isComplete) return Colors.green;
9 return Colors.blue;
10}不包含的內容
1. Widget 程式碼
1// 反例:ViewModel 中包含 Widget
2class EnrichmentProgressViewModel {
3 Widget buildProgressBar() { // 違規
4 return LinearProgressIndicator(...);
5 }
6}
7
8// 正例:Widget 在 Extension 中
9extension EnrichmentProgressWidgets on Widget {
10 Widget enrichmentProgressBar(EnrichmentProgressViewModel vm) {
11 return LinearProgressIndicator(
12 value: vm.domainProgress.percentageComplete / 100,
13 color: vm.progressColor,
14 );
15 }
16}2. 直接依賴 Flutter 框架(除 ChangeNotifier)
1// 反例:依賴 Flutter Material
2import 'package:flutter/material.dart';
3
4class MyViewModel {
5 BuildContext? context; // 違規
6}
7
8// 正例:使用 Dart 原生類型
9class MyViewModel {
10 Color progressColor; // 可以使用 Color(來自 dart:ui)
11 IconData statusIcon; // 可以使用 IconData
12}3. 業務邏輯
1// 反例:在 ViewModel 中執行業務邏輯
2class EnrichmentProgressViewModel {
3 Future<void> enrichBook(Book book) {
4 // 呼叫 API、驗證資料、儲存到資料庫
5 // 這些是 Domain 層的職責
6 }
7}
8
9// 正例:業務邏輯在 Domain Service
10class IBookInfoEnrichmentService {
11 Future<EnrichedBookInfo> enrichBookInfo(Book book);
12}
13
14// ViewModel 只負責狀態管理
15class EnrichmentProgressViewModel {
16 final EnrichmentProgress domainProgress;
17 // 不包含業務邏輯
18}ViewModel 結構範本
基本結構
1/// UI 層專用的 [Feature] 顯示模型
2///
3/// 職責:
4/// - 將 Domain 模型轉換為 UI 需要的格式
5/// - 提供 UI 專用的計算屬性
6/// - 管理 UI 狀態
7///
8/// 需求:[需求編號]
9class [Feature]ViewModel {
10 // =============================================================================
11 // Domain 來源(不可變)
12 // =============================================================================
13
14 /// Domain 模型來源
15 final [DomainModel] domainModel;
16
17 /// 額外的 Domain 資料(如失敗清單)
18 final List<[Entity]> additionalData;
19
20 // =============================================================================
21 // UI 專用欄位(計算屬性)
22 // =============================================================================
23
24 /// 狀態顯示文字
25 String get displayStatus => _mapStatus();
26
27 /// 狀態圖標
28 IconData get statusIcon => _mapIcon();
29
30 /// 進度顏色
31 Color get progressColor => _mapColor();
32
33 /// 摘要文字
34 String get summaryText => _formatSummary();
35
36 // =============================================================================
37 // 建構子
38 // =============================================================================
39
40 const [Feature]ViewModel({
41 required this.domainModel,
42 this.additionalData = const [],
43 });
44
45 // =============================================================================
46 // Domain → UI 轉換方法(私有)
47 // =============================================================================
48
49 /// 對應狀態到顯示文字
50 String _mapStatus() {
51 // 轉換邏輯
52 }
53
54 /// 對應狀態到圖標
55 IconData _mapIcon() {
56 // 轉換邏輯
57 }
58
59 /// 對應狀態到顏色
60 Color _mapColor() {
61 // 轉換邏輯
62 }
63
64 /// 格式化摘要文字
65 String _formatSummary() {
66 // 格式化邏輯
67 }
68}完整範例:EnrichmentProgressViewModel
1import 'package:flutter/material.dart';
2import 'package:book_overview_app/domains/import/value_objects/enrichment_progress.dart';
3import 'package:book_overview_app/domains/library/entities/book.dart';
4
5/// UI 層專用的補充進度顯示模型
6///
7/// 職責:
8/// - 將 EnrichmentProgress Domain 模型轉為 UI 格式
9/// - 提供進度顏色、圖標、文字等 UI 屬性
10/// - 管理失敗書籍清單的顯示
11///
12/// 需求:UC-01.Enrichment.Progress
13class EnrichmentProgressViewModel {
14 // =============================================================================
15 // Domain 來源
16 // =============================================================================
17
18 /// Domain 進度模型
19 final EnrichmentProgress domainProgress;
20
21 /// 失敗補充的書籍清單
22 final List<Book> failedBooks;
23
24 // =============================================================================
25 // UI 專用欄位(計算屬性)
26 // =============================================================================
27
28 /// 狀態顯示文字
29 ///
30 /// 對應規則:
31 /// - processedBooks == 0 → "準備中"
32 /// - processedBooks > 0 && !isComplete → "補充中"
33 /// - isComplete → "已完成"
34 String get displayStatus {
35 if (domainProgress.isComplete) return '已完成';
36 if (domainProgress.processedBooks == 0) return '準備中';
37 return '補充中';
38 }
39
40 /// 狀態圖標
41 ///
42 /// 對應規則:
43 /// - 準備中 → Icons.pending
44 /// - 補充中 → Icons.sync
45 /// - 已完成 → Icons.check_circle
46 IconData get statusIcon {
47 if (domainProgress.isComplete) return Icons.check_circle;
48 if (domainProgress.processedBooks == 0) return Icons.pending;
49 return Icons.sync;
50 }
51
52 /// 進度顏色
53 ///
54 /// 對應規則:
55 /// - 有失敗 → 橘色警告
56 /// - 已完成 → 綠色成功
57 /// - 進行中 → 藍色
58 Color get progressColor {
59 if (domainProgress.failedEnrichments > 0) return Colors.orange;
60 if (domainProgress.isComplete) return Colors.green;
61 return Colors.blue;
62 }
63
64 /// 摘要文字
65 ///
66 /// 格式:「已處理 X/Y 本(成功 A,失敗 B)」
67 String get summaryText {
68 final processed = domainProgress.processedBooks;
69 final total = domainProgress.totalBooks;
70 final success = domainProgress.successfulEnrichments;
71 final failed = domainProgress.failedEnrichments;
72
73 if (failed > 0) {
74 return '已處理 $processed/$total 本(成功 $success,失敗 $failed)';
75 }
76 return '已處理 $processed/$total 本';
77 }
78
79 /// 失敗書籍摘要清單
80 ///
81 /// 提供簡化的書籍資訊供 UI 顯示
82 List<BookSummary> get failedBooksSummary {
83 return failedBooks.map((book) => BookSummary.fromBook(book)).toList();
84 }
85
86 /// 進度百分比(直接使用 Domain 計算)
87 double get progressPercentage => domainProgress.percentageComplete;
88
89 /// 當前處理書名(如果有)
90 String? get currentBookTitle => domainProgress.currentBook?.title.value;
91
92 /// 是否顯示失敗清單
93 bool get showFailedBooks => failedBooks.isNotEmpty;
94
95 /// 是否可以重試
96 bool get canRetry => domainProgress.isComplete && failedBooks.isNotEmpty;
97
98 // =============================================================================
99 // 建構子
100 // =============================================================================
101
102 const EnrichmentProgressViewModel({
103 required this.domainProgress,
104 this.failedBooks = const [],
105 });
106}
107
108/// 書籍摘要(UI 專用簡化資料)
109class BookSummary {
110 final String id;
111 final String title;
112 final String author;
113
114 const BookSummary({
115 required this.id,
116 required this.title,
117 required this.author,
118 });
119
120 factory BookSummary.fromBook(Book book) {
121 return BookSummary(
122 id: book.id.value,
123 title: book.title.value,
124 author: book.author.value,
125 );
126 }
127}Mapper 模式
Mapper 職責
Mapper 負責 Domain 模型 → ViewModel 的轉換邏輯。
Mapper 結構
1/// Domain [DomainModel] → UI ViewModel 轉換器
2///
3/// 職責:
4/// - 將 Domain 模型轉換為 ViewModel
5/// - 整合多個 Domain 資料來源
6/// - 處理轉換過程中的資料格式化
7class [Feature]Mapper {
8 /// 轉換 Domain 模型為 ViewModel
9 static [Feature]ViewModel toViewModel(
10 [DomainModel] domainModel,
11 // 額外的 Domain 資料來源
12 ) {
13 return [Feature]ViewModel(
14 domainModel: domainModel,
15 // 額外欄位轉換
16 );
17 }
18
19 /// 批量轉換
20 static List<[Feature]ViewModel> toViewModelList(
21 List<[DomainModel]> domainModels,
22 ) {
23 return domainModels.map((model) => toViewModel(model)).toList();
24 }
25}完整範例:EnrichmentProgressMapper
1import 'package:book_overview_app/domains/import/value_objects/enrichment_progress.dart';
2import 'package:book_overview_app/domains/library/entities/book.dart';
3import 'package:book_overview_app/presentation/import/enrichment_progress_viewmodel.dart';
4
5/// Domain EnrichmentProgress → UI ViewModel 轉換器
6///
7/// 職責:
8/// - 整合 EnrichmentProgress 和失敗書籍清單
9/// - 轉換為 UI 層需要的 ViewModel 格式
10class EnrichmentProgressMapper {
11 /// 轉換 Domain 進度模型為 ViewModel
12 ///
13 /// 參數:
14 /// - [progress]: Domain 進度模型
15 /// - [failedBooks]: 失敗補充的書籍清單(從 Service 取得)
16 ///
17 /// 回傳:UI 層專用的 ViewModel
18 static EnrichmentProgressViewModel toViewModel(
19 EnrichmentProgress progress,
20 List<Book> failedBooks,
21 ) {
22 return EnrichmentProgressViewModel(
23 domainProgress: progress,
24 failedBooks: failedBooks,
25 );
26 }
27
28 /// 批量轉換(如果需要顯示多個進度)
29 static List<EnrichmentProgressViewModel> toViewModelList(
30 List<EnrichmentProgress> progressList,
31 Map<String, List<Book>> failedBooksMap,
32 ) {
33 return progressList.map((progress) {
34 // 假設每個 progress 有唯一 ID
35 final failedBooks = failedBooksMap[progress.hashCode.toString()] ?? [];
36 return toViewModel(progress, failedBooks);
37 }).toList();
38 }
39}Provider 整合模式
StreamProvider 整合
當 Domain 資料是 Stream 時使用 StreamProvider。
1/// ViewModel StreamProvider 定義
2///
3/// 職責:
4/// - 整合多個 Domain Provider
5/// - 使用 Mapper 轉換為 ViewModel
6/// - 提供給 Widget 使用
7final enrichmentProgressViewModelProvider =
8 StreamProvider.family<EnrichmentProgressViewModel, String>(
9 (ref, operationId) {
10 // 1. 監聽 Domain Progress Stream
11 final domainProgressStream = ref.watch(
12 enrichmentProgressProvider(operationId)
13 );
14
15 // 2. 監聽失敗書籍 Stream
16 final failedBooksStream = ref.watch(
17 failedBooksProvider(operationId)
18 );
19
20 // 3. 合併 Stream 並轉換為 ViewModel
21 return Rx.combineLatest2(
22 domainProgressStream,
23 failedBooksStream,
24 (EnrichmentProgress progress, List<Book> failedBooks) {
25 return EnrichmentProgressMapper.toViewModel(
26 progress,
27 failedBooks,
28 );
29 },
30 );
31 },
32 );StateProvider 整合
當 ViewModel 需要狀態管理時使用 Notifier。
1/// ViewModel State 定義
2class LibraryDisplayState {
3 final DisplayMode displayMode;
4 final List<LibraryBookModel> books;
5 final Set<String> selectedBookIds;
6
7 const LibraryDisplayState({
8 this.displayMode = DisplayMode.simple,
9 this.books = const [],
10 this.selectedBookIds = const {},
11 });
12
13 LibraryDisplayState copyWith({
14 DisplayMode? displayMode,
15 List<LibraryBookModel>? books,
16 Set<String>? selectedBookIds,
17 }) {
18 return LibraryDisplayState(
19 displayMode: displayMode ?? this.displayMode,
20 books: books ?? this.books,
21 selectedBookIds: selectedBookIds ?? this.selectedBookIds,
22 );
23 }
24}
25
26/// ViewModel Notifier
27class LibraryDisplayViewModel extends Notifier<LibraryDisplayState> {
28 @override
29 LibraryDisplayState build() {
30 return const LibraryDisplayState();
31 }
32
33 /// 切換顯示模式
34 void toggleDisplayMode() {
35 final newMode = state.displayMode == DisplayMode.simple
36 ? DisplayMode.management
37 : DisplayMode.simple;
38 state = state.copyWith(displayMode: newMode);
39 }
40
41 /// 選擇書籍
42 void toggleBookSelection(String bookId) {
43 final selectedIds = Set<String>.from(state.selectedBookIds);
44 if (selectedIds.contains(bookId)) {
45 selectedIds.remove(bookId);
46 } else {
47 selectedIds.add(bookId);
48 }
49 state = state.copyWith(selectedBookIds: selectedIds);
50 }
51}
52
53/// Provider 定義
54final libraryDisplayViewModelProvider =
55 NotifierProvider<LibraryDisplayViewModel, LibraryDisplayState>(
56 LibraryDisplayViewModel.new,
57 );Widget 使用方式
StreamProvider 使用
1class EnrichmentProgressWidget extends ConsumerWidget {
2 final String operationId;
3
4 const EnrichmentProgressWidget({
5 required this.operationId,
6 super.key,
7 });
8
9 @override
10 Widget build(BuildContext context, WidgetRef ref) {
11 final viewModelAsync = ref.watch(
12 enrichmentProgressViewModelProvider(operationId)
13 );
14
15 return viewModelAsync.when(
16 data: (viewModel) => _buildProgressContent(viewModel),
17 loading: () => const CircularProgressIndicator(),
18 error: (error, stack) => ErrorWidget(error),
19 );
20 }
21
22 Widget _buildProgressContent(EnrichmentProgressViewModel vm) {
23 return Column(
24 children: [
25 // 使用 ViewModel 的 UI 屬性
26 Icon(vm.statusIcon, color: vm.progressColor),
27 Text(vm.displayStatus),
28 LinearProgressIndicator(
29 value: vm.progressPercentage / 100,
30 color: vm.progressColor,
31 ),
32 Text(vm.summaryText),
33
34 // 失敗清單
35 if (vm.showFailedBooks)
36 _buildFailedBooksList(vm.failedBooksSummary),
37 ],
38 );
39 }
40}StateProvider 使用
1class LibraryDisplayPage extends ConsumerWidget {
2 @override
3 Widget build(BuildContext context, WidgetRef ref) {
4 final state = ref.watch(libraryDisplayViewModelProvider);
5 final viewModel = ref.read(libraryDisplayViewModelProvider.notifier);
6
7 return Scaffold(
8 appBar: AppBar(
9 title: Text('書庫'),
10 actions: [
11 IconButton(
12 icon: Icon(Icons.view_list),
13 onPressed: viewModel.toggleDisplayMode,
14 ),
15 ],
16 ),
17 body: ListView.builder(
18 itemCount: state.books.length,
19 itemBuilder: (context, index) {
20 final book = state.books[index];
21 final isSelected = state.selectedBookIds.contains(book.id);
22
23 return ListTile(
24 title: Text(book.title),
25 selected: isSelected,
26 onTap: () => viewModel.toggleBookSelection(book.id),
27 );
28 },
29 ),
30 );
31 }
32}測試要求
單元測試覆蓋率
每個 ViewModel 必須有單元測試,覆蓋率 ≥ 90%。
測試項目
- Domain → UI 轉換邏輯
- UI 專用計算邏輯
- 狀態管理邏輯(如果是 Notifier)
- 邊界條件和錯誤處理
測試範例
1import 'package:flutter_test/flutter_test.dart';
2import 'package:book_overview_app/domains/import/value_objects/enrichment_progress.dart';
3import 'package:book_overview_app/presentation/import/enrichment_progress_viewmodel.dart';
4import 'package:book_overview_app/presentation/import/enrichment_progress_mapper.dart';
5
6void main() {
7 group('EnrichmentProgressViewModel', () {
8 group('displayStatus', () {
9 test('準備中 - processedBooks == 0', () {
10 // Arrange
11 final progress = EnrichmentProgress.initial(10);
12 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
13
14 // Act & Assert
15 expect(vm.displayStatus, '準備中');
16 });
17
18 test('補充中 - processedBooks > 0 且未完成', () {
19 // Arrange
20 final progress = EnrichmentProgress(
21 totalBooks: 10,
22 processedBooks: 5,
23 successfulEnrichments: 5,
24 failedEnrichments: 0,
25 );
26 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
27
28 // Act & Assert
29 expect(vm.displayStatus, '補充中');
30 });
31
32 test('已完成 - processedBooks == totalBooks', () {
33 // Arrange
34 final progress = EnrichmentProgress(
35 totalBooks: 10,
36 processedBooks: 10,
37 successfulEnrichments: 10,
38 failedEnrichments: 0,
39 );
40 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
41
42 // Act & Assert
43 expect(vm.displayStatus, '已完成');
44 });
45 });
46
47 group('statusIcon', () {
48 test('準備中 - Icons.pending', () {
49 final progress = EnrichmentProgress.initial(10);
50 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
51
52 expect(vm.statusIcon, Icons.pending);
53 });
54
55 test('補充中 - Icons.sync', () {
56 final progress = EnrichmentProgress(
57 totalBooks: 10,
58 processedBooks: 5,
59 successfulEnrichments: 5,
60 failedEnrichments: 0,
61 );
62 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
63
64 expect(vm.statusIcon, Icons.sync);
65 });
66
67 test('已完成 - Icons.check_circle', () {
68 final progress = EnrichmentProgress(
69 totalBooks: 10,
70 processedBooks: 10,
71 successfulEnrichments: 10,
72 failedEnrichments: 0,
73 );
74 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
75
76 expect(vm.statusIcon, Icons.check_circle);
77 });
78 });
79
80 group('progressColor', () {
81 test('有失敗 - Colors.orange', () {
82 final progress = EnrichmentProgress(
83 totalBooks: 10,
84 processedBooks: 10,
85 successfulEnrichments: 8,
86 failedEnrichments: 2,
87 );
88 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
89
90 expect(vm.progressColor, Colors.orange);
91 });
92
93 test('已完成無失敗 - Colors.green', () {
94 final progress = EnrichmentProgress(
95 totalBooks: 10,
96 processedBooks: 10,
97 successfulEnrichments: 10,
98 failedEnrichments: 0,
99 );
100 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
101
102 expect(vm.progressColor, Colors.green);
103 });
104
105 test('進行中 - Colors.blue', () {
106 final progress = EnrichmentProgress(
107 totalBooks: 10,
108 processedBooks: 5,
109 successfulEnrichments: 5,
110 failedEnrichments: 0,
111 );
112 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
113
114 expect(vm.progressColor, Colors.blue);
115 });
116 });
117
118 group('summaryText', () {
119 test('無失敗 - 顯示處理進度', () {
120 final progress = EnrichmentProgress(
121 totalBooks: 10,
122 processedBooks: 5,
123 successfulEnrichments: 5,
124 failedEnrichments: 0,
125 );
126 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
127
128 expect(vm.summaryText, '已處理 5/10 本');
129 });
130
131 test('有失敗 - 顯示成功和失敗數', () {
132 final progress = EnrichmentProgress(
133 totalBooks: 10,
134 processedBooks: 10,
135 successfulEnrichments: 8,
136 failedEnrichments: 2,
137 );
138 final vm = EnrichmentProgressMapper.toViewModel(progress, []);
139
140 expect(vm.summaryText, '已處理 10/10 本(成功 8,失敗 2)');
141 });
142 });
143 });
144}ViewModel 開發檢查清單
Phase 1: 設計階段
- 確認 Domain 模型已完成
- 定義 ViewModel 需要的 UI 屬性
- 設計 Domain → UI 轉換邏輯
- 規劃 Mapper 結構
- 定義 Provider 整合方式
Phase 2: 實作階段
- 建立 ViewModel 類別和欄位
- 實作 UI 專用計算屬性
- 實作 Mapper 轉換方法
- 定義 Provider
- 撰寫完整註解(包含需求編號)
Phase 3: 測試階段
- 撰寫 ViewModel 單元測試
- 測試所有計算屬性
- 測試 Mapper 轉換邏輯
- 測試邊界條件
- 達成 90% 以上覆蓋率
Phase 4: 整合階段
- Widget 整合 ViewModel Provider
- 驗證 UI 正確顯示
- 執行 Widget 測試
- Code Review 確認符合規範
常見問題和最佳實踐
Q1: ViewModel 可以包含 StatefulWidget 的狀態嗎?
A: 不可以。ViewModel 應該是純資料模型,不包含 Widget 生命週期邏輯。
1// 反例
2class MyViewModel extends StatefulWidget { }
3
4// 正例
5class MyViewModel {
6 final MyDomainModel domainModel;
7 // 純資料模型
8}Q2: 如何處理複雜的 UI 狀態?
A: 使用 Notifier 管理狀態,定義 State 類別。
1// 正例
2class MyState {
3 final DisplayMode mode;
4 final List<Item> items;
5 final Set<String> selectedIds;
6
7 MyState copyWith({...}) { }
8}
9
10class MyViewModel extends Notifier<MyState> { }Q3: ViewModel 可以呼叫 Domain Service 嗎?
A: 可以,但建議透過 Provider 整合而非直接呼叫。
1// 推薦:透過 Provider 整合
2final myViewModelProvider = Provider((ref) {
3 final domainData = ref.watch(domainServiceProvider);
4 return MyMapper.toViewModel(domainData);
5});
6
7// 可接受但不推薦:直接呼叫
8class MyViewModel {
9 final MyDomainService service;
10
11 Future<void> fetchData() async {
12 final data = await service.getData();
13 // ...
14 }
15}Q4: 多個 Domain 模型如何整合到一個 ViewModel?
A: 在 Mapper 中整合多個來源。
1class MyMapper {
2 static MyViewModel toViewModel(
3 DomainModel1 model1,
4 DomainModel2 model2,
5 List<Entity> entities,
6 ) {
7 return MyViewModel(
8 field1: model1.value,
9 field2: model2.value,
10 items: entities.map(_mapEntity).toList(),
11 );
12 }
13}