作為學習flutter過程的練習,筆者想要做一個記帳軟體,正在研究相關功能如何構築的路上。

今天要練習的是使用sqflite這個庫來替「待辦事項APP」加入SQLite資料庫,並實現簡單的CRUD。

本文預設讀者已建立flutter開發環境

目錄

功能規劃

使用者介面

建立兩個route:(在flutter中,一個route即為一個畫面)

  1. home route: TodoList,顯示所有的Todo
    1. 每一則Todo:
      1. 顯示待辦事項內容,已完成的Todo用刪除線表示
      2. Checkbox:點擊更新是/否完成這個待辦事項
      3. 修改按鈕:點擊進入edit route後,可以修改待辦事項
      4. 刪除按鈕:點擊後刪除待辦事項
    2. 新增按鈕:點擊進入edit route,可以新增待辦事項
  2. edit route: Edit Page,修改Todo的內容
    1. TextField,修改Todo的內容
      1. 以修改按鈕進入時,預設內容為該則Todo原本的內容
      2. 以新增按鈕進入時,預設內容為空,並顯示提示詞
    2. 儲存按鈕:點擊時
      1. 若TextField為空,則在底部顯示「待辦事項不得為空」
      2. 若不為空,則儲存或更新這則待辦事項,並且返回上一頁

alt text

資料設計

每一則Todo紀錄:( Dart變數型別 / SQL 資料型別 )

  • 待辦事項內容 ( String / text )
  • 是否完成( bool / int )
  • 建立日期( String / text ) 並作為資料庫 pirmary key

資料庫欄位設計

table name: todo columns:

  • INT id PRIMARY KEY
  • TEXT name
  • INT done

建立專案

在目的資料夾底下command line呼叫flutter create sqlpractice,建立名為sqlpractice的專案資料夾與flutter相關的檔案。

首先進入lib底下的main.dart把範例程式碼清光,並且新增如下的資料夾與檔案

程式碼架構

因為重點在於練習sqflite函式庫,所以先不講究,隨便照route稍微切一下檔案

lib
├ main.dart
├ TodoDB.dart
└ route
  ├ TodoList.dart 
  └ EditPage.dart

UI 實作

規劃好之後,先把UI介面刻出來。(如果只想看SQLite部分的code,可以跳過這個段落!)

main.dart

首先在 main.dart裡面,先把App入口跟主題的定型文打好。

import 'package:flutter/material.dart';
import 'route/TodoList.dart';

void main() {
  runApp(const TodoApp());
}

class TodoApp extends StatelessWidget {
  const TodoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SQLite Practice',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.purple, brightness: Brightness.light),
        useMaterial3: true,
      ),
      home: const TodoList(),
    );
  }
}

這裡採用的是MaterialApptheme的設定只是把預設的藍色主題稍微改一下,省略也無所謂。

home: const TodoList()讓App首頁導向 TodoList

TodoDB.dart

因為在刻畫面的時候會希望至少有一筆假資料可以呈現,所以我們先把Todo的物件型態定義好:


class Todo {
  final String? id;
  final String name;
  final int? done;

  Todo({this.id, this.name = '', this.done});

}

EditPage.dart

import 'package:flutter/material.dart';
import 'package:sqlpractice/TodoDB.dart';

class EditPage extends StatefulWidget {
  const EditPage({super.key, required this.todo, required this.onSave});

  final Todo todo;
  final Function onSave;

  @override
  _EditPageState createState() => _EditPageState();
}

class _EditPageState extends State<EditPage> {
  final itemController = TextEditingController();

  void onSaveButtonPressed() {
    if (itemController.text == '') {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
        content: Center(
          child: Text(
            '待辦事項不得為空',
            style: TextStyle(
                color: Theme.of(context).colorScheme.onSurfaceVariant),
          ),
        ),
      ));
    } else {
      widget.onSave(itemController.text, widget.todo);
      Navigator.pop(context);
    }
  }

  @override
  void initState() {
    super.initState();
    itemController.text = widget.todo.name;
  }

  @override
  void dispose() {
    itemController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('add a todo'),
        actions: [
          IconButton(
            onPressed: onSaveButtonPressed,
            icon: const Icon(Icons.save),
          )
        ],
      ),
      body: Center(
        child: Container(
          padding: const EdgeInsets.all(30),
          child: TextField(
            controller: itemController,
            obscureText: false,
            decoration:
                const InputDecoration(labelText: 'Todo:', hintText: '買牛奶'),
          ),
        ),
      ),
    );
  }
}

