Flutter中的存储

Flutter 是 Google 开源的 UI 工具包,一套代码多端应用极大的提升了开发效率,此外直接调用skia(c/c++)代码的能力,也使得它具备媲美原生的渲染性能。但是涉及到非UI层的任务时,Flutter仍然需要依托原生框架,比如相机、存储、蓝牙等功能,于是各种对原生能力封装的Plugin就产生了,Android、iOS各自平台提供原生能力,flutter侧进行对接,提供dart语言编写的api 给flutter侧调用,使得flutter开发人员依然可以一套代码,多端应用。

存储数据到磁盘是开发中常见的操作,比如用户信息、一些不经常变动的数据、通讯录等,以便下次打开APP用户可以不经过网络请求,快速预览APP中的内容。根据需要存储的数据量的大小,用户可以选择适合自己的方案。对于少量的数据,在原生侧iOS一般直接使用UserDefaults,Android使用SharedPreferences,这两种存储方式一般用来存储用户或者APP信息等少量的数据。当数据量大的时候就不适合使用了,一般会考虑基于SQLite的数据库存储,或者是基于文件的存储。

以上也是本文将要要讲述核心:详细介绍几个存储的优质框架的原理及使用,以便对大家需要使用存储能力的时候有所帮助。

少量数据存储

少量数据建议直接使用shared_preferences

preferences.png

这是官方维护的仓库,它是对iOS中UserDefaults和Android中SharedPreferences的plugin封装,iOS UserDefaults存储在plist中,Android preferences存储在xml中,原本各自操作都很简单,所以flutter侧的封装也很简单,整个代码包含注释不到200行。

因此在flutter侧基于它来进行少量数据的存储也是十分方便的,在flutter侧的类名也叫SharedPreferences,是个单例,实例化的时候会从磁盘中读取到内存,并且在内存中保存一份,之后如果有新的数据存入的话,会同时进行内存和磁盘的更新,当然写磁盘操作有极小的概率可能失败,因此内存中数据和磁盘中数据有极小概率不一致。

Future<bool> _setValue(String valueType, String key, Object value) {
    final String prefixedKey = '$_prefix$key';
    if (value == null) {
      _preferenceCache.remove(key);
      return _store.remove(prefixedKey);
    } else {
      if (value is List<String>) {
        // Make a copy of the list so that later mutations won't propagate
        _preferenceCache[key] = value.toList();
      } else {
        _preferenceCache[key] = value;
      }
      return _store.setValue(valueType, prefixedKey, value);
    }
  }

还需要注意点的一点是,如果native侧进行了SharedPreferences或者NSUserDefaults的存储、修改操作,flutter侧的SharedPreferences单例,并不会自行更新到内存中,需要调用reload方法进行内存的更新。

Future<void> reload() async {
    final Map<String, Object> preferences =
        await SharedPreferences._getSharedPreferencesMap();
    _preferenceCache.clear();
    _preferenceCache.addAll(preferences);
  }
// 读取操作api
Set<String> getKeys()
dynamic get(String key)
bool getBool(String key)
int getInt(String key)
double getDouble(String key)
String getString(String key)
bool containsKey(String key)
List<String> getStringList(String key)

// 写入操作api
Future<bool> setBool(String key, bool value)
Future<bool> setInt(String key, int value)
Future<bool> setDouble(String key, double value)
Future<bool> setString(String key, String value)
Future<bool> setStringList(String key, List<String> value)
Future<bool> remove(String key)

在项目开发的时候,我们先获取preferences单例,然后按照上述api进行操作即可,由于比较简单,这里不再做实际示例介绍。

大量数据存储

数据量大的话一般会基予SQLite进行操作,目前flutter侧最好的基于sqlite的插件是sqflite

sqflite.png

它在原生iOS侧基于FMDB封装,Android侧基于系统的sqlite封装,使用起来比preferences稍微复杂点,需要打开关闭数据库,自己建表进行增、删、改、查。其原理通过channel通信,将sql指令发送到原生侧,原生操作完数据库,再将数据返回给flutter侧。plugin flutter侧将用户的操作最终都是转化为sql指令,当然flutter侧不只是转发用户的sql操作,接下来会详细进行讲述。

