iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 7

Day 7 - Repository 模式:軟體架構的自由基石

  • 分享至 

  • xImage
  •  

大家好,歡迎來到第七天!在 Day 6,我們建立了完整的狀態管理架構,深入探討了 Riverpod 2.0 的實戰應用。今天,我們將開始打造第一個核心功能:在首頁顯示活動列表

這立刻引出了一個問題:資料從何而來?更重要的是,我們的 App 如何設計,才能在未來輕鬆地更換資料來源(例如從假資料換成真實 API),而不需要重寫整個 App?

今天,我們將深入探討一個極其重要的設計模式——Repository 模式,並理解它為何是我們軟體架構自由的基石。

Repository Pattern 的實作策略

在我們的專案中,Repository 模式是資料存取層的核心設計。透過這個模式,我們將資料來源的複雜性隱藏在抽象介面後面,讓業務邏輯層能夠專注於核心功能。

Repository 模式的實作讓我們能夠:

  • 抽象化資料來源:業務邏輯不需要知道資料來自本地還是遠端
  • 統一介面設計:所有資料操作都通過一致的方法進行
  • 靈活的實作切換:可以輕鬆替換不同的資料來源實作
  • 簡化測試流程:透過 Mock 實作進行單元測試

問題:一個緊密耦合的世界

讓我們先想像一個「不那麼好」的設計:如果我們的 UI 層或業務邏輯層,直接去呼叫一個 FirebaseDataSource.getActivities() 或是 Http.get(...),會發生什麼事?

  1. 邏輯與實作綁死: 我們的業務邏輯現在與 Firebase 或某個特定的 API 緊緊地綁在一起。如果未來要更換後端服務,那將是一場災難。

  2. 難以測試: 我們無法在沒有真實網路連線的情況下,輕易地對業務邏輯進行單元測試。

  3. 違反單一職責: 業務邏輯層承擔了它不該知道的職責——它不應該關心資料是「如何」被獲取的,它只需要「得到」資料就夠了。

Repository 模式的抽象層價值

Repository 模式的核心,就是建立一個「抽象層」,像一個「中介者」或「翻譯官」,優雅地解決上述所有問題。它的魔法在於「契約式程式設計」:

定義「契約」 (The Contract):

我們首先在 Domain Layer (領域層) 中,定義一個 abstract class (抽象類別),你可以把它想像成一份「契約」或「介面 (Interface)」。這份契約只做一件事:宣告所有「活動 Repository」都必須遵守的規範。

履行「契約」 (The Implementation):

接著,我們在 Data Layer (資料層) 中,建立一個具體的 class,去 implements (實作/履行) 上面那份契約。這個 class 才是真正負責做事的傢伙。

這就引出了一個關鍵問題:

未來如果要把假資料換成真實 API,我們的 ViewModel 或 Domain 層需要改動嗎?

答案是:完全不需要。 因為我們的業務邏輯,永遠只依賴 Domain 層那份抽象的「契約」,它從頭到尾都不知道 Data 層的具體實作是什麼。我們可以隨意抽換 Data 層的實作,而上層建築毫無察覺。這就是「依賴反轉原則」的完美展現。

Repository 模式的深層價值:為什麼它是軟體架構的基石?

解耦合的藝術

在傳統的緊密耦合架構中,業務邏輯直接依賴具體的資料來源:

// lib/features/activity/presentation/providers/activity_provider.dart

// (imports omitted)

// ❌ 緊密耦合的設計
class ActivityViewModel {
  Future<List<Activity>> getActivities() async {
    // 直接依賴 Firebase
    final snapshot = await FirebaseFirestore.instance
        .collection('activities')
        .get();
    return snapshot.docs.map((doc) => Activity.fromFirestore(doc)).toList();
  }
}

這種設計的問題在於:

  • 無法測試:需要真實的 Firebase 連線
  • 無法替換:要換成 REST API 需要重寫整個 ViewModel
  • 違反 SOLID 原則:違反了依賴反轉原則

而 Repository 模式通過抽象層解決了這些問題:

// lib/features/activity/presentation/providers/activity_provider.dart

// (imports omitted)

// ✅ 解耦合的設計
class ActivityViewModel {
  final ActivityRepository _repository;
  
  Future<List<Activity>> getActivities() async {
    return await _repository.getActivities();
  }
}

測試性的革命性提升

Repository 模式讓單元測試變得輕而易舉:

// test/features/activity/presentation/providers/activity_provider_test.dart

// (imports omitted)

// 測試時可以輕鬆注入 Mock Repository
class MockActivityRepository implements ActivityRepository {
  @override
  Future<List<Activity>> getActivities() async {
    return [Activity(id: '1', title: '測試活動1')];
  }
}

// 測試程式碼
test('should return activities from repository', () async {
  final mockRepository = MockActivityRepository();
  final viewModel = ActivityViewModel(mockRepository);
  
  final activities = await viewModel.getActivities();
  expect(activities.length, 1);
});

資料來源的靈活切換

