需要申明一点,这是我接过最坑的渠道了,各种神奇的问题,首先是接口比较奇怪而且新旧版本搞得很混乱,其次是平台底层实现性能差而且很多限制。此外,这里需要理清楚一个概念:QQ 玩一玩
和 QQ 玩吧
并非同一个东西,QQ 玩一玩也叫 QQ 轻游戏
或 厘米游戏
,是基于 bricks 引擎实现的。
技术限制
-
玩一玩平台不支持基于DOM Document对象的HTML元素处理
-
玩一玩平台不支持皮肤的远程加载,所有皮肤必须声明到egret项目的
default.thm.json
文件当中。 -
不允许动态执行代码能力。
不支持的功能:
渲染相关
-
不规则遮罩
-
动态截屏
-
位图缓存
触摸相关
-
像素级碰撞检测
-
点击穿透
调试相关
-
脏矩形调试显示
-
fps监视器
-
屏幕调试日志
适配步骤
参考 Egret 的官方文档 ,我们当前使用的引擎版本为 5.2.6
,直接使用 egret run –target bricks
命令生成一个 Xcode 工程。
假如 iOS 系统是最新的 11.4.1 ,则需要安装最新版本的 Xcode (这也要求 Mac 是 10.13.2 或更新的系统)
由于我们游戏中使用了 fairygui 来制作 UI,在厘米游戏这里需要使用二进制的导出形式,原来的 xml 导出无法加载出来。即全局设置中:扩展名改为 zip ,然后勾选 使用二进制格式
。
资源管理
玩一玩与微信在资源限制上是相似:
-
微信要求首包 4M 以内,其他资源放在 CDN;
-
手 Q 玩一玩要求首包 10M 以内,其他资源放在 CDN。
打包发布
-
打包:
参考官方文档 文件打包与运行 的相关规则,将代码和部分资源打成一个 zip 包,命名为
cmshow_game_xxx.zip
,其中 xxx 是游戏的 gameId 。 -
测试:
从手 Q7.6.0 开始 QQ 轻游戏不再提供特殊版本手 Q,开发者统一通过上传脚本至管理端进行测试。
我们游戏使用的是 egret 引擎,在 Egret Launcher 中可以直接发布 QQ玩一玩
的版本包,其发布出来的是一个 Xcode 工程,而工程中 PublicBrickEngineGame\Res
目录下便是最终上传给平台的游戏代码包。除了首次发布需要用到 Egret Launcher ,之后更新代码直接使用 egret publish --target bricks
即可。
常见报错
2018-09-05 11:32:37.055825+0800 PublicBrickEngineGame[76458:6693859] [MC] Lazy loading NSBundle MobileCoreServices.framework
2018-09-05 11:32:37.056608+0800 PublicBrickEngineGame[76458:6693859] [MC] Loaded MobileCoreServices.framework
level=1, code=0, info1=SetKeepScreenOn , info2=, info3=
level=0, code=0, info1=brick_log, info2=filemanage.js is loaded, info3=
level=0, code=0, info1=brick_log, info2=Load Canvas.js succeed., info3=
level=0, code=0, info1=brick_log, info2=Load Sprite.js succeed., info3=
level=0, code=0, info1=BK.File.bkIsFileExist! error = No such file or directory, info2=, info3=
level=1, code=0, info1=brick_log, info2=Xcode 环境, info3=
level=0, code=0, info1=brick_log, info2=Load SandBoxCanvas.js succeed., info3=
level=1, code=0, info1=brick_log, info2= Using default export (`import mobx from 'mobx'`) is deprecated and won’t work in mobx@4.0.0
Use `import * as mobx from 'mobx'` instead, info3=
level=1, code=0, info1=brick_log, info2= [11:32:40+239ms] Failed getting local storage item (key: MusicEnabled)., info3=
level=1, code=0, info1=brick_log, info2= [11:32:40+241ms] Failed getting local storage item (key: SoundEnabled)., info3=
level=1, code=-1, info1=BK.TickerPlt.-[TickerPlatform onFrame:]! isAppActive = 0, info2=, info3=
关键问题在于最后一句 BK.TickerPlt.-[TickerPlatform onFrame:]! isAppActive = 0
直接黑屏 QQ玩一玩XCode运行模拟器黑屏,求助 是 egret 的bug ,更新引擎只 5.2.8,然后在 js/main.min.js
末尾加入如下内容:
;global.Main = Main;
加载资源卡住:
info2=BK.MQQ.SsoRequest.callback errCode:1
这是通过 Http 去加载图片时出现的,这是表示索取的资源地址不存,检测 CDN 资源即可。
WebSocket
这是需要使用 BK.WebSocket 进行重写的部分,使用普通的 websocket 库的话会在获取 websocket 的 readyState 时报错。
假如是断开网络:
会先有 BK.Socket.DisconnectEvent
事件,然后触发 onError
回调,并带有错误码和错误信息:
错误码:1006
错误信息:abnormal closure
息屏 > 5 分钟,服务器会主动断开连接,也会在 websockt 的 onError 会收到错误码:
错误码:1006
错误信息:abnormal closure
假如是服务器通过直接断开 TCP 实现主动将玩家踢下线的话,需要主动掉一次 close 再创建新的连接,否则会一直卡在 connecting 状态没办法切换到 connected 状态。
设计断线重连方案:
-
不做自动重连,只有发送网络请求时才检测当前网络的可用性并进行重连恢复
-
当出现异常状况只记录网络不可用状态
-
假如网络正常但发包等待回包超时,需要弹窗提示
-
被踢下线也应该有弹窗提示
-
恢复网络前先检测网络是否可用,假如不可用需要弹窗提示
-
恢复失败需要弹窗提示
另外,假如网络协议发送底层实现是队列式异步发送而非阻塞的话,最好把登录和重登这些与业务逻辑无关的协议使用单独阻塞的通道去发送,不要与业务队列混到一起,否则处理重连时会很混乱。
数据转化
一般的平台都是使用 ArrayBuffer 来存储二进制数据,而厘米秀使用了自己的一个特殊的结构 BK.Buff ,所以需要两个接口来实现:
-
ArrayBuffer 转 BK.Buff
// ArrayBuffer 转为 BK.Buff public getBKBuffFromArrayBuffer(arrayBuffer: ArrayBuffer) { let len = arrayBuffer.byteLength; let bkBuff = new BK.Buffer(len, true); let uint = new Uint8Array(arrayBuffer); for (let i = 0; i < len; i++) { bkBuff[i] = bkBuff.writeUint8Buffer(uint[i]); } return bkBuff; }
-
BK.Buff 转 ArrayBuffer
// BK.Buff 转为 ArrayBuffer public getArrayBufferFromBKBuff(bkBuff: BK.Buffer) { let len = bkBuff.bufferLength(); let array = new Uint8Array(len); for (let i = 0; i < len; i++) { array[i] = bkBuff.readUint8Buffer(); } return array.buffer; }
调试
Xcode 调试
使用 Egret Launcher 工具打出的包本身就是一个 Xcode 工程,可以在 Mac 下的 Xcode 打开,然后安装到 iOS 手机上运行游戏。
除了可以直接在 Xcode 中看到执行的日志,还可以直接在 Safair 上远程调试游戏,具体步骤参考:Xcode工程下使用Safari远程调试游戏 。上面的方法也只能解决一些类似资源加载和存储、网络和音频,但关于平台登录相关的就测试不了,因为通过 openId 获取 openKey 会返回错误码 1;
VSCode 调试插件
为此,厘米游戏官方也推出了替代的调试方案,即借助 Visual Studio Code 和额外的插件,实现在 Windows 和 Mac 下实现调试。参考 7.3 调试工具 在 VS Code 中安装对应的调试插件。
在 VS Code 中打开 PublicBrickEngineGame\Res
目录,然后打开 main.js
脚本(这是厘米游戏的启动入口),在编辑窗口的右上角会出现 应用管理
、调试
、部署
和 配置
4 个按钮。根据提示配置相应的 gameId 、appId 和 appKey 即可开始调试。
然而,使用 node.js 版本出现 使用检查器协议进行调试,因为无法确定 Node.js 版本 (Error: connect ECONNREFUSED 127.0.0.1:2507)
错误,看了 pre-attach.js
源码发现是调用 adb reverse
的时候报错,那么问题就出在 adb 工具,结果发现原本使用的 adb 工具的版本为 1.0.31
,换成 1.0.39
的版本就正常了。
而使用 python 版本则打开了 debugBrick.apk ,而且按照插件中的 pre-attach.py
这个任务 python 脚本执行到最后的 try to attach brick ...
日志,但是启动便黑屏了,也没有任何日志输出。
常见问题
1.账号权限的问题
由于调试工具黑屏无法显示界面内容,所以只能选择上传到后台再扫码测试了,结果出现了:
使用手机扫码之后,提示 该游戏已下架,无法继续玩耍
,原因是管理后台开发管理处提示: Warning: appid没有通过审核,不可申请上架;
。
2.启动报错
官方客服提示只能用 Android 手机进行测试,扫码提示 启动失败,请稍后重试哦~
,然后在手机文件管理器中打开 内部存储/tencent/MobileQQ/.apollo/game
目录下(是隐藏目录,需要设置显示隐藏文件或文件夹才能查看),查看是否有以游戏 GameId 取名的目录和 .zip
包,这就是游戏包体保存的文件夹。
平台包内文件的缓存路径:
GameRes://resource/
和GameSandBox://
实际上也都在game/xxx
目录下。
最后,发现使用开发者主账号一直启动不了,而使用另一个 QQ 添加到白名单反而可以,十分奇葩的问题。
3.adb 查看报错日志
确保 adb devices -l
下可以看到手机设备,然后使用 adb logcat
来捕获游戏日志,需要使用 sava_native_log
标签来进行过滤:
adb shell "logcat |grep sava_native_log"
其中 sava_native_log
是玩一玩日志输出的标识。或者,直接写入到文件中:
adb logcat -v time process > log.txt
需要注意,使用
BK.Script.log
打印的日志在这里是不会输出的,只会输出 console.log 的日志。
4.Android 扫码启动卡在 99%
通常是由于代码报错导致的,经过一下午的排查才发现是少了 promise.js
的引入(参考:玩一玩游戏FAQ)
5.动画很鬼畜或抖动,遮罩不生效
这是因为打包的时候,默认帧率是 30 :
egret.runEgret(
{
renderMode: window.renderMode,
frameRate: 30,
contentWidth: 640,
contentHeight: 1136,
entryClassName: "Main",
scaleMode: "showAll",
orientation: "auto",
background: 0x888888
}
);
应该改为 60 帧,然后 UI 适配方案根据自己游戏的情况调整:
egret.runEgret(
{
renderMode: window.renderMode,
frameRate: 60, // 默认是 30 帧,会有各种鬼畜的问题(动画抽搐、遮罩变黑)
contentWidth: 720, // 默认是 640 X 1136
contentHeight: 1280,
entryClassName: "Main",
scaleMode: "fixedWidth", // 默认 showAll
orientation: "portrait", // 默认 auto
background: 0x888888
}
);
6.扫码弹窗提示报错
在扫码之后弹窗报错信息如下:
[game:3958]Execute JS Error!
[TypeError:undefined is not an object (evaluating 'e.prototype')]:line = 13,column = 20,
可以直接将 内部存储/tencent/MobileQQ/.apollo/game/游戏GameId/main.js
脚本复制到打包前的工程中,替换原本的 main.js ,使用 VS Code 调试工具调试,可以看到执行输出:
*********************************出现未捕获异常***********************************
11-01 10:38:40.875 17189 17213 I System.out: sava_native_log [printNativeLog], level:1,code:1,info1:jswrapper.__onMessageCallback! brick_exception ERROR: Uncaught TypeError: Cannot read property 'prototype' of undefined, /sdcard/tencent/MobileQQ/Test/main.js:0:0
**********************************异常信息结束***********************************
*********************************出现未捕获异常***********************************
11-01 10:38:40.875 17189 17213 I System.out: brick_exception STACK:
11-01 10:38:40.875 17189 17213 I System.out: brick_exception [0]__extends@/sdcard/tencent/MobileQQ/Test/main.js:13
**********************************异常信息结束***********************************
定位到问题出现在 main.js 中的第 13 行,即 r.prototype = e.prototype, t.prototype = new r();
这一行,那么加一个 try catch 捕获导致异常的调用:
var window = this;
var global = global || this;
global.bricks = {};
this.navigator = { userAgent: 'bricks' };
this.setTimeout = this.setTimeout || function () {
};
var __extends = function (t, e) {
function r() {
this.constructor = t;
}
for (var i in e)
e.hasOwnProperty(i) && (t[i] = e[i]);
try{
r.prototype = e.prototype, t.prototype = new r();
}catch(e){
console.log('err = '+t.name);
console.log('err = '+e.stack);
}
};
再次调试,发现报错调用堆栈信息:
[11-1-2018 11:19:19] [BRICK_LOG] LEVEL = 1, ERRCODE = 0, INFO = err = AdapterTexture
[11-1-2018 11:19:19] [BRICK_LOG] LEVEL = 1, ERRCODE = 0, INFO = err = TypeError: Cannot read property 'prototype' of undefined
at __extends (/sdcard/tencent/MobileQQ/Test/main.js:14:20)
at /sdcard/tencent/MobileQQ/Test/main.js:66122:9
at window.spine.window.spine (/sdcard/tencent/MobileQQ/Test/main.js:66138:6)
at /sdcard/tencent/MobileQQ/Test/main.js:66267:2
at /sdcard/tencent/MobileQQ/Test/main.js:158576:2
我出现此问题的原因是:修改了 spine 运行时的引入方式,之前的做法是源码引入跟工程一起打包,后来把它单独抽出来,以库的方式引入,从而导致了此问题。
7.游戏内所有文字往下偏移
这是因为 egret.brick.js
在将 TextField 转为 BKTextField 时计算高度有问题,修改如下:
// 修改前
BKCanvasRenderer.prototype.renderText = function (node, context) {
...
context.fillText(text, x + context.$offsetX, -y + context.$offsetY + node.height);
}
// 修改后
BKCanvasRenderer.prototype.renderText = function (node, context) {
...
context.fillText(text, x + context.$offsetX, -y + context.$offsetY + node.height + context.lineWidth + 2); // 解决文字整体下移的问题
}
8.邀请类分享
分享可用的接口有两个:
-
share
-
shareToArk
给分享的 extendInfo 塞入扩展信息,然后在 onLoad 和 onEnterforeground 监听中去通过 GameStatusInfo.gameParam
获取这个扩展信息。
9.玩家头像
与其他渠道不同,玩一玩获取玩家头像的方式并非在登录渠道时直接返回头像的网络地址,而是需要使用玩家的 openId 通过 BK.MQQ.Account.getHeadEx
接口去下载头像,下载头像保存的路径是:"GameSandBox://_head/" + openId + ".jpg"
,大概逻辑如下:
let openId = GameStatusInfo.openId;
let tex = await new Promise<egret.Texture>(resolve => {
let absolutePath = "GameSandBox://_head/" + openId + ".jpg";
let isExit = BK.FileUtil.isFileExist(absolutePath);
gLog(absolutePath + " is exit :" + isExit);
//如果指定目录中存在此图像就直接显示否则从网络获取
if (isExit) {
loadTexture(absolutePath).then(tex => {
resolve(tex);
});
} else {
BK.MQQ.Account.getHeadEx(GameStatusInfo.openId, function (oId, imgPath) {
gLog("openId:" + oId + " imgPath:" + imgPath);
loadTexture(imgPath).then(tex => {
resolve(tex);
});
});
}
})
return tex;
function loadTexture(path: string){
let texture = new egret.Texture();
let imageData = new egret.BitmapData(path);
gLog('图片:' + path + '加载成功');
texture._setBitmapData(imageData);
return texture;
}
其次,圆形头像裁剪似乎有问题,大概情况是:
-
使用 Shape 作为 mask 的或直接导致裁剪后的图片不显示;
-
使用不透明的圆形图片作为 mask 的话,有些设备上能裁剪成功,但有些设备上也会出现图片直接显示不出来的情况。
因此,玩一玩平台中使用玩家头像的成本比较高:一方面是不支持额外的裁剪,只能使用方形的;另一方面是需要先下载头像到本地才能展示。
性能测试
玩一玩提交审核时需要提供自测报告,可以借助 wetest 工具来测试,Android 手机下载 WeTest 助手 ,然后在助手中注册一个账号,选择 通用性能分析
,选择 QQ 应用,然后点击 开始测试
然后在 QQ 中打开游戏开始玩游戏即可。(前提是手机已经 root 了,假如是非 root 的手机,则还需要借助 PC 上的 cube-pc 助手),手机用 USB 线连着 pc 助手进行测试。但是,非 root 的手机无法测试 Mono 内存和 Drawcall)。
此外需要在设置中打开弹出悬浮框的权限
测试报告需要在网页打开 通用测试报告 ,登录与助手一致的 WeTest 账号,即可查看测试数据。
性能瓶颈
-
FPS (帧率)
问题:首先是 FPS 帧率方面,在 QQ 玩一玩平台,使用 egret 引擎开发的游戏中假如使用 spine 动画会有明显的掉帧现象。
解决方案:将所有的 spine 动画通过 DragonBones Pro 转为二进制格式的龙骨动画,掉帧的问题基本上能够解决。
-
内存和 DrawCall
在 WeTest 上想要测这两项数据需要 root 手机才能获得
2018.11.29 补充
BK.localStorage 有坑
之前很作死地使用玩一玩新版 API 引入的 BK.localStorage
来存储本地数据,结果偶现的在启动时弹窗报错:
Error:Storage setItem failed, item bytes is too large
看报错内容会以为是往 localStorage 中写入了太大的数据导致无法读取,其实不然,是平台底层实现的问题导致启动时初始化已写入的 localStorage 数据解析失败导致的。而且,官方技术人员反馈这个报错还是概率性的而非必现。
当然,也有必现的方法:写入带有三字节的内容,例如:emoji 表情
解决方案:
可用的解决方案有两种
-
自己通过读写本地缓存文件的方式实现一套 localStorage ,假如还想实现多账号数据隔离,以用户的 openId 作为缓存文件的名称即可;
-
在数据写入时进行一次 base64 编码,取出时再进行 base64 解码,也能解决此问题。
这里提供第一个方案的代码:
// 确保多用户隔离
const cacheFilePath = `GameSandBox://` + GameStatusInfo.openId;
let storage: any;
const qqfm = QQFileManager.instance;
function GetStorageItem(key: string): any {
if (!storage) {
ReadCacheFile();
}
return storage[key];
}
function SetStorageItem(key: string, value: string) {
storage[key] = value;
SaveCacheFile();
}
function ReadCacheFile() {
if (qqfm.exists(cacheFilePath)) {
let txt = qqfm.readFile(cacheFilePath, true) as string;
if (txt) {
console.log('storage 数据:', txt);
storage = JSON.parse(txt);
} else {
storage = {};
}
} else {
storage = {};
}
}
function SaveCacheFile() {
if (!qqfm.exists(cacheFilePath)) {
console.log('storage 缓存文件:' + cacheFilePath + ' 不存在');
}
if (!qqfm.writeStringToFile(cacheFilePath, JSON.stringify(storage))) {
console.error('写 storage 缓存文件:' + cacheFilePath + ' 失败');
}
}
function RemoveStorageItem(key: string) {
delete storage[key];
SaveCacheFile();
}
function ClearStorage() {
storage = {};
if (qqfm.exists(cacheFilePath)) {
qqfm.removeFile(cacheFilePath);
console.log('storage 缓存文件:' + cacheFilePath + ' 已清理');
}
}
LocalStorage.getItem = function <T>(key: string, defValue?: T): T {
try {
let rawValue = GetStorageItem(key);
if (!rawValue) {
rawValue = JSON.stringify(defValue);
if (!rawValue || rawValue.length == 0) {
console.warn('qq LocalStorage set "" unable');
} else {
SetStorageItem(key, rawValue);
}
}
// rawValue = rawValue.replace(/@\^@/g, '"');
return JSON.parse(rawValue);
} catch (error) {
console.error(`Failed getting local storage item (key: ${key}).`);
return defValue;
}
}
LocalStorage.setItem = function (key: string, value: any) {
try {
let valueStr = JSON.stringify(value);
// valueStr = valueStr.replace(/"/g, '@^@');
if (valueStr.length >= 4194304 || valueStr.length < 0) {
gWarn('qq LocalStorage.setItem fail, value to large');
return;
}
if (!valueStr || valueStr.length == 0) {
console.warn('qq LocalStorage set "" unable');
return;
}
SetStorageItem(key, valueStr);
} catch (error) {
console.error(`Failed setting local storage item (key: ${key}).`);
}
}
LocalStorage.removeItem = function (key: string) {
return RemoveStorageItem(key);
}
LocalStorage.clear = function () {
return ClearStorage();
}
第二个方案加入 base 64 的编码和解码的函数即可:参考 js的Base64编码与解码