cocos2d-x游戏引擎核心之七——数据持久化
一、XML与JSON
XML 和 JSON 都是当下流行的数据存储格式,它们的共同特点就是数据明文,十分易于阅读。XML 源自于 SGML,是一种标记性数据描述语言,而 JSON 则是一种轻量级数据交换格式,比 XML 更为简洁。鉴于 C++对 XML 的支持更为完善,Cocos2d-x选择了 XML 作为主要的文件存储格式。
XML 文档的语法非常简洁。文档由节点组成,节点的定义是递归的,节点内可以是一个字符串,也可以是由一组<tag></tag>包围的若干节点,其中 tag 可以是任意符合命名规则的标识符。这样的递归嵌套结构非常灵活,特别适合以键值对形式存储的数据,比如数组和字典等。对于游戏开发中的大部分情景,XML 文档都可以游刃有余地处理它们。
随 Cocos2d-x 一起分发的还有一个处理 XML 的开源库 LibXML2,它用纯 C 语言的接口封装了对 XML 的创建、寻址、读和写等操作,极大地方便了开发。这里我们可以仿照 CCUserDefault 的做法,将对象存储到指定的 XML 文件中。和 XML 语言的规范相对应,LibXML2 库同样十分简洁,只有两个核心的概念:
下面我们开始以外部 XML 文件的方式存储 UserRecord 对象,并从中看到 XML 文档的操作和 LibXML 的具体用法,在 UserRecord 类中,我们添加如下两个接口,分别负责将对象从 XML 文件中读出和写入:
void saveToXMLFile(const char* filename="default.xml");
void readFromXMLFile(const char* filename="default.xml");
在开始之前,我们可以进一步抽象出两个函数,完成对象和字符串间的序列化和反序列化,以便在 XML 的读写接口和CCUserDefault 的读写接口间共享,相关代码如下:
void UserRecord::readFromString(const string& str)
{
int coin = ;
int experience = ;
int music = ;
sscanf(str.c_str(), "%d %d %d", &coin, &experience, &music);
this->setCoin(coin);
this->setExp(experience);
this->setIsMusicOn(music != );
}
void UserRecord::writeToString(string& str)
{
char buff[] = "";
sprintf(buff,"%d %d %d",
this->getCoin(),
this->getExp(),
this->getIsMusicOn() ? :
);
str = buff;
}
完成了序列化与反序列化的功能后,通过 CCUserDefault 读写 UserRecord 的实现就十分简洁了。下面是相关的代码:(CCUserDefault是Cocos2d-x引擎提供的持久化方案,其作用是存储所有游戏通用的用户配置信息,例如音乐和音效配置等。)
void UserRecord::readFromCCUserDefault()
{
string key("UserRecord.");
key += this->getUserID();
string buff = CCUserDefault::sharedUserDefault()->getStringForKey(key.c_str());
this->readFromString(buff);
xmlFreeDoc(node->doc);
}
void UserRecord::saveToCCUserDefault()
{
string buff;
this->writeToString(buff);
string key("UserRecord.");
key += this->getUserID();
CCUserDefault::sharedUserDefault()->setStringForKey(key.c_str(),buff);
xmlFreeDoc(node->doc);
}
注:上面是使用CCUserDefault持久化数据的一个例子,下面开始介绍如何使用XML持久化数据。
有了对字符的序列化和反序列化,实际上我们只需要关心如何正确地在 XML 文档中读写键值对。我们暂且将对象都写到文档的根节点下,不考虑存储数组等复合数据结构的情景,尽管这些情景在操作上是类似的。首先,我们在一个指定的文档的根节点下找到一个键值,如果根节点下不存在指定的键值,将根据参数指定来创建,相关代码如下:
xmlNodePtr getXMLNodeForKey(const char* pKey, const char* filename, bool creatIfNotExists = true)
{
xmlNodePtr curNode = NULL,rootNode = NULL;
if (! pKey) {
return NULL;
}
do {
//得到根节点
xmlDocPtr doc = getXMLDocument(filename);
rootNode = xmlDocGetRootElement(doc);
if (NULL == rootNode) {
CCLOG("read root node error");
break;
}
//在根节点下找到目标节点
curNode = (rootNode)->xmlChildrenNode;
while (NULL != curNode) {
if (!xmlStrcmp(curNode->name, BAD_CAST pKey)){
break;
}
curNodecurNode = curNode->next;
}
//如果没找到且需要创建,则创建该节点
if(NULL == curNode && creatIfNotExists) {
curNode = xmlNewNode(NULL, BAD_CAST pKey);
xmlAddChild(rootNode, curNode);
}
} while ();
return curNode;
}
在上述代码中,我们首先根据文件名获得了对应的 XML 文档指针,然后通过 xmlDocGetRootElement 函数获得了该文档的根节点 rootNode。一个节点的子节点是以链表形式存储的,通过 xmlChildrenNode 获得第一个子节点指针,再通过 next 函数迭代整个子节点列表。如果没有找到指定节点,且函数参数指定了必须创建对应键值的子节点,则函数会根据给定的键值 key 创建并添加到根节点中。
接下来,则是根据文件名获得 XML 文档指针的方法,相关代码如下:
xmlDocPtr getXMLDocument(const char* filename)
{
if(!isFileExists(filename) && !createXMLFile(filename)) {
return NULL;
}
return xmlReadFile(filename, "utf-8", XML_PARSE_RECOVER);
} bool createXMLFile(const char* filename, const char* rootNodeName = "root")
{
bool bRet = false;
xmlDocPtr doc = NULL;
do {
//创建 XML 文档
doc = xmlNewDoc(BAD_CAST"1.0");
if (doc == NULL) {
CCLOG("can not create xml doc");
break;
}
//创建根节点
xmlNodePtr rootNode = xmlNewNode(NULL, BAD_CAST rootNodeName);
if (rootNode == NULL) {
CCLOG("can not create root node");
break;
}
xmlDocSetRootElement(doc, rootNode);
//保存文档
xmlSaveFile(filename, doc);
bRet = true;
} while ();
//释放文档
if (doc) {
xmlFreeDoc(doc);
}
return bRet;
} bool isFileExists(const char *filename)
{
FILE *fp = fopen(filename, "r");
bool bRet = false;
if (fp) {
bRet = true;
fclose(fp);
}
return bRet;
}
这 3 段代码分别做了 3 件事情:创建一个具有特定根节点的 XML 文档,获取一个特定文件名的 XML 文件,测试文件是否存在。
二、加密与解密
细心的读者应该已经注意到了,XML 的一个很严重的问题是明文存储,存储在外部的数据一旦被截获,就将直接暴露在攻击者面前,小则篡改用户数据,大则泄露用户隐私信息。因此,对存储在文件中的信息加密不可忽视。幸运的是,前面我们已经设计好了序列化和反序列化过程,只要在其中加入合适的加密和解密算法,即可保证我们的数据不会被轻易窃取。这里我们只使用一个简单的编码轮换来加密,相关代码如下:
void encode(string &str)
{
for(int i = ; i < str.length(); i++) {
int ch = str[i];
ch = 0xff & (((ch & ( << )) >> ) & (ch << ));
str[i] = ch;
}
}
void decode(string &str)
{
for(int i = ; i < str.length(); i++) {
int ch = str[i];
ch = 0xff & (((ch & ()) << ) & (ch >> ));
str[i] = ch;
}
}
得益于之前已经抽象的对字符串的序列化和反序列化,只要将加密和解密分别放在这两个函数的最后,就可以完成对CCUserDefault 和 XML 文档的读、写及加密、解密。
三、SQLite
从性能上说,XML 方式的存储基本可以满足 1 MB 以下的存储要求。但在更复杂的情景中,我们可能需要存储多种不同的类,每个类也需要存储不同的对象,此时 XML 存储的速度就将成为瓶颈。即便分文件存储,管理起来也很麻烦,这个时候可以引入数据库来提升存储效率。
关系数据库是一种经典的数据库,其中的数据被组织成表的形式,具有相同形式的数据存放在同一张表中,表内每一行代表一个数据。在表的基础上,数据库为我们提供增、删、改、查等操作,这些操作通常采用 SQL(结构化查询语言)表达。这种格式化、集中的存储再加上结构化的操作语言带来一个非常大的好处:可以进行深度的优化,大大提升存储和操作的效率。
SQLite 是移动设备上常用的一个嵌入式数据库,具有开源、轻量等特点,其源代码只有两个".c"文件和两个".h"文件,并且已经包括了充分的注释说明。相比 MySQL 或者 SQL Server 这样的专业级数据库,甚至是比起同样轻量级的 Access,SQLite的部署都可谓非常简单,只要将这 4 个文件导入工程中即可,这使得编译之后的 SQLite 非常小。
SQLite 将数据库的数据存储在磁盘的单一文件中,并通过简单的外部接口提供 SQL 支持。由于其设计之初即是针对小规模数据的操作,在查询优化、高并发读写等方面做了极简化的处理,可以保证不占用系统额外的资源,因此,在大多数的嵌入式开发中,会比专业数据库有更快速、高效的执行效率。
SQLite 的核心接口函数只有一个,如下所示:
int sqlite3_exec(
sqlite3*, //一个已打开的数据库
const char *sql, //将要执行的 SQL 语句
int (*callback)(void*, int, char**, char**), //回调函数
void *, //回调函数的第一个参数(用于传递自定义数据)
char **errmsg //出错时返回的错误信息
);
这个函数在一个打开的数据库中为我们执行一条 SQL 语句,并通过回调函数处理结果,其参数的含义已经由注释给出。为了开发上的便利,我们还可以通过第四个参数指定一个任意类型的对象传递给回调函数。当此函数运行出错时,错误信息会以字符串形式输出在 errmsg 中。具体的用法我们将在下面详细介绍。
我们依然沿用 UserRecord 类作为例子,在其中添加 3 个接口函数,具体如下所示:
sqlite3* prepareTableInDB(const char* table, const char* dbFilename);
void saveToSQLite(const char* table = "UserRecord", const char* dbFilename = "sql.db");
void readFromSQLite(const char* table = "UserRecord",const char* dbFilename = "sql.db");
首先,我们需要为一次读写操作准备数据库,相关代码如下:
sqlite3* UserRecord::prepareTableInDB(const char* table,const char *dbFilename)
{
sqlite3 *pDB = NULL;
char *errorMsg = NULL;
if(SQLITE_OK != sqlite3_open(dbFilename, &pDB)) {
CCLOG("open sql file failed!");
return NULL;
}
string sql = "create table if not exists " + string(table) +
"(id char(80) primary key,coin integer,experience integer)";
sqlite3_exec(pDB, sql.c_str(), NULL, NULL, &errorMsg);
if(errorMsg != NULL) {
CCLOG("exec sql %s fail with msg: %s", sql.c_str(), errorMsg);
sqlite3_close(pDB);
return NULL;
}
return pDB;
}
这里我们完成两部分操作,首先用 sqlite3_open 打开数据库,如果数据库文件不存在,则会自动创建。打开成功后,如果目标表格不存在,则创建表格。这里我们执行了一句 SQL 语句,用了最基本的 sqlite3_exec 的方式,单纯地执行并查看是否成功,不涉及数据库操作后与游戏数据的交互。
准备完数据库之后,我们来尝试将数据从 SQLite 读取到内存数据中,相关代码如下:
void UserRecord::readFromSQLite(const char* table, const char *dbFilename)
{
char sql[];
sqlite3* pDB = prepareTableInDB(table, dbFilename);
if(pDB != NULL) {
int count = ;
char *errorMsg;
sprintf(sql,"select * from %s where id = %s", table, this->getUserID().c_str());
sqlite3_exec(pDB, sql, loadUserRecord, this, &errorMsg);
if(errorMsg!=NULL) {
CCLOG("exec sql %s fail with msg: %s", sql, errorMsg);
sqlite3_close(pDB);
return;
}
}
sqlite3_close(pDB);
}
这里同样执行了一条 SQL 语句,将目标对象根据 ID 从数据库中读出,但不同的是,这里我们用到了下面这个回调函数并在其中将查询结果读取到 UserRecord 对象中:
int loadUserRecord(void* para,int n_column,char** column_value,char **column_name)
{
UserRecord* record = (UserRecord*)para;
int coin, experience;
sscanf(column_value[],"%d",&coin);
sscanf(column_value[],"%d",&experience);
record->setCoin(coin);
record->setExp(experience);
return ;
}
该回调函数用于处理 SQL 操作成功后返回的数据。返回的数据可能是一个字符串或整型量,也可能是数据表中的若干行数据,而每组数据都会调用回调函数一次,若查询操作得到了 N 行结果,则回调函数会被调用 N 次,每次传输一行待处理的结果。回调函数一共有 4 个参数,第一个参数是需要供回调函数使用的某段数据的指针,通常指向一个对象或一个数组,以便根据查询结果修改数据;第二个参数是操作结果返回的记录的列数;第三个参数是返回结果的数组,这些返回结果中的每一列都是一个字符串;第四个参数则是每一列的列名。对于一条特定的 SQL 语句来说,第二个和第四个参数通常是固定不变的。
在上面这个回调函数中,我们传入的是一个 UserRecord 类型的指针,因为我们要把查询结果存入这个 UserRecord 对象之中以便后续使用。查询请求已经限制了返回结果最多仅有一个,因此我们不需要额外的判断。只需要从返回的字符串中提取出金币数量和经验值,并把相应的数据填充到 UserRecord 对象中就可以了。
同样,我们可以编写将 UserRecord 写入 SQLite 数据库的接口函数,相关代码如下:
void UserRecord::saveToSQLite(const char* table, const char *dbFilename)
{
char sql[];
sqlite3* pDB = prepareTableInDB(table, dbFilename);
if(pDB!=NULL) {
int count = ;
char *errorMsg;
sprintf(sql, "select count(*) from %s where id = %s",
table, this->getUserID().c_str());
sqlite3_exec(pDB, sql, loadRecordCount, &count, &errorMsg);
if(errorMsg != NULL) {
CCLOG("exec sql %s fail with msg: %s", sql, errorMsg);
sqlite3_close(pDB);
return;
}
if(count) {
sprintf(sql, "update %s set coin = %d,experience=%d where id = %s",
table, this->getCoin(), this->getExp(), this->getUserID().c_str());
}
else {
sprintf(sql, "insert into %s values( %s,%d,%d)",
table, this->getUserID().c_str(), this->getCoin(), this->getExp());
}
sqlite3_exec(pDB, sql, NULL, NULL, &errorMsg);
if(errorMsg != NULL){
CCLOG("exec sql %s fail with msg: %s", sql, errorMsg);
sqlite3_close(pDB);
return;
}
}
sqlite3_close(pDB);
} int loadRecordCount(void* para, int n_column, char** column_value, char** column_name)
{
int *pCount=(int*)para;
sscanf(column_value[], "%d", pCount);
return ;
}
这个功能同样是由一个调用数据库接口的主调函数和一个处理返回结果的回调函数共同完成的。由于数据库中的更新和插入使用不同的命令,所以我们必须先查询数据库中是否存在同 ID 的对象,再决定是更新当前对象还是插入数据库中。
注意上面的每一个 SQLite 操作后,我们都检查了操作是否成功,在失败的情况下及时中止后面的操作。而在一切操作完成之后,不管操作是否成功,都必须关闭数据库,以保证对数据库的改变能够正确保存。 最后,我们可以尝试查看读写的效果。除了直接从数据库中读取特定的数据之外,还可以借助工具查看整个数据库的状态。SQLite Database Browser 就是一个可以方便地查看 SQLite 数据库的图形化工具,它是开源而且免费的。
尽管数据库中的文件已经被封装为数据库专用格式的文件,无法通过简单的文本工具查看其内容,但是如果通过合适的工具打开,SQLite 数据库和 XML 同样存在明文存放数据的问题。对于敏感的数据,同样需要通过加密来提高安全性,其做法与 XML 类似,在此就不再赘述了。
cocos2d-x游戏引擎核心之七——数据持久化的更多相关文章
- cocos2d-x游戏引擎核心(3.x)----事件分发机制之事件从(android,ios,desktop)系统传到cocos2dx的过程浅析
(一) Android平台下: cocos2dx 版本3.2,先导入一个android工程,然后看下AndroidManifest.xml <application android:label= ...
- cocos2d-x游戏引擎核心之十一——并发编程(消息通知中心)
[续] cocos2d-x游戏引擎核心之八——多线程 这里介绍cocos2d-x的一种消息/数据传递方式,内置的观察者模式,也称消息通知中心,CCNotificationCenter. 虽然引擎没有为 ...
- cocos2d-x游戏引擎核心之六——绘图原理和绘图技巧
一.OpenGL基础 游戏引擎是对底层绘图接口的包装,Cocos2d-x 也一样,它是对不同平台下 OpenGL 的包装.OpenGL 全称为 Open Graphics Library,是一个开放的 ...
- cocos2d-x游戏引擎核心之八——多线程
一.多线程原理 (1)单线程的尴尬 重新回顾下 Cocos2d-x 的并行机制.引擎内部实现了一个庞大的主循环,在每帧之间更新各个精灵的状态.执行动作.调用定时函数等,这些操作之间可以保证严格独立,互 ...
- cocos2d-x游戏引擎核心(3.x)----启动渲染流程
(1) 首先,这里以win32平台下为例子.win32下游戏的启动都是从win32目录下main文件开始的,即是游戏的入口函数,如下: #include "main.h" #inc ...
- cocos2d-x游戏引擎核心之四——动作调度机制
一.动作机制的用法 在深入学习动作机制在 Cocos2d-x 里是如何实现的之前,我们先来学习整套动作机制的用法,先知道怎么用,再深入学习它如何实现,是一个很好很重要的学习方法. (1)基本概念 CC ...
- cocos2d-x游戏引擎核心之三——主循环和定时器
一.游戏主循环 在介绍游戏基本概念的时候,我们曾介绍了场景.层.精灵等游戏元素,但我们却故意避开了另一个同样重要的概念,那就是游戏主循环,这是因为 Cocos2d 已经为我们隐藏了游戏主循环的实现.读 ...
- cocos2d-x游戏引擎核心之一——坐标系
cocos2d-x:OpenGL坐标系.绝对坐标系.相对坐标系.屏幕坐标系 cocos2d-x采用的是笛卡尔平面坐标系,也就是平面上两条垂直线构成的坐标系,平面上任意一点都可以用(x,y)来表示. ( ...
- cocos2d-x游戏引擎核心之九——跨平台
一.cocos2d-x跨平台 cocos2d-x到底是怎样实现跨平台的呢?这里以Win32和Android为例. 1. 跨平台项目目录结构 先看一下一个项目创建后的目录结构吧!这还是以HelloCpp ...
随机推荐
- 百度编辑器UEditor不能插入音频视频的解决方法
引用:https://my.oschina.net/u/379795/blog/787985 xssFilter导致插入视频异常,编辑器在切换源码的过程中过滤掉img的_url属性(用来存储视频url ...
- 10分钟-jQuery过滤选择器
1.:first过滤选择器 本次我们介绍过滤选择器,该类型的选择器是依据某过滤规则进行元素的匹配.书写时以":"号开头,通经常使用于查找集合元素中的某一位置的单个元素. 在jQue ...
- 【Unity/SVN】使用SVN管理Unity项目
本文转载自:http://blog.csdn.net/neil3d/article/details/38437237 Unity提供了自己的XXXServer,不过大家评论好像不是很好用,主要是不支持 ...
- Tomcat性能优化之(一) 启动GZIP压缩
Tomcat性能优化之(一) 启动GZIP压缩 1:设置TOMCAT启用GZIP压缩,通过浏览器HTTP访问对应的资源会根据配置进行压缩. <Connector port="8080& ...
- socket编程函数
连接 TCP/IP协议规定网络数据传输应采用大端字节序 socket地址 struct sockaddr{ unsigned short sa_family; char sa_data[14]; }; ...
- golang web开发获取get、post、cookie参数
在成熟的语言java.python.php要获取这些参数应该来讲都非常简单,过较新的语言golang用获取这些个参数还是费了不少劲,特此记录一下. golang版本:1.3.1在贴代码之前如果能先理解 ...
- iOS边练边学--应用数据存储的常用方式(plist,Preference,NSKeyedArchiver)其中的三种
iOS应用数据存储的常用方式: XML属性列表(plist)归档 Preference(偏好设置) NSKeyedArchiver归档(NSCoding) SQLite3--这里暂且不讲 Core D ...
- PHP 获取图片中的器材信息
function getExif($img){ $exif = exif_read_data($img, 'IFD0'); return array ( '文件名' => $exif['File ...
- 231个javascript特效分享
1.文本框焦点问题onBlur:当失去输入焦点后产生该事件onFocus:当输入获得焦点后,产生该文件Onchange:当文字值改变时,产生该事件Onselect:当文字加亮后,产生该文件 <i ...
- thinkphp 连接mssql 当local失效时
<?php return array( //'配置项'=>'配置值' //'USERNAME'=>'admin', //赋值 //数据库配置信息 'DB_TYPE' => 'm ...