本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、为什么需要数据存储
上一章,我们完成了无限空间,这标志着已经实现了 生命游戏 的核心玩法。其中可以绘制细胞格点,不过辛辛苦苦画了很久,但数据都在内存中,关闭应用程序后,数据将会消失。这肯定是我们不远见到的,所以存储数据是非常必要的一项功能,本文将要探讨生命游戏的数据存储功能,让世界不再 失忆。
注: 本节内容不仅限于 Flame ,普通的 Flutter 项目也是适用的。
1. 要存储的数据是什么
想要存数据,首先得弄明白我们想要存什么数据内容。对于一个生命游戏空间来说,首要的信息是:
存活细胞的格点坐标数据:String
其次需要一些数据信息用于标记记录,比如
唯一标识: uuid
名称: String
描述: String
创建时间: DateTime
修改时间: DateTime
于是在 Dart 中可以创建一个类型,作为实体对象对数据进行记录。如下的 Frame 类:
class FramePo {
final String uuid;
final String data;
final String title;
final String description;
final int createAt;
final int updateAt;
FramePo({
required this.uuid,
required this.data,
required this.title,
required this.description,
required this.createAt,
required this.updateAt,
});
}
2. 以什么形式存储本地数据
常用的本地存储形式有 文件
和 数据库
。 其中文件有 json、xml 或 普通文件,数据库有 sqlite 、hive 等。对于大量的关系型数据,希望支持搜索、分页加载等功能,sqlite 数据库是一个不错的选择,在 Flutter 中 sqflite 插件已经支持全平台应用程序:
本文将采用 sqflite 插件,通过 sqlite 数据库存储。此时可以准备如下的 frame 表,用于记录上述的 FramePo 类型;其中 title 和 description 作为索引,优化模糊搜索:
CREATE TABLE frame (
uuid TEXT NOT NULL,
data TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
create_at INTEGER NOT NULL,
update_at INTEGER NOT NULL,
PRIMARY KEY("uuid")
);
CREATE INDEX idx_frames_title ON frame(title);
CREATE INDEX idx_frames_description ON frame(description);
3.格点数据的存储形式
格点数据是个点列表,而一个点有两个 int 值,所以很容易想到将格点数据转换为 二维数组,再通过 json 序列化为字符串存。但仔细分析可以看出,这样会产生大量的 []
,如果点集非常多,会造成很多空间浪费。
其实录入的数据本身只是为了进行读取解析渲染,存成二维和一维都没有关系。如下代码中,data 字符串呈一维排列,只要在解析时稍作处理,每次解析两个数字,形成点集即可:
List<XY> points = [];
List<dynamic> src = jsonDecode(data) as List;
for (int i = 0; i < src.length; i+=2) {
int x = src[i].toInt();
int y = src[i + 1].toInt();
points.add((x, y));
}
这样处理之后,需要存储的数据将减少 20%~30% 的体积。所以,对于存储来说,好的结构和解析方式是非常重要的。
二、创建数据库表
根据功能需求的划分,在一个应用程序中可能存在多个数据库,每个是数据库中有可能存在多个数据表。接下来,我们将通过 sqflite 插件来看看如何新建数据库和数据表:
首先在 pubspec.yaml 文件中添加如下依赖:
dependencies:
...
path_provider: ^2.1.4
sqflite: ^2.3.3+1
sqflite_common_ffi: ^2.3.3
1. 数据库的路径处理
对于数据库来说,最重要的有三件事:
- 数据库的打开:需要得到数据库的路径
- 数据表的关闭:关闭连接资源
- 数据表的升级:需要旧版本数据库兼容升级
如下所示,创建一个 DbStore
的接口,定义数据库的路径、版本、开启、创建、升级、关闭 的抽象行为:
import 'dart:async';
import 'package:sqflite/sqflite.dart';
abstract class DbStore {
Future<String> get dbpath;
int get version;
Future<void> onCreate(Database db, int version);
FutureOr<void> onUpgrade(Database db, int oldVersion, int newVersion);
FutureOr<void> onOpen(Database db);
Future<void> close();
}
数据库的打开是一套标准流程,我们可以将其封装为一个 DbOpenMixin
混入类,统一维护打开数据库的功能逻辑,也便于数据库文件的统一管理。
如下所示,其中维护一个 Database
数据库操作对象,由于不同的数据库名称不同,而且数据库所在的文件夹统一获取,也就是说数据库的名字是决定数据库路径的唯一变量。而这个量需要使用者自行处理,因为上层的封装并无法了解实现的具体细节。这里通过了一个 get dbname 的抽象方法,交由派生类实现:
可以看出,DbStore 中抽象出的 dbpath 异步方法,经由 DbOpenMixin 的统一处理,像外界提供了一个更简洁的 dbname 同步方法,并且实现了数据库所在文件夹的统一处理:
mixin DbOpenMixin on DbStore {
Database? _database;
Database get database => _database!;
String get dbname;
@override
Future<String> get dbpath async {
return p.join(await databasePath, dbname);
}
Future<String> get databasePath async {
Directory docDir = await getApplicationSupportDirectory();
String dirPath;
if (Platform.isWindows || Platform.isLinux) {
dirPath = p.join(docDir.path, 'databases');
} else {
dirPath = await getDatabasesPath();
}
Directory result = Directory(dirPath);
if (!result.existsSync()) {
result.createSync(recursive: true);
}
return dirPath;
}
2. 打开数据库
路径准备完毕之后,就可以通过 openDatabase
方法,打开指定的数据库文件,其中需要提供版本号和三个回调函数,分别表示创建、更新和打开。另外,在打开前后设置了 beforeOpen
和 afterOpen
两个钩子方法,便于派生类复写监听事件。其中打开之前 windows 和 linux 需要设置一下 databaseFactory
:
Future<void> open() async {
if (kIsWeb) return;
beforeOpen();
String dbPath = await dbpath;
_database = await openDatabase(
dbPath,
version: version,
onCreate: onCreate,
onUpgrade: onUpgrade,
onOpen: onOpen,
);
afterOpen(dbPath);
}
void afterOpen(String dbpath) {}
void beforeOpen() {
if (Platform.isWindows || Platform.isLinux) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
}
当打开时数据库不存在,就会走 onCreate
方法,其中需要执行创建数据表的操作,该操作是派生类需要处理的细节。将通过 DbStore 的 onCreate 抽象接口进行处理。
另外 onOpen 在数据库打开后被触发,第一次数据库创建之后也会走 onOpen 回调;onUpgrade 会在数据库打开时,版本变化时出发,处理数据表的升级操作;这两者可以给一个默认的空实现,派生类可以选择复写。关闭方法只需要调用 _database
的 close 方法即可:
@override
FutureOr<void> onOpen(Database db) {}
@override
FutureOr<void> onUpgrade(Database db, int oldVersion, int newVersion) {}
@override
Future<void> close() async {
await _database?.close();
}
3. 创建数据库表
当抽象接口和混入类准备完毕后,就可以派生具体的数据库子类,来处理具体的数据库操作。如下所示,定义 LifeGameDbStore
派生类,继承自 DbStore
并混入 DbOpenMixin
。
- 通过抽象方法
dbname
和version
提供数据库的名称和版本号; - 实现 onCreate 方法,通过 Database 对象执行建表语句;
- 复写 afterOpen 方法,可以打印一下数据库路径的磁盘位置;
class LifeGameDbStore extends DbStore with DbOpenMixin{
@override
String get dbname => 'life_game_store.db';
@override
int get version => 1;
@override
Future<void> onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE frame (
uuid TEXT NOT NULL,
data TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
create_at INTEGER NOT NULL,
update_at INTEGER NOT NULL,
PRIMARY KEY("uuid")
);
CREATE INDEX idx_frames_title ON frame(title);
CREATE INDEX idx_frames_description ON frame(description);
''');
}
@override
void afterOpen(String dbpath) {
super.afterOpen(dbpath);
print("====Opend:$dbpath===========");
}
}
最后,可以通过以后 GameLifeStorage 单例对象,来维护 LifeGameDbStore
数据库操作对象,提供 init 方法打开数据库。在游戏主类的 onLoad 方法中,触发 GameLifeStorage().init 方法,即可打开数据库:
class GameLifeStorage{
GameLifeStorage._();
static GameLifeStorage? _instance;
factory GameLifeStorage() => _instance ??= GameLifeStorage._();
final LifeGameDbStore _lifeGame = LifeGameDbStore();
Future<void> init() async{
await _lifeGame.open();
}
}
这样,数据库的创建就完成了,可以通过一些数据库查看软件来查阅其中的内容:
三、 数据表的操作
众所周知,数据表有 增删改查
的系列操作。查询操作会 加载数据 为界面提供数据; 应用交互的过程中在对应的事件,可以触发增删改的操作,来维护数据的正确性。首先我们来定义一下,frame 数据表的操作接口 FrameStore ,如下所示:
abstract class FrameStore {
Future<List<FramePo>> query({QueryArgs args = const QueryArgs()});
Future<FramePo> queryById(String id);
Future<int> deleteById(String id);
Future<int> insert(FramePo frame);
Future<int> update(String id, FramePo frame);
}
class QueryArgs {
final String? title;
final String? subtitle;
final int page;
final int pageSize;
const QueryArgs({
this.title,
this.subtitle,
this.page = 1,
this.pageSize = 20,
});
}
1. 插入数据
俗话说,巧妇难为无米之炊。对于应用程序来说,数据就是它存活所依赖的资源。现在我们想要在绘制后,点击保存按钮,将当前绘制的数据保存到数据库中。界面交互逻辑如下所示,通过按钮打开添加面板,其中可以输入标题和介绍文字:
这里界面交互相关的代码就不赘述了,主要基于 tolyui 进行构建,细节方面可以参见源码。这里主要介绍数据存储的知识。比如输入了如下,点击确定按钮,会将当前世界中的细胞记录到数据库中:
通过数据库产看软件可以看到其中的内容:
对于 数据表
的操作,这里封装一层 dao 实现数据操作接口。比如 frame 数据表通过 FrameDao
进行增删改查的具体操作,它将实现上面的 FrameStore 接口。这里先实现一个 insert 方法插入数据。其中通过 database#insert 方法,将一个 Map 对象插入到指定数据表:
class FrameDao implements FrameStore{
final Database database;
FrameDao(this.database);
@override
Future<int> insert(FramePo frame) {
return database.insert('frame', frame.toJson());
}
/// 暂略,其他...
}
我们说过,一个应用程序可能有多个数据库,一个数据库可能有多张数据表。其中数据表对应的操作者是 Dao,数据库对应的操作者是 DbStore
。在情理上 LifeGameDbStore 应该持有多个 Dao 层的对象,从而提供数据表的访问点:可以在数据库打开后,创建 Dao 对象:
class LifeGameDbStore extends DbPersistence with DbOpenMixin {
late FrameDao _frameDao;
FrameStore get frameStore => _frameDao;
@override
void afterOpen(String dbpath) {
super.afterOpen(dbpath);
_frameDao = FrameDao(database);
print("====Opend:$dbpath===========");
}
通理,一个应用程序可能有多个数据库,GameLifeStorage 作为是数据库的统一访问点,理应维护多个数据库 DbStore 对象。可以通过 DbStore ,像外界提供各个表操作的 Dao 对象。如下所示:
这样,在确认添加按钮中,就可以通过 GameLifeStorage().frameStore 来操作数据表,从而实现数据插入的功能:
Future<bool> _onSubmit(SubmitType type, FramePayload payload) async {
String uuid = const Uuid().v4();
String data = game.frame.store;
String title = payload.title;
String description = payload.description;
int createAt = DateTime.now().millisecondsSinceEpoch;
await GameLifeStorage().frameStore.insert(FramePo(
uuid: uuid,
data: data,
title: title,
description: description,
createAt: createAt,
updateAt: createAt,
));
$message.success(message: '保存数据成功!');
game.frameEvolve.handleAction(ToolAction.list);
return true;
}
2. 读取数据
数据插入成功之后,关闭添加面板,展开记录列表面板。这就涉及数据从库中的读取,需要实现 query
方法。交互如下所示,点击图标时,展开记录面板,其中读取数据库记录,展示条目:
对于查询数据而言,关键在于查询语句。上面在定义表操作接口时,定义了一个 FrameQueryArgs 类维护查询的参数,可以在其中提供一个 parserSql 方法,得到查询语言条件的相关内容:
class FrameQueryArgs {
final String? title;
final String? subtitle;
final int page;
final int pageSize;
const FrameQueryArgs({
this.title,
this.subtitle,
this.page = 1,
this.pageSize = 20,
});
(String,List<Object?>?) get parserSql {
String args = '';
List<String> conditions= [];
List<Object?>? arguments = [];
if(title!=null){
conditions.add('title like ?');
arguments.add('%$title%');
}
if(subtitle!=null){
conditions.add('subtitle like ?');
arguments.add('%$subtitle%');
}
if(conditions.isNotEmpty){
args = 'WHERE ';
}
args+=conditions.join(' AND ');
args+= ' ORDER BY update_at DESC LIMIT ? OFFSET ?';
arguments.add(pageSize);
arguments.add((page-1)*pageSize);
return (args,arguments);
}
}
这样,在 FrameDao 中,查询语句就非常轻松了。注意这里在查询时并没有将 data 数据查出来。因为data 比较大,而且列表条目中并不需要呈现,从而节约查询成本和内存空间。我们可以在交互过程中,动态的加载 data 数据:
@override
Future<List<FramePo>> query({FrameQueryArgs args = const FrameQueryArgs()}) async {
var (sqlStr, sqlArgs) = args.parserSql;
String sql = 'SELECT uuid,title,description,create_at,update_at FROM frame $sqlStr';
List<Map<String, Object?>> data = await database.rawQuery(sql, sqlArgs);
return data.map(FramePo.fromMap).toList();
}
3.快速新建与创建副本
对于创建来说,保存输入的交互成本比较高。所以这里增加了两个快速创建的功能。在列表的右上角有个添加按钮,点击时可以迅速创建一个默认命名的记录,并且激活它:
在条目上右键可以展开操作按钮,可以便捷地根据一个已有记录创建记录副本并激活。效果如下:
这两个功能都是 插入数据 相关的操作,只不过是记录的信息已经准备好了而言。为了编译插入默认数据,这里给出一个 FramePo.insert
构造,创建默认的对象:
case MenuAction.newFrame:
FramePo frame = FramePo.insert();
await GameLifeStorage().frameStore.insert(frame);
game.activeFrameNtf.value = frame.uuid;
break;
case MenuAction.copyFrame:
if(item==null) return false;
String uuid = const Uuid().v4();
int time = DateTime.now().millisecondsSinceEpoch;
FramePo po = await GameLifeStorage().frameStore.queryById(item.uuid);
FramePo frame = FramePo(
uuid: uuid,
title: '[副本]'+po.title,
description: '[副本]'+po.description,
data: po.data,
createAt: time,
updateAt: time
);
await GameLifeStorage().frameStore.insert(frame);
game.activeFrameNtf.value = frame.uuid;
break;
4. 记录的删除
删除操作将调用删除接口,删除成功后,重新加载数据即可:
Dao 的操作也非常简单,执行一条删除语句,通过 id 删除数据记录:
@override
Future<int> deleteById(String? id) {
return database.rawDelete('DELETE FROM frame WHERE uuid = ?',[id]);
}
四、数据更新与界面处理
数据简单的增删查已基本完成,接下来看一下最重要的更新操作。包括修改标题、介绍;选择条目时,世界中的数据同步变化;以及编辑过程中,动态地更新激活条目数据。效果如下:
- untitled 记录中绘制格点后,将实时记录到数据库中
- 切换记录时,世界会展示对应的格点数据。
- 切回 untitled 记录,之前绘制的数据也不会消失。
1. 绘制时实时更新数据库
为了便于操作,这里在 FrameStore 中单独提供了 updateData
接口,用于修改指定 id 的格点数据。然后再 FrameDao 中通过更新语句实现更新逻辑:
abstract class FrameStore {
//...
Future<int> updateData(String id, String data);
}
class FrameDao implements FrameStore {
//...
@override
Future<int> updateData(String id, String data) {
String sql = 'UPDATE frame SET data = ? WHERE uuid = ?';
return database.rawUpdate(sql,[data,id]);
}
}
上一节说过,绘制和擦出的手势会触发频率非常高。每次触发都要更新数据库,会造成很多无用的开销。在 《Flutter 知识集锦 | 跟源码学节流 Throttled》 一文中根据源码中的 UndoHistory 介绍了节流操作,可以减少频繁触发事件的频率。正好可以用在当前场景中:
在 _LifeGameViewState
初始化时,创建间隔为 500ms 的节流器,触发 updateFrameData
来更新数据库内容。在绘制过程中,通过节流器触发事件,传入格点的数据:
---->[_LifeGameViewState]----
late Throttled<String> _updateThrottled;
@override
void initState() {
//...
_updateThrottled = throttle<String>(
duration: const Duration(milliseconds: 500),
function: updateFrameData,
);
super.initState();
}
void updateFrameData(String data) {
String activeId = game.activeFrameNtf.value ?? '';
GameLifeStorage().frameStore.updateData(activeId, data);
}
2. 激活 id 与更新世界数据
这里通过 ValueNotifier
来维护激活 id ,数据层可以监听这个对象,在构建过程中更新激活项。由于激活项的变化都要加载 data 数据,触发世界中格点的更新,而 activeFrameNtf
是可监听对象,所以可以统一监听处理。并且开始时激活最新的记录:
class LifeGame extends FlameGame<LifeWord> with TransformableMixin, TransformGame<LifeWord> {
//...
ValueNotifier<String?> activeFrameNtf = ValueNotifier(null);
这里的 _onActiveFrameChange
方法,会查询格点数据,解析为坐标值。并通过 Frame#setData
方法将数据设置到世界中:
void _onActiveFrameChange() async{
FramePo po = await GameLifeStorage().frameStore.queryById(activeFrameNtf.value??'');
String data = po.data;
List<XY> points = [];
if(data.isNotEmpty){
List<dynamic> src = jsonDecode(data) as List;
for (int i = 0; i < src.length; i += 2) {
int x = src[i].toInt();
int y = src[i + 1].toInt();
points.add((x, y));
}
}
frameEvolve.frame.setData(points);
fit();
}
最后在条目点击时,只需要将 activeFrameNtf
更新为对应 id 即可:
3. 编辑信息
最后来看一下对标题和和描述信息的修该,视图交互如下所示:
之前将 frame 数据内容的修改单独封装一个接口,这里同理,定义 updateInfo
来更新内容和描述信息。接口实现如下。在更新事件中调用接口即可:
@override
Future<int> updateInfo(String id, FramePayload frame) {
String sql = 'UPDATE frame SET title = ?, description = ? WHERE uuid = ?';
return database.rawUpdate(sql,[frame.title,frame.description,id]);
}
到这里,对生命游戏数据的增删改查工作已经基本结束。现在绘制的数据可以实时保存到数据库,与不怕丢失了。通过这五章,我们已经构建了一个足以玩刷的 生命游戏 PlayGround 。就像会造魔方的人并一定会玩魔方;及时我们写出了生命游戏的游乐场,这也不代表我们对生命游戏有多么深刻的认识。但这个过程,会让我们对生命游戏有更深的认知。站在更高的视角去审视它:
当然还有很多可以优化的空间,比如计算下一代的算法、更新的细节、甚至可以基于 opengl 通过 GUP 来更完美地渲染海量的点集。到这里 生命游戏 系列在 第二季中就到达尾声。以后有机会还会进一步地优化它,希望通过这几篇文章,可以让你认识生命游戏的乐趣。那本文就到这里,谢谢观看 ~