iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Mobile Development

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

Day 16 - 選擇 Flutter 本地儲存:為何我擁抱 Drift

  • 分享至 

  • xImage
  •  

在開發我的 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 成為我唯一的選擇,理由非常明確:

  1. 高度的型態安全 (Type-Safety): 這是 Drift 最吸引我的地方。它利用程式碼生成技術,將我的資料表定義轉化為強型別的 Dart 類別。這意味著在編譯階段,IDE 就能即時提示我資料欄位名稱或型別的錯誤,避免了 sqflite 中常見的 SQL 語法錯誤,大大減少了除錯時間。
  2. 輕鬆處理複雜關聯: 行程與活動之間是一對多的關係,Drift 讓我能輕鬆定義這類關聯。我可以用 Dart 程式碼來建立複雜的 JOIN 查詢,而不是手動拼接冗長的 SQL 字串。這讓我的程式碼更簡潔、可讀性更高,也更易於維護。
  3. 現代化開發體驗: Drift 提供了強大的 Streams API,可以讓我在資料庫資料變動時,自動觸發 UI 介面更新。例如,當我在行程中新增一個活動,行程列表會立即刷新,無需手動管理狀態,這為使用者帶來了流暢的體驗。

總結來說,儘管 Drift 的學習門檻略高,但它為我的行程 App 提供了穩固的基石。它不僅解決了當前的資料儲存需求,更保證了未來功能的擴展性與程式碼的健壯性。選擇 Drift,就是選擇一種更安全、更高效、更現代的開發方式。

Drift 學習計畫

由於是初次使用 Drift 再加上這一個套件學習曲線較高,所以決定善用 AI,將套件教學網站丟給 Gemini 並使用職涯導師功能,幫我規劃學習路線,討論後他給了我以下目標:

  • 基本概念與設定: 了解 Drift 的核心概念,並學會在你的 Flutter 專案中完成基本設定。
  • 資料模型與操作: 學習如何建立資料表、定義資料模型,並執行基本的 CRUD(增、讀、改、刪)操作。
  • 進階關係與應用: 學習處理複雜的資料關係,例如一個行程有多個活動,以及如何在你的 App 中整合並使用這些功能。

環境設定

要使用 Drift,需要在 pubspec.yaml 檔案中加入以下幾個套件:

dependencies:
  drift: ^2.28.1
  sqlite3: ^2.9.0
  sqlite3_flutter_libs: ^2.9.0
  path_provider: ^2.1.5
  path: ^1.9.1

dev_dependencies:
  drift_dev: ^2.28.1
  build_runner: ^2.7.0

核心套件:

  • drift: 核心套件,提供 ORM 與查詢功能。
  • sqlite3: 實際的資料庫引擎,提供 Dart 語言的 SQLite API。
  • sqlite3_flutter_libs: 它負責將底層的 SQLite 原生函式庫(例如在 Android 和 iOS 上所需的檔案)打包進應用程式中,讓 sqlite3 套件能夠在不同平台上正常運作。
  • path_provider: 取得不同平台上的應用程式檔案路徑(例如儲存資料庫的目錄)。
  • path: 一個用於處理檔案路徑的函式庫,方便建立跨平台的檔案路徑。

開發用套件,用於程式碼自動生成:

  • drift_dev: 這是 Drift 的開發工具套件,負責產生資料庫操作的程式碼。
  • build_runner: 執行程式碼生成的指令。

資料夾架構

我將資料庫相關程式碼集中管理:

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
  • tables.dart:定義表格結構(像是 Trips、Activities),單純描述有哪些欄位。
  • database.dart:建立資料庫實例,把表格和 DAO 串起來,App 透過它來操作資料。
  • daos/:將複雜的查詢邏輯封裝起來,例如「查所有行程」或「新增活動」,讓程式碼更乾淨。
  • converters.dart:處理型別轉換(例如 enum ↔ int),讓資料能正確存進資料庫。

這樣一來,資料庫層次就很分明:表格負責定義、DAO 負責操作、Database 負責統合、Converter 負責轉換。