sql.PNG

使用sqlite存储 demo地址

  • 首先是获取默认数据库存放路径
var databasesPath = await getDatabasesPath();

通过getDatabasesPath 第一次调用的时候,通过channel向native侧发送一条消息,native将路径地址返回给flutter侧,flutter缓存此地址,之后再调用此方法,直接返回缓存的地址。

  • 然后定义自己的数据库db路径
String path = join(databasesPath, 'demo.db');
以上两步操作的结果:
flutter: databasesPath /var/mobile/Containers/Data/Application/9EAD6644-1A9A-4741-BC5E-51D9D678CA30/Documents
flutter: db path /var/mobile/Containers/Data/Application/9EAD6644-1A9A-4741-BC5E-51D9D678CA30/Documents/demo.db

这时只是获取了地址db实例并不会创建。

创建数据库

Database database = await openDatabase(_dbPath);

创建数据库最少只需要给定一个路径就行,当然这个api中还有其他可选参数

Future<Database> openDatabase(String path,
    {int version,
    OnDatabaseConfigureFn onConfigure,
    OnDatabaseCreateFn onCreate,
    OnDatabaseVersionChangeFn onUpgrade,
    OnDatabaseVersionChangeFn onDowngrade,
    OnDatabaseOpenFn onOpen,
    bool readOnly = false,
    bool singleInstance = true})
  • version db版本,用来决定是否进行创建、升降级。只有设置了version,onCreate、onUpgrade、onDowngrade这三个可选回调才可能被调用,这三个回调最多只会调用一个,当version不变时,三个回调都不会被调用。
  • onConfigure 打开db时首先执行这个回调,在这个回调中可以执行db的初始化操作,比如外键的设置或者提前写日志。
  • onCreate 只有当db不存在时,第一次调用openDatabase才会执行此回调,可以利用这个时机,创建一些所需的table。
  • onUpgrade 有两个场景会执行此回调,1.初始创建db时,onCreate回调未设置。2.db已经存在,并且version比db中上次记录的的version大。可以在此方法中执行必要的迁移操作。
  • onDowngrade 只有version比db中记录的的version小时才会执行。这种情况很少见,只有当新版本的代码创建了一个数据库,然后与旧版本的代码交互时才会出现这种情况,应该尽量避免这种情况。
  • onOpen 这个回调最后执行,在version被重置之后,openDatabase返回结果之前。
  • readOnly 默认false,如果设置为true,则不允许任何修改操作
  • singleInstance 默认为true,这样针对同样的dbPath,将返回同一个db实例。当多次调用openDatabase的时候,只有首次调用的回调会生效,再次调用同一path时候,只会返回db实例,忽略新设置的参数。

这几个可选回调顺序是

1. [onConfigure]
2. [onCreate] or [onUpgrade] or [onDowngrade]
5. [onOpen]

我们可以创建多个db实例,每个db实例中可以创建多张table。这点和原生操作数据库是一致的。

除了在每次数据库初始创建onCreate或者升级时的onUpgrade回调中操作表,还可以在其他时机进行操作,我们可以新建表、修改表字段,修改表字段对应值得类型等,对表的操作都是通过sql语句进行操作,下面展示几个示例:

  • 新增一张表
_database.execute('CREATE TABLE Test2 (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)');

如果db中已经存在相同的表,再次创建不会生效,对原有表不会有影响。

  • 新增表字段
_database.execute('alter table Test2 ADD num2 REAL NOT NULL Default 0');
  • 更改表字段
_database.execute('alter table Test2 rename column num to num3');

合适的场景是,当表里的字段不满足时,可以在数据库升级的回调onUpgrade中进行表的更改,当然这有业务决定。

其他操作,还有这些sql语句和原生侧操作一样。

