iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Mobile Development

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

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

  • 分享至 

  • xImage
  •  

在開發我的 AI 行程 App 時,我面臨了一個關鍵的決策:該如何選擇本地資料庫?市面上有許多優秀的套件,從輕量級的 shared_preferences、高效的 HiveIsar,到功能強大的 sqflitedrift。經過一番研究和評估,我最終選擇了 drift

選擇與考量

我的行程 App 核心需求是儲存複雜、結構化的資料。每個行程都包含多個活動,而每個活動又可能包含多個子活動。這樣的需求更適合關聯式資料庫。不過,為了全面評估,我仍然將 sqflitedrift 這兩個關聯式方案,以及 HiveIsar 這類高效的 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
  path_provider: ^2.1.5
  path: ^1.9.1

dev_dependencies:
  drift_dev: ^2.28.1
  build_runner: ^2.7.0
  • drift:核心套件,提供 ORM 與查詢功能。
  • drift_dev:開發時使用,產生資料庫操作程式碼。
  • build_runner:執行程式碼生成。
  • sqlite3:實際的資料庫引擎。

資料夾架構

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

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

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

資料庫與表格的定義

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

Trips 表格定義

在模型層,我原本定義了一個 Trip 類別,包含三個屬性:idtitleactivities

// 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. 建立兩個獨立的表格:TripsActivities
  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)。
對應到資料庫,我使用了兩個表格:ActivitiesChildActivities

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 的儲存

ActivitiesChildActivities 中,我用了 ActivityTypeTransportType 這兩種 enum。
由於資料庫不支援 enum,需要使用 TypeConverter 把它轉換成 intString

範例:

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(...):傳入 TripsActivitiesChildActivities 三個表格,告訴 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 真正能夠新增、查詢、修改與刪除資料。


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

尚未有邦友留言

立即登入留言