有了 Repository 抽象層,我們可以輕鬆切換不同的資料來源:

// lib/features/activity/data/repositories/activity_repository_impl.dart

// (imports omitted)

// 本地資料來源
class LocalActivityRepository implements ActivityRepository {
  @override
  Future<List<Activity>> getActivities() async {
    return await ActivityLocalStorage.getAllActivities();
  }
}

// 遠端 API 資料來源
class RemoteActivityRepository implements ActivityRepository {
  @override
  Future<List<Activity>> getActivities() async {
    final response = await http.get(Uri.parse('https://api.example.com/activities'));
    return Activity.fromJsonList(jsonDecode(response.body));
  }
}

// 混合資料來源(快取 + 遠端)
class HybridActivityRepository implements ActivityRepository {
  final LocalActivityRepository _localRepo;
  final RemoteActivityRepository _remoteRepo;
  
  @override
  Future<List<Activity>> getActivities() async {
    try {
      final activities = await _remoteRepo.getActivities();
      await _localRepo.saveActivities(activities);
      return activities;
    } catch (e) {
      return await _localRepo.getActivities();
    }
  }
}

業務邏輯的純粹性

Repository 模式讓業務邏輯層保持純粹,專注於業務規則:

// lib/features/activity/domain/usecases/get_activities_usecase.dart

// (imports omitted)

class ActivityUseCase {
  final ActivityRepository _repository;
  
  Future<List<Activity>> getActiveActivities() async {
    final allActivities = await _repository.getActivities();
    return allActivities.where((activity) => 
      activity.status == ActivityStatus.inProgress
    ).toList();
  }
}

Repository 模式的進階設計模式

泛型 Repository 設計

// lib/common/domain/repositories/base_repository.dart

// (imports omitted)

abstract class Repository<T, ID> {
  Future<List<T>> getAll();
  Future<T?> getById(ID id);
  Future<void> save(T entity);
}

// lib/features/activity/domain/repositories/activity_repository.dart

// (imports omitted)

abstract class ActivityRepository extends Repository<Activity, String> {
  Future<List<Activity>> getActivitiesByCategory(ActivityCategory category);
}

錯誤處理策略

// lib/features/activity/domain/repositories/activity_repository.dart

// (imports omitted)

abstract class ActivityRepository {
  Future<Result<List<Activity>>> getActivities();
  Future<Result<Activity>> getActivityById(String id);
}

// lib/common/domain/entities/result.dart

// (imports omitted)

// 結果類型
class Result<T> {
  final T? data;
  final String? error;
  final bool isSuccess;
}

契約設計解析:Repository 介面的核心

在 Clean Architecture 中,Repository 介面就像是 Domain Layer 與 Data Layer 之間的「契約」。這份契約定義了所有資料操作必須遵守的規範,讓業務邏輯層能夠專注於核心業務,而不需要關心資料的具體來源。

概念設計:什麼是「活動 (Activity)」?

在設計契約前,我們先定義契約的主體。在我們的 App 中,一個「活動」應該包含 id, 標題, 圖片URL, 成員數量, 地點等資訊。這就是我們業務的核心,我們稱之為 Entity。

Repository 契約的設計原則

在設計 Repository 契約時,我們遵循以下原則:

  1. 單一職責原則:每個方法只負責一個特定的資料操作
  2. 介面隔離原則:只定義客戶端真正需要的方法
  3. 依賴反轉原則:高層模組不依賴低層模組,都依賴抽象
  4. 開放封閉原則:對擴充開放,對修改封閉
  5. 錯誤處理原則:使用 Result 類型統一處理成功和失敗情況

專案實踐導覽:Repository 模式的完整實作

接下來,讓我們看看這些概念在我們的「Crew Up!」專案中是如何被完整地實踐的。這包含了 Repository 的具體實作、實體設計,以及它們在 Use Case 中的應用。

Repository 實作架構

Domain Layer 定義了 ActivityRepository 介面,包含完整的業務契約,而 Data Layer 則實作了 ActivityRepositoryImpl,支援本地和遠端雙資料來源的架構。

這樣的設計讓我們能夠:

  • 離線優先:優先使用本地資料,確保 App 在網路不佳時仍能正常運作
  • 自動同步:在網路可用時自動同步遠端資料
  • 錯誤處理:使用 Result 類型統一處理成功和失敗情況
  • 依賴注入:透過 Riverpod 進行依賴注入,便於測試和維護

實體設計與業務邏輯

Activity 實體包含了豐富的業務邏輯,如判斷活動是否可以報名、是否為最新活動等。同時,我們也定義了 ActivityCategoryActivityStatusActivityCardType 等枚舉類型,讓業務邏輯更加清晰和型別安全。

這樣的設計讓我們能夠:

  • 業務邏輯內聚:將相關的業務規則集中在實體中
  • 型別安全:使用枚舉避免魔法字串
  • 可擴展性:容易添加新的活動屬性和業務規則

Use Case 的實際應用

