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
这是官方维护的仓库,它是对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
它在原生iOS侧基于FMDB封装,Android侧基于系统的sqlite封装,使用起来比preferences稍微复杂点,需要打开关闭数据库,自己建表进行增、删、改、查。其原理通过channel通信,将sql指令发送到原生侧,原生操作完数据库,再将数据返回给flutter侧。plugin flutter侧将用户的操作最终都是转化为sql指令,当然flutter侧不只是转发用户的sql操作,接下来会详细进行讲述。
使用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
- 首先 仍是需要制定/获取文件路径
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 然后按照下图操作:
选中运行的程序,下载所在APP运行的数据,双击显示包内容:
我们的db文件
和操作文件
存储在此,preferences
文件存储在AppData -> Library -> Preferences目录下,操作文件
和preferences
可以直接打开查看,这里说下db文件:
每次对数据有修改,我们可以在开发时,查看对应db中的表或者数据的改动。
以上就是我对Flutter中存储的总结,大家根据业务情况选择适合自己的方案,当然还有其他优秀框架,欢迎一起交流。