异步执行
js是单线程的,分为同步任务和异步任务,同一个时刻只能去处理一个任务
有一个任务执行栈,同步任务都放到同步栈里面,异步任务执行有结果了,会放到异步栈
任务执行栈会从同步栈里取任务执行,当所有的同步栈任务执行结束,会从异步栈里取任务,
异步栈里也可以分的再细一点就是宏观异步栈和微观异步栈,在同一时刻,微观异步栈要先于宏观异步栈执行
宏观异步栈指的是:setTimeout(func,time);
微观异步栈指的是:(new Promise()).then(func)
浏览器刷新触发函数:window.requestAnimationFrame
上面三个触发器有一个最明显的差别,就是浏览器刷新函数最准确,其它两个异步触发器就算时间到了,也不一定触发,他有一个先后触发顺序,这一点细微的差别要格外注意
运行环境简要
微信小游戏运行在多种平台上:iOS(iPhone/iPad)微信客户端、Android 微信客户端、PC 微信客户端、Mac 微信客户端和用于调试的微信开发者工具。
各平台脚本执行环境是各不相同的:
在 iOS 上,小程序逻辑层的 javascript 代码运行在 JavaScriptCore 中;
在 Android 上,小程序逻辑层的 javascript 代码运行在 V8 中;
在 开发工具上,小程序逻辑层的 javascript 代码是运行在 NW.js 中
微信内部植入了一个叫runtime的浏览器内核,它和html5浏览器内核有一些区别,没有放入html解析引擎和css解析引擎,但最牛逼的js引擎(JavaScriptCore or v8)还是搞不来还是植入了,自己还做了一些业务封装,框架如下:
普通html5框架:
微信runtime框架:
代码分包
js分包比较简单,就是一个解析js代码,找出之前合并的所有文件(文件名+文件内容),然后以s[‘文件名’]=文件内容;这个为一个最小单位往子包中填充,满4兆则为一个子包,重新再生成一个子包文件,再继续填充,依次类推,关键代码如下,只包含了index.js,module.js,nameMap.js这三个js文件就可以轻松分解js代码,就是这么简单
其中index.js文件内容的末尾那个函数是拆包的入口函数
index.js
const fs = require('fs');
const path = require('path');
const fileUtils = require('../utils/file');
const acorn = require('acorn');
const escodegen = require('escodegen');
const estraverse = require('estraverse');
const {scanScripts} = require('./nameMap');
const scriptsToken = '__scripts';
const CODE_NAME = "index.js"
const codegenOpt = {
format: {
compact: true
}
}
let settingsName;
function getChild(node, child, type) {
let c = node[child]
if (!c || c.type != type) {
throw new Error('parsed error')
}
return c;
}
function isConsoleExpression(node) {
if (node.type == "LogicalExpression"
&& node.right.type == 'CallExpression'
&& node.right.callee.type == 'MemberExpression'
&& node.right.callee.object.type == 'Identifier'
&& node.right.callee.object.name == 'console') {
if (node.right.callee.property.type == 'Identifier' && node.right.callee.property.name == 'log') {
return false;
}
return true;
}
if (node.type == 'CallExpression'
&& node.callee.type == 'MemberExpression'
&& node.callee.object.type == 'Identifier'
&& node.callee.object.name == 'console') {
if (node.callee.property.type == 'Identifier' && node.callee.property.name == 'log') {
return false;
}
return true;
}
return false;
}
function removeConsole(ast) {
estraverse.replace(ast, {
enter: function (node) {
if (node.type == 'ExpressionStatement' && isConsoleExpression(node.expression)) {
return this.remove();
}
if (isConsoleExpression(node)) {
return this.remove();
}
}
})
estraverse.traverse(ast, {
enter: function (node, parent) {
if (node.type == 'IfStatement' && !node.consequent) {
node.consequent = {
type: 'BlockStatement',
body: []
}
}
if (node.type == 'ConditionalExpression') {
if (!node.consequent) {
node.consequent = {
type: 'BlockStatement',
body: []
}
} else if (!node.alternate) {
node.alternate = {
type: 'BlockStatement',
body: []
}
}
}
if (node.type == 'UnaryExpression' && node.operator == 'void' && !node.argument) {
node.argument = {
type: 'Literal',
value: 0
}
}
if (node.type == 'CallExpression'
&& node.callee.type == 'MemberExpression'
&& node.callee.object.type == 'Identifier'
&& node.callee.object.name == 'console') {
// console.log(node.type, parent.type)
}
}
})
}
function slice(ast) {
let body = ast.body[0];
let expression = getChild(body, 'expression', 'AssignmentExpression');
let callExp = getChild(expression, 'right', 'CallExpression')
let args = callExp.arguments;
let modules = args[0]['properties'];
args[0] = getScriptsToken();
//扫描脚本
scanScripts(modules);
let scripts = [];
let script = '';
let size = 0;
let maxSize = 4 * 1000 * 1000; //4M;
for (let i = 0; i < modules.length; i++) {
let module = modules[i];
//获取文件名
let keyStr = escodegen.generate(module.key, codegenOpt);
//获取文件内容
let valueStr = escodegen.generate(module.value, codegenOpt);
//此处会多7个字节 因为s['']=;这个结构正好是7个字节
//我们每个文件都会以这样的形式存放 s['文件名']=文件内容;
let moduleSize= keyStr.length + valueStr.length + 7;//s['xxx']=yyy;
if (size + moduleSize > maxSize) {
//满4兆了 OK 保存起来
scripts.push(script);
script = '';
size = 0;
}
script += `s['${keyStr}']=${valueStr};`
size += moduleSize;
}
//每一个子包内容都保存在这个scripts数组中
scripts.push(script);
scripts = scripts.map((s) => {
return `(function(s){${s}})(window.__scripts||(window.__scripts={}))`
});
scripts.push(escodegen.generate(ast, codegenOpt))
return scripts
}
function getScriptsToken() {
return {
type: 'MemberExpression',
object: {
type: 'Identifier',
name: 'window'
},
property: {
type: 'Identifier',
name: scriptsToken
}
}
}
/**
* js源码路径
* @param {*} src
*/
module.exports = function(src) {
let code = fs.readFileSync(src, 'utf8');
let ast = acorn.parse(code);
removeConsole(ast);
return slice(ast);
}
nameMap.js
let map = {};
function scanScripts(properties) {
for (let i = 0; i < properties.length; i++) {
let property = properties[i];
let key = property.key;
let name
if (key.type == "Literal") {
name = key.value
property.key = {
type: 'Identifier',
name: name
}
} else if (key.type == "Identifier") {
name = key.name;
}
if (!name || map[name]) {
throw new Error('script name is duplicated:' + key);
}
map[name] = next();
}
}
function getShortName(key) {
return map[key];
}
let letters = 'abcdefghijklmnopqrstuvwxyz'
let numbers = '0123456789'
let lettersLen = letters.length;
let numbersLen = numbers.length;
let nameIndex = 0;
function next() {
let index = nameIndex;
let name = letters.charAt(index % lettersLen);
index = Math.floor(index / lettersLen);
while (index > 0) {
name += getCharAt(index % (lettersLen + numbersLen));
index = Math.floor(index / (lettersLen + numbersLen))
}
nameIndex++;
return name;
}
function getCharAt(i) {
if (i < lettersLen) {
return letters.charAt(i)
} else {
return numbers.charAt(i - lettersLen)
}
}
module.exports = {
getShortName: getShortName,
scanScripts: scanScripts
}
module.js
const {getShortName} = require('./nameMap')
function Module(ast) {
this.name = ast.key;
let value= ast.value;
this.function = value[0];
this.map = value[1];
}
let proto = Module.prototype;
proto.convertName = function() {
this.shortName = getShortName(this.name)
this.convertedFunc = {
type: this.function.type
}
}
proto.toString = function() {
}
module.exports = Module
防破解
对于微信小游戏而言想要获取它的代码和资源太简单了,现成的脚本工具,几乎用不了几分钟就可以拿到,可是要想连到小游戏的服务端这个问题就复杂了,微信后台做了保护,大致如下:
//发起登录
public login() {
let wx: any = window['wx'];
let fail = function () {
UIPopupHelper.showOfflineDialog(Lang.get("login_network_timeout"), null, this.login.bind(this));
G_WaitingMask.showWaiting(false);
}.bind(this);
let this1 = this;
G_WaitingMask.showWaiting(true);
wx.login({
success(res) {
//微信登录成功后 会返回一个临时code
this1._loginServer(res.code, (ret, data) => {
G_WaitingMask.showWaiting(false);
this1._onGetToken(ret, data);
}, fail);
},
fail: fail
})
}
//拿到这个微信返回的code 去访问我们自己的服务器
//我们的服务器会拿着这个code和appid还有appsecret这三个值去再一次访问微信来判断有效性
//如果有效 则合法 允许登录
//否则 登陆不合法
private _loginServer(code: string, success?: Function, fail?: Function) {
// console.log("NativeAgentWeChat loginServer", code);
let url = config.LOGIN_URL_TEMPLATE;
url = url.replace("#domain#", config.LOGIN_URL);
let requestData = {
appID: this.getGameId(),
channelID: this.getChannelId(),
extension: "",
sdkVersionCode: "1.0",
deviceID: "",
userType: "",
sign: ""
}
requestData.extension = JSON.stringify({ code: code });
let sign = "appID=" + requestData.appID + "channelID=" + requestData.channelID + "extension=" + requestData.extension + this._appkey;
// console.log("sign:", sign);
requestData.sign = window['md5'](sign);
let srcs = JSON.stringify(requestData);
// console.log("requestData", srcs);
let aesRequestData = CryptoJS.AES.encrypt(srcs, this._aesKey, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 });
// console.log(aesRequestData.toString());
url = url.replace("#data#", encodeURIComponent(aesRequestData));
// console.log(url);
let http = new HttpRequest();
http.get(url, (response) => {
// console.log(response);
let ret = JSON.parse(response);
if (ret.state != 1 || ret.data == null) {
fail && fail();
return;
}
let decrypt = CryptoJS.AES.decrypt(ret.data, this._aesKey, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 });
let data = decrypt.toString(CryptoJS.enc.Utf8);
console.log("decrypt", data);
success && success(ret.state, data);
}, fail);
}
private _onGetToken(ret, response?: string) {
if (ret != 1) {
this._dispatch({ event: NativeConst.SDKLoginResult, ret: NativeConst.STATUS_FAILED, param: "" });
return;
}
let responseData = JSON.parse(response);
console.log("_onGetToken:", ret, responseData);
this._openid = responseData.extension.openid;
this._sessionKey = responseData.extension.session_key;
// if (!ALDStatistics.instance.isFirstLoginGame() && !ALDStatistics.instance.hasMarkAB) {
// this.abCode = this._openid.charCodeAt(this._openid.length - 1);
// } else {
// var code = window['md5'](this._openid);
// this.abCode = code.charCodeAt(code.length - 1);
// }
// wx.aldUserAB(this.getversionAB());
// console.log('AB_code: ', this.getversionAB());
this._topUserID = responseData.topUserID.toString();
let topUserName = responseData.topUserName.toString();
this._topUserName = topUserName;
let data: any = {};
data.topUserID = topUserName;
data.topUserName = topUserName;
data.platformID = responseData.platformID;
data.sdkUserName = "";
data.sdkUserID = this._openid;
data.channelID = this.getPlatformId(); // responseData.channelID;
data.token = "这是一个自定义token";
data.timestamp = (new Date().getTime() * 1000).toString();
data.extension = "gptxxxxxxx|1|1";
let sign = topUserName + topUserName + data.sdkUserID + data.token + config.TOKEN_KEY;
data.sign = window['md5'](sign);
console.log("_onGetToken:", data);
this._data = data;
if (G_StorageManager.load('server')) {
this._dispatch({ event: NativeConst.SDKLoginResult, ret: NativeConst.STATUS_SUCCESS, param: data });
}else {
G_RoleListManager.checkUpdateList();
}
}
由代码和图可以知道,如果想要破解微信小游戏和服务器连接,你得知道人家的appid,微信验证登录的时候是结合code+appid+appsecret这三个值来判断的,我们的小游戏在打包成功以后,她如果调用wx.login,那么除了code是临时的,appid和appsecret都是确定的,所以我们还要把我们的小游戏发布到原版的微信后台,什么这不是自投罗网吗?
resources目录下的资源与library目录下的关系
resources目录下的每个资源都会带有一个meta文件,这个meta文件会存放一个唯一的uuid来标记该文件
资源之图片:
resources目录下的.meta文件内容如下,红框中为图片的uuid
在library库中找到对应的存放位置:
在library库下还有个配置文件uuid-to-mtime.json,这个是用来建立resources目录下的资源和library/imports目录下资源的桥梁,那么当前这个图片在配置文件的记录如下:
打包后这个资源的位置如下:
由于构建发布后,资源会进行自动合并到图集,所以这个资源的存储位置可能已经镶嵌到一张大图中了,但是路径是不会变的,我们可以拿着下面的路径去找
"relativePath": "resources\\icon\\achievement\\bg_signpics.png"
构建发布后,主包的资源会放在这个文件夹下
打开这张图里的config,输入路径,会看到他被放到了一个叫11953下标的位置存放,继续搜索这个下标
会看到11953这个下标又被一个07d5dfd4a这个下标的位置存放,继续搜索
会看到这个07d5dfd4a被存到下面这个数组里,ok这个算是到顶层了,打开import这个文件夹,搜索07d5dfd4a
07d5dfd4a.md5.json
打开这个07d5dfd4a.md5.json
注意到texture也存了一个MD5值,拿这个值在native文件夹下搜索就可以找到打包后的资源了啊
资源之预制体:未完待续
resources文件下对应文件的.meta文件的记录
library文件夹的uuid-to-mtime.json中的记录
library文件夹的imports文件下
构建成微信小游戏包后的资源存放目录:查看一下这个config.MD5.json文件
输入资源的名字
prefab/achievement/DailyActivityHint
在import文件夹下输入:04bfb49bb
资源之声音:未完待续
总结:对于原生资源,比如一张图片或者一个声音文件,一般情况即没有被引用的情况下,会在import文件夹生成一个json,我们通过json可以获取到每个资源的native的路径MD5值,拿这个值在native文件下找到对应的文件,我们在外围访问这个资源的时候是通过和import文件夹同目录的config文件里找到访问路径的
资源的相对路径===========》输入到config.md5.json配置文件中========》获取到import文件下的json文件MD5名========》打开import文件夹下对应的json文件,即可以找到对应native文件夹下资源的MD5名=====》在native文件夹输入对应的MD5名即可以获取到资源
再次化简:
资源的相对路径==》config==》import==》native==>资源
其实这个过程就是config负责建立外围和import的联系,而import是为了建立和native原生资源的联系
发现:图片会被自动的合并起来,如果预制体里使用了图片,图片的json信息也会和预制体的json信息合并成一个json存放在import文件夹下
AB包
ab包通过一个文件夹生成,这个文件夹里包含了所有的图片资源,声音,脚本文件等,那么最后生成一个AB包的时候,最终的产物一个import文件夹,一个naitive文件夹,一个config.md5.json
如果包含脚本的话,会单独生成一个index.js文件,将所有脚本文件合并
注意:
1:Creator 有 4 个 内置AB包,包括 resources、internal、main、start-scene,在设置 Bundle 名称 时请不要使用这四个名称
2:小游戏分包只能放在本地,不能配置为远程包,所以当 压缩类型 设置为 小游戏分包 时,配置为远程包 项不可勾选
3:Zip 压缩类型主要是为了降低网络请求数量,如果放在本地,不用网络请求,则没什么必要,所以要求与 配置为远程包 搭配使用
进一步说一下:import naitive config.md5.json
import:你可以理解为creator将一个显示页面导出一个配置文件,creator加载这个配置文件还是可以还原显示页面的
native:这个是资源,是实实在在的资源
config.md5.json:每一个AB包生成以后最外层都会有一个config文件,看他的名字很特别,加了一个MD5值,你可以理解这个MD5值就是当前这个AB包的名字,因为最后creator会根据这个值来找这个AB包
过程
下面这张图是位于src/setting.js的文件内容,他是一份配置
bundleVers:这个字段记录了当前包使用的各个AB包的版本,用一个MD5值去记录,内部会自动根据这个MD5值来找到对应的配置
关于AB包说一下:creator自己内置四个AB包
1:interval这个内置包主要是creator自己使用的资源(shader,图片等)
2:start-scene这个内置包主要是我们如果在构建工程的时候,如果勾选了初始场景分包,那么就会为我们生成这个子包,这里面主要的内容就是我们的启动场景所用到的所有资源和脚本,
3:remote这个内置包是我们构建的时候,勾选了将主包设置为远程资源包,那么就会生成这么一个内置包,
4:resources这个内置包是游戏主资源包,如果我们勾选了主包设置为远程资源包,那么该文件夹下的所有资源就会跑到同级remote文件夹下,这个remote下的所有资源可以考虑打成zip包,上传到资源服,然后再资源服将其解压
特别注意1:我们一般将主要资源都放在resources这个文件夹中,资源主要包括预制体声音图片动画脚本等文件,其中脚本文件会单独的放到根目录的src文件夹下,假设我们之前用的是js文件,那么此处src中将会放置相对路径的js文件,如果之前都是ts脚本文件,那么将统一合并到相对路径的index.js文件中,这里的相对路径指的是之前是基于resources为根目录,现在是基于src为根目录
特别注意2:小游戏如果代码包超过了限制,那么就要分包,这里分包的代码其实就是分的主包的代码,也是上面提到的index.js,我们一般都是使用ts开发的,这些放在resouces文件夹下的ts,最终都会合并到src/scripts/resources/index.js中,我们只要读取这个文件进行代码拆分即可
特别注意3:start-scene中也会包含脚本文件,这个是不会合并到src目录下的index.js中的,它属于启动场景,不是主包,所以在自己的文件夹进行合并生成index.js,
ccRequire.js:这里面包含要加载的脚本文件,一部分脚本文件是我们在工程中自己使用的,还有一部分是生成assetboudle过程中产生,assetboudle对应的资源也有可能包含脚本代码,那这个代码就会自动合并到index.js文件夹中
游戏启动:
调用了下面这句话来加载各个AB包,如果当前这个AB包是启动场景,那么游戏就会依据启动场景的逻辑代码开始加载游戏资源,从而进入游戏
cc.assetManager.loadBundle(bundleRoot[i], cb);
main.js
"use strict";
window.boot = function () {
var settings = window._CCSettings;
window._CCSettings = undefined;
var onStart = function onStart() {
cc.view.enableRetina(true);
cc.view.resizeWithBrowserSize(true);
var launchScene = settings.launchScene; // load scene
cc.director.loadScene(launchScene, null, function () {
console.log('Success to load scene: ' + launchScene);
});
};
var isSubContext = cc.sys.platform === cc.sys.WECHAT_GAME_SUB;
var option = {
id: 'GameCanvas',
debugMode: settings.debug ? cc.debug.DebugMode.INFO : cc.debug.DebugMode.ERROR,
showFPS: !isSubContext && settings.debug,
frameRate: 60,
groupList: settings.groupList,
collisionMatrix: settings.collisionMatrix
};
cc.assetManager.init({
bundleVers: settings.bundleVers,
subpackages: settings.subpackages,
remoteBundles: settings.remoteBundles,
server: settings.server,
subContextRoot: settings.subContextRoot
});
var _cc$AssetManager$Buil = cc.AssetManager.BuiltinBundleName,
RESOURCES = _cc$AssetManager$Buil.RESOURCES,
INTERNAL = _cc$AssetManager$Buil.INTERNAL,
START_SCENE = _cc$AssetManager$Buil.START_SCENE;
var bundleRoot = [INTERNAL];
settings.hasStartSceneBundle && bundleRoot.push(START_SCENE);
settings.hasResourcesBundle && bundleRoot.push(RESOURCES);
var count = 0;
function cb(err) {
if (err) return console.error(err.message, err.stack);
count++;
if (count === bundleRoot.length + 1) {
cc.game.run(option, onStart);
}
} // load plugins
//加载脚本
cc.assetManager.loadScript(settings.jsList.map(function (x) {
return 'src/' + x;
}), cb); // load bundles
//加载所有bundle里生成的index.js脚本
//这里包含了内置的start-scene这个AB包所包含的启动脚本
for (var i = 0; i < bundleRoot.length; i++) {
cc.assetManager.loadBundle(bundleRoot[i], cb);
}
};
拓展
1:微信小程序开发者模式右上角的RT-FPS和Min-FPS和EX-FPS分别含义?
rt-fps : runtime fps 实时 帧率
ex-fps :是极限帧率,可以理解为在不受驱动帧率的限制下(大部分手机微 60fps),仅仅计算 js 运行耗时,可以达到的极限帧率。这个数字可以用于评估在满帧的前提下,运行性能是否有变化
min-fps: 最小帧率
那creator中又是如何设置帧率的呢?
window.requestAnimationFrame这个是浏览器的刷新界面函数,每秒调用60次,这个是死的,如果我们想一秒钟调用30次,下面的实现的逻辑就是奇偶帧错开渲染,这不就是30帧了吗,目前只支持这两种帧率,如果低于这个帧率,那么就会显示不正常,但是根据这个规律,其实可以自定义各种帧率,只是没有意义而已
如果想该改变window.requestAnimationFrame这个函数的刷新时间,可以通过wx.setPreferredFramesPerSecond(fps)这个函数
// @Game play control
/**
* !#en Set frame rate of game.
* !#zh 设置游戏帧率。
* @method setFrameRate
* @param {Number} frameRate
*/
setFrameRate: function (frameRate) {
var config = this.config;
config.frameRate = frameRate;
if (this._intervalId)
window.cancelAnimFrame(this._intervalId);
this._intervalId = 0;
this._paused = true;
this._setAnimFrame();
this._runMainLoop();
},
// @Time ticker section
_setAnimFrame: function () {
this._lastTime = performance.now();
var frameRate = game.config.frameRate;
this._frameTime = 1000 / frameRate;
cc.director._maxParticleDeltaTime = this._frameTime / 1000 * 2;
if (CC_JSB || CC_RUNTIME) {
jsb.setPreferredFramesPerSecond(frameRate);
window.requestAnimFrame = window.requestAnimationFrame;
window.cancelAnimFrame = window.cancelAnimationFrame;
}
else {
if (frameRate !== 60 && frameRate !== 30) {
window.requestAnimFrame = this._stTime;
window.cancelAnimFrame = this._ctTime;
}
else {
window.requestAnimFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
this._stTime;
window.cancelAnimFrame = window.cancelAnimationFrame ||
window.cancelRequestAnimationFrame ||
window.msCancelRequestAnimationFrame ||
window.mozCancelRequestAnimationFrame ||
window.oCancelRequestAnimationFrame ||
window.webkitCancelRequestAnimationFrame ||
window.msCancelAnimationFrame ||
window.mozCancelAnimationFrame ||
window.webkitCancelAnimationFrame ||
window.oCancelAnimationFrame ||
this._ctTime;
}
}
},
_stTime: function(callback){
//获取页面加载到现在的时间 单位(毫秒)
var currTime = performance.now();
var timeToCall = Math.max(0, game._frameTime - (currTime - game._lastTime));
var id = window.setTimeout(function() { callback(); },
timeToCall);
game._lastTime = currTime + timeToCall;
return id;
},
_ctTime: function(id){
window.clearTimeout(id);
},
//Run game.
_runMainLoop: function () {
if (CC_EDITOR) {
return;
}
if (!this._prepared) return;
var self = this, callback, config = self.config,
director = cc.director,
skip = true, frameRate = config.frameRate;
debug.setDisplayStats(config.showFPS);
callback = function (now) {
if (!self._paused) {
self._intervalId = window.requestAnimFrame(callback);
if (!CC_JSB && !CC_RUNTIME && frameRate === 30) {
if (skip = !skip) {
return;
}
}
director.mainLoop(now);
}
};
self._intervalId = window.requestAnimFrame(callback);
self._paused = false;
},