Provider Architecture Skill
Riverpod Provider 架構設計規範——確保正確的依賴注入、介面隔離、接線閉環和測試可行性。
核心設計原則
狀態操作必須透過介面語意化方法封裝;DI 契約必須與 wiring 驗證閉環(見「配對規則」)。ref 誤用的根源是直接操作狀態而非透過介面;DI 缺陷的根源是契約與驗證之間的迴路沒有閉合——兩者都不是單純的 API 用法問題。
1. 介面隔離原則
對內和對外使用不同的介面:
| 介面類型 | 暴露對象 | 範例方法 |
|---------|---------|---------|
| 對外(Widget 層) | 語意化方法 | selectFile(), startImport(), reset() |
| 對內(ViewModel) | 私有方法 | _updateProgress(), _handleError() |
2. 語意化操作原則
不直接操作狀態,而是透過有意義的方法名稱:
// 錯誤:直接操作狀態
ref.read(provider.notifier).state = newState;
// 正確:透過語意化方法
ref.read(provider.notifier).selectFile();
3. 依賴注入透過介面原則
服務透過 Provider 注入,不硬編碼實例:
// 錯誤:ViewModel 直接依賴具體實作
class MyViewModel {
final _dataService = DataService(); // 硬編碼,無法測試替換
}
// 正確:透過 Provider 注入
class MyViewModel extends Notifier<MyState> {
late final DataService _dataService; // 延遲初始化
@override
MyState build() {
// 在 build() 中透過 ref.read() 取得服務,測試中可 override
_dataService = ref.read(dataServiceProvider);
return MyState.initial();
}
}
標準 ViewModel 模式
/// 職責:管理資料匯入流程的狀態
/// 設計原則:服務經 Provider 注入(支援測試替換);對外只暴露語意化方法;
/// 狀態操作封裝在內部。
class DataImportViewModel extends Notifier<DataImportState> {
late final DataService _dataService;
late final FileService _fileService;
@override
DataImportState build() {
_dataService = ref.read(dataServiceProvider);
_fileService = ref.read(fileServiceProvider);
return const DataImportState();
}
// === 對外語意化方法 ===
Future<void> selectFile() async {
final file = await _fileService.pickFile();
if (file != null) {
state = state.copyWith(selectedFile: file);
}
}
Future<void> startImport() async {
_updateProgress(0.0);
try {
final records = await _fileService.parse(state.selectedFile!);
await _dataService.saveAll(records);
_updateProgress(1.0);
} on AppException catch (e) {
_handleError(e);
}
}
void reset() => state = const DataImportState();
// === 對內私有方法 ===
void _updateProgress(double progress) =>
state = state.copyWith(progress: progress);
void _handleError(AppException error) =>
state = state.copyWith(error: error);
}
/// Provider 定義:使用 .new 簡化寫法,Notifier 在 build() 中自行取得依賴
final dataImportViewModelProvider =
NotifierProvider<DataImportViewModel, DataImportState>(
DataImportViewModel.new,
);
watch / read 紀律
// 錯誤:在普通方法中使用 watch(導致不必要的重建)
void someMethod() {
final service = ref.watch(serviceProvider);
}
// 正確:build() 內 watch 響應式資料、read 服務實例;其他方法一律 read
@override
MyState build() {
final reactiveData = ref.watch(someDataProvider); // OK:響應式資料
_service = ref.read(serviceProvider); // OK:服務實例
return MyState(data: reactiveData);
}
void someMethod() {
final service = ref.read(serviceProvider); // OK:一次性讀取
}
配對規則:必接線 provider 與 wiring test 閉環
fail-fast DI 契約(未接線即拋錯)是正確設計,但契約只在有人執行該路徑時才 fail。 測試若全部自備 override,真實接線路徑就是零覆蓋——契約永不被演練,漏接要到 實機啟動才炸開,且全部畫面一起死。
規則:每宣告一個必接線 provider(宣告端形態見下),必須同時存在兩個測試。
觸發形態(宣告端)
觸發判準是語意不是字面:任何「未接線即拋錯的 DI 注入點」都算——UnimplementedError 只是慣用形態,StateError、late 未初始化、強制轉型失敗等等價寫法同樣觸發配對規則。
/// 資料層注入點。app root 須以真實實作 override,未 override 即拋錯,
/// 強制呼叫端明確接線(DI 契約)。
final repositoryProvider = Provider<Repository>((ref) {
throw UnimplementedError('repositoryProvider must be overridden at app root');
});
形態無關性:本規則的論證(fail-fast 契約 + 全 mock 測試 ⇒ 真實接線零覆蓋)與 DI 形態無關——GetIt 的未 register 拋錯、InheritedWidget 的
of(context)!同構適用,本 skill 僅提供 Riverpod 落地形式。 雙路由:wiring test 屬「綠燈後補的防護型測試」,須依/tddskillreferences/phase2/rules.md組 4 Q12 做一次故意破壞實測;其替身邊界(本例 ffi + in-memory 屬 host 層結構替身,不涵蓋 on-device 接縫)見同檔「紅燈層級順序」節替身術語。
配對測試 (a):真實接線 wiring test
用與 main.dart 相同的實作型別建立 override,pump 真實 app root widget, 驗證首畫面 build 不拋 ProviderException。這條測試覆蓋的是「契約可被真實實作 滿足」,不是 mock 頂替:
testWidgets('app root wiring: real overrides build without ProviderException',
(tester) async {
final container = ProviderContainer(
overrides: [
// 與 main.dart 相同的接線型別(真實 Repository 實作 + in-memory 資源)
repositoryProvider.overrideWithValue(
SqliteRepository(databaseFactory: databaseFactoryFfi,
path: inMemoryDatabasePath),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(container: container, child: const MyApp()),
);
await tester.pump(); // 真實 I/O 場景勿用 pumpAndSettle(FakeAsync 無法結算)
expect(tester.takeException(), isNull);
});
配對測試 (b):未 override 迴歸鎖
把 fail-fast 行為本身鎖進測試,防止契約被靜默改成回傳假實作:
test('un-overridden mandatory provider still throws', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(
() => container.read(repositoryProvider),
throwsA(isA<ProviderException>()),
);
});
為什麼通用生成會漏掉這條
「通用生成」指未載入本規範的 LLM 依通用知識產生程式碼。它會把契約(拋錯 provider)和測試(mock override)各自寫對, 但不知道兩者之間有一條必須閉合的迴路——mock override 教學愈完整,真實接線 路徑愈沒有測試理由存在。本規則就是把這條迴路顯性化。
測試最佳實踐
Provider Override 機制
在測試中透過 ProviderScope.overrides 注入 Mock 服務:
testWidgets('完整匯入流程', (tester) async {
final mockFileService = MockFileService();
mockFileService.setPickResult(tempFile); // 語意化配置
await tester.pumpWidget(
ProviderScope(
overrides: [
fileServiceProvider.overrideWithValue(mockFileService),
],
child: const MaterialApp(home: DataImportScreen()),
),
);
await tester.tap(find.byKey(const Key('import_button')));
await tester.pumpAndSettle();
expect(find.text('匯入完成'), findsOneWidget);
});
Mock override 適用於功能行為測試;它不能取代配對規則的 wiring test—— 兩者驗證的是不同命題(行為正確 vs 接線存在)。
Mock 服務設計原則
Mock 服務也提供語意化配置方法:
class MockFileService implements FileService {
File? _pickResult;
void setPickResult(File? file) => _pickResult = file; // 語意化配置
@override
Future<File?> pickFile() async => _pickResult;
}
禁止行為
| 禁止 | 修正 |
|------|------|
| 直接操作 .state(notifier.state = x) | 語意化方法(notifier.updateX(v)) |
| 建構函式硬編碼服務實例 | late final + build() 內 ref.read() |
| 非 build 方法中 ref.watch() | build 內 watch、其他一律 read |
| 宣告拋錯式必接線 provider 而無配對測試 | 補 wiring test (a) + 迴歸鎖 (b) |
| wiring test 用 mock 頂替真實實作型別 | 用與 app root 相同的實作型別 + in-memory 資源 |
檢查清單
ViewModel 設計
- [ ] 服務以
late final宣告、在build()中ref.read()取得? - [ ] 對外方法都是語意化的(動詞開頭)?狀態操作封裝在私有方法?
- [ ] Provider 定義使用
.new簡化寫法、不在建構函式傳依賴?
接線閉環(配對規則)
- [ ] 每個
throw UnimplementedError式 provider 都有真實接線 wiring test? - [ ] wiring test 用與 main.dart 相同的實作型別(非 mock)?
- [ ] 未 override 拋錯行為有迴歸鎖測試?
- [ ] 真實 I/O 的 wiring test 用
pump()而非pumpAndSettle()?
測試設計
- [ ] 功能測試使用
ProviderScope.overrides注入 Mock? - [ ] Mock 服務提供語意化配置方法?
- [ ] 透過 UI 互動驗證行為(而非檢查內部狀態)?
本 skill 維護約束(反漂移)
前版(1.0.0)因內容綁定單一專案而被移除。維護本檔時:
- 禁止引用任何專案的檔案路徑作「參考範例」——範例一律以自足程式碼呈現
- 禁止引用專案層級 error-pattern / ticket 編號;需引用教訓時,將教訓以通用化敘述內嵌本檔
- 範例命名使用通用領域詞(DataImport、Repository),不用專案業務詞
- 新增內容前自問:替換專案名稱後此段是否仍成立?否則不屬於本 skill
版本紀錄在同目錄的 CHANGELOG.md。