Cocos2dx-lua热更新实现:版本管理与资源更新详解 | 南锋

南锋

南奔万里空,脱死锋镝余

Cocos2dx-lua热更新实现:版本管理与资源更新详解

每次登陆游戏利用Cocos的assetManager从服务器拉去当前最新的两个文件。 一个是version.mainifest,一个project.mainifest. 这两个文件都是xml的描述文件。

一个包含了版本信息,第二个包含了游戏所有资源的MD5码。首先通过version文件对比本地的版本是否相同,如果不相同,再通过跟本地的project文件对比MD5码来判断哪些文件需要重新下载,替换资源。

步骤:

  1. 有一个文件下载的热更新服务器,将最新项目资源(res/ src/ 目录)放入热更新服务器中,添加版本信息母文件(version_info.json)和python脚本文件eneateManifest.py(生成project.manifest、version.manifest文件)。

2.version_info.json文件: 主要用来配置信息

1
2
3
4
5
6
7
8
9
10
11

{
"packageUrl" : "http://ip:port/update/MyProj/assets/",
"remoteManifestUrl" : "http://ip:port/update/MyProj/version/project.manifest",
"remoteVersionUrl" : "http://ip:port/update/MyProj/version/version.manifest",
"engineVersion" : "3.3",
"update_channel" : "Android",
"bundle" : "2018111701",
"version" : "1.0.0",
}

3.eneateManifest.py文件: 这个文件是一个python。目的是生成对应的version和project文件。project文件可以帮你给每个资源生成独一无二的MD5码,相当于每个资源的标记。下面是一段python文件的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#coding:utf-8

import os
import sys
import json
import hashlib
import subprocess
import getpass

username = getpass.getuser()
# 改变当前工作目录
#os.chdir('/Users/' + username + '/Documents/client/MyProj/')

assetsDir = {
#MyProj文件夹下需要进行热跟的文件夹
"searchDir" : ["src", "res"],
#需要忽略的文件夹
"ignorDir" : ["Cocos", "framework", ".svn"],
#需要忽略的文件
"ignorFile":[".DS_Store"],
}

versionConfigFile = "version/version_info.json" #版本信息的配置文件路径
versionManifestPath = "version/version.manifest" #由此脚本生成的version.manifest文件路径
projectManifestPath = "version/project.manifest" #由此脚本生成的project.manifest文件路径
# projectManifestPath = "/Users/ximi/Documents/client/MyProj/res/version/project.manifest" #由此脚本生成的project.manifest文件路径(mac机)

class SearchFile:
def __init__(self):
self.fileList = []

for k in assetsDir:
if (k == "searchDir"):
for searchdire in assetsDir[k]:
self.recursiveDir(searchdire)

def recursiveDir(self, srcPath):
''' 递归指定目录下的所有文件'''
dirList = [] #所有文件夹

files = os.listdir(srcPath) #返回指定目录下的所有文件,及目录(不含子目录)

for f in files:
#目录的处理
if (os.path.isdir(srcPath + '/' + f)):
if (f[0] == '.' or (f in assetsDir["ignorDir"])):
#排除隐藏文件夹和忽略的目录
pass
else:
#添加非需要的文件夹
dirList.append(f)

#文件的处理
elif (os.path.isfile(srcPath + '/' + f)) and (f not in assetsDir["ignorFile"]):
self.fileList.append(srcPath + '/' + f) #添加文件

#遍历所有子目录,并递归
for dire in dirList:
#递归目录下的文件
self.recursiveDir(srcPath + '/' + dire)

def getAllFile(self):
''' get all file path'''
return tuple(self.fileList)


def CalcMD5(filepath):
""'generate a md5 code by a file path'"
with open(filepath,'rb') as f:
md5obj = hashlib.md5()
md5obj.update(f.read())
return md5obj.hexdigest()


def getVersionInfo():
'''get version config data'''
configFile = open(versionConfigFile,"r")
json_data = json.load(configFile)

configFile.close()
# json_data["version"] = json_data["version"] + '.' + str(GetSvnCurrentVersion())
json_data["version"] = json_data["version"]
return json_data


