在實作行程、活動及子活動的 CRUD 功能時,我發現了一個資料庫設計的潛在問題。為了確保資料的一致性,我決定重構 ActivitiesTable
並進行版本遷移。接下來,我將分享我的實作過程與這段過程的心得。
在實作行程、活動及子活動的 CRUD 功能時,初期過程相當順利。我一併補齊了部分 UI 介面,像是行程的新增/編輯頁面、活動的刪除按鈕等。由於沒有遇到太多阻礙,我錄製了幾段影片來展示目前的進度:
行程 CRUD | 活動 CRUD | 子活動 CRUD |
---|---|---|
![]() |
![]() |
![]() |
在實際應用中,我發現 durationInSeconds
和 endTime
同時存在並非必要。當其中一個值更新(例如修改了 duration 卻忘記同步更新 endTime),就可能引發「時間不一致」的 Bug,大幅增加了資料庫的維護成本。
在思考過後,我認為應以停留時間為基礎去推算結束時間,因此決定移除 endTime
欄位,並將 durationInSeconds
改為必填。調整後的 ActivitiesTable
結構如下:
class ActivitiesTable extends Table {
// 活動 ID,使用 autoIncrement 的整數主鍵
IntColumn get id => integer().autoIncrement()();
// 外來鍵,連結到 Trips 表格的 id
IntColumn get tripId => integer().references(
TripsTable,
#id,
// 當父資料被刪除時,也一併刪除此活動
onDelete: KeyAction.cascade,
)();
// 核心欄位
IntColumn get type => integer().map(const ActivityTypeConverter())();
TextColumn get location => text()();
DateTimeColumn get startTime => dateTime()();
IntColumn get durationInSeconds => integer()();
// 其他欄位
IntColumn get transportType =>
integer().nullable().map(const TransportTypeConverter())();
TextColumn get note => text().nullable()();
}
其實這一步驟原本在開發初期並不需要。但基於想完整學習新套件的考量,我決定連同 migration 一併處理。在正常開發階段(本地端,尚未上架),如果資料庫結構有變動,最簡單的方式是直接刪除應用程式的資料或整個資料庫檔案,讓 Drift 依照最新 schema 重建。
然而,當應用程式正式上線後,每次的 schema 變動都必須謹慎處理,並撰寫對應的 migration 腳本,否則舊有使用者的資料將會遺失,這將導致使用體驗不佳甚至資料流失等嚴重問題。
回到正題,當你對資料庫的結構進行任何更改時(例如新增、刪除或修改欄位),都必須更新 schemaVersion
。Drift 會比較程式碼中的 schemaVersion
和裝置上已存資料庫的版本號。當版本號不同時,Drift 會呼叫你在 MigrationStrategy
中定義的遷移邏輯。
在 Drift 中,資料庫的版本管理與遷移是透過 MigrationStrategy
來實現的。它是一個在 AppDatabase
中定義的屬性,用來處理資料庫在建立、升級或降級時的行為。
當資料庫第一次開啟或版本變更時,Drift 會依照以下方法自動執行:
方法名稱 | 說明 | 觸發時機 |
---|---|---|
onBeforeOpen | 在資料庫打開之前、任何遷移邏輯執行前被呼叫。常用於檢查資料庫檔案、設定 pragma 或預先初始化。 |
資料庫開啟前 |
onCreate | 在資料庫第一次被建立時呼叫。通常只需要呼叫 m.createAll() ,Drift 會自動為你建立所有表格。 |
App 第一次執行,且沒有舊資料庫時 |
onUpgrade | 當資料庫升級時呼叫。你需要在此撰寫將舊版本結構轉換成新版本結構的邏輯。 | 程式碼中的 schemaVersion > 裝置上的資料庫版本 |
onDowngrade | 當資料庫降級時呼叫。預設會丟出異常,以避免資料損毀。除非有特殊需求,否則不建議實作。 | 程式碼中的 schemaVersion < 裝置上的資料庫版本 |
在 onCreate
與 onUpgrade
內部,你會使用 Migrator
物件來操作資料庫。它提供了安全的抽象層來進行結構變更。
函式名稱 | 說明 | 範例 |
---|---|---|
m.createTable | 建立新的資料表。 | await m.createTable(myNewTable); |
m.addColumn | 為現有表格新增欄位。 | await m.addColumn(tripsTable, tripsTable.imageUrl); |
m.drop | 除非有特殊需求,否則建議搭配 m.alterTable 或 m.migrateTable 進行複雜操作,因為 SQLite 不支援直接刪除單一欄位。 |
await m.drop(tripsTable); |
m.alterTable | 修改表格結構(例如重命名)。通常搭配 TableMigration 使用。 |
await m.alterTable(TableMigration(tripsTable, renameTo: 'new_trips')); |
m.migrateTable | 進行複雜遷移(例如修改欄位型別、移除欄位)。它會自動建立暫存表、複製資料、再刪除舊表。 | await m.migrateTable(tripsTable); |
這些函式不屬於 MigrationStrategy
,但在實務上常用於資料庫管理:
函式名稱 | 說明 | 範例 |
---|---|---|
attachedDatabase.customStatement | 執行原生 SQL。當 Drift 提供的 API 不足時,這是最後手段。 | await attachedDatabase.customStatement('ALTER TABLE trips ADD COLUMN note TEXT;'); |
beforeOpen | 在 _openConnection 傳入的 callback,用於設定 SQLite 行為。注意它與 MigrationStrategy.onBeforeOpen 不同。 |
_openConnection(beforeOpen: (db) { db.execute('PRAGMA foreign_keys = ON;'); }); |
attachedDatabase.transaction | 將多個操作包裝成一個原子性交易,確保資料一致性。 | await attachedDatabase.transaction(() async { ... }); |
這次的調整屬於複雜的「修改欄位定義」。由於 SQLite 的原生限制,當你需要刪除欄位或更改欄位型別時,必須透過重建表的方式來實現。儘管 Drift 提供了高階函式,但由於底層限制,處理此類複雜遷移時,程式碼可能會變得相對冗長。因此,我選擇採用 SQLite 官方建議的做法,直接使用 SQL 語法撰寫,流程如下:
endTime - startTime
轉換為 durationInSeconds
)。MigrationStrategy
程式碼會放在 AppDatabase
類別中。當資料庫版本號不同時,Drift 會自動呼叫你在 onUpgrade
或 onDowngrade
中定義的邏輯來執行遷移。
// database.dart
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) {
return m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from == 1 && to == 2) {
// 使用 attachedDatabase 呼叫 customStatement,以確保在一個事務中執行所有操作
await attachedDatabase.customStatement('''
-- 步驟一:創建一個帶有新 schema 的臨時表格,包含新的 durationInSeconds 欄位
-- 注意:這裡的 start_time, duration_in_seconds 和 transport_type 都是根據 Dart 程式碼的命名規則
-- 重命名為小寫蛇形命名法
CREATE TABLE activities_table_temp (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips_table(id) ON DELETE CASCADE,
type INTEGER NOT NULL,
location TEXT NOT NULL,
start_time INTEGER NOT NULL,
duration_in_seconds INTEGER NOT NULL,
transport_type INTEGER,
note TEXT
);
-- 步驟二:從舊表格將資料複製到新表格
-- 在這裡,我們直接用 endTime - startTime 來計算 durationInSeconds
INSERT INTO activities_table_temp (
id,
trip_id,
type,
location,
start_time,
duration_in_seconds,
transport_type,
note
)
SELECT
id,
trip_id,
type,
location,
start_time,
CAST(endTime - startTime AS INTEGER),
transport_type,
note
FROM activities_table;
-- 步驟三:刪除舊表格
DROP TABLE activities_table;
-- 步驟四:重命名臨時表格為正確的名稱
ALTER TABLE activities_table_temp RENAME TO activities_table;
''');
}
},
);
}
這段過程看似成功,但事實上,它背後隱藏了一段與 Gemini 職涯導師的「拉鋸戰」。
起初,我天真地以為只要像昨天一樣,單純地跟著 Gemini 的教學,把程式碼順過一遍就行。但或許是「職涯導師」模式的緣故,它一開始極力說服我不要異動資料欄位。它建議在 ActivitiesTable
中新增一個 sortOrder
欄位來處理排序。
然而,根據我的需求,行程本身就是以時間為順序,具有其天然的排序性。多增加一個欄位來儲存排序反而顯得多餘,也增加了資料庫的維護成本。在經過一番溝通後,我最終說服它採用我的方案:移除 endTime
欄位,並將 durationInSeconds
改為必填。
現在回想起來,它當初拼命勸阻我的原因,很可能是出於對 SQL 的不熟悉(畢竟我傳給他的資料只有 Drift ?)。因為它給我的前幾版遷移程式碼,就踩了許多明顯的坑:
endTime
欄位,後續卻又要求我用它來計算 durationInSeconds
。ALTER TABLE activities_table DROP COLUMN endTime;
在 SQLite 中會直接報錯,因為 SQLite 不支援這個語法。strftime
的使用也存在問題,沒有考慮到欄位是 INTEGER
(epoch) 時,可以直接相減而不需要型別轉換。原本以為使用了 Drift 就能告別複雜的 SQL 語法,但果然還是有例外,而且這麼快就遇到了。今天的解決方案,最後是透過同時參考 ChatGPT 和 Gemini,交叉比對才獲得這段得來不易的 MigrationStrategy
。
明天我會繼續分享 Drift 的心得,主要會深入探討 Day 13 的行程拖曳邏輯,以及 Day 15 AI 行程生成要如何存進資料庫。沒想到一個我以為的簡單資料庫,竟然寫了這麼多天。