Use Cases 如 CreateActivityUseCaseGetActivitiesUseCase 都透過 Repository 進行業務邏輯處理,包含完整的驗證和錯誤處理。

這樣的設計讓我們能夠:

  • 業務邏輯集中:將複雜的業務規則集中在 Use Case 中
  • 完整的錯誤處理:使用 Result 類型統一處理成功和失敗情況
  • 詳細的日誌記錄:便於除錯和監控
  • 依賴注入:透過建構子注入 Repository,便於測試

Repository 模式在 Clean Architecture 中的位置

在 Clean Architecture 中,Repository 扮演著關鍵的角色。結合我們的 Feature-First 架構,整個系統的結構如下:

╔═══════════════════════════════════════════════╗
║              Presentation Layer               ║
║                                               ║
║    [Widgets]   [Providers]   [Screens]        ║
║                (Riverpod)                     ║
╚═══════════════════════════════════════════════╝
                       │
                       ▼
╔═══════════════════════════════════════════════╗
║                Domain Layer                   ║
║                                               ║
║   [Entities]  [Use Cases]  [Repositories]     ║
║                            (Interfaces)       ║
╚═══════════════════════════════════════════════╝
                       ▲
                       │
╔═══════════════════════════════════════════════╗
║                 Data Layer                    ║
║                                               ║
║    [Models]   [Repositories]  [Data Sources]  ║
║               (Implementations)               ║
╚═══════════════════════════════════════════════╝

在我們的專案中,Repository 模式已經被完整實作,並且完美整合了 Feature-First 架構:

Domain Layer (領域層):

  • ActivityRepository 介面定義在 lib/features/activity/domain/repositories/
  • Activity 實體定義在 lib/features/activity/domain/entities/
  • Use Cases 定義在 lib/features/activity/domain/usecases/
  • 業務驗證服務定義在 lib/features/activity/domain/services/

Data Layer (資料層):

  • ActivityRepositoryImpl 實作在 lib/features/activity/data/repositories/
  • ActivityLocalDataSource 本地資料來源在 lib/features/activity/data/datasources/
  • ActivityDataSource 遠端資料來源介面

Presentation Layer (表現層):

  • Provider 使用 Use Cases 和 Repository(透過 Riverpod 依賴注入)
  • Widget 透過 Provider 獲取資料
  • 狀態管理完全解耦,不依賴 BuildContext

這樣的架構實現了:

  • 依賴反轉:Domain Layer 不依賴 Data Layer
  • 測試性:可以輕鬆替換為 Mock 實作
  • 靈活性:可以隨時更換資料來源(本地 ↔ 遠端)
  • 錯誤處理:使用 Result 類型統一處理成功和失敗情況
  • Feature 隔離:每個功能模組完全獨立,便於團隊合作

結語與下一步

恭喜!今天我們深入探討了 Repository 模式的「道」——為什麼抽象是軟體自由的基石。我們不僅理解了理論,更看到了實際的實作。

在我們的專案中,Repository 模式已經被完整實作,並且完美整合了 Feature-First 架構:

  1. Domain Layer 定義了 ActivityRepository 介面,包含完整的業務契約
  2. Data Layer 實作了 ActivityRepositoryImpl,支援本地和遠端雙資料來源
  3. Use Cases 使用 Repository 進行業務邏輯處理,包含完整的驗證和錯誤處理
  4. Providers 透過 Use Cases 獲取資料,使用 Riverpod 進行依賴注入
  5. 錯誤處理 使用 Result 類型統一管理成功和失敗情況

這樣的架構讓我們獲得了:

  • 測試性:可以輕鬆替換為 Mock 實作,支援完整的單元測試
  • 靈活性:可以隨時更換資料來源(從本地儲存到 Firebase API)
  • 可維護性:業務邏輯與資料來源完全解耦
  • 可擴展性:可以輕鬆添加新的資料操作和業務規則
  • 團隊合作:Feature-First 架構讓不同團隊成員可以獨立開發不同功能

與前幾天的關聯

  • Day 1:我們選擇了 Clean Architecture 作為基礎架構,今天我們看到了這個選擇的實際價值
  • Day 6:我們建立了 Riverpod 狀態管理架構,今天我們看到了它如何與 Repository 模式完美整合
  • Feature-First 架構:我們在 Day 1 確立的 Feature-First 組織原則,在今天得到了完美的實踐

藍圖已經畫好,實作也已完成。

下一步

明天,我們將深入探討 Repository 模式的另一個重要環節:本地儲存與安全儲存。我們會學習如何為不同類型的資料選擇合適的儲存方案,實作完整的資料持久化架構,並確保敏感資料的安全防護。

期待與您在 Day 8 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 7 - Repository 模式:軟體架構的自由基石
  • 文章日期: 2025-09-21
  • 技術棧: Flutter, Clean Architecture, Repository Pattern, Feature-First 架構

上一篇
Day 6 - Riverpod 2.0 實戰攻略:從架構設計到效能優化的完整指南
下一篇
Day 8 - 儲存架構設計:分層儲存策略與安全邊界
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言