2019-11-09 Flutter 数据的持久化

Flutter 数据的持久化

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

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

文件

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

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

临时目录是操作系统可以随时清除的目录,通常被用来存放一些不重要的临时缓存数据。 这个目录在 iOS 上对应着 NSTemporaryDirectory 返回的值,而在 Android 上则对应着 getCacheDir 返回的值。

文档目录则是只有在删除应用程序时才会被清除的目录,通常被用来存放应用产生的重要数据文件。在 iOS 上,这个目录对应着 NSDocumentDirectory,而在 Android 上则对 应着 AppData 目录。

接下来,我通过一个例子与你演示如何在 Flutter 中实现文件读写。
在下面的代码中,我分别声明了三个函数,即创建文件目录函数、写文件函数与读文件函
数。这里需要注意的是,由于文件读写是非常耗时的操作,所以这些操作都需要在异步环境
下进行。另外,为了防止文件读取过程中出现异常,我们也需要在外层包上 try-catch:
有了文件读写函数,我们就可以在代码中对 content.txt 这个文件进行读写操作了。

import 'dart:io';
import 'package:path_provider/path_provider.dart';

class DataTool {
  ///文件
  //创建文件目录
  static Future<File> getLocalFile() async {
    final directory = await getApplicationDocumentsDirectory();
    final path = directory.path;
    return File('$path/content.txt');
  }

  //将字符串写入文件
  static Future<File> writeFileToContent(String content) async {
    final file = await getLocalFile();
    return file.writeAsString(content);
  }

  // 从文件读出字符串
  static Future<String> readFileContent() async {
    try {
      final file = await getLocalFile();
      String contents = await file.readAsString();
      return contents;
    } catch (e) {
      return '';
    }
  }
}

除了字符串读写之外,Flutter 还提供了二进制流的读写能力,可以支持图片、压缩包等二进制文件的读写。如果你想要深入研究的话,可以查阅官方文档。

SharedPreferences

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

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

接下来,我通过一个例子来演示在 Flutter 中如何通过 SharedPreferences 实现数据的读 写。在下面的代码中,我们将计数器持久化到了 SharedPreferences 中,并为它分别提供了读方法和递增写入的方法。
这里需要注意的是,setter(setInt)方法会同步更新内存中的键值对,然后将数据保存至磁盘,因此我们无需再调用更新方法强制刷新缓存。同样地,由于涉及到耗时的文件读写, 因此我们必须以异步的方式对这些操作进行包装:

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

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

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

数据库

SharedPrefernces 的使用固然方便,但这种方式只适用于持久化少量数据的场景,我们并不能用它来存储大量数据,比如文件内容(文件路径是可以的)。
如果我们需要持久化大量格式化后的数据,并且这些数据还会以较高的频率更新,为了考虑进一步的扩展性,我们通常会选用 sqlite 数据库来应对这样的场景。与文件和 SharedPreferences 相比,数据库在数据读写上可以提供更快、更灵活的解决方案。
接下来,我就以一个例子分别与你介绍数据库的使用方法。 我们以上一篇文章中提到的 Student 类为例:
```

class Student {
  String id;
  String name;
  int score;

  Student({
    this.id,
    this.name,
    this.score,
  });

  factory Student.fromJson(Map<String, dynamic> parsedJson) {
    return Student(
      id: parsedJson['id'],
      name: parsedJson['name'],
      score: parsedJson['score'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'score': score,
    };
  }
}
```

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

var student1 = Student(id: '${++studentID}', name: '张三', score: 90);
var student2 = Student(id: '${++studentID}', name: '李四', score: 80);
var student3 = Student(id: '${++studentID}', 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
    print("old:$oldVersion, new:$newVersion");
  },
  version: 1,
);

//插入数据方法
Future<void> insertStudent(Student std) async {
  final Database db = await database;
  await db.insert(
    'students',
    std.toJson(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

//插入数据
await insertStudent(student1);
await insertStudent(student2);
await insertStudent(student3);

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

  1. 在设定数据库存储地址时,使用 join 方法对两段地址进行拼接。join 方法在拼接时会 使用操作系统的路径分隔符,这样我们就无需关心路径分隔符究竟是“/”还 是“\”了。
  2. 在创建数据库时,传入了一个参数 version:1,在 onCreate 方法的回调里面也有一个 参数 version。前者代表当前版本的数据库版本,后者代表用户手机上的数据库版本。
    比如,我们的应用有 1.0、1.1 和 1.2 三个版本,在 1.1 把数据库 version 升级到了 2。考虑到用户的升级顺序并不总是连续的,可能会直接从 1.0 升级到 1.2。因此我们可以在onCreate 函数中,根据数据库当前版本和用户手机上的数据库版本进行比较,制定数据库升级方案。
    数据库创建好了之后,接下来我们就可以把之前创建的 3 个 Student 对象插入到数据库中 了。数据库的插入需要调用 insert 方法,在下面的代码中,我们将 Student 对象转换成了 JSON,在指定了插入冲突策略(如果同样的对象被插入两次,则后者替换前者)和目标数 据库表后,完成了 Student 对象的插入:

数据完成插入之后,接下来我们就可以调用 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]));
}

可以看到,在面对大量格式化的数据模型读取时,数据库提供了更快、更灵活的持久化解决
方案。
除了基础的数据库读写操作之外,sqlite 还提供了更新、删除以及事务等高级特性,这与原生 Android、iOS 上的 SQLite 或是 MySQL 并无不同,因此这里就不再赘述了。你可以参考 sqflite 插件的API 文档,或是查阅SQLite 教程了解具体的使用方法。

总结

首先,我带你学习了文件,这种最常见的数据持久化方式。Flutter 提供了两类目录,即临 时目录与文档目录。我们可以根据实际需求,通过写入字符串或二进制流,实现数据的持久化。
然后,我通过一个小例子和你讲述了 SharedPreferences,这种适用于持久化小型键值对 的存储方案。
最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库
的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面
对持续变更的需求,适配能力和灵活性都更强了。
数据持久化是 CPU 密集型运算,因此数据存取均会大量涉及到异步操作,所以请务必使用异步等待或注册 then 回调,正确处理读写操作的时序关系。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容