Flutter&Flame游戏实践#19 | 生命游戏 - 数据存储

732 阅读12分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


一、为什么需要数据存储

上一章,我们完成了无限空间,这标志着已经实现了 生命游戏 的核心玩法。其中可以绘制细胞格点,不过辛辛苦苦画了很久,但数据都在内存中,关闭应用程序后,数据将会消失。这肯定是我们不远见到的,所以存储数据是非常必要的一项功能,本文将要探讨生命游戏的数据存储功能,让世界不再 失忆

18.gif

注: 本节内容不仅限于 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 插件已经支持全平台应用程序:

image.png

本文将采用 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 序列化为字符串存。但仔细分析可以看出,这样会产生大量的 [],如果点集非常多,会造成很多空间浪费。

image.png

其实录入的数据本身只是为了进行读取解析渲染,存成二维和一维都没有关系。如下代码中,data 字符串呈一维排列,只要在解析时稍作处理,每次解析两个数字,形成点集即可:

image.png

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 插件来看看如何新建数据库和数据表:

image.png

首先在 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 方法,打开指定的数据库文件,其中需要提供版本号和三个回调函数,分别表示创建、更新和打开。另外,在打开前后设置了 beforeOpenafterOpen 两个钩子方法,便于派生类复写监听事件。其中打开之前 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

  • 通过抽象方法 dbnameversion 提供数据库的名称和版本号;
  • 实现 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 方法,即可打开数据库:

image.png

class GameLifeStorage{

  GameLifeStorage._();

  static GameLifeStorage? _instance;

  factory GameLifeStorage() => _instance ??= GameLifeStorage._();

  final LifeGameDbStore _lifeGame = LifeGameDbStore();

  Future<void> init() async{
    await _lifeGame.open();
  }

}

这样,数据库的创建就完成了,可以通过一些数据库查看软件来查阅其中的内容:

image.png


三、 数据表的操作

众所周知,数据表有 增删改查 的系列操作。查询操作会 加载数据 为界面提供数据; 应用交互的过程中在对应的事件,可以触发增删改的操作,来维护数据的正确性。首先我们来定义一下,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. 插入数据

俗话说,巧妇难为无米之炊。对于应用程序来说,数据就是它存活所依赖的资源。现在我们想要在绘制后,点击保存按钮,将当前绘制的数据保存到数据库中。界面交互逻辑如下所示,通过按钮打开添加面板,其中可以输入标题和介绍文字:

14.gif

这里界面交互相关的代码就不赘述了,主要基于 tolyui 进行构建,细节方面可以参见源码。这里主要介绍数据存储的知识。比如输入了如下,点击确定按钮,会将当前世界中的细胞记录到数据库中:

image.png

通过数据库产看软件可以看到其中的内容:

image.png


对于 数据表 的操作,这里封装一层 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 对象。如下所示:

image.png

这样,在确认添加按钮中,就可以通过 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 方法。交互如下所示,点击图标时,展开记录面板,其中读取数据库记录,展示条目:

image.png

对于查询数据而言,关键在于查询语句。上面在定义表操作接口时,定义了一个 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.快速新建与创建副本

对于创建来说,保存输入的交互成本比较高。所以这里增加了两个快速创建的功能。在列表的右上角有个添加按钮,点击时可以迅速创建一个默认命名的记录,并且激活它:

15.gif

在条目上右键可以展开操作按钮,可以便捷地根据一个已有记录创建记录副本并激活。效果如下:

16.gif

这两个功能都是 插入数据 相关的操作,只不过是记录的信息已经准备好了而言。为了编译插入默认数据,这里给出一个 FramePo.insert 构造,创建默认的对象:

image.png

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. 记录的删除

删除操作将调用删除接口,删除成功后,重新加载数据即可:

17.gif

Dao 的操作也非常简单,执行一条删除语句,通过 id 删除数据记录:

@override
Future<int> deleteById(String? id) {
  return database.rawDelete('DELETE FROM frame WHERE uuid = ?',[id]);
}

四、数据更新与界面处理

数据简单的增删查已基本完成,接下来看一下最重要的更新操作。包括修改标题、介绍;选择条目时,世界中的数据同步变化;以及编辑过程中,动态地更新激活条目数据。效果如下:

  • untitled 记录中绘制格点后,将实时记录到数据库中
  • 切换记录时,世界会展示对应的格点数据。
  • 切回 untitled 记录,之前绘制的数据也不会消失。

18.gif


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 来更新数据库内容。在绘制过程中,通过节流器触发事件,传入格点的数据:

image.png

---->[_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 是可监听对象,所以可以统一监听处理。并且开始时激活最新的记录:

image.png

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 即可:

image.png


3. 编辑信息

最后来看一下对标题和和描述信息的修该,视图交互如下所示:

19.gif


之前将 frame 数据内容的修改单独封装一个接口,这里同理,定义 updateInfo 来更新内容和描述信息。接口实现如下。在更新事件中调用接口即可:

image.png

 @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 。就像会造魔方的人并一定会玩魔方;及时我们写出了生命游戏的游乐场,这也不代表我们对生命游戏有多么深刻的认识。但这个过程,会让我们对生命游戏有更深的认知。站在更高的视角去审视它:

image.png

当然还有很多可以优化的空间,比如计算下一代的算法、更新的细节、甚至可以基于 opengl 通过 GUP 来更完美地渲染海量的点集。到这里 生命游戏 系列在 第二季中就到达尾声。以后有机会还会进一步地优化它,希望通过这几篇文章,可以让你认识生命游戏的乐趣。那本文就到这里,谢谢观看 ~