作為學習flutter過程的練習,筆者想要做一個記帳軟體,正在研究相關功能如何構築的路上。
今天要練習的是使用sqflite這個庫來替「待辦事項APP」加入SQLite資料庫,並實現簡單的CRUD。
本文預設讀者已建立flutter開發環境。
目錄
功能規劃
使用者介面
建立兩個route:(在flutter中,一個route即為一個畫面)
- home route: TodoList,顯示所有的Todo
- 每一則Todo:
- 顯示待辦事項內容,已完成的Todo用刪除線表示
- Checkbox:點擊更新是/否完成這個待辦事項
- 修改按鈕:點擊進入edit route後,可以修改待辦事項
- 刪除按鈕:點擊後刪除待辦事項
- 新增按鈕:點擊進入edit route,可以新增待辦事項
- 每一則Todo:
- edit route: Edit Page,修改Todo的內容
- TextField,修改Todo的內容
- 以修改按鈕進入時,預設內容為該則Todo原本的內容
- 以新增按鈕進入時,預設內容為空,並顯示提示詞
- 儲存按鈕:點擊時
- 若TextField為空,則在底部顯示「待辦事項不得為空」
- 若不為空,則儲存或更新這則待辦事項,並且返回上一頁
- TextField,修改Todo的內容
資料設計
每一則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(),
);
}
}
這裡採用的是MaterialApp
,theme
的設定只是把預設的藍色主題稍微改一下,省略也無所謂。
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
因此,要求構築頁面時傳入兩個參數:todo
和onSave
要求傳入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,並且將對應的資料庫互動onAddTodo
以OnSave
參數傳入給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
並在底下新增sqflite
與path
套件:
注意YAML
格式對縮排嚴格。
dependencies:
sqflite: ^2.3.2
path: ^1.8.3
flutter:
sdk: flutter
套件的穩定版本和文件都可以在官方套件庫pub.dev查詢。
修改完成之後在Command line執行flutter pub get
,就會自動下載/更新套件
2. 實作資料庫與CRUD
打開 TodoDB.dart
,實作一個TodoDB
的class
來封裝資料庫操作指令。
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],
);
}
}
-
引入
path
和sqflite
兩個套件 -
引入基本函式庫的
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
為PKprint('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()
。第一個參數為要修改的table
,where
指定查詢匹配的指令,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
,來測試看看我們實作的增刪查改是否都能運行無誤吧!
結語
感謝你的閱讀,如果有不同的想法或心得,歡迎留言一同討論!