由于 Flutter 仅接管了渲染层,真正涉及到存储等操作系统底层行为时,还需要依托于原生 Android、iOS,因此与原生开发类似的,根据需要持久化数据的大小和方式不同,Flutter 提供了三种数据持久化方法,即文件、SharedPreferences 与数据库。接下来,我将与你详细讲述这三种方式。

文件

文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。

Flutter 提供了两种文件存储的目录,即临时(Temporary)目录与文档(Documents)目录:

  • 临时目录是操作系统可以随时清除的目录,通常被用来存放一些不重要的临时缓存数据。这个目录在 iOS 上对应着 NSTemporaryDirectory 返回的值,而在 Android 上则对应着 getCacheDir 返回的值。
  • 文档目录则是只有在删除应用程序时才会被清除的目录,通常被用来存放应用产生的重要数据文件。在 iOS 上,这个目录对应着 NSDocumentDirectory,而在 Android 上则对应着 AppData 目录。

在下面的代码中,我分别声明了三个函数,即创建文件目录函数写文件函数读文件函数。这里需要注意的是,由于文件读写是非常耗时的操作,所以这些操作都需要在异步环境下进行。另外,为了防止文件读取过程中出现异常,我们也需要在外层包上 try-catch

  1. //创建文件目录
  2. Future<File> get _localFile async {
  3. final directory = await getApplicationDocumentsDirectory();
  4. final path = directory.path;
  5. return File('$path/content.txt');
  6. }
  7. //将字符串写入文件
  8. Future<File> writeContent(String content) async {
  9. final file = await _localFile;
  10. return file.writeAsString(content);
  11. }
  12. //从文件读出字符串
  13. Future<String> readContent() async {
  14. try {
  15. final file = await _localFile;
  16. String contents = await file.readAsString();
  17. return contents;
  18. } catch (e) {
  19. return "";
  20. }
  21. }

有了文件读写函数,我们就可以在代码中对 content.txt 这个文件进行读写操作了。在下面的代码中,我们往这个文件写入了一段字符串后,隔了一会又把它读了出来:

  1. writeContent("Hello World!");
  2. ...
  3. readContent().then((value)=>print(value));

SharedPreferences

文件比较适合大量的、有序的数据持久化,如果我们只是需要缓存少量的键值对信息(比如记录用户是否阅读了公告,或是简单的计数),则可以使用 SharedPreferences。

SharedPreferences 会以原生平台相关的机制,为简单的键值对数据提供持久化存储,即在 iOS 上使用 NSUserDefaults,在 Android 使用 SharedPreferences。

在下面的代码中,我们将计数器持久化到了 SharedPreferences 中,并为它分别提供了读方法和递增写入的方法。

这里需要注意的是,setter(setInt)方法会同步更新内存中的键值对,然后将数据保存至磁盘,因此我们无需再调用更新方法强制刷新缓存。同样地,由于涉及到耗时的文件读写,因此我们必须以异步的方式对这些操作进行包装:

  1. //读取SharedPreferences中key为counter的值
  2. Future<int>_loadCounter() async {
  3. SharedPreferences prefs = await SharedPreferences.getInstance();
  4. int counter = (prefs.getInt('counter') ?? 0);
  5. return counter;
  6. }
  7. //递增写入SharedPreferences中key为counter的值
  8. Future<void>_incrementCounter() async {
  9. SharedPreferences prefs = await SharedPreferences.getInstance();
  10. int counter = (prefs.getInt('counter') ?? 0) + 1;
  11. prefs.setInt('counter', counter);
  12. }
  13. //读出counter数据并打印
  14. _loadCounter().then((value)=>print("before:$value"));
  15. //递增counter数据后,再次读出并打印
  16. _incrementCounter().then((_) {
  17. _loadCounter().then((value)=>print("after:$value"));
  18. });

不过需要注意的是,以键值对的方式只能存储基本类型的数据,比如 int、double、bool 和 string。
**

数据库

