Persisting data rất quan trọng đối với người dùng vì họ sẽ bất tiện khi nhập thông tin của họ mỗi lần hoặc đợi mạng tải lại cùng một dữ liệu. Trong những tình huống như thế này, tốt hơn là lưu dữ liệu của họ local.
Trong bài viết này, mình sẽ chứng minh điều này bằng cách sử dụngSQLite
trong Flutter.
Tại sao sử dụng SQLite?
SQLite là một trong những cách phổ biến nhất để lưu trữ dữ liệu cục bộ (local). Đối với bài viết này, chúng ta sẽ sử dụng package sqflite để kết nối với SQLite. Sqflite là một trongsqflite
package được sử dụng nhiều nhất và cập nhật để kết nối với cơ sở dữ liệu SQLite databases trong Flutter.
1. Thêm dependency vào dự án của bạn
Trong dự án của bạn, hãy truy cậppubspec.yaml
và tìm kiếm cácdependencies
. Trongdependencies
, hãy thêm phiên bản mới nhất củasqflite
vàpath_provider
(sử dụng đúng số từ Pub).
dependencies:
flutter:
sdk: flutter
sqflite: any
path_provider: any
GHI CHÚ:
Chúng ta sử dụngpath_provider
package để lấy vị trí thường được sử dụng nhưTemporaryDirectory
và ApplicationDocumentsDirectory
.
2. Khởi tạo DB Client
Bây giờ trong dự án của bạn, hãy tạo một tệp Database.dart
mới. Trong tệp mới tạo, chúng ta cần tạo một singleton.
Tại sao chúng ta cần singleton: Chúng ta sử dụng singleton pattern để đảm bảo rằng chúng ta chỉ có một class instance và cung cấp quyền truy cập điểm toàn cục vào nó.
1. Tạo một private constructor chỉ có thể được sử dụng bên trong class:
class DBProvider {
DBProvider._();
static final DBProvider db = DBProvider._();
}
2. Thiết lập database
Tiếp theo, chúng ta sẽ tạo database object và cung cấp cho nó một getter nơi chúng ta sẽ khởi tạo database nếu nó chưa được khởi tạo (lazy initialization).
static Database _database;
Future<Database> get database async {
if (_database != null)
return _database;
// if _database is null we instantiate it
_database = await initDB();
return _database;
}
Nếu không có object nào được gán cho database, chúng ta sử dụng hàminitDB
để tạo database. Trong hàm này, chúng ta sẽ nhận được đường dẫn để lưu trữ database và tạo các table mong muốn:
initDB() async {
Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = join(documentsDirectory.path, "TestDB.db");
return await openDatabase(path, version: 1, onOpen: (db) {
}, onCreate: (Database db, int version) async {
await db.execute("CREATE TABLE Client ("
"id INTEGER PRIMARY KEY,"
"first_name TEXT,"
"last_name TEXT,"
"blocked BIT"
")");
});
}
LƯU Ý: Tên database là TestDB và table duy nhất chúng ta có được gọi là Client.
3. Khởi tạo các Model
Dữ liệu bên trong database của bạn sẽ được chuyển đổi thành Dart Maps nên trước tiên, chúng ta cần tạo các model với toMap
method vàfromMap
method. Mình sẽ không trình bày cách thực hiện việc này theo cách thủ công.
Model của chúng ta:
/// ClientModel.dart
import 'dart:convert';
Client clientFromJson(String str) {
final jsonData = json.decode(str);
return Client.fromMap(jsonData);
}
String clientToJson(Client data) {
final dyn = data.toMap();
return json.encode(dyn);
}
class Client {
int id;
String firstName;
String lastName;
bool blocked;
Client({
this.id,
this.firstName,
this.lastName,
this.blocked,
});
factory Client.fromMap(Map<String, dynamic> json) => new Client(
id: json["id"],
firstName: json["first_name"],
lastName: json["last_name"],
blocked: json["blocked"] == 1,
);
Map<String, dynamic> toMap() => {
"id": id,
"first_name": firstName,
"last_name": lastName,
"blocked": blocked,
};
}
4. CRUD operations
Create (Khởi tạo)
SQFlite
package cung cấp hai cách để xử lý các operation này bằng cách sử dụng RawSQL
queries hoặc bằng cách sử dụng tên table và map chứa dữ liệu:
Sử dụngrawInsert
:
newClient(Client newClient) async {
final db = await database;
var res = await db.rawInsert(
"INSERT Into Client (id,first_name)"
" VALUES (${newClient.id},${newClient.firstName})");
return res;
}
Sử dụnginsert
:
newClient(Client newClient) async {
final db = await database;
var res = await db.insert("Client", newClient.toMap());
return res;
}
Một ví dụ khác sử dụng ID lớn nhất làm ID mới:
newClient(Client newClient) async {
final db = await database;
//get the biggest id in the table
var table = await db.rawQuery("SELECT MAX(id)+1 as id FROM Client");
int id = table.first["id"];
//insert to the table using the new id
var raw = await db.rawInsert(
"INSERT Into Client (id,first_name,last_name,blocked)"
" VALUES (?,?,?,?)",
[id, newClient.firstName, newClient.lastName, newClient.blocked]);
return raw;
}
Read
Get Client by id
getClient(int id) async {
final db = await database;
var res =await db.query("Client", where: "id = ?", whereArgs: [id]);
return res.isNotEmpty ? Client.fromMap(res.first) : Null ;
}
Trong đoạn code trên, chúng ta cung cấp query với mộtid
làm tham số bằngwhereArgs
. Sau đó, chúng ta trả về kết quả đầu tiên nếu danh sách không rỗng, nếu không chúng ta trả về null.
Get all Clients với điều kiện
Trong ví dụ này, mình đã sử dụngrawQuery
và mình đã map danh sách kết quả thành danh sách cácClient
object:
getAllClients() async {
final db = await database;
var res = await db.query("Client");
List<Client> list =
res.isNotEmpty ? res.map((c) => Client.fromMap(c)).toList() : [];
return list;
}
Ví dụ: Chỉ nhận được những Blocked Client
getBlockedClients() async {
final db = await database;
var res = await db.rawQuery("SELECT * FROM Client WHERE blocked=1");
List<Client> list =
res.isNotEmpty ? res.toList().map((c) => Client.fromMap(c)) : null;
return list;
}
Update
Cập nhật dữ liệu
updateClient(Client newClient) async {
final db = await database;
var res = await db.update("Client", newClient.toMap(),
where: "id = ?", whereArgs: [newClient.id]);
return res;
}
Ví dụ: Block hoặc unblock một Client:
blockOrUnblock(Client client) async {
final db = await database;
Client blocked = Client(
id: client.id,
firstName: client.firstName,
lastName: client.lastName,
blocked: !client.blocked);
var res = await db.update("Client", blocked.toMap(),
where: "id = ?", whereArgs: [client.id]);
return res;
}
Delete
Xóa dữ liệu
deleteClient(int id) async {
final db = await database;
db.delete("Client", where: "id = ?", whereArgs: [id]);
}
Xóa tất cả Client
deleteAll() async {
final db = await database;
db.rawDelete("Delete * from Client");
}
Demo
Đối với bản demo của chúng ta, chúng ta sẽ tạo một ứng dụng Flutter đơn giản để tương tác với database của chúng ta.
Trước tiên, chúng ta sẽ bắt đầu với bố cục của ứng dụng:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Flutter SQLite")),
body: FutureBuilder<List<Client>>(
future: DBProvider.db.getAllClients(),
builder: (BuildContext context, AsyncSnapshot<List<Client>> snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (BuildContext context, int index) {
Client item = snapshot.data[index];
return ListTile(
title: Text(item.lastName),
leading: Text(item.id.toString()),
trailing: Checkbox(
onChanged: (bool value) {
DBProvider.db.blockClient(item);
setState(() {});
},
value: item.blocked,
),
);
},
);
} else {
return Center(child: CircularProgressIndicator());
}
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
Client rnd = testClients[math.Random().nextInt(testClients.length)];
await DBProvider.db.newClient(rnd);
setState(() {});
},
),
);
}
Ghi chú:
1. FutureBuilder
được sử dụng để lấy dữ liệu từ database.
2. FAB để thêm một random client vào database khi nó được nhấp vào.
List<Client> testClients = [
Client(firstName: "Raouf", lastName: "Rahiche", blocked: false),
Client(firstName: "Zaki", lastName: "oun", blocked: true),
Client(firstName: "oussama", lastName: "ali", blocked: false),
];
3. CircularProgressIndicator
được hiển thị nếu không có dữ liệu.
4. Khi người dùng nhấp vào checkbox, client sẽ bị chặn hoặc bỏ chặn tùy theo state hiện tại.
Giờ đây, rất dễ dàng để thêm các feature mới, ví dụ: nếu bạn muốn xóa một client khi mục được vuốt, chỉ cần wrapListTile
bằng mộtDismissible
Widget như sau:
return Dismissible(
key: UniqueKey(),
background: Container(color: Colors.red),
onDismissed: (direction) {
DBProvider.db.deleteClient(item.id);
},
child: ListTile(...),
);
Đối với hàmOnDismissed
của chúng ta, chúng ta đang sử dụng Database provider để calldeleteClient
method. Đối với đối số, chúng ta chuyển id của mục.
Cấu trúc lại để sử dụng BLoC Pattern
Chúng ta đã làm rất nhiều điều trong bài viết này nhưng trong ứng dụng thế giới thực, việc biến state thành một phần của giao diện người dùng không thực sự là một điều tốt. Thay vào đó, chúng ta nên luôn giữ chúng tách biệt.
Có rất nhiều pattern để quản lý state trong Flutter nhưng mình sẽ sử dụng BLoC trong bài viết này vì nó rất linh hoạt.
Khởi tạo BLoC:
class ClientsBloc {
ClientsBloc() {
getClients();
}
final _clientController = StreamController<List<Client>>.broadcast();
get clients => _clientController.stream;
dispose() {
_clientController.close();
}
getClients() async {
_clientController.sink.add(await DBProvider.db.getAllClients());
}
}
Ghi chú:
getClients
sẽ lấy dữ liệu từ Database (Client table) một cách không đồng bộ (asynchronously). Chúng ta sẽ call method này bất cứ khi nào chúng ta cập nhật table, do đó là lý do để đặt nó vào phần body của constructor.StreamController<T>.broadcast
constructor để chúng ta có thể nghe stream nhiều lần. Trong ví dụ của chúng ta, nó không tạo ra nhiều sự khác biệt vì chúng ta chỉ nghe stream một lần nhưng sẽ tốt hơn nếu xem xét các trường hợp bạn muốn nghe stream nhiều lần.- Đừng quên đóng stream của bạn. Điều này giúp chúng ta không bị rò rỉ bộ nhớ. Trong ví dụ của chúng ta, chúng ta sẽ đóng nó bằng cách sử dụng dispose method của
StatefulWidget
của chúng ta.
Bây giờ, hãy thêm một số method vào block của chúng ta để tương tác với database:
blockUnblock(Client client) {
DBProvider.db.blockOrUnblock(client);
getClients();
}
delete(int id) {
DBProvider.db.deleteClient(id);
getClients();
}
add(Client client) {
DBProvider.db.newClient(client);
getClients();
}
Và đó là tất cả cho BLoC của chúng ta!
Bước tiếp theo của chúng ta sẽ là tìm cách cung cấp bloc cho các widget của chúng ta. Chúng ta cần một cách để làm cho bloc có thể truy cập từ các phần khác nhau của tree đồng thời có thể tự giải phóng khỏi bộ nhớ khi không sử dụng.
Đối với điều này, có thể xem thư viện này bởi
Trong trường hợp của chúng ta, bloc sẽ chỉ được sử dụng bởi một widget nên chúng ta có thể khai báo và loại bỏ nó khỏi stateful widget của chúng ta.
final bloc = ClientsBloc();
@override
void dispose() {
bloc.dispose();
super.dispose();
}
Tiếp theo, chúng ta cần sử dụngStreamBuilder
thay vìFutureBuilder
. Điều này là do chúng ta hiện đang nghe stream (clients
stream) thay vì tương lai.
StreamBuilder<List<Client>>(
stream: bloc.clients,
...
)
Bước cuối cùng sẽ là cấu trúc lại code của chúng ta để chúng ta call các method từ bloc của chúng ta chứ không phải database trực tiếp:
onDismissed: (direction) {
bloc.delete(item.id);
},
Đây là kết quả cuối cùng
Cuối cùng, bạn có thể tìm thấy code source cho ví dụ này trong repo này (kiểm tra nhánh sqlite_demo_bloc để xem phiên bản mới sau khi tái cấu trúc). Mình hy vọng bạn thích bài viết này.
Bài viết được lược dịch từ Raouf Rahiche.
Chau Le
Cực thích đồ ngọt như sữa, kem. Thích phiêu lưu mạo hiểm nhưng vì Covid mà vô tình trở thành người chia sẻ nội dung lập trình :))
follow me :
Bài viết liên quan
Tự học Dart: Các Dart Operators (toán tử) bạn cần biết
Sep 02, 2023 • 18 min read
Flutter cơ bản: Điều cần biết khi lập trình ứng dụng đầu tiên
Aug 23, 2023 • 13 min read
Dart là gì? Giới thiệu cơ bản về ngôn ngữ lập trình Dart
Aug 21, 2023 • 11 min read
Flutter là gì? Vì sao nên học công cụ lập trình Flutter?
Aug 19, 2023 • 11 min read
Flutter cơ bản: Widget Tree, Element Tree & Render Tree
Aug 19, 2023 • 10 min read
So sánh StatelessWidget và StatefulWidget
Jul 17, 2022 • 12 min read