iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
自我挑戰組

攜手 AI 從零開始打造一款 Flutter 應用程式系列 第 13

Day 13: 期中整合與重構 - 分離 UI 與業務邏輯

  • 分享至 

  • xImage
  •  

前言

大家好!在 Day 12,我們成功地讓 App 與雲端資料庫雙向溝通,實現了即時的資料新增與讀取。我們的「省錢拍拍」App 現在是一個功能完整的雲端應用了,這是一個巨大的成就!

然而,當我們回頭檢視 HomePageAddTransactionPage 的程式碼時,會發現一個問題:UI 相關的 Widget 程式碼,與直接呼叫 Firebase 的資料庫邏輯,全都混雜在一起。

這就像一個餐廳的廚師,不僅要負責炒菜,還要親自跑去倉庫拿原料。在餐廳規模小時還能應付,但隨著業務變複雜,廚房就會變得一團亂。

今天,我們不新增任何功能,而是要做一件讓我們的 App 更上一層樓、更專業的事情——重構 (Refactoring)。我們將學習如何分離 UI 與業務邏輯,建立一個專門的「數據服務層」,讓我們的程式碼結構更清晰、更易於維護。

Step 1: 建立 FirestoreService - 我們的數據管家

關注點分離 (Separation of Concerns)」是軟體工程的核心原則。UI 層應該只關心「如何顯示畫面」,而資料層應該只關心「如何存取資料」。

為此,我們來建立一個專門負責與 Firestore 溝通的管家:FirestoreService

  1. lib 資料夾下,建立一個新資料夾 services
  2. services 資料夾中,建立一個新檔案 firestore_service.dart

在這個檔案中,我們將把所有散落在各個頁面中的 Firestore 操作集中起來。

// lib/services/firestore_service.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class FirestoreService {
  // 取得 users 集合的參照
  final CollectionReference _usersCollection =
      FirebaseFirestore.instance.collection('users');

  // C for Create: 新增一筆交易紀錄
  Future<void> addTransaction({
    required String userId,
    required String title,
    required double amount,
  }) async {
    try {
      await _usersCollection.doc(userId).collection('transactions').add({
        'title': title,
        'amount': amount,
        'category': '未分類', // 暫時寫死
        'date': Timestamp.now(),
      });
    } catch (e) {
      print('新增 Transaction 失敗: $e');
      // 可以選擇 rethrow e 或進行其他錯誤處理
    }
  }

  // R for Read: 取得指定使用者的交易紀錄串流
  Stream<QuerySnapshot> getTransactionsStream({required String userId}) {
    return _usersCollection
        .doc(userId)
        .collection('transactions')
        .orderBy('date', descending: true)
        .snapshots();
  }

  // U for Update (未來預留)
  // D for Delete (未來預留)
}

Step 2: 重構 AddTransactionPage - 讓新增頁面更專注

接下來,我們來改造 add_transaction_page.dart,讓它從「親自去倉庫拿原料」變成「呼叫管家去處理」。

// lib/add_transaction_page.dart
// ... 導入 ...
import 'package:snapsaver/services/firestore_service.dart'; // 導入我們的服務

class _AddTransactionPageState extends State<AddTransactionPage> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _amountController = TextEditingController();

  // 1. 建立 FirestoreService 的實例
  final FirestoreService _firestoreService = FirestoreService();

  @override
  void dispose() { /* ... */ }

  Future<void> _submitForm() async {
    if (_formKey.currentState!.validate()) {
      final user = FirebaseAuth.instance.currentUser;
      if (user == null) return;

      // 2. 原本複雜的 Firestore 程式碼,現在變成乾淨的一行!
      await _firestoreService.addTransaction(
        userId: user.uid,
        title: _titleController.text,
        amount: double.parse(_amountController.text),
      );

      if (mounted) Navigator.pop(context);
    }
  }

  @override
  Widget build(BuildContext context) {
    // build 方法的內容完全不需要改變!
    return Scaffold( /* ... */ );
  }
}

重構的好處AddTransactionPage 現在變得非常「笨」,而這是一件好事!它不再關心資料是存到 Firestore、MySQL 還是本地檔案,它的唯一職責就是收集使用者輸入,然後呼叫 _firestoreService.addTransaction。這使得我們的 UI 程式碼極其清晰。

Step 3: 重構 HomePage - 讓主頁面更清爽

同樣地,我們來改造 _HomePageState,讓它也透過 FirestoreService 來取得資料流。

// lib/main.dart -> _HomePageState

import 'package:snapsaver/services/firestore_service.dart'; // 導入我們的服務

class _HomePageState extends State<HomePage> {
  // 1. 建立 FirestoreService 的實例
  final FirestoreService _firestoreService = FirestoreService();
  