因為TextField的內容會因使用者輸入而有所變動,並且我們希望頁面能使用這個資料,所以要用StatefulWidget來構築,並且使用itemController來取用使用者在TextField輸入的值。

這個頁面有兩個用途需求:新增或修改一筆Todo

因此,要求構築頁面時傳入兩個參數:todoonSave

要求傳入onSave這個callback,是因為在新增和刪除時,和資料庫交互的行為不同。而這個行為是視我們在首頁點選的按鈕不同而決定的。因此,把所有的邏輯都交給首頁控制,從首頁導入這個頁面時,一併把「首頁希望這個頁面在按下Save按鈕時所做的行為」也一起用onSave傳入,來達到控制的效果。

當使用者按下Save按鈕時,頁面先檢查TextField是否為空,若為空則跳出訊息要求不得為空;若不空,則呼叫OnSave,並且返回前一頁面。

TodoList.dart

最後來刻主畫面:

import 'package:flutter/material.dart';
import 'package:sqlpractice/TodoDB.dart';
import 'EditPage.dart';

// define ExtraActions (Update or Delete)
enum ExtraAction { edit, delete }

class TodoList extends StatefulWidget {
  const TodoList({super.key});

  @override
  _TodoListState createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  List<Todo> _todosList = [Todo(name: 'Todo', done: 0)];

  // Read All Todos & rebuild UI
  void getList() async {
    // 未完成
  }

  // Add todo to DB
  void onAddTodo(String name, Todo todo) async {
    // 未完成
    getList();
  }

  // Press Add Button
  void onAdd() {
    Navigator.push<void>(
        context,
        MaterialPageRoute(
            builder: (context) => EditPage(todo: Todo(), onSave: onAddTodo)));
  }

  // Update Checkbox val of todo
  void onChangeCheckbox(val, todo) async {
    // 未完成
    getList();
  }

  // Update todo
  void onEditTodo(name, todo) async {
    // 未完成
    getList();
  }

  // Delete todo
  void onDeleteTodo(todo) async {
    // 未完成
    getList();
  }

  // Select from ExtraActions (Update or Delete)
  void onSelectExtraAction(context, action, todo) {
    switch (action) {
      case ExtraAction.edit:
        Navigator.push<void>(
            context,
            MaterialPageRoute(
                builder: (context) => EditPage(todo: todo, onSave: onEditTodo),
                fullscreenDialog: true));
        break;

      case ExtraAction.delete:
        onDeleteTodo(todo);

      default:
        print('error!!');
    }
  }



  // State control
  @override
  void initState() {
    super.initState();
    getList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TodoList')),
      body: Column(children: <Widget>[
        Expanded(
            child: ListView(
          children: _todosList.map((todo) {
            return ListTile(
              leading: Checkbox(
                value: todo.done == 1,
                onChanged: (value) => onChangeCheckbox(value, todo),
              ),
              title: Text(
                todo.name,
                style: TextStyle(
                    color: todo.done == 1 //
                        ? Theme.of(context).colorScheme.primaryContainer
                        : Theme.of(context).colorScheme.primary,
                    decoration: todo.done == 1 //
                        ? TextDecoration.lineThrough
                        : null),
              ),
              trailing: PopupMenuButton<ExtraAction>(
                onSelected: (action) =>
                    onSelectExtraAction(context, action, todo),
                itemBuilder: (context) => const [
                  PopupMenuItem(
                      value: ExtraAction.edit, child: Icon(Icons.edit)),
                  PopupMenuItem(
                      value: ExtraAction.delete, child: Icon(Icons.delete))
                ],
              ),
            );
          }).toList(),
        )),
      ]),
      floatingActionButton: FloatingActionButton(
        onPressed: onAdd,
        child: const Icon(Icons.add),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
    );
  }
}

