
                            
                            因收到Google相关通知，网站将会择期关闭。相关通知内容
                            
                            
                            25 本地存储与数据库的使用和优化
                            你好，我是陈航。

在上一篇文章中，我带你一起学习了Flutter的网络编程，即如何建立与Web服务器的通信连接，以实现数据交换，以及如何解析结构化后的通信信息。

其中，建立通信连接在Flutter中有三种基本方案，包括HttpClient、http与dio。考虑到HttpClient与http并不支持复杂的网络请求行为，因此我重点介绍了如何使用dio实现资源访问、接口数据请求与提交、上传及下载文件、网络拦截等高级操作。

而关于如何解析信息，由于Flutter并不支持反射，因此只提供了手动解析JSON的方式：把JSON转换成字典，然后给自定义的类属性赋值即可。

正因为有了网络，我们的App拥有了与外界进行信息交换的通道，也因此具备了更新数据的能力。不过，经过交换后的数据通常都保存在内存中，而应用一旦运行结束，内存就会被释放，这些数据也就随之消失了。

因此，我们需要把这些更新后的数据以一定的形式，通过一定的载体保存起来，这样应用下次运行时，就可以把数据从存储的载体中读出来，也就实现了数据的持久化。

数据持久化的应用场景有很多。比如，用户的账号登录信息需要保存，用于每次与Web服务验证身份；又比如，下载后的图片需要缓存，避免每次都要重新加载，浪费用户流量。

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

文件

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

Flutter提供了两种文件存储的目录，即临时（Temporary）目录与文档（Documents）目录：


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


接下来，我通过一个例子与你演示如何在Flutter中实现文件读写。

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

//创建文件目录
Future<File> get _localFile async {
  final directory = await getApplicationDocumentsDirectory();
  final path = directory.path;
  return File('$path/content.txt');
}
//将字符串写入文件
Future<File> writeContent(String content) async {
  final file = await _localFile;
  return file.writeAsString(content);
}
//从文件读出字符串
Future<String> readContent() async {
  try {
    final file = await _localFile;
    String contents = await file.readAsString();
    return contents;
  } catch (e) {
    return "";
  }
}


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

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


除了字符串读写之外，Flutter还提供了二进制流的读写能力，可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点，如果你想要深入研究的话，可以查阅官方文档。

SharedPreferences

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

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

接下来，我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中，我们将计数器持久化到了SharedPreferences中，并为它分别提供了读方法和递增写入的方法。

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

//读取SharedPreferences中key为counter的值
Future<int>_loadCounter() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int  counter = (prefs.getInt('counter') ?? 0);
  return counter;
}

//递增写入SharedPreferences中key为counter的值
Future<void>_incrementCounter() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
    int counter = (prefs.getInt('counter') ?? 0) + 1;
    prefs.setInt('counter', counter);
}


在完成了计数器存取方法的封装后，我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中，我们先是读取并打印了计数器数据，随后将其递增，并再次把它读取打印：

//读出counter数据并打印
_loadCounter().then((value)=>print("before:$value"));

//递增counter数据后，再次读出并打印
_incrementCounter().then((_) {
  _loadCounter().then((value)=>print("after:$value"));
});


可以看到，SharedPreferences的使用方式非常简单方便。不过需要注意的是，以键值对的方式只能存储基本类型的数据，比如int、double、bool和string。

数据库

SharedPrefernces的使用固然方便，但这种方式只适用于持久化少量数据的场景，我们并不能用它来存储大量数据，比如文件内容（文件路径是可以的）。

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

接下来，我就以一个例子分别与你介绍数据库的使用方法。

我们以上一篇文章中提到的Student类为例：

class Student{
  String id;
  String name;
  int score;
  //构造方法
  Student({this.id, this.name, this.score,});
  //用于将JSON字典转换成类对象的工厂类方法
  factory Student.fromJson(Map<String, dynamic> parsedJson){
    return Student(
      id: parsedJson['id'],
      name : parsedJson['name'],
      score : parsedJson ['score'],
    );
  }
}


JSON类拥有一个可以将JSON字典转换成类对象的工厂类方法，我们也可以提供将类对象反过来转换成JSON字典的实例方法。因为最终存入数据库的并不是实体类对象，而是字符串、整型等基本类型组成的字典，所以我们可以通过这两个方法，实现数据库的读写。同时，我们还分别定义了3个Student对象，用于后续插入数据库：

class Student{
  ...
  //将类对象转换成JSON字典，方便插入数据库
  Map<String, dynamic> toJson() {
    return {'id': id, 'name': name, 'score': score,};
  }
}

var student1 = Student(id: '123', name: '张三', score: 90);
var student2 = Student(id: '456', name: '李四', score: 80);
var student3 = Student(id: '789', name: '王五', score: 85);


有了实体类作为数据库存储的对象，接下来就需要创建数据库了。在下面的代码中，我们通过openDatabase函数，给定了一个数据库存储地址，并通过数据库表初始化语句，创建了一个用于存放Student对象的students表：

final Future<Database> database = openDatabase(
  join(await getDatabasesPath(), 'students_database.db'),
  onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
  onUpgrade: (db, oldVersion, newVersion){
     //dosth for migration
  },
  version: 1,
);


以上代码属于通用的数据库创建模板，有三个地方需要注意：


在设定数据库存储地址时，使用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对象的插入：

Future<void> insertStudent(Student std) async {
  final Database db = await database;
  await db.insert(
    'students',
    std.toJson(),
    //插入冲突策略，新的替换旧的
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}
//插入3个Student对象
await insertStudent(student1);
await insertStudent(student2);
await insertStudent(student3);


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

Future<List<Student>> students() async {
  final Database db = await database;
  final List<Map<String, dynamic>> maps = await db.query('students');
  return List.generate(maps.length, (i)=>Student.fromJson(maps[i]));
}

//读取出数据库中插入的Student对象集合
students().then((list)=>list.forEach((s)=>print(s.name)));
//释放数据库资源
final Database db = await database;
db.close();


可以看到，在面对大量格式化的数据模型读取时，数据库提供了更快、更灵活的持久化解决方案。

除了基础的数据库读写操作之外，sqlite还提供了更新、删除以及事务等高级特性，这与原生Android、iOS上的SQLite或是MySQL并无不同，因此这里就不再赘述了。你可以参考sqflite插件的API文档，或是查阅SQLite教程了解具体的使用方法。

总结

好了，今天的分享就这里。我们简单回顾下今天学习的内容吧。

首先，我带你学习了文件，这种最常见的数据持久化方式。Flutter提供了两类目录，即临时目录与文档目录。我们可以根据实际需求，通过写入字符串或二进制流，实现数据的持久化。

然后，我通过一个小例子和你讲述了SharedPreferences，这种适用于持久化小型键值对的存储方案。

最后，我们一起学习了数据库。围绕如何将一个对象持久化到数据库，我与你介绍了数据库的创建、写入和读取方法。可以看到，使用数据库的方式虽然前期准备工作多了不少，但面对持续变更的需求，适配能力和灵活性都更强了。

数据持久化是CPU密集型运算，因此数据存取均会大量涉及到异步操作，所以请务必使用异步等待或注册then回调，正确处理读写操作的时序关系。

我把今天分享所涉及到的知识点打包到了GitHub中，你可以下载下来，反复运行几次，加深理解与记忆。

思考题

最后，我给你留下两道思考题吧。


请你分别介绍一下文件、SharedPreferences和数据库，这三种持久化数据存储方式的适用场景。
我们的应用经历了1.0、1.1和1.2三个版本。其中，1.0版本新建了数据库并创建了Student表，1.1版本将Student表增加了一个字段age（ALTER TABLE students ADD age INTEGER）。请你写出1.1版本及1.2版本的数据库升级代码。


//1.0版本数据库创建代码
final Future<Database> database = openDatabase(
  join(await getDatabasesPath(), 'students_database.db'),
  onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"),
  onUpgrade: (db, oldVersion, newVersion){
     //dosth for migration
  },
  version: 1,
);


欢迎你在评论区给我留言分享你的观点，我会在下一篇文章中等待你！感谢你的收听，也欢迎你把这篇文章分享给更多的朋友一起阅读。

                        
                        
                            