一、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 = 0; int experience = 0; int music = 0; sscanf(str.c_str(), "%d %d %d", &coin, &experience, &music); this->setCoin(coin); this->setExp(experience); this->setIsMusicOn(music != 0); } void UserRecord::writeToString(string& str) { char buff[100] = ""; sprintf(buff,"%d %d %d", this->getCoin(), this->getExp(), this->getIsMusicOn() ? 1 : 0 ); 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 (0); 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 (0); //释放文档 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 = 0; i < str.length(); i++) { int ch = str[i]; ch = 0xff & (((ch & (1 << 7)) >> 7) & (ch << 1)); str[i] = ch; } } void decode(string &str) { for(int i = 0; i < str.length(); i++) { int ch = str[i]; ch = 0xff & (((ch & (1)) << 7) & (ch >> 1)); 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[1024]; sqlite3* pDB = prepareTableInDB(table, dbFilename); if(pDB != NULL) { int count = 0; 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[1],"%d",&coin); sscanf(column_value[2],"%d",&experience); record->setCoin(coin); record->setExp(experience); return 0; }
该回调函数用于处理 SQL 操作成功后返回的数据。返回的数据可能是一个字符串或整型量,也可能是数据表中的若干行数据,而每组数据都会调用回调函数一次,若查询操作得到了 N 行结果,则回调函数会被调用 N 次,每次传输一行待处理的结果。回调函数一共有 4 个参数,第一个参数是需要供回调函数使用的某段数据的指针,通常指向一个对象或一个数组,以便根据查询结果修改数据;第二个参数是操作结果返回的记录的列数;第三个参数是返回结果的数组,这些返回结果中的每一列都是一个字符串;第四个参数则是每一列的列名。对于一条特定的 SQL 语句来说,第二个和第四个参数通常是固定不变的。
在上面这个回调函数中,我们传入的是一个 UserRecord 类型的指针,因为我们要把查询结果存入这个 UserRecord 对象之中以便后续使用。查询请求已经限制了返回结果最多仅有一个,因此我们不需要额外的判断。只需要从返回的字符串中提取出金币数量和经验值,并把相应的数据填充到 UserRecord 对象中就可以了。
同样,我们可以编写将 UserRecord 写入 SQLite 数据库的接口函数,相关代码如下:
void UserRecord::saveToSQLite(const char* table, const char *dbFilename) { char sql[1024]; sqlite3* pDB = prepareTableInDB(table, dbFilename); if(pDB!=NULL) { int count = 0; 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[0], "%d", pCount); return 0; }
这个功能同样是由一个调用数据库接口的主调函数和一个处理返回结果的回调函数共同完成的。由于数据库中的更新和插入使用不同的命令,所以我们必须先查询数据库中是否存在同 ID 的对象,再决定是更新当前对象还是插入数据库中。
注意上面的每一个 SQLite 操作后,我们都检查了操作是否成功,在失败的情况下及时中止后面的操作。而在一切操作完成之后,不管操作是否成功,都必须关闭数据库,以保证对数据库的改变能够正确保存。 最后,我们可以尝试查看读写的效果。除了直接从数据库中读取特定的数据之外,还可以借助工具查看整个数据库的状态。SQLite Database Browser 就是一个可以方便地查看 SQLite 数据库的图形化工具,它是开源而且免费的。
尽管数据库中的文件已经被封装为数据库专用格式的文件,无法通过简单的文本工具查看其内容,但是如果通过合适的工具打开,SQLite 数据库和 XML 同样存在明文存放数据的问题。对于敏感的数据,同样需要通过加密来提高安全性,其做法与 XML 类似,在此就不再赘述了。