這邊暫時用假資料代替,不過之後_todosList是會改變的,並且我們希望TodoList的畫面隨著_todosList的變動一起變化,因此使用StatefulWidget來構築這個畫面。

_TodoListState中,先宣告_todosList這個state是一個List,裡面裝有要呈現的`,並且暫時用假資料將其初始化。(在後續的步驟中,則是透過從database裡面撈資料來將其初始化)。

這裡先保留所有跟資料庫互動的callback function不實作,並且把靜態的UI先一起刻出來,這樣後面就可以專心在資料庫相關code的實作上。

主畫面的功能比較多:

  • getList:刷新TodoList

    這裡我們用ListView搭配ListTile來呈現。每一則Todo,將其映射成一個ListTile Widget

    根據flutter的基本機制,在呼叫setState並更新_TodoList的資料時,TodoList就會重新build,並且將畫面刷新成新的資料(Todo)所構成的ListTile所構成的ListView

    換言之,在這裡要串接資料庫的Read

    連接資料庫取得最新的資料,並且呼叫setState來更新資料。

  • onAdd():按下Add按鈕,或者按下某筆Todo下拉式選單中的Edit按鈕時,要導轉進EditPage,並且將對應的資料庫互動onAddTodoOnSave參數傳入給EditPage

  • onAddTodo(String name, Todo todo):將內容為name的Todo寫進資料庫

    在這裡要串接資料庫的Create

    將資料寫進資料庫之後,重新呼叫getList()來取得新資料

  • onChangeCheckbox(val, todo):按下某筆todo前方的checkbox時,要修改並切換其done屬性為val

    在這裡要串接資料庫的Update

  • onEditTodo(String name, Todo todo):將todo的內容更新為name

    在這裡也要串接資料庫的Update

    將資料寫進資料庫之後,重新呼叫getList()來取得新資料

  • onDeleteTodo(todo):按下某筆todo下拉式選單中的Delete按鈕時,要刪除該筆todo

    在這裡要串接資料庫的Delete

修改完四個檔案之後,在專案目錄下使用command line呼叫flutter run

SQLite相關實作

1. 安裝相關套件

打開專案資料夾底下的pubspec.yaml,找到dependencies並在底下新增sqflitepath套件:

注意YAML格式對縮排嚴格。

dependencies:
  sqflite: ^2.3.2  
  path:  ^1.8.3
  flutter:
    sdk: flutter

套件的穩定版本和文件都可以在官方套件庫pub.dev查詢。

修改完成之後在Command line執行flutter pub get,就會自動下載/更新套件

2. 實作資料庫與CRUD

打開 TodoDB.dart,實作一個TodoDBclass來封裝資料庫操作指令。

import 'dart:async';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

class Todo {
  final String? id;
  final String name;
  final int? done;

  Todo({this.id, this.name = '', this.done});

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'name': name,
      'done': done,
    };
  }
}

class TodoDB {
  static Database? database;

  // init
  static Future<Database> initDatabase() async {
    database = await openDatabase(
      join(await getDatabasesPath(), 'todo.db'),
      onCreate: (db, version) => db.execute(
          'CREATE TABLE todos(id TEXT PRIMARY KEY, name TEXT, done INTEGER)'),
      version: 1,
    );
    print('database initialized!');
    return database!;
  }

  // connect
  static Future<Database> getDBConnect() async {
    if (database != null) {
      return database!;
    }
    return await initDatabase();
  }

  // Create
  static Future<void> addTodo(Todo todo) async {
    final Database db = await getDBConnect();
    await db.insert(
      'todos',
      todo.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  // Read
  static Future<List<Todo>> getTodos() async {
    final Database db = await getDBConnect();
    final List<Map<String, dynamic>> maps = await db.query('todos');
    return List.generate(maps.length, (i) {
      return Todo(
        id: maps[i]['id'],
        name: maps[i]['name'],
        done: maps[i]['done'],
      );
    });
  }

  // Update
  static Future<void> updateTodo(Todo todo) async {
    final Database db = await getDBConnect();
    await db.update(
      'todos',
      todo.toMap(),
      where: 'id = ?',
      whereArgs: [todo.id],
    );
  }

  // Delete
  static Future<void> deleteTodo(String id) async {
    final Database db = await getDBConnect();
    await db.delete(
      'todos',
      where: 'id = ?',
      whereArgs: [id],
    );
  }
}
  • 引入pathsqflite兩個套件

  • 引入基本函式庫的async,因為資料庫操作都是非同步

  • 實作Todo的物件方法toMap() 因為sqflite套件在CRUD的時候,是以map的資料型態作為參數來傳入要新增或修改的row。所以實作一個toMap方法,將Todo物件轉換為map

  • initDatabase():開啟資料庫openDatabase()並回傳。

    • getDataBasesPath()返回資料庫的根路徑
    • todo.db為我們替資料庫取的名稱
    • 當資料庫不存在時會執行onCreate,執行傳入db.execute()的指令
    • 'CREATE TABLE todos(id TEXT PRIMARY KEY, name TEXT, done INTEGER)' 建立一個名為todos的TABLE,並設定三個欄位id name done,指定id為PK
    • print('database initialized!')後面run App的時候可以發現,因為有後面的防呆機制,在整個App的生命週期,initDatabase()只會被執行一次。
  • getDBConnect():連接資料庫。

    這裡引入簡單的防呆機制:如果已經有既存的資料庫,就繼續連接同一個資料庫。這樣才不會一直呼叫initDatabase浪費資源。

  • addTodo(Todo todo):實作Create。

    先連接資料庫,執行db.insert(),第一個參數為要修改的table,第二個參數為要新增的row(以map的形式)

    conflictAlgorithm指定當要新增的PK已經既存時如何處理

  • getTodos():實作Read。

    先連接資料庫,db.query()取出要查詢的資料(以List<Map>的形式),將其轉為List<Todo>,把每筆資料轉化為Todo物件,方便後續串接前端。

  • updateTodo(Todo todo):實作Update。

    先連接資料庫,執行db.update(),第一個參數為要修改的table,第二個參數為要修改的row(以map的形式),where指定查詢匹配的指令,whereArgs指定要查詢的內容。

  • deleteTodo(String id):實作Delete。

    先連接資料庫,執行db.delete()。第一個參數為要修改的tablewhere指定查詢匹配的指令,whereArgs指定要查詢的內容。

    因為刪除todo只需要查找其PK,就不需要將整個todo傳進來。當然為了CRUD介面的一致性,要全傳Todo進來也不是不行。

    不過考慮到多筆刪除時的情況,只傳PK在效能上還是比較優秀的。

3. 串接前後端

再次打開TodoList.dart,將前面我們刻好的資料庫互動串接上去。

(只列出有更動的部分)

class _TodoListState extends State<TodoList> {

  List<Todo> _todosList = []; // 移除假資料

  // ...

  void getList() async {
    final list = await TodoDB.getTodos(); 
    setState(() {
      _todosList = list;
    });
  }

  void onAddTodo(String name, Todo todo) async {
    final newTodo = Todo(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        name: name,
        done: 0);
    await TodoDB.addTodo(newTodo);
    getList();
  }

  void onChangeCheckbox(val, todo) async {
    final updateTodo = Todo(id: todo.id, name: todo.name, done: val ? 1 : 0);
    await TodoDB.updateTodo(updateTodo);
    getList();
  }

  void onEditTodo(name, todo) async {
    final updateTodo = Todo(
      id: todo.id,
      name: name,
      done: todo.done,
    );
    await TodoDB.updateTodo(updateTodo);
    getList();
  }

  void onDeleteTodo(todo) async {
    await TodoDB.deleteTodo(todo.id);
    getList();
  }

}

注意這邊都要使用非同步函式。

因為步驟2中寫好的資料庫操作指令都是非同步,所以如果沒有await C/R/U/D之後才重新getList(),則可能發生先取得資料之後才對其增刪查改,如此一來將造成使用者畫面與實際資料不同步的bug發生。

到這裡我們的App就完成了!

立刻在command line呼叫R (hot reload),或者重新flutter run,來測試看看我們實作的增刪查改是否都能運行無誤吧!

alt text

結語

感謝你的閱讀,如果有不同的想法或心得,歡迎留言一同討論!

參考資料

sqflite官方文件

Medium文章:Flutter 使用 SQLite 本地資料庫