【Quick 3.3】资源脚本加密及热更新(三)热更新模块
【Quick 3.3】资源脚本加密及热更新(三)热更新模块
注:本文基于Quick-cocos2dx-3.3版本编写
一、介绍
lua相对于c++开发的优点之一是代码可以在运行的时候才加载,基于此我们不仅可以在编写的时候热更新代码(针对开发过程中的热更新将在另外一篇文章中介绍),也可以在版本已经发布之后更新代码。
二、热更新模块
cocos2dx的热更新已经有很多篇文章介绍了,这里主要是基于 quick-cocos2d-x的热更新机制实现(终极版2)(更新3.3版本)基础上修改。
1、launcher模块(lua更新模块)
launcher模块的具体介绍可以看原文,不过这里的更新逻辑是稍微修改了的。
先请求服务器的launcher模块文件,如果本地launcher模块文件和服务器不同则替换新的模块再重新加载。
具体更新逻辑如流程图所示:
原文是把文件md5和版本信息都放在同一个文件里面,这里把版本信息和文件md5分成两个文件,这样的好处是不用每次都把完整的md5文件列表下载下来。除此之外还增加了程序版本号判断,优化了一些逻辑。具体代码见最后的资源链接。
2、版本文件/文件md5信息生成
原文的md5信息(flist)是通过lua代码调用引擎模块生成,但是鉴于工程太大不利于分享(其实目的只是要生成文件md5信息),所以这里把代码改成python版本的了。
注意,如果你也想要尝试把lua改成其他语言实现,你可能会发现生成的md5和lua版本的不同,这是因为lua版本将字节流转换成大写的十六进制来生成md5的。
#lua 版本
local function hex(s)
s=string.gsub(s,"(.)",function (x) return string.format("%02X",string.byte(x)) end)
return s
end
#python 版本
def toHex(s):
return binascii.b2a_hex(s).upper()
具体的python脚本代码(还是基于上个教程的脚本增加代码)
#coding=utf-8
#!/usr/bin/python
import os
import os.path
import sys, getopt
import subprocess
import shutil
import time, datetime
import platform
from hashlib import md5
import hashlib
import binascii
def removeDir(dirName):
if not os.path.isdir(dirName):
return
filelist=[]
filelist=os.listdir(dirName)
for f in filelist:
filepath = os.path.join( dirName, f )
if os.path.isfile(filepath):
os.remove(filepath)
elif os.path.isdir(filepath):
shutil.rmtree(filepath,True)
def copySingleFile(sourceFile, targetFile):
if os.path.isfile(sourceFile):
if not os.path.exists(targetFile) or(os.path.exists(targetFile) and (os.path.getsize(targetFile) != os.path.getsize(sourceFile))):
open(targetFile, "wb").write(open(sourceFile, "rb").read())
def copyFiles(sourceDir, targetDir, isAll):
for file in os.listdir(sourceDir):
sourceFile = os.path.join(sourceDir, file)
targetFile = os.path.join(targetDir, file)
if os.path.isfile(sourceFile):
if not isAll:
extName = file.split('.', 1)[1]
if IgnoreCopyExtFileDic.has_key(extName):
continue
if not os.path.exists(targetDir):
os.makedirs(targetDir)
if not os.path.exists(targetFile) or(os.path.exists(targetFile) and (os.path.getsize(targetFile) != os.path.getsize(sourceFile))):
open(targetFile, "wb").write(open(sourceFile, "rb").read())
if os.path.isdir(sourceFile):
First_Directory = False
copyFiles(sourceFile, targetFile, isAll)
def toHex(s):
return binascii.b2a_hex(s).upper()
def md5sum(fname):
def read_chunks(fh):
fh.seek(0)
chunk = fh.read(8096)
while chunk:
yield chunk
chunk = fh.read(8096)
else: #最后要将游标放回文件开头
fh.seek(0)
m = hashlib.md5()
if isinstance(fname, basestring) and os.path.exists(fname):
with open(fname, "rb") as fh:
for chunk in read_chunks(fh):
m.update(toHex(chunk))
#上传的文件缓存 或 已打开的文件流
elif fname.__class__.__name__ in ["StringIO", "StringO"] or isinstance(fname, file):
for chunk in read_chunks(fname):
m.update(toHex(chunk))
else:
return ""
return m.hexdigest()
def calMD5ForFolder(dir):
md5Dic = []
folderDic = {}
for root, subdirs, files in os.walk(dir):
#get folder
folderRelPath = os.path.relpath(root, dir)
if folderRelPath != '.' and len(folderRelPath) > 0:
normalFolderPath = folderRelPath.replace('\\', '/') #convert to / path
folderDic[normalFolderPath] = True
#get md5
for fileName in files:
filefullpath = os.path.join(root, fileName)
filerelpath = os.path.relpath(filefullpath, dir)
size = os.path.getsize(filefullpath)
normalPath = filerelpath.replace('\\', '/') #convert to / path
if IgnoreMd5FileDic.has_key(fileName): #ignode special file
continue
print normalPath
md5 = md5sum(filefullpath)
md5Dic.append({'name' : normalPath, 'code' : md5, 'size' : size})
print 'MD5 figure end'
return md5Dic, folderDic
#-------------------------------------------------------------------
def initEnvironment():
#注意:复制的资源分两种
#第一种是加密的资源,从packres目录复制到APP_RESOURCE_ROOT。加密资源的类型在PackRes.php的whitelists定义。
#第二种是普通资源,从res目录复制到APP_RESOURCE_ROOT。IgnoreCopyExtFileDic定义了不复制的文件类型(1、加密资源,如png文件;2、无用资源,如py文件)
global ANDROID_APP_VERSION
global IOS_APP_VERSION
global ANDROID_VERSION
global IOS_VERSION
global BOOL_BUILD_APP #是否构建app
global APP_ROOT #工程根目录
global APP_ANDROID_ROOT #安卓根目录
global QUICK_ROOT #引擎根目录
global QUICK_BIN_DIR #引擎bin目录
global APP_RESOURCE_ROOT #生成app的资源目录
global APP_RESOURCE_RES_DIR #资源目录
global IgnoreCopyExtFileDic #不从res目录复制的资源
global IgnoreMd5FileDic #不计算md5的文件名
global APP_BUILD_USE_JIT #是否使用jit
global PHP_NAME #php
global SCRIPT_NAME #scriptsName
global BUILD_PLATFORM #生成app对应的平台
BOOL_BUILD_APP = True
IgnoreCopyExtFileDic = {
'jpg' : True,
'png' : True,
'tmx' : True,
'plist' : True,
'py' : True,
}
IgnoreMd5FileDic = {
'.DS_Store' : True,
'version' : True,
'flist' : True,
'launcher.zip' : True,
'.' : True,
'..' : True,
}
SYSTEM_TYPE = platform.system()
APP_ROOT = os.getcwd()
APP_ANDROID_ROOT = APP_ROOT + "/frameworks/runtime-src/proj.android"
QUICK_ROOT = os.getenv('QUICK_V3_ROOT')
if QUICK_ROOT == None:
print "QUICK_V3_ROOT not set, please run setup_win.bat/setup_mac.sh in engine root or set QUICK_ROOT path"
return False
if(SYSTEM_TYPE =="Windows"):
QUICK_BIN_DIR = QUICK_ROOT + "quick/bin"
PHP_NAME = QUICK_BIN_DIR + "/win32/php.exe" #windows
BUILD_PLATFORM = "android" #windows dafault build android
SCRIPT_NAME = "/compile_scripts.bat"
else:
PHP_NAME = "php"
BUILD_PLATFORM = "ios" #mac default build ios
QUICK_BIN_DIR = QUICK_ROOT + "/quick/bin" #mac add '/'
SCRIPT_NAME = "/compile_scripts.sh"
if(BUILD_PLATFORM =="ios"):
APP_BUILD_USE_JIT = False #ios not use jit
if BOOL_BUILD_APP:
APP_RESOURCE_ROOT = APP_ROOT + "/Resources"
APP_RESOURCE_RES_DIR = APP_RESOURCE_ROOT + "/res"
else:
APP_RESOURCE_ROOT = APP_ROOT + "/server/game/cocos2dx/udp"
APP_RESOURCE_RES_DIR = APP_RESOURCE_ROOT
else:
APP_BUILD_USE_JIT = True
if BOOL_BUILD_APP:
APP_RESOURCE_ROOT = APP_ANDROID_ROOT + "/assets" #default build android
APP_RESOURCE_RES_DIR = APP_RESOURCE_ROOT + "/res"
else:
APP_RESOURCE_ROOT = APP_ROOT + "/server/game/cocos2dx/udp"
APP_RESOURCE_RES_DIR = APP_RESOURCE_ROOT
print 'App root: %s' %(APP_ROOT)
print 'App resource root: %s' %(APP_RESOURCE_ROOT)
return True
def svnUpdate():
print "1:svn update"
try:
args = ['svn', 'update']
proc = subprocess.Popen(args, shell=False, stdout = subprocess.PIPE, stderr=subprocess.STDOUT)
while proc.poll() == None:
print proc.stdout.readline(),
print proc.stdout.read()
except Exception,e:
print Exception,":",e
def packRes():
print "2:pack res files"
removeDir(APP_ROOT + "/packres/") #--->删除旧加密资源
scriptName = QUICK_BIN_DIR + "/lib/pack_files.php"
try:
args = [PHP_NAME, scriptName, '-c', 'PackRes.php']
proc = subprocess.Popen(args, shell=False, stdout = subprocess.PIPE, stderr=subprocess.STDOUT)
while proc.poll() == None:
print proc.stdout.readline(),
print proc.stdout.read()
except Exception,e:
print Exception,":",e
def copyResourceFiles():
print "3:copy resource files"
print "remove old resource files"
removeDir(APP_RESOURCE_ROOT)
if not os.path.exists(APP_RESOURCE_ROOT):
print "create resource folder"
os.makedirs(APP_RESOURCE_ROOT)
if BOOL_BUILD_APP: #copy all resource
print "copy config"
copySingleFile(APP_ROOT + "/config.json", APP_RESOURCE_ROOT + "/config.json")
copySingleFile(APP_ROOT + "/channel.lua", APP_RESOURCE_ROOT + "/channel.lua")
print "copy src"
copyFiles(APP_ROOT + "/scripts/", APP_RESOURCE_ROOT + "/src/", True)
print "copy res"
copyFiles(APP_ROOT + "/res/", APP_RESOURCE_RES_DIR, False)
print "copy pack res"
copyFiles(APP_ROOT + "/packres/", APP_RESOURCE_RES_DIR, True)
def compileScriptFile(compileFileName, srcName, compileMode):
scriptDir = APP_RESOURCE_RES_DIR + "/code/"
if not os.path.exists(scriptDir):
os.makedirs(scriptDir)
try:
scriptsName = QUICK_BIN_DIR + SCRIPT_NAME
srcName = APP_ROOT + "/" + srcName
outputName = scriptDir + compileFileName
args = [scriptsName,'-i',srcName,'-o',outputName,'-e',compileMode,'-es','XXTEA','-ek','ilovecocos2dx']
if APP_BUILD_USE_JIT:
args.append('-jit')
proc = subprocess.Popen(args, shell=False, stdout = subprocess.PIPE, stderr=subprocess.STDOUT)
while proc.poll() == None:
outputStr = proc.stdout.readline()
print outputStr,
print proc.stdout.read(),
except Exception,e:
print Exception,":",e
def compileFile():
print "4:compile script file"
compileScriptFile("game.zip", "src", "xxtea_zip") #--->代码加密
compileScriptFile("launcher.zip", "pack_launcher", "xxtea_zip") #--->更新模块加密
def writeFile(fileName, strArr):
if os.path.isfile(fileName):
print "Remove old file!"
os.remove(fileName)
#write file
f = file(fileName, 'w')
for _, contentStr in enumerate(strArr):
f.write(contentStr)
f.close()
def genFlist():
print "5: generate flist"
# flist文件格式 lua table
# key
# --> dirPaths 目录
# --> fileInfoList 文件名,md5,size
folderPath = APP_RESOURCE_RES_DIR
md5Dic, folderDic = calMD5ForFolder(folderPath)
#sort md5
sortMd5Dic = sorted(md5Dic, cmp=lambda x,y : cmp(x['name'], y['name']))
#convert folder dic to arr
folderNameArr = []
for folderName, _ in folderDic.iteritems():
folderNameArr.append(folderName)
#sort folder name
sortFolderArr = sorted(folderNameArr, cmp=lambda x,y : cmp(x, y))
#str arr generate
strArr = []
strArr.append('local flist = {\n')
#dirPaths
strArr.append('\tdirPaths = {\n')
for _,folderName in enumerate(sortFolderArr):
strArr.append('\t\t{name = "%s"},\n' % folderName)
strArr.append('\t},\n')
#fileInfoList
strArr.append('\tfileInfoList = {\n')
for index, md5Info in enumerate(sortMd5Dic):
name = md5Info['name']
code = md5Info['code']
size = md5Info['size']
strArr.append('\t\t{name = "%s", code = "%s", size = %d},\n' % (name, code, size))
strArr.append('\t},\n')
strArr.append('}\n')
strArr.append('return flist\n')
writeFile(folderPath + "/flist", strArr)
def genVersion():
print "6: generate version"
folderPath = APP_RESOURCE_RES_DIR
#str arr generate
strArr = []
strArr.append('local version = {\n')
strArr.append('\tandroidAppVersion = %d,\n' % ANDROID_APP_VERSION)
strArr.append('\tiosAppVersion = %d,\n' % IOS_APP_VERSION)
strArr.append('\tandroidVersion = "%s",\n' % ANDROID_VERSION)
strArr.append('\tiosVersion = "%s",\n' % IOS_VERSION)
strArr.append('}\n')
strArr.append('return version\n')
writeFile(folderPath + "/version", strArr)
if __name__ == '__main__':
print 'Pack App start!--------->'
isInit = initEnvironment()
if isInit == True:
#若不更新资源则直接执行copyResourceFiles和compileScript
svnUpdate() #--->更新svn
packRes() #--->资源加密(若资源如图片等未更新则此步可忽略)
copyResourceFiles() #--->复制res资源
compileFile() #--->lua文件加密
genFlist() #--->生成flist文件
ANDROID_APP_VERSION = 1 #app 更新版本才需要更改
IOS_APP_VERSION = 1 #app 更新版本才需要更改
ANDROID_VERSION = "1.0.1"
IOS_VERSION = "1.0.1"
genVersion() #--->生成version文件
print '<---------Pack App end!'
注意:这个脚本是集成代码加密、资源加密、热更新文件生成的。具体使用的时候肯定会遇到很多坑的
-
坑1:项目使用luajit。
热更新和luajit有点不完美适应,因为iOS的luajit是2.1beta的(iOS的坑),而其他平台是使用的是旧版本luajit,这意味着它们的更新文件不能通用,iOS和android下载服务器的加密代码要区分开,当然如果项目没有用luajit的话就没有这个烦恼了。 -
坑2: 资源文件的位置。
android/iOS的文件引用时注意不要把未加密的代码复制进去了,上面的pyhton脚本已经帮你做了部分操作了,但是还有一些需要自己手动去改。
iOS:xcode工程注意要把原来的资源引用换成加密的资源(Mac下执行脚本会把假面资源拷贝到Resource目录下)
Android:如果你是用build_apk、build_native、build_native_release来编译的话,注意把proj.android里面的build_native_release脚本的资源复制删除语句屏蔽掉
windows:因为windows是开发的时候才用,所以是直接引用源代码的。不过你要发布windows版本的话,需要自行替换加密资源了。 -
坑3:检查脚本是否放在正确的位置。
python脚本/PackRes.php放在工程根目录(res、src同级目录);FilesPacker.php/pack_files.php放在引擎相应目录; -
坑4:检查QUICK_ROOT是否已经设置。
因为脚本要用到引擎自带的加密脚本,注意Mac使用的shell命令(.sh文件)有权限执行 -
坑5:检查参数是否正确设置。
python脚本中,APP_BUILD_USE_JIT是否使用luajit加密脚本,BOOL_BUILD_APP是否打包apk还是热更新(复制的目录不同)
3、引擎修改
因为代码已经加密,而且加入了热更新模块,所以lua的加载入口需要修改。
首先找到AppDelegate.cpp文件,加入初始化资源搜索路径initResourcePath方法,然后增加更新文件和加密文件判断。
这里有三种情况。
1:更新模式(发布版本使用)
2:加密模式(无更新,windows版本使用)
3:普通模式(无更新和无加密,开发时候使用)
void AppDelegate::initResourcePath()
{
FileUtils* sharedFileUtils = FileUtils::getInstance();
std::string strBasePath = sharedFileUtils->getWritablePath();
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)|| (CC_TARGET_PLATFORM == CC_PLATFORM_IOS)
sharedFileUtils->addSearchPath("res/");
#else
sharedFileUtils->addSearchPath("../../res/");
#endif
sharedFileUtils->addSearchPath(strBasePath + "upd/", true);
}
bool AppDelegate::applicationDidFinishLaunching()
{
#if CC_TARGET_PLATFORM == CC_PLATFORM_WIN32
initRuntime();
#elif (COCOS2D_DEBUG > 0 && CC_CODE_IDE_DEBUG_SUPPORT > 0)
// NOTE:Please don't remove this call if you want to debug with Cocos Code IDE
if (_launchMode)
{
initRuntime();
}
#endif
//add resource path
initResourcePath();
// initialize director
auto director = Director::getInstance();
auto glview = director->getOpenGLView();
if(!glview) {
Size viewSize = ConfigParser::getInstance()->getInitViewSize();
string title = ConfigParser::getInstance()->getInitViewName();
#if (CC_TARGET_PLATFORM == CC_PLATFORM_WIN32 || CC_TARGET_PLATFORM == CC_PLATFORM_MAC)
extern void createSimulator(const char* viewName, float width, float height, bool isLandscape = true, float frameZoomFactor = 1.0f);
bool isLanscape = ConfigParser::getInstance()->isLanscape();
createSimulator(title.c_str(),viewSize.width,viewSize.height, isLanscape);
#else
glview = cocos2d::GLViewImpl::createWithRect(title.c_str(), Rect(0, 0, viewSize.width, viewSize.height));
director->setOpenGLView(glview);
#endif
director->startAnimation();
}
auto engine = LuaEngine::getInstance();
ScriptEngineManager::getInstance()->setScriptEngine(engine);
lua_State* L = engine->getLuaStack()->getLuaState();
lua_module_register(L);
// use Quick-Cocos2d-X
quick_module_register(L);
LuaStack* stack = engine->getLuaStack();
stack->setXXTEAKeyAndSign("ilovecocos2dx", strlen("ilovecocos2dx"), "XXTEA", strlen("XXTEA"));
stack->addSearchPath("src");
FileUtils *utils = FileUtils::getInstance();
//1: try to load launcher module
const char *updateFileName = "code/launcher.zip";
std::string updateFilePath = utils->fullPathForFilename(updateFileName);
bool isUpdate = false;
if (updateFilePath.compare(updateFileName) != 0) //check if update file exist
{
printf("%s\n", updateFilePath.c_str());
isUpdate = true;
engine->executeScriptFile(ConfigParser::getInstance()->getEntryFile().c_str());
}
if (!isUpdate) //no update file
{
//2: try to load game script module
const char *zipFilename ="code/game.zip";
std::string zipFilePath = utils->fullPathForFilename(zipFilename);
if (zipFilePath.compare(zipFilename) == 0) //no game zip file use default lua file
{
engine->executeScriptFile(ConfigParser::getInstance()->getEntryFile().c_str());
}
else
{
//3: default load game script
stack->loadChunksFromZIP(zipFilename);
stack->executeString("require 'main'");
}
}
return true;
}
4、加入新的main入口(配合更新模块)
对于热更新,游戏执行后首先执行main.lua的代码,main.lua再调用launcher模块的代码,launcher根据版本情况决定接下来的逻辑。
这里的main.lua放在script目录里,执行python脚本后main.lua会复制到对应的src目录下
//main.lua
function __G__TRACKBACK__(errorMessage)
print("----------------------------------------")
print("LUA ERROR: " .. tostring(errorMessage) .. "\n")
print(debug.traceback("", 2))
print("----------------------------------------")
end
local fileUtils = cc.FileUtils:getInstance()
fileUtils:setPopupNotify(false)
-- 清除fileCached 避免无法加载新的资源。
fileUtils:purgeCachedEntries()
cc.LuaLoadChunksFromZIP("code/launcher.zip")
package.loaded["launcher.launcher"] = nil
require("launcher.launcher")