  void _navigateToAddTransactionPage() { /* ... */ }

  @override
  Widget build(BuildContext context) {
    final user = FirebaseAuth.instance.currentUser;
    return Scaffold(
      // ... AppBar 和 FAB ...
      body: Column(
        children: [
          // ... 頂部總覽區塊 ...
          Expanded(
            child: user == null
                ? const Center(child: Text('請先登入以查看紀錄'))
                : StreamBuilder<QuerySnapshot>(
                    // 2. 將 stream 的來源指向我們的服務方法
                    stream: _firestoreService.getTransactionsStream(userId: user.uid),
                    builder: (context, snapshot) {
                      // builder 內部的邏輯完全不需要改變!
                      // ...
                      return ListView.builder( /* ... */ );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

重構的好處HomePage 現在也變得更專注於 UI 顯示。它只需要向 FirestoreService 索取一個 Stream,然後用 StreamBuilder 將其渲染出來即可,完全不用理會這個 Stream 究竟是從哪裡、如何來的。

  • 進階思維:服務應該如何被管理?

在這個範例中,我們直接在 _HomePageState_AddTransactionPageState 中建立了 FirestoreService 的實例。這很簡單,但如果未來有十個頁面都需要這個服務,我們就會建立十個實例。

在更大型的專案中,開發者通常會使用「服務定位器 (Service Locator)」或「依賴注入 (Dependency Injection)」模式來管理這些服務,例如使用 get_itProvider 套件,確保整個 App 中只有一個 FirestoreService 實例,讓架構更乾淨、高效。

Step 4: 強化我們的 Transaction 模型

目前,我們在 ListView.builder 中是這樣取得資料的:final data = doc.data() as Map<String, dynamic>;,然後用 data['title'] 這樣的方式取值。這種方式不是「型別安全」的,如果欄位名稱打錯,編譯器不會報錯,只會在執行時崩潰。

我們可以強化 Transaction 模型來解決這個問題。

打開 lib/models/transaction.dart,加入一個 fromFirestore 的工廠建構子:

// lib/models/transaction.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Transaction {
  final String id; // 我們也加上 id,方便未來更新或刪除
  final String title;
  final String category;
  final double amount;
  final DateTime date;

  Transaction({
    required this.id,
    required this.title,
    required this.category,
    required this.amount,
    required this.date,
  });

  // 新增這個工廠建構子
  factory Transaction.fromFirestore(DocumentSnapshot doc) {
    Map data = doc.data() as Map<String, dynamic>;
    return Transaction(
      id: doc.id,
      title: data['title'] ?? '無標題',
      category: data['category'] ?? '未分類',
      amount: (data['amount'] as num? ?? 0).toDouble(),
      date: (data['date'] as Timestamp).toDate(),
    );
  }
}

回到 _HomePageStateListView.builder,我們可以用這個更安全、更優雅的方式來處理資料:

// lib/main.dart -> _HomePageState -> ListView.builder
// ...
final transactionDocs = snapshot.data!.docs;

return ListView.builder(
  itemCount: transactionDocs.length,
  itemBuilder: (context, index) {
    // 將 DocumentSnapshot 轉換為強型別的 Transaction 物件
    final transaction = Transaction.fromFirestore(transactionDocs[index]);

    // 現在可以用物件導向的方式安全地取值
    return Card(
      child: ListTile(
        title: Text(transaction.title),
        subtitle: Text(transaction.category),
        trailing: Text('NT\$ ${transaction.amount.toStringAsFixed(0)}'),
      ),
    );
  },
);

今日結語

今天我們沒有為 App 添加任何新功能,但我們所做的「重構」工作,價值甚至高於添加新功能。我們學會了:

  1. 分離關注點:將 UI 邏輯與資料存取邏輯分離,是寫出乾淨、可維護程式碼的基石。
  2. 建立服務層:FirestoreService 作為我們 App 的數據管家,統一處理所有資料庫操作。
  3. 強化資料模型:透過 fromFirestore 工廠建構子,實現了從 Map 到強型別物件的轉換,讓程式碼更安全、更健壯。

經過這次重構,我們的 App 架構已經提升到了一個新的層次。這為我們未來添加更複雜的功能(例如 AI 整合)打下了堅實的基礎。

我們的 CRUD 還差最後兩塊拼圖。明天,我們將實作更新 (Update)刪除 (Delete) 功能,並學習如何使用 Flutter 的手勢偵測,例如透過滑動列表項來刪除一筆紀錄。


上一篇
Day 12: 與雲端對話 - Firestore 的 CRUD 實戰操作
下一篇
Day 14: 補完 CRUD 最後一哩路 - 實作更新與滑動刪除
系列文
攜手 AI 從零開始打造一款 Flutter 應用程式20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言