修改字段默认值
alter table 表名 drop constraint 约束名字 // 删除表的字段的原有约束
alter table 表名 add constraint 约束名字 DEFAULT 默认值 for 字段名称  // 添加一个表的字段的约束并指定默认值

修改字段类型:
alter table 表名 alter column name nvarchar(10) not null

当数据库和表都建立好之后,接下来就是数据操作了,这里flutter侧有两种方式操作,可以直接编写sql语句,也可以使用flutter侧 helpers操作,两种操作各有优劣。Raw Sql方式更加直观,但是sql语句编写容易出错

-- Raw Sql SQL helpers
优势 直观,sql直接发送到native侧处理 书写简单,不易出错
劣势 直接编写sql 语句容易出错 需要一层转换,底层仍是调用Raw Sql方式
  • Raw Sql方式
// 增
int id1 = await txn.rawInsert(
      'INSERT INTO Test(name, value, num) VALUES("some name", 1234, 456.789)');

// 删
await _database.rawDelete('DELETE FROM Test WHERE name = ?', ['another name']);

// 改
await _database.rawUpdate('UPDATE Test SET name = ?, value = ? WHERE name = ?', ['updated name', '9876', 'some name']);

// 查
List<Map> list = await _database.rawQuery('SELECT * FROM Test');
  • SQL helpers
    SQL helpers是flutter侧对直接操作sql的封装,以insert为例,借助SqlBuilder这个类提供的能力,将SQL helpers Api转化成sql字符串,最终还是转换为RAW Sql方式执行。
Future<int> insert(String table, Map<String, dynamic> values,
      {String nullColumnHack, ConflictAlgorithm conflictAlgorithm}) {
    final builder = SqlBuilder.insert(table, values,
        nullColumnHack: nullColumnHack, conflictAlgorithm: conflictAlgorithm);
    return rawInsert(builder.sql, builder.arguments);
  }
// 增
// table表名;values Map数据,可以是model2json转成的数据;
// nullColumnHack字段为空时处理语句,conflictAlgorithm冲突处理枚举
uture<int> insert(String table, Map<String, dynamic> values,
      {String nullColumnHack, ConflictAlgorithm conflictAlgorithm});

// 删
// where筛选条件,如果where为null,则删除整个表中的数据,whereArgs即其参数
Future<int> delete(String table, {String where, List<dynamic> whereArgs});

// 改
// values将要更新到表中的值,如果后面的筛选条件不设置,将更新整个表
Future<int> update(String table, Map<String, dynamic> values,
      {String where,
      List<dynamic> whereArgs,
      ConflictAlgorithm conflictAlgorithm});

// 查
// distinct是否排重,true的话返回的每行数据都是唯一的
// 返回表中哪几列的数据,传null将返回所有列,最好对数据进行过滤,以免读取太多不相干的数据
Future<List<Map<String, dynamic>>> query(String table,
      {bool distinct,
      List<String> columns,
      String where,
      List<dynamic> whereArgs,
      String groupBy,
      String having,
      String orderBy,
      int limit,
      int offset});

SQL helpers可以和数据模型结合使用,根据业务需求,编写对应的增删改查api,外部使用就会非常精简,下面是个具体的小例子:

final String tableTodo = 'todo';
final String columnId = '_id';
final String columnTitle = 'title';
final String columnDone = 'done';

class Todo {
  int id;
  String title;
  bool done;

  Map<String, dynamic> toMap() {
    var map = <String, dynamic>{
      columnTitle: title,
      columnDone: done == true ? 1 : 0
    };
    if (id != null) {
      map[columnId] = id;
    }
    return map;
  }

  Todo();

  Todo.fromMap(Map<String, dynamic> map) {
    id = map[columnId];
    title = map[columnTitle];
    done = map[columnDone] == 1;
  }
}

class TodoProvider {
  Database db;

  Future open(String path) async {
    db = await openDatabase(path, version: 1,
        onCreate: (Database db, int version) async {
      await db.execute('''
create table $tableTodo ( 
  $columnId integer primary key autoincrement, 
  $columnTitle text not null,
  $columnDone integer not null)
''');
    });
  }

