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

5、代码地址

https://github.com/chenquanjun/Cocos2dxEncyptAndUpdate

posted @ 2015-11-24 14:28  CreeperChange  阅读(3406)  评论(1编辑  收藏  举报