前言

為了提升AI開發前端的穩定性,我想依賴MVVM確實定義前端的狀態跟模型,以及責任分層,這樣可以降低除錯的複雜度

核心概念

ViewModel 定位

ViewModel 是 MVVM 架構的核心層,負責:

  1. Domain → UI 轉換:將 Domain 模型轉為 UI 需要的格式
  2. UI 狀態管理:管理 Widget 狀態和互動邏輯
  3. Provider 定義:定義 Riverpod Provider 供 Widget 使用
  4. 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.dart

ViewModel 職責定義

包含的職責

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%

測試項目

  1. Domain → UI 轉換邏輯
  2. UI 專用計算邏輯
  3. 狀態管理邏輯(如果是 Notifier)
  4. 邊界條件和錯誤處理

測試範例

  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}