如果我们需要持久化大量格式化后的数据,并且这些数据还会以较高的频率更新,为了考虑进一步的扩展性,我们通常会选用 sqlite 数据库来应对这样的场景。与文件和 SharedPreferences 相比,数据库在数据读写上可以提供更快、更灵活的解决方案。

  1. class Student{
  2. String id;
  3. String name;
  4. int score;
  5. //构造方法
  6. Student({this.id, this.name, this.score,});
  7. //用于将JSON字典转换成类对象的工厂类方法
  8. factory Student.fromJson(Map<String, dynamic> parsedJson){
  9. return Student(
  10. id: parsedJson['id'],
  11. name : parsedJson['name'],
  12. score : parsedJson ['score'],
  13. );
  14. }
  15. }

分别定义了 3 个 Student 对象,用于后续插入数据库:

  1. class Student{
  2. ...
  3. //将类对象转换成JSON字典,方便插入数据库
  4. Map<String, dynamic> toJson() {
  5. return {'id': id, 'name': name, 'score': score,};
  6. }
  7. }
  8. var student1 = Student(id: '123', name: '张三', score: 90);
  9. var student2 = Student(id: '456', name: '李四', score: 80);
  10. var student3 = Student(id: '789', name: '王五', score: 85);

在下面的代码中,我们通过 openDatabase 函数,给定了一个数据库存储地址,并通过数据库表初始化语句,创建了一个用于存放 Student 对象的 students 表:

  1. final Future<Database> database = openDatabase(
  2. join(await getDatabasesPath(), 'students_database.db'),
  3. onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
  4. onUpgrade: (db, oldVersion, newVersion){
  5. //dosth for migration
  6. },
  7. version: 1,
  8. );
  • 在设定数据库存储地址时,使用 join 方法对两段地址进行拼接。join 方法在拼接时会使用操作系统的路径分隔符,这样我们就无需关心路径分隔符究竟是“/”还是“\”了。
  • 创建数据库时,传入了一个 version 1,在 onCreate 方法的回调里面也有一个 version。这两个 version 是相等的。
  • 数据库只会创建一次,也就意味着 onCreate 方法在应用从安装到卸载的生命周期中只会执行一次。如果我们在版本升级过程中,想对数据库的存储字段进行改动又该如何处理呢?

sqlite 提供了 onUpgrade 方法,我们可以根据这个方法传入的 oldVersion 和 newVersion 确定升级策略。其中,前者代表用户手机上的数据库版本,而后者代表当前版本的数据库版本。比如,我们的应用有 1.0、1.1 和 1.2 三个版本,在 1.1 把数据库 version 升级到了 2。考虑到用户的升级顺序并不总是连续的,可能会直接从 1.0 升级到 1.2,因此我们可以在 onUpgrade 函数中,对数据库当前版本和用户手机上的数据库版本进行比较,制定数据库升级方案。

数据库创建好了之后,接下来我们就可以把之前创建的 3 个 Student 对象插入到数据库中了。数据库的插入需要调用 insert 方法,在下面的代码中,我们将 Student 对象转换成了 JSON,在指定了插入冲突策略(如果同样的对象被插入两次,则后者替换前者)和目标数据库表后,完成了 Student 对象的插入:

  1. Future<void> insertStudent(Student std) async {
  2. final Database db = await database;
  3. await db.insert(
  4. 'students',
  5. std.toJson(),
  6. //插入冲突策略,新的替换旧的
  7. conflictAlgorithm: ConflictAlgorithm.replace,
  8. );
  9. }
  10. //插入3个Student对象
  11. await insertStudent(student1);
  12. await insertStudent(student2);
  13. await insertStudent(student3);

数据完成插入之后,接下来我们就可以调用 query 方法把它们取出来了。需要注意的是,写入的时候我们是一个接一个地有序插入,读的时候我们则采用批量读的方式(当然也可以指定查询规则读特定对象)。读出来的数据是一个 JSON 字典数组,因此我们还需要把它转换成 Student 数组。最后,别忘了把数据库资源释放掉

  1. Future<List<Student>> students() async {
  2. final Database db = await database;
  3. final List<Map<String, dynamic>> maps = await db.query('students');
  4. return List.generate(maps.length, (i)=>Student.fromJson(maps[i]));
  5. }
  6. //读取出数据库中插入的Student对象集合
  7. students().then((list)=>list.forEach((s)=>print(s.name)));
  8. //释放数据库资源
  9. final Database db = await database;
  10. db.close();