  Future<Todo> insert(Todo todo) async {
    todo.id = await db.insert(tableTodo, todo.toMap());
    return todo;
  }

  Future<Todo> getTodo(int id) async {
    List<Map> maps = await db.query(tableTodo,
        columns: [columnId, columnDone, columnTitle],
        where: '$columnId = ?',
        whereArgs: [id]);
    if (maps.length > 0) {
      return Todo.fromMap(maps.first);
    }
    return null;
  }

  Future<int> delete(int id) async {
    return await db.delete(tableTodo, where: '$columnId = ?', whereArgs: [id]);
  }

  Future<int> update(Todo todo) async {
    return await db.update(tableTodo, todo.toMap(),
        where: '$columnId = ?', whereArgs: [todo.id]);
  }

  Future close() async => db.close();
}

以上代码可以直在sqlite存储 demo地址中查看。

  • 事务
    如果有一组不可分割的操作,可以使用transaction进行处理,也即事务
Future<T> transaction<T>(Future<T> Function(Transaction txn) action,
      {bool exclusive});

// 一个具体小例子
database.transaction((txn) async {
                int id1 = await txn.rawInsert(
                    'INSERT INTO Test(name, value, num) VALUES("some name", 1234, 456.789)');
                print('inserted1: $id1');
                int id2 = await txn.rawInsert(
                    'INSERT INTO Test(name, value, num) VALUES(?, ?, ?)',
                    ['another name', 12345678, 3.1416]);
                print('inserted2: $id2');
              });

需要注意的是,事务中不要直接再调用database进行操作,否则可能造成死锁,而是使用变量txn操作。

  • 批处理
    经过上面的介绍我们可以知道,所有操作都是通过channel发送到原生侧处理的,操作多的话,就会产生很多的来回通信数据,为了优化这个过程可以将一组操作放到Batch中,它会将一组指令打包成一条,注意最后有个batch.commit();操作
batch = db.batch();
batch.insert('Test', {'name': 'item'});
batch.update('Test', {'name': 'new_item'}, where: 'name = ?', whereArgs: ['item']);
batch.delete('Test', where: 'name = ?', whereArgs: ['item']);
results = await batch.commit();

存储数据到文件

除了以上两种方式,还可以将数据存储到文件中,这里也推荐官方维护的path_provider

path_provider.png

  • 首先 仍是需要制定/获取文件路径
Future<String> get _localPath async {
    Directory _path = await getApplicationDocumentsDirectory();
    Directory _directory = await Directory("${_path.path}/test").create(recursive: true);
    return _directory.path;
  }
  • 然后获取文件
    文件格式类型按自己需求指定。
Future<File> get _localFile async {
    final path = await _localPath;
    return File('$path/homePageId001.json');
  }

  • 读写操作不只有字符串,还可以是其他类型。
Future<String> readStr() async {
   try {
     final file = await _localFile;
     var content = await file.readAsString();
     return content;
   } catch (e) {
     return 'error';
   }
  }
Future<File> writeStr(str) async {
    final file = await _localFile;
    return file.writeAsString('$str');
  }

开发时检查

无论是以上哪种方式,都是可以在开发时查看存储到磁盘中的数据是否正常,这也和原生开发时类似。以iOS为例,每个APP运行数据都是隔离的,有自己的文件夹,我们可以将所开发APP的文件夹下载下来,选中Xcode-> Window -> Devices and simulators 然后按照下图操作:


下载.png

选中运行的程序,下载所在APP运行的数据,双击显示包内容:


path.png

我们的db文件操作文件存储在此,preferences文件存储在AppData -> Library -> Preferences目录下,操作文件preferences可以直接打开查看,这里说下db文件:

dbdemo.png

每次对数据有修改,我们可以在开发时,查看对应db中的表或者数据的改动。

以上就是我对Flutter中存储的总结,大家根据业务情况选择适合自己的方案,当然还有其他优秀框架,欢迎一起交流。

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

推荐阅读更多精彩内容