【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")