def GenerateVersionManifestFile():
''' 生成大版本的version.manifest'''
json_str = json.dumps(getVersionInfo(), indent = 2)
fo = open(versionManifestPath,"w")
fo.write(json_str)
fo.close()


def GenerateProjectManifestFile():
searchfile = SearchFile()
fileList = list(searchfile.getAllFile())
project_str = {}
project_str.update(getVersionInfo())
dataDic = {}
for f in fileList:
dataDic[f] = {"md5" : CalcMD5(f)}
print f

project_str.update({"assets":dataDic})
json_str = json.dumps(project_str, sort_keys = True, indent = 2)

fo = open(projectManifestPath,"w")
fo.write(json_str)
fo.close()

if __name__ == "__main__":
GenerateVersionManifestFile()
GenerateProjectManifestFile()

生成version.manifest如下

1
2
3
4
5
6
7
{
"packageUrl": "http://ip:port/update/MyProj/assets/",
"engineVersion": "3.3",
"version": "1.0.0",
"remoteVersionUrl": "http://ip:port/update/MyProj/version/version.manifest",
"remoteManifestUrl": "http://ip:port/update/MyProj/version/project.manifest"
}

生成project.manifest如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"assets": {
"src/packages/mvc/init.lua": {
"md5": "6b9173481a1300c5e737ad5885ebef00"
},
"src/protobuf.lua": {
"md5": "f790fe35eb179a4341ff41d94e488a5d"
}
...
},
"packageUrl": "http://ip:port/update/MyProj/assets/",
"engineVersion": "3.3",
"version": "1.0.0",
"remoteVersionUrl": "http://ip:port/update/MyProj/version/version.manifest",
"remoteManifestUrl": "http://ip:port/update/MyProj/version/project.manifest"
}

