Facebook Pixel

Sử dụng SQLite trong Flutter

23 Jan, 2022

Chau Le

Author

SQLite là một trong những cách phổ biến nhất để lưu trữ dữ liệu cục bộ.

Sử dụng SQLite trong Flutter

Mục Lục

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ụngSQLitetrong 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 trongsqflitepackage đượ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.yamlvà tìm kiếm cácdependencies. Trongdependencies, hãy thêm phiên bản mới nhất củasqflitepath_provider(sử dụng đúng số từ Pub).

Yaml
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ưTemporaryDirectoryApplicationDocumentsDirectory.

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.dartmớ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:

Dart
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).

Dart
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:

Dart
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 toMapmethod vàfromMapmethod. 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:

Dart
/// 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)

SQFlitepackage 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:

Dart
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:

Dart
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:

Dart
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

Dart
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ộtidlà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ụngrawQueryvà mình đã map danh sách kết quả thành danh sách cácClientobject:

Dart
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

Dart
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

Dart
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:

Dart
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

Dart
deleteClient(int id) async {
    final db = await database;
    db.delete("Client", where: "id = ?", whereArgs: [id]);
  }

Xóa tất cả Client

Dart
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:

Dart
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.

Dart
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 wrapListTilebằng mộtDismissibleWidget như sau:

Dart
return Dismissible(
   key: UniqueKey(),
   background: Container(color: Colors.red),
   onDismissed: (direction) {
    DBProvider.db.deleteClient(item.id);
   },
    child: ListTile(...),
  );

Đối với hàmOnDismissedcủa chúng ta, chúng ta đang sử dụng Database provider để calldeleteClientmethod. Đố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:

Dart
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ú:

  1. getClientssẽ 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.
  2. StreamController<T>.broadcastconstructor để 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.
  3. Đừ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ủaStatefulWidgetcủ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:

Dart
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.

Dart
final bloc = ClientsBloc();

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

Tiếp theo, chúng ta cần sử dụngStreamBuilderthay vìFutureBuilder. Điều này là do chúng ta hiện đang nghe stream (clients stream) thay vì tương lai.

Dart
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:

Dart
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.

Bài viết liên quan

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab