You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

252 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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还提供了二进制流的读写能力可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点如果你想要深入研究的话可以查阅[官方文档](https://api.flutter.dev/flutter/dart-io/File-class.html)。
## SharedPreferences
文件比较适合大量的、有序的数据持久化如果我们只是需要缓存少量的键值对信息比如记录用户是否阅读了公告或是简单的计数则可以使用SharedPreferences。
SharedPreferences会以原生平台相关的机制为简单的键值对数据提供持久化存储即在iOS上使用NSUserDefaults在Android使用SharedPreferences。
接下来我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中我们将计数器持久化到了SharedPreferences中并为它分别提供了读方法和递增写入的方法。
这里需要注意的是settersetInt方法会同步更新内存中的键值对然后将数据保存至磁盘因此我们无需再调用更新方法强制刷新缓存。同样地由于涉及到耗时的文件读写因此我们必须以异步的方式对这些操作进行包装
```
//读取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,
);
```
以上代码属于通用的数据库创建模板,有三个地方需要注意:
1. 在设定数据库存储地址时使用join方法对两段地址进行拼接。join方法在拼接时会使用操作系统的路径分隔符这样我们就无需关心路径分隔符究竟是“/”还是“\\”了。
2. 创建数据库时传入了一个version 1在onCreate方法的回调里面也有一个version。这两个version是相等的。
3. 数据库只会创建一次也就意味着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文档](https://pub.dev/documentation/sqflite/latest/),或是查阅[SQLite教程](http://www.sqlitetutorial.net/)了解具体的使用方法。
## 总结
好了,今天的分享就这里。我们简单回顾下今天学习的内容吧。
首先我带你学习了文件这种最常见的数据持久化方式。Flutter提供了两类目录即临时目录与文档目录。我们可以根据实际需求通过写入字符串或二进制流实现数据的持久化。
然后我通过一个小例子和你讲述了SharedPreferences这种适用于持久化小型键值对的存储方案。
最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面对持续变更的需求,适配能力和灵活性都更强了。
数据持久化是CPU密集型运算因此数据存取均会大量涉及到异步操作所以请务必使用异步等待或注册then回调正确处理读写操作的时序关系。
我把今天分享所涉及到的知识点打包到了[GitHub](https://github.com/cyndibaby905/25_data_persistence)中,你可以下载下来,反复运行几次,加深理解与记忆。
## 思考题
最后,我给你留下两道思考题吧。
1. 请你分别介绍一下文件、SharedPreferences和数据库这三种持久化数据存储方式的适用场景。
2. 我们的应用经历了1.0、1.1和1.2三个版本。其中1.0版本新建了数据库并创建了Student表1.1版本将Student表增加了一个字段ageALTER 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,
);
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。