4.游戏客户端: 利用Cocos assetManager来从服务器获取文件并且进行资源的替换(这里所谓的替换并不是真正的替换,利用了Fileutils->searchPath() 设置资源文件读取的优先级。也就是老资源和代码并没有删除,而是舍弃不用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
--region *.lua
--Date

local AssetsManager = class("AssetsManager",function ()
return cc.LayerColor:create(cc.c4b(20, 20, 20, 220))
end)

function AssetsManager:ctor()
self:onNodeEvent("exit", handler(self, self.onExitCallback))
self:initUI()
self:setAssetsManage()
end

function AssetsManager:onExitCallback()
self.assetsManagerEx:release()
end

function AssetsManager:initUI()

local hintLabel = cc.Label:createWithTTF("正在更新...", CONFIG.TTF_FONT_2, 20)
:addTo(self)
:move(600, 80)

local progressBg = display.newSprite("sprites/hyd_progress_bg.png")
:addTo(self)
:move(600, 40)

self.progress = cc.ProgressTimer:create(display.newSprite("sprites/hyd_progress.png"))
:addTo(progressBg)
:move(380, 19)
self.progress:setType(cc.PROGRESS_TIMER_TYPE_BAR)
self.progress:setBarChangeRate(cc.p(1, 0))
self.progress:setMidpoint(cc.p(0.0, 0.5))
self.progress:setPercentage(0)

--触摸吞噬
self.listener = cc.EventListenerTouchOneByOne:create()
self.listener:setSwallowTouches(true)
local onTouchBegan = function (touch, event)
return true
end

self.listener:registerScriptHandler(onTouchBegan, cc.Handler.EVENT_TOUCH_BEGAN)
cc.Director:getInstance():getEventDispatcher():addEventListenerWithSceneGraphPriority(self.listener, self)
end

function AssetsManager:setAssetsManage()
--创建可写目录与设置搜索路径
local storagePath = cc.FileUtils:getInstance():getWritablePath() .. "NewRes/"
local resPath = storagePath.. '/res/'
local srcPath = storagePath.. '/src/'
if not (cc.FileUtils:getInstance():isDirectoryExist(storagePath)) then
cc.FileUtils:getInstance():createDirectory(storagePath)
cc.FileUtils:getInstance():createDirectory(resPath)
cc.FileUtils:getInstance():createDirectory(srcPath)
end
local searchPaths = cc.FileUtils:getInstance():getSearchPaths()
table.insert(searchPaths, 1, storagePath)
table.insert(searchPaths, 2, resPath)
table.insert(searchPaths, 3, srcPath)
cc.FileUtils:getInstance():setSearchPaths(searchPaths)

self.assetsManagerEx = cc.AssetsManagerEx:create("version/project.manifest", storagePath)
self.assetsManagerEx:retain()

local eventListenerAssetsManagerEx = cc.EventListenerAssetsManagerEx:create(self.assetsManagerEx,
function (event)
self:handleAssetsManagerEvent(event)
end)

local dispatcher = cc.Director:getInstance():getEventDispatcher()
dispatcher:addEventListenerWithFixedPriority(eventListenerAssetsManagerEx, 1)

--检查版本并升级
self.assetsManagerEx:update()
end

function AssetsManager:handleAssetsManagerEvent(event)
local eventCodeList = cc.EventAssetsManagerEx.EventCode

local eventCodeHand = {

[eventCodeList.ERROR_NO_LOCAL_MANIFEST] = function ()
print("发生错误:本地资源清单文件未找到")
end,

[eventCodeList.ERROR_DOWNLOAD_MANIFEST] = function ()
print("发生错误:远程资源清单文件下载失败") --资源服务器没有打开,
self:downloadManifestError()
end,

[eventCodeList.ERROR_PARSE_MANIFEST] = function ()
print("发生错误:资源清单文件解析失败")
end,

[eventCodeList.NEW_VERSION_FOUND] = function ()
print("发现找到新版本")
end,

[eventCodeList.ALREADY_UP_TO_DATE] = function ()
print("已经更新到服务器最新版本")
self:updateFinished()
end,

[eventCodeList.UPDATE_PROGRESSION]= function ()
print("更新过程的进度事件")
self.progress:setPercentage(event:getPercentByFile())
end,

[eventCodeList.ASSET_UPDATED] = function ()
print("单个资源被更新事件")
end,

[eventCodeList.ERROR_UPDATING] = function ()
print("发生错误:更新过程中遇到错误")
end,

[eventCodeList.UPDATE_FINISHED] = function ()
print("更新成功事件")
self:updateFinished()
end,

[eventCodeList.UPDATE_FAILED] = function ()
print("更新失败事件")
end,

[eventCodeList.ERROR_DECOMPRESS] = function ()
print("解压缩失败")
end
}
local eventCode = event:getEventCode()
if eventCodeHand[eventCode] ~= nil then
eventCodeHand[eventCode]()
end
end

function AssetsManager:updateFinished()
self:setVisible(false)
self.listener:setEnabled(false)
end

function AssetsManager:downloadManifestError()
self:setVisible(false)
self.listener:setEnabled(false)
end

return AssetsManager

--endregion

Android apk 安装后在手机中还是以apk存在,apk 不可写入和删除,所以热更新下载的最新资源都存在缓存中,并添加缓存目录为最高优先级搜索目录,加载资源时从最高优先级目录中加载从而起到替换更新的作用。

Cocos2dx中有一个热更新类AssetsManagerEx,用这个类实现热更功能时需要有两个文件,project.manifest以及version.manifest。这里主要是project.manifest文件

Cocos自身也封装了热更新的模块AssetsManager、AssetsManagerEx。

AssetsManager采用的是升级包的管理方式,首先进行版本号对比,然后根据URL获取对应的升级包,解压升级包,设置资源加载路径,通过加载writepath目录下最新文件的方式来实现更新。问题是当涉及跳版本更新,或只有一个文件被改动时,用户就要下载前面全部的升级内容,升级包会越来越大。

AssetsManagerEx是AssetsManager的加强版,不同的是不再使用升级包的方式,而是采用单个文件拉取的方式。首先获取本地更新配置,之后与服务器的更新配置比对,得出差异文件,之后单个拉取差异文件。当本地版本大于服务器版本时,会清理掉本地更新缓存。AssetsManagerEx也有尚未解决的问题,例如多个更新序列无法并行,只能顺序启动。另外版本后期随着项目庞大配置文件几乎包含了所有的文件信息,对比文件时间的耗时会越来越长。

+