正因为有网络,App拥有与外界进行信息交换的通道,也因此具备了更新数据的能力。不过,经过交换后的数据通常都保存在内存中,而应用一旦运行结束,内存就会被释放,这些数据也就随之消失了。
因此,我们需要把这些更新后的数据以一定的形式,通过一定的载体保存起来,这样应用下次运行时,就可以把数据从存储的载体中读出来,也就实现 数据持久化。
数据持久化应用场景很多。如:
- 用户的账号登录信息需要保存,用于每次与Web服务验证身份
- 下载后的图片需要缓存,避免每次都要重新加载,浪费用户流量
由于Flutter仅接管渲染层,真正涉及到存储等操作系统底层行为时,还需要依托于原生Android、iOS,因此与原生开发类似的,根据需要持久化数据的大小和方式不同,Flutter提供了三种数据持久化方法,即文件、SharedPreferences与数据库。
1 文件
文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。
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还提供了二进制流的读写能力,可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点,如果你想要深入研究的话,可以查阅 官方文档。
2 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。
3 数据库
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教程 了解具体的使用方法。
4 总结
首先,我带你学习了文件,这种最常见的数据持久化方式。Flutter提供了两类目录,即临时目录与文档目录。我们可以根据实际需求,通过写入字符串或二进制流,实现数据的持久化。
然后,我通过一个小例子和你讲述了SharedPreferences,这种适用于持久化小型键值对的存储方案。
最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面对持续变更的需求,适配能力和灵活性都更强了。
数据持久化是CPU密集型运算,因此数据存取均会大量涉及到异步操作,所以请务必使用异步等待或注册then回调,正确处理读写操作的时序关系。
FAQ
- 请你分别介绍一下文件、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, );