大家好!在 Day 12,我們成功地讓 App 與雲端資料庫雙向溝通,實現了即時的資料新增與讀取。我們的「省錢拍拍」App 現在是一個功能完整的雲端應用了,這是一個巨大的成就!
然而,當我們回頭檢視 HomePage
和 AddTransactionPage
的程式碼時,會發現一個問題:UI 相關的 Widget 程式碼,與直接呼叫 Firebase 的資料庫邏輯,全都混雜在一起。
這就像一個餐廳的廚師,不僅要負責炒菜,還要親自跑去倉庫拿原料。在餐廳規模小時還能應付,但隨著業務變複雜,廚房就會變得一團亂。
今天,我們不新增任何功能,而是要做一件讓我們的 App 更上一層樓、更專業的事情——重構 (Refactoring)。我們將學習如何分離 UI 與業務邏輯,建立一個專門的「數據服務層」,讓我們的程式碼結構更清晰、更易於維護。
「關注點分離 (Separation of Concerns)」是軟體工程的核心原則。UI 層應該只關心「如何顯示畫面」,而資料層應該只關心「如何存取資料」。
為此,我們來建立一個專門負責與 Firestore 溝通的管家:FirestoreService
。
lib
資料夾下,建立一個新資料夾 services
。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 (未來預留)
}
接下來,我們來改造 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 程式碼極其清晰。
同樣地,我們來改造 _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_it
或Provider
套件,確保整個 App 中只有一個FirestoreService
實例,讓架構更乾淨、高效。
目前,我們在 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(),
);
}
}
回到 _HomePageState
的 ListView.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 添加任何新功能,但我們所做的「重構」工作,價值甚至高於添加新功能。我們學會了:
FirestoreService
作為我們 App 的數據管家,統一處理所有資料庫操作。fromFirestore
工廠建構子,實現了從 Map
到強型別物件的轉換,讓程式碼更安全、更健壯。經過這次重構,我們的 App 架構已經提升到了一個新的層次。這為我們未來添加更複雜的功能(例如 AI 整合)打下了堅實的基礎。
我們的 CRUD 還差最後兩塊拼圖。明天,我們將實作更新 (Update) 與刪除 (Delete) 功能,並學習如何使用 Flutter 的手勢偵測,例如透過滑動列表項來刪除一筆紀錄。