在開發我的 AI 行程 App 時,我面臨了一個關鍵的決策:該如何選擇本地資料庫?市面上有許多優秀的套件,從輕量級的 shared_preferences
、高效的 Hive
和 Isar
,到功能強大的 sqflite
和 drift
。經過一番研究和評估,我最終選擇了 drift
。
我的行程 App 核心需求是儲存複雜、結構化的資料。每個行程都包含多個活動,而每個活動又可能包含多個子活動。這樣的需求更適合關聯式資料庫。不過,為了全面評估,我仍然將 sqflite
、drift
這兩個關聯式方案,以及 Hive
、Isar
這類高效的 NoSQL 方案一起納入比較。
套件 | 資料庫類型 | 適用情境 | 為何不選? |
---|---|---|---|
sqflite | 關聯式 (SQLite) | 簡單的關聯查詢,或需要完全控制 SQL 的專案 | ❌ 程式碼無型態安全,需要手動編寫大量 SQL 語句,易錯。 |
Hive | 非關聯式 (NoSQL) | 輕量、高效的鍵值對儲存,或簡單資料快取 | ❌ 不支援複雜關聯查詢,難以處理行程與活動的多對一關係。 |
Isar | 非關聯式 (NoSQL) | 性能敏感,資料結構有一定複雜度但關聯不嚴謹的專案 | ❌ 不適合多層次的複雜關聯,無法像關聯式資料庫那樣輕鬆管理資料完整性。 |
Drift | 關聯式 (SQLite) | 需要處理複雜、結構化、有強關聯的資料 | ✅ 無明顯缺點,唯一挑戰是學習曲線稍高,但換來的是長期的開發效率。 |
經過比較,Drift
成為我唯一的選擇,理由非常明確:
Drift
最吸引我的地方。它利用程式碼生成技術,將我的資料表定義轉化為強型別的 Dart 類別。這意味著在編譯階段,IDE 就能即時提示我資料欄位名稱或型別的錯誤,避免了 sqflite
中常見的 SQL 語法錯誤,大大減少了除錯時間。Drift
讓我能輕鬆定義這類關聯。我可以用 Dart 程式碼來建立複雜的 JOIN
查詢,而不是手動拼接冗長的 SQL 字串。這讓我的程式碼更簡潔、可讀性更高,也更易於維護。Drift
提供了強大的 Streams API,可以讓我在資料庫資料變動時,自動觸發 UI 介面更新。例如,當我在行程中新增一個活動,行程列表會立即刷新,無需手動管理狀態,這為使用者帶來了流暢的體驗。總結來說,儘管 Drift
的學習門檻略高,但它為我的行程 App 提供了穩固的基石。它不僅解決了當前的資料儲存需求,更保證了未來功能的擴展性與程式碼的健壯性。選擇 Drift
,就是選擇一種更安全、更高效、更現代的開發方式。
由於是初次使用 Drift 再加上這一個套件學習曲線較高,所以決定善用 AI,將套件教學網站丟給 Gemini 並使用職涯導師功能,幫我規劃學習路線,討論後他給了我以下目標:
要使用 Drift,需要在 pubspec.yaml 檔案中加入以下幾個套件:
dependencies:
drift: ^2.28.1
sqlite3: ^2.9.0
path_provider: ^2.1.5
path: ^1.9.1
dev_dependencies:
drift_dev: ^2.28.1
build_runner: ^2.7.0
我將資料庫相關程式碼集中管理:
lib/
├── database/
│ ├── tables.dart # 定義所有資料表的地方
│ ├── database.dart # 資料庫實例檔案
│ ├── converters.dart # 集中放置 TypeConverter 程式碼
│ └── daos/ # 存放 DAO (Data Access Object) 檔案
│ ├── trips_dao.dart
│ ├── activities_dao.dart
│ └── child_activities_dao.dart
└── main.dart
Trips
、Activities
),單純描述有哪些欄位。這樣一來,資料庫層次就很分明:表格負責定義、DAO 負責操作、Database 負責統合、Converter 負責轉換。
在 Drift 中,我們可以用 Dart 程式碼直接定義資料表,而不是像傳統方式那樣寫 SQL。這種物件導向的方式,讓結構更直觀、可維護性也更高。
在模型層,我原本定義了一個 Trip
類別,包含三個屬性:id
、title
和 activities
。
// lib/models/trip.dart
class Trip {
final String id;
final String title;
final List<Activity> activities;
}
integer().autoIncrement()
來建立主鍵,確保唯一性並交由資料庫自動處理。TextColumn
。List<Activity>
,代表一個行程可能包含多個活動。但在關聯式資料庫中,表格不能直接儲存清單,否則會違反設計原則。因此,處理「一對多」關係的標準做法是:
Trips
和 Activities
。Activities
表格中新增一個欄位(通常稱為 外來鍵 Foreign Key),指向它所屬的行程 ID。舉例來說:在 Activities
表格裡新增一個 trip_id
欄位,用來儲存該活動隸屬的 Trip
。這樣就能透過 trip_id
反查某個行程下的所有活動。
最後,Trips
表格的實際定義如下:
// lib/database/tables.dart
import 'package:drift/drift.dart';
class Trips extends Table {
// primary key,自動產生 ID
IntColumn get id => integer().autoIncrement()();
// 行程名稱
TextColumn get name => text().withLength(min: 1, max: 32)();
}
在一個行程 (Trip
) 中,會包含多個活動 (Activity
),而活動本身也可能再拆成子活動 (ChildActivity
)。
對應到資料庫,我使用了兩個表格:Activities
和 ChildActivities
。
class Activities extends Table {
// 主鍵:活動 ID,自動遞增
IntColumn get id => integer().autoIncrement()();
// 外來鍵:指向所屬的 Trip
IntColumn get tripId => integer().references(Trips, #id)();
// 活動核心欄位
IntColumn get type => integer().map(const ActivityTypeConverter())();
TextColumn get location => text()();
DateTimeColumn get startTime => dateTime()();
IntColumn get durationInSeconds => integer().nullable()();
DateTimeColumn get endTime => dateTime()();
IntColumn get transportType =>
integer().nullable().map(const TransportTypeConverter())();
TextColumn get note => text().nullable()();
}
class ChildActivities extends Table {
// 主鍵:子活動 ID,自動遞增(而非使用 String id)
IntColumn get id => integer().autoIncrement()();
// 外來鍵:指向父層活動的 id
IntColumn get activityId => integer().references(Activities, #id)();
// 子活動核心欄位
TextColumn get name => text()();
IntColumn get durationInSeconds => integer().nullable()();
IntColumn get transportType =>
integer().nullable().map(const TransportTypeConverter())();
TextColumn get note => text().nullable()();
}
在 Activities
表格中有這段程式碼:
IntColumn get tripId => integer().references(Trips, #id)();
拆解來看:
integer()
:宣告這是一個整數欄位。.references(Trips, #id)
:建立關聯,指定要參考 Trips
表格中的某個欄位。Trips
:這裡傳入的是先前定義好的 Trips
類別,表示要參考這個表格。#id
:這是 Dart 的 Symbol,代表 Trips
類別裡的 id
欄位。同理,在 ChildActivities
表格中,activityId
也用同樣方式指向 Activities
的主鍵。
Duration
不能直接存在 SQLite 裡,所以我把它轉換成 整數(秒數) 來儲存:
IntColumn
是 Drift 支援的基本型別。在 Activities
和 ChildActivities
中,我用了 ActivityType
與 TransportType
這兩種 enum。
由於資料庫不支援 enum,需要使用 TypeConverter 把它轉換成 int
或 String
。
範例:
class ActivityTypeConverter extends TypeConverter<ActivityType, int> {
const ActivityTypeConverter();
@override
ActivityType fromSql(int fromDb) => ActivityType.values[fromDb];
@override
int toSql(ActivityType fromDart) => fromDart.index;
}
// 在表格中使用
class Activities extends Table {
IntColumn get type => integer().map(const ActivityTypeConverter())();
}
透過 TypeConverter
,程式碼中仍能用 enum,資料庫則儲存整數,兼顧可讀性與效能。
在 Drift 中,資料庫實例是 App 與資料庫溝通的唯一入口。你需要先建立一個類別來代表你的資料庫,這個類別通常會繼承 _$AppDatabase
。
@DriftDatabase(tables: [Trips, Activities, ChildActivities])
class AppDatabase extends _$AppDatabase {
AppDatabase._internal() : super(_openConnection());
static final AppDatabase _instance = AppDatabase._internal();
factory AppDatabase() => _instance;
@override
int get schemaVersion => 1;
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'db.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
拆解來看:
@DriftDatabase(...)
:傳入 Trips
、Activities
和 ChildActivities
三個表格,告訴 Drift 這是資料庫的藍圖。AppDatabase extends _$AppDatabase
:_$AppDatabase
是 Drift 程式碼生成器自動產生的類別,包含了與表格互動的所有程式碼。schemaVersion
:定義資料庫版本號。當你對表格結構(新增、刪除、修改欄位)進行變更時,必須增加此數字。LazyDatabase
:採用延遲初始化,不會在 App 啟動時就打開資料庫,而是等到首次查詢或寫入時才建立連線。_openConnection()
db.sqlite
,所有資料都會儲存在這裡。當資料庫類別定義完成後,必須執行 Drift 的程式碼生成器,否則 _$AppDatabase
這個類別不會出現。
flutter pub run build_runner build
這個指令會自動產生 database.g.dart
檔案,裡面包含所有與資料表互動所需的程式碼。
到這裡,已經完成了資料庫的基礎設定:定義表格、建立資料庫實例,並且準備好程式碼生成器。這些步驟相當於打好地基,讓接下來的開發能順利展開,明天會繼續深入,實作最核心的 CRUD(Create、Read、Update、Delete)操作,讓 App 真正能夠新增、查詢、修改與刪除資料。