iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Mobile Development

《30 天 Flutter:跨平台 AI 行程規劃 App》系列 第 18

Day 18 - Drift 實戰:從 UI 介面到 DB Migration 的跌跌撞撞

  • 分享至 

  • xImage
  •  

在實作行程、活動及子活動CRUD 功能時,我發現了一個資料庫設計的潛在問題。為了確保資料的一致性,我決定重構 ActivitiesTable 並進行版本遷移。接下來,我將分享我的實作過程與這段過程的心得。


UI/UX 實作與初步進度

在實作行程、活動及子活動的 CRUD 功能時,初期過程相當順利。我一併補齊了部分 UI 介面,像是行程的新增/編輯頁面、活動的刪除按鈕等。由於沒有遇到太多阻礙,我錄製了幾段影片來展示目前的進度:

行程 CRUD 活動 CRUD 子活動 CRUD

資料模型優化:從源頭解決資料不一致

在實際應用中,我發現 durationInSecondsendTime 同時存在並非必要。當其中一個值更新(例如修改了 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()();
}

資料庫版本遷移:當 Schema 改變時

其實這一步驟原本在開發初期並不需要。但基於想完整學習新套件的考量,我決定連同 migration 一併處理。在正常開發階段(本地端,尚未上架),如果資料庫結構有變動,最簡單的方式是直接刪除應用程式的資料或整個資料庫檔案,讓 Drift 依照最新 schema 重建。

然而,當應用程式正式上線後,每次的 schema 變動都必須謹慎處理,並撰寫對應的 migration 腳本,否則舊有使用者的資料將會遺失,這將導致使用體驗不佳甚至資料流失等嚴重問題

回到正題,當你對資料庫的結構進行任何更改時(例如新增、刪除或修改欄位),都必須更新 schemaVersion。Drift 會比較程式碼中的 schemaVersion 和裝置上已存資料庫的版本號。當版本號不同時,Drift 會呼叫你在 MigrationStrategy 中定義的遷移邏輯。


Drift 資料庫遷移核心概念:掌握 Migrator 的藝術

在 Drift 中,資料庫的版本管理與遷移是透過 MigrationStrategy 來實現的。它是一個在 AppDatabase 中定義的屬性,用來處理資料庫在建立、升級或降級時的行為。

當資料庫第一次開啟或版本變更時,Drift 會依照以下方法自動執行:

方法名稱 說明 觸發時機
onBeforeOpen 在資料庫打開之前、任何遷移邏輯執行前被呼叫。常用於檢查資料庫檔案、設定 pragma 或預先初始化。 資料庫開啟前
onCreate 在資料庫第一次被建立時呼叫。通常只需要呼叫 m.createAll(),Drift 會自動為你建立所有表格。 App 第一次執行,且沒有舊資料庫時
onUpgrade 當資料庫升級時呼叫。你需要在此撰寫將舊版本結構轉換成新版本結構的邏輯。 程式碼中的 schemaVersion > 裝置上的資料庫版本
onDowngrade 當資料庫降級時呼叫。預設會丟出異常,以避免資料損毀。除非有特殊需求,否則不建議實作。 程式碼中的 schemaVersion < 裝置上的資料庫版本

onCreateonUpgrade 內部,你會使用 Migrator 物件來操作資料庫。它提供了安全的抽象層來進行結構變更。

函式名稱 說明 範例
m.createTable 建立新的資料表。 await m.createTable(myNewTable);
m.addColumn 為現有表格新增欄位。 await m.addColumn(tripsTable, tripsTable.imageUrl);
m.drop 除非有特殊需求,否則建議搭配 m.alterTablem.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 { ... });

一個 DB Migration 的漫長旅程

這次的調整屬於複雜的「修改欄位定義」。由於 SQLite 的原生限制,當你需要刪除欄位或更改欄位型別時,必須透過重建表的方式來實現。儘管 Drift 提供了高階函式,但由於底層限制,處理此類複雜遷移時,程式碼可能會變得相對冗長。因此,我選擇採用 SQLite 官方建議的做法,直接使用 SQL 語法撰寫,流程如下:

  1. 建立帶有新結構的臨時表。
  2. 將舊表的資料搬移到新表,並在過程中進行必要的資料轉換(例如:將原有的 endTime - startTime 轉換為 durationInSeconds)。
  3. 刪除舊表,並將新表重新命名。

MigrationStrategy 程式碼會放在 AppDatabase 類別中。當資料庫版本號不同時,Drift 會自動呼叫你在 onUpgradeonDowngrade 中定義的邏輯來執行遷移。

// 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 行程生成要如何存進資料庫。沒想到一個我以為的簡單資料庫,竟然寫了這麼多天。


上一篇
Day 17 - Drift CRUD 入門:跟著 Gemini 玩轉資料庫
下一篇
Day 19 - Drift 實戰:Transaction 與 Batch 的效能與安全實踐
系列文
《30 天 Flutter:跨平台 AI 行程規劃 App》19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言