游戏项目的Json文件合并打包处理
前言
对于一个游戏项目来说,配置文件是一个必不可少的东西。策划配置Excel表格,程序通过工具将其转化成Json文件,再读取到项目中使用,这是十分常见的流程。
而随着项目的开发,业务功能的增多,相对应的配置文件数量也会越来越多,如何优化配置文件的读取流程就显得相当重要。
配置压缩
在我以前的工作中,常规的配置优化工作,其实就是对配置进行压缩处理。把多余的空格和换行删除,减小文件体积。
压缩前:
{
"name": "json"
}
压缩后:
{"name":"json"}
更进一步的处理,则是提取配置中的Key值,然后在游戏内进行额外的解析工作。
例如:
[
{
"key1": "json1",
"key2": "hello",
"key3": 1
},
{
"key1": "json2",
"key2": "hi",
"key3": 2
}
]
对key值进行提取之后,配置格式也需要进行相应的修改:
{
"key": ["key1","key2","key3"],
"list":[
{
"0": "json1",
"1": "hello",
"2": 1
},
{
"0": "json2",
"1": "hi",
"2": 2
}
]
}
然后再对其进行压缩,删除多余的空格和换行符。
合并配置
以上的两个处理,是我工作过程中使用的最多的,能够有效减小Json的体积大小,能一定程度上优化配置的读取。
但是还存在着另外一个问题,那就是IO读取和网络请求问题。
当项目的Json配置越来越多,业务功能关联性越来越强,每次打开一个功能,可能都需要加载很多张相关的Json配置。那么就会导致需要多次的IO读取,对于将Json配置文件存放在远程服务器上的项目,还需要进行多次网络请求和下载。
所以为了优化减少IO的读取和网络请求次数,可以将配置文件进行合并,合并成一个文件,在进游戏时,于某个时机进行加载和缓存,这样用到的时候就可以直接解析后使用,不用再额外请求。
工具脚本分享
在一份工作中,看到了公司使用了相关的工具进行配置的合并,但是很可惜,工具是被打包成exe执行文件了,没办法查看到源码。
所以通过项目内对配置的解析和使用,大概分析了一下合并的逻辑,在这里做一下分享和记录。
通过对项目内代码的分析,可以了解到合并后的文件,大致可以分为两个部分:
- 第一部分记录了所有的Json文件的相关信息,信息包括了几个部分
- Json文件的总数量
- 每一个Json文件的文件名、文件名长度、文件内容对应的解析时的Begin值,文件内容数据的bytes长度
- 第二部分存储了所有的Json文件具体内容
从上面的可以看出,这个合并后的文件,是使用二进制格式存储的。了解了这部分信息之后。脚本工具就可以使用js来实现了,具体代码如下:
const fs = require('fs');
const path = require('path');
/** Json文件存放目录 */
const fileDir = './resDir';
/** 输出MPQ压缩文件存放目录 */
const outDir = './mpqDir';
/** 整体所占的字节数 */
let totalBytes = 0;
/** 所有的Json文件名数组 */
const p = path.resolve(fileDir);
/** 读取目录下的所有Json文件 */
const files = fs.readdirSync(p);
/**
* 文件信息Map: { [name: string]: { fileName: string, fileNameLen: number, fileData: string, fileDataLen: number, fileBegin: number } }
* @param fileName json文件名
* @param fileNameLen json文件名长度
* @param fileData json文件数据字符串
* @param fileDataLen json文件数据长度
* @param fileBegin json文件数据开始字节位置
*/
const fileMap = {};
// 处理Json文件信息,存储到Map中
for (let i = 0; i < files.length; i++) {
/** json文件名 */
const fileName = files[i];
/** 文件名的字符长度,例如 Cfg_Pet.json ,长度就是 12 了 */
const fileNameLen = fileName.length;
/** Json文件内容的总长度,一个字符就占一个字节,中文字符占两个字节,要用encoder转换一下才能拿到实际的字节数 */
/** 将Json文件内容读取为string */
let fileData = fs.readFileSync(fileDir + '/' + fileName, 'utf-8');
let encoder = new TextEncoder();
let bytes = encoder.encode(fileData);
let fileDataLen = bytes.length;
/** Json文件数据开始的字节位置,由于需要先将文件信息的内容先写入到Buffer中,所以这里先初始化为0,后面动态写入 */
const fileBegin = 0;
fileMap[fileName] = { fileName, fileNameLen, fileData, fileDataLen, fileBegin };
}
// 开始计算Buffer所需要的总字节数
// 最开始的两个字节,用于存储总文件数量
totalBytes += 2;// 2个字节 存总文件数量
// 遍历所有文件信息,计算每个文件信息所占的字节数
for (let key in fileMap) {
const ele = fileMap[key];
totalBytes += 2; // 2个字节 存文件名长度+2
const nameLen = ele.fileNameLen;
totalBytes += nameLen; // nameLen个字节 存文件名
totalBytes += 4;// 4个字节 存文件的begin值,也就是Json文件数据开始的字节位置
totalBytes += 4; // 4个字节 存文件的数据长度length值
}
const infoBytesLen = totalBytes;
// 开始计算每一个Json文件的具体数据内容所需要的字节数
let begin = totalBytes;// 第一个文件的数据开始位置
for (let key in fileMap) {
const ele = fileMap[key];
/** Json文件数据的总长度 */
const dataLen = ele.fileDataLen;
// 这里就开始填充Json的文件信息里的Begin
ele.fileBegin = begin;
// 下一个文件数据的开始位置
begin += dataLen;
// 对应增加总字节数
totalBytes += dataLen; // 文件数据长度
}
// 至此Buffer所需要的总字节数已经计算完毕,每一个Json文件的信息也已经有了,开始写入二进制
// 创建一个Buffer
const buff = new ArrayBuffer(infoBytesLen);
// 创建一个DataView,用于操作Buffer
const dataview = new DataView(buff);
// 开始写入Buffer
let offset = 0;
// 写入总文件数量,占用两个字节
dataview.setInt16(offset, files.length); // 2个字节 存总文件数量
offset += 2;
// 写入所有文件的基础信息,这里不包括Json文件数据
for (let key in fileMap) {
const ele = fileMap[key];
const name = ele.fileName;
const nameLen = ele.fileNameLen;
const dataLen = ele.fileDataLen;
const begin = ele.fileBegin;
// 写入Json文件名字长度,占两个字节
dataview.setInt16(offset, nameLen);
offset += 2;
// 写入Json文件名,每一个字符占一个字节,所以总共占用nameLen个字节
for (let i = 0; i < nameLen; i++) {
dataview.setUint8(offset, name.charCodeAt(i));
offset += 1;
}
// 写入Json文件数据开始的位置,占四个字节
dataview.setUint32(offset, begin);
offset += 4;
// 写入Json文件数据长度,占四个字节
dataview.setUint32(offset, dataLen);
offset += 4;
}
// 将以上二进制数据写入到txt文件中
fs.writeFileSync(outDir + '/cfg_0.txt', dataview, { encoding: 'binary' });
// 开始写入每一个Json文件的实际数据,这里直接按顺序增量写入字符串数据,实际网络加载时,会自动将里面的字符串转换成对应的二进制数据
for (let key in fileMap) {
const ele = fileMap[key];
const data = ele.fileData;
fs.appendFileSync(outDir + '/cfg_0.txt', data)
}
console.log('====== 写入成功 ======')
通过上面的Js脚本,基本上可以满足将多个Json文件合并成一个二进制文件的需求了。当然这个还可以继续进行优化,比如我上面所说的,提取每一个Json文件的key值,不过这里我没有处理,有需要的人可以自行进行拓展,也不是太难。
配置解析
合并完配置之后,通过在游戏内加载和解析,就可以使用相关的配置内容。
由于我使用的是cocos引擎,所以在这里我就用cocos引擎来简单说一下解析的过程。
/**
* mpq文件Map,用于存储每一个Json文件的信息,不做具体配置内容的解析,按需解析,节省性能
* @param name json文件名
* @param begin json文件数据开始位置
* @param length json文件数据长度
* @param data 整个Buffer的DataView视图,用于解析读取数据时使用
*/
const mpqMap: { [jsonName: string]: { name: string, begin: number, length: number, data: ArrayBuffer } } = {};
// 二进制文件的远端存放地址
const url = 'cfg_0.txt';
cc.assetManager.loadRemote(url, { ext: '.bin' }, (err, text: cc.BufferAsset | ArrayBuffer) => {
if (!err) {
// 加载文件成功
// 微信小游戏底层使用wx.request(在wx-download.js中做了处理)加载txt文件,出来的结果为ArrayBuffer
// h5中出来的是Unit8Array
const _plf = cc.sys.platform;
let buffer: ArrayBuffer = null;
if (_plf === cc.sys.VIVO_GAME || _plf === cc.sys.XIAOMI_GAME || _plf === cc.sys.WECHAT_GAME || _plf === cc.sys.OPPO_GAME) {
buffer = text;
} else {
buffer = text['_buffer'];
}
cc.assetManager.releaseAsset(text);
text = null;
// 根据Buffer创建DataView视图
const mpqData = new DataView(buffer);
let offset = 0;
// 读取总文件数量,占两个字节
const fileLen: number = mpqData.getInt16(offset);
offset += 2;
// 开始解析全部文件信息
for (let i = 0; i < fileLen; i++) {
/** 配置信息结构体
* @param name 文件名
* @param begin 文件数据开始位置
* @param length 文件数据长度
* @param data 整个Buffer的DataView视图,用于解析读取数据时使用
*/
const fileInfo: { data: ArrayBuffer, begin: Number, length: Number, name: string } = {};
// Json文件名长度,占两个字节
const nameLen: number = mpqData.getInt16(offset);
offset += 2;
// 获取Json文件名Buffer数据,解析Json文件名,占nameLen个字节
const nameBuffer = new Uint8Array(buffer, offset, nameLen);
const name: string = String.fromCharCode.apply(null, nameBuffer);
offset += nameLen;
// 获取Json文件数据开始位置,占4个字节
const begin: number = mpqData.getUint32(offset);
offset += 4;
// 获取Json文件数据总长度,占4个字节
const length: number = mpqData.getUint32(offset);
offset += 4;
fileInfo.name = name;
fileInfo.begin = begin;
fileInfo.length = length;
fileInfo.data = buffer;
mpqMap[name] = fileInfo;
}
}
})
const jsonMap: { [jsonName: string]: Object } = {};
// 解析配置
public parseMPQDataToJson(jsonName: string): void {
const fileInfo: { name: string, begin: number, length: number, data: ArrayBuffer } = mpqMap[jsonName];
if (!fileInfo) {
return;
}
// 获取Buffer数据
const buffer: ArrayBuffer = fileInfo.data;
// 获取Json文件数据的Buffer
const jsonDataBuffer = new Uint8Array(buffer, fileInfo.begin, fileInfo.length);
// 将Buffer转化为字符串
let jsonDataStr = String.fromCharCode.apply(null, jsonDataBuffer);
// 对字符串进行转义,兼容处理中文字符,没有这一步中文会乱码
jsonDataBuffer = decodeURIComponent(escape(jsonDataStr));
// 将字符串转化为Json对象
jsonMap[jsonName] = JSON.parse(jsonDataBuffer);
}
具体代码的作用,上面的注释都很清楚了,就不多说了。
结语
到这里Json文件的合并打包处理就结束了,它可以有效减少配置文件的读取次数和下载次数,但是同样存在缺陷,那就是它需要占用两份内存,一份是下载下来的二进制数据,一份则是使用的时候解析出来的Json数据,目前我还没想到有什么好办法可以解决这个缺陷,有好办法的朋友,也希望能够告诉我,让我学习一下,感谢。