資料庫與表格的定義

在 Drift 中,我們可以用 Dart 程式碼直接定義資料表,而不是像傳統方式那樣寫 SQL。這種物件導向的方式,讓結構更直觀、可維護性也更高。

Trips 表格定義

在模型層,我原本定義了一個 Trip 類別,包含三個屬性:id、title 和 activities。

// lib/models/trip.dart
class Trip {
  final String id;
  final String title;
  final List<Activity> activities;
}
  • id:通常會用 integer().autoIncrement() 來建立主鍵,確保唯一性並交由資料庫自動處理。
  • title:行程名稱,對應到 Drift 的 TextColumn。
  • activities:這是一個 List<Activity>,代表一個行程可能包含多個活動。但在關聯式資料庫中,表格不能直接儲存清單,否則會違反設計原則。

因此,處理「一對多」關係的標準做法是:

  1. 建立兩個獨立的表格:Trips 和 Activities。
  2. 在 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)();
}

Activities、ChildActivities 表格定義

在一個行程 (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 欄位的處理

Duration 不能直接存在 SQLite 裡,所以我把它轉換成 整數(秒數) 來儲存:

  • 資料庫相容性:IntColumn 是 Drift 支援的基本型別。
  • 空間效率:整數比文字或 JSON 更省空間。
  • 計算效率:在需要加總活動時長時,整數運算速度遠快於字串解析。

Enum 的儲存

在 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());

  // 單例模式:確保整個 App 中只會有一個資料庫實例
  static final AppDatabase _instance = AppDatabase._internal();

  factory AppDatabase() => _instance;

  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    // 延遲初始化:App 啟動時不打開資料庫,首次查詢或寫入時才建立連線,可縮短啟動時間
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    
    return NativeDatabase(file, setup: (db) {
      // 設定外來鍵強制執行,維護資料完整性
      db.execute('PRAGMA foreign_keys = ON;');
    });
  });
}

拆解說明:

  • @DriftDatabase(...): 傳入 Trips、Activities 和 ChildActivities 三個表格,告訴 Drift 這是資料庫的藍圖。

  • AppDatabase extends _$AppDatabase: _$AppDatabase 是 Drift 程式碼生成器自動產生的類別,包含與表格互動的所有程式碼。

  • 單例模式: 透過 AppDatabase._internal() + factory AppDatabase(),確保整個 App 中只有一個資料庫實例,避免多次建立連線導致資源浪費或資料衝突。

  • schemaVersion: 定義資料庫版本號。當表格結構(新增、刪除、修改欄位)變更時,必須增加此數字。

  • LazyDatabase: 採用延遲初始化,不會在 App 啟動時打開資料庫,而是等到首次使用時才建立連線,可縮短啟動時間。

  • _openConnection()

    • 負責實際建立資料庫檔案,存放在應用程式資料目錄中,預設檔名為 db.sqlite。
    • NativeDatabase(file, setup: ...): 新版且推薦的寫法,在資料庫打開時執行 setup 回呼函式,確保外來鍵能夠被強制執行,以維護資料完整性。

啟動程式碼生成器

當資料庫類別定義完成後,必須執行 Drift 的程式碼生成器,否則 _$AppDatabase 這個類別不會出現。

flutter pub run build_runner build

這個指令會自動產生 database.g.dart 檔案,裡面包含所有與資料表互動所需的程式碼。


到這裡,已經完成了資料庫的基礎設定:定義表格、建立資料庫實例,並且準備好程式碼生成器。這些步驟相當於打好地基,讓接下來的開發能順利展開,明天會繼續深入,實作最核心的 CRUD(Create、Read、Update、Delete)操作,讓 App 真正能夠新增、查詢、修改與刪除資料。


上一篇
Day 15 - AI 可以幫我排行程了!從假資料到真實情境完整演練
下一篇
Day 17 - Drift CRUD 入門:跟著 Gemini 玩轉資料庫
系列文
《30 天 Flutter:跨平台 AI 行程規劃 App》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言