quick-cocos2d-x的热更新机制实现(转)

(原文地址:http://zengrong.net/post/2131.htm

quick-cocos2d-x的热更新机制实现

Hot update in quick-cocos2d-x

0 依赖

这里说的热更新,指的是客户端的更新。

大致的流程是,客户端在启动后访问更新api,根据更新api的反馈,下载更新资源,然后使用新的资源启动客户端,或者直接使用新资源不重启客户端。

这种方式可以跳过AppStore的审核,避免了用户频繁下载、安装、覆盖产品包。

我们一般使用这种方式快速修复产品BUG和增加新功能。

本文基于 quick-cocos2d-x zrong 修改版 。

1 前言

1.1 他山之石

在实现这个机制之前,我研究了这几篇文章:

另外,我也查看了 AssetsManager 的源码和 sample 。

不幸的是,这几个方案我都不能直接拿来用。因此思考再三,还是自己写了一套方案。

==重要提醒==

这篇文章很长,但我不愿意将其分成多个部分。这本来就是一件事,分开的话有种开房时洗完澡妹子却说两个小时后才能来。这中间干点啥呢?

所以,如果你不能坚持两个小时(能么?不能?),或者你的持久度不能坚持到把这篇文章看完(大概要10~30分钟吧),那还是不要往下看的比较好。

当然,你也可能坚挺了30分钟之后才发现妹子是凤姐,不要怪我这30分钟里面没开灯哦……

1.2 为什么要重复造轮子

上面的几个方案侧重于尽量简化用户(使用此方案的程序员)的操作,而简化带来的副作用就是会损失一些灵活性。

正如 Roberto Ierusalimschy 在 Lua程序设计(第2版) 第15章开头所说:

通常,Lua不会设置规则(policy)。相反,Lua会提供许多强有力的机制来使开发者有能力实现出最适合的规则。

我认为更新模块也不应该设置规则,而是尽可能提供一些机制来满足程序员的需要。这些机制并不是我发明的,而是Lua和quick本来就提供的。让程序员自己实现自己的升级系统,一定比我这个无证野路子的方法更好.

因此,本文中讲述的并非是一套通用的机制,而是我根据上面说到的这些机制实现的一套适合自己的方法。当然你可以直接拿去用,但要记住:

  • 用的好,请告诉你的朋友。
  • 出了问题,请告诉别找我。

1.3 需求的复杂性

热更新有许多的必要条件,每个产品的需求可能都不太相同。

例如,每个产品的版本号设计都不太相同,有的有大版本、小版本;有的则有主版本、次版本、编译版本。我以前的习惯,是在主版本变化的时候需要整包更新,而次版本变化代表逻辑更新,编译版本代表资源更新等等。这些需要自己来定义升级规则。

再例如,有的产品希望逐个下载升级包,有的产品希望把所有资源打包成一个升级包;有的产品直接使用文件名作为资源名在游戏中调用,而有的产品会把资源名改为指纹码(例如MD5)形式来实现升级的多版本共存和实时回滚,还有的产品甚至要求能在用户玩游戏的过程中完成自动更新。

AssetsManager 那套机制就太死板,在真实的产品中不修改很难使用。

而我也不建议使用 CCUserDefault 这种东西——在Lua的世界里,为什么要用XML做配置文件?

如果抽象出我的需求,其实只有1点:

能更新一切

这个说的有点大了,准确的说,应该是 能更新一切Lua代码与资源 。

如果你的整个游戏都是Lua写的(对于quick的项目来说应该是这样),其实也就是更新一切。

1.4 版本号设计

关于上面 需求的复杂性 中提到的版本号的问题,可以参考一下这篇文章:语义化版本2.0.0 。

我基于语义化版本设计了一套规则在团队内部使用:项目版本描述规则 。

在这里,我尽量详细地阐述我的思路和做法,抛砖引玉吧。

2 特色

基本的热更新功能就不说了大家都有。我这套机制还有如下几个特色:

2.1 可以更新frameworks_precompiled.zip 模块

为了行文方便,后面会把 frameworks_precompiled.zip 简称为 framework 。

frameworks 模块是 quick 的核心模块,在quick 生成的项目中,它直接在 AppDelegate.cpp 中载入 main.lua 之前进行载入。如下:

bool AppDelegate::applicationDidFinishLaunching()
{
    // initialize director
    CCDirector *pDirector = CCDirector::sharedDirector();
    pDirector->setOpenGLView(CCEGLView::sharedOpenGLView());
    pDirector->setProjection(kCCDirectorProjection2D);

    // set FPS. the default value is 1.0/60 if you don't call this
    pDirector->setAnimationInterval(1.0 / 60);

    // register lua engine
    CCLuaEngine *pEngine = CCLuaEngine::defaultEngine();
    CCScriptEngineManager::sharedManager()->setScriptEngine(pEngine);

    CCLuaStack *pStack = pEngine->getLuaStack();

#if (CC_TARGET_PLATFORM == CC_PLATFORM_IOS || CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    // load framework
    pStack->loadChunksFromZIP("res/framework_precompiled.zip");

    // set script path
    string path = CCFileUtils::sharedFileUtils()->fullPathForFilename("scripts/main.lua");
    ......

这可以说明这个核心模块对quick的重要性。正因为它重要,所以必须要能更新它。

2.2 可以更新 update 模块自身

更新功能是客户端启动后载入的第一个lua模块,它负责载入更新资源,以及启动主项目。一般情况下,这个模块是不需要改动的。对它进行改动,既不科学,也不安全(安全啊……)。

但是万一呢?大家知道策划和运营同学都是二班的,或许哪天就有二班同学找你说:改改怕什么?又不会怀孕…… 所以这个必须有。

2.3 纯lua实现

把这个拿出来说纯粹是撑数的。不凑个三大特色怎么好意思?

上面SunLightJuly和Henry同学的方案当然也是纯lua的。用quick你不搞纯lua好意思出来混?小心廖大一眼瞪死你。

当然,我这个不是纯lua的,我基于 AssetsManager(C++) 的代码实现了一个 Updater 模块。

而且,我还改了 AppDelegate 中的启动代码。

所以,你看,我不仅是撑数,还是忽悠。

3 Updater(C++)

AssetsManager 中提供了下载资源,访问更新列表,解压zip包,删除临时文件,设置搜索路径等等一系列的功能。但它的使用方式相当死板,我只能传递一个获取版本号的地址,一个zip包的地址,一个临时文件夹路径,然后就干等着。期间啥也干不了。

当然,我可以通过 quick 为其增加的 registerScriptHandler 方法让lua得知下载进度和网络状态等等。但下载进度的数字居然以事件名的方式通过字符串传过来的!这个就太匪夷所思了点。

于是,我对这个 AssetsManager 进行了修改。因为修改的东西实在太多,改完后就不好意思再叫这个名字了(其实主要是现在的名字比较短 XD)。我们只需要记住这个 Updater 是使用AssetsManager 修改的即可。

在上面SunLightJuly和Henry同学的方法中,使用的是 CCHTTPRequest 来获取网络资源的。CCHTTPRequest 封装了cURL 操作。而在 Updater 中,是直接封装的 cURL 操作。

在我的设计中,逻辑应该尽量放在lua中,C++部分只提供功能供lua调用。因为lua可以进行热更新,而C++部分则只能整包更新。

Updater 主要实现的内容如下:

3.1 删除了不需要的方法

get和set相关的一堆方法都被删除了。new对象的时候也不必传递参数了。

3.2 增加 getUpdateInfo 方法

这个方法通过HTTP协议获取升级列表数据,获取到的数据直接返回,C++并不做处理。

3.3 修改 update 方法

这个方法通过HTTP协议下载升级包,需要提供四个参数:

  1. zip文件的url;
  2. zip文件的保存位置;
  3. zip 文件的解压临时目录;
  4. 解压之前是否需要清空临时目录。

3.4 修改事件类型

我把把传递给lua的事件分成了四种类型:

3.4.1 UPDATER_MESSAGE_UPDATE_SUCCEED

事件名为 success,代表更新成功,zip文件下载并解压完毕;

3.4.2 UPDATER_MESSAGE_STATE

事件名为 state,更新过程中的状态(下载开始、结束,解压开始、结束)也传递给了lua。这个方法是这样实现的:

void Updater::Helper::handlerState(Message *msg)
{
    StateMessage* stateMsg = (StateMessage*)msg->obj;
    if(stateMsg->manager->_delegate)
    {
        stateMsg->manager->_delegate->onState(stateMsg->code);
    }
    if (stateMsg->manager->_scriptHandler)
    {
        std::string stateMessage;
        switch ((StateCode)stateMsg->code)
        {
            case kDownStart:
                stateMessage = "downloadStart";
                break;

            case kDownDone:
                stateMessage = "downloadDone";
                break;

            case kUncompressStart:
                stateMessage = "uncompressStart";
                break;
            case kUncompressDone:
                stateMessage = "uncompressDone";
                break;

            default:
                stateMessage = "stateUnknown";
        }

        CCScriptEngineManager::sharedManager()
            ->getScriptEngine()
            ->executeEvent(
                           stateMsg->manager->_scriptHandler,
                           "state",
                           CCString::create(stateMessage.c_str()),
                           "CCString");
    }

    delete ((StateMessage*)msg->obj);
}

3.4.3 UPDATER_MESSAGE_PROGRESS

事件名为 progress,传递的对象为一个 CCInteger ,代表进度。详细的实现可以看 源码

3.4.4 UPDATER_MESSAGE_ERROR

事件名为 error,传递的对象是一个 CCString,值有这样几个:

  • errorCreateFile
  • errorNetwork
  • errorNoNewVersion
  • errorUncompress
  • errorUnknown

方法的实现和上面的 UPDATER_MESSAGE_STATE 类似,这里就不贴了。详细的实现可以看 源码

Updater(C++) 部分只做了这些苦力工作,而具体的分析逻辑(分析getUserInfo返回的数据决定是否升级、如何升级和升级什么),下载命令的发出(调用update方法),解压成功之后的操作(比如合并新文件到就文件中,更新文件索引列表等等),全部需要lua来做。下面是一个处理Updater(C++)事件的lua函数的例子。

function us._updateHandler(event, value)
    updater.state = event
    if event == "success" then
        updater.stateValue = value:getCString()
        -- 成功之后更新资源列表,合并新资源
        updater.updateFinalResInfo()
        -- 调用成功后的处理函数
        if us._succHandler then
            us._succHandler()
        end
    elseif event == "error" then
        updater.stateValue = value:getCString()
    elseif event == "progress" then
        updater.stateValue = tostring(value:getValue())
    elseif event == "state" then
        updater.stateValue = value:getCString()
    end
    -- us._label 是一个CCLabelTTF,用来显示进度和状态
    us._label:setString(updater.stateValue)
    assert(event ~= "error", 
        string.format("Update error: %s !", updater.stateValue))
end

 

updater:registerScriptHandler(us._updateHandler)

 

4. update包(lua)

update包是整个项目的入口包,quick会首先载入这个包,甚至在 framework 之前。

4.1 为update包所做的项目修改

我修改了quick项目文件 AppDelegate.cpp 中的 applicationDidFinishLaunching 方法,使其变成这样:

bool AppDelegate::applicationDidFinishLaunching()
{
    // initialize director
    CCDirector *pDirector = CCDirector::sharedDirector();
    pDirector->setOpenGLView(CCEGLView::sharedOpenGLView());
    pDirector->setProjection(kCCDirectorProjection2D);

    // set FPS. the default value is 1.0/60 if you don't call this
    pDirector->setAnimationInterval(1.0 / 60);

    // register lua engine
    CCLuaEngine *pEngine = CCLuaEngine::defaultEngine();
    CCScriptEngineManager::sharedManager()->setScriptEngine(pEngine);

    CCLuaStack *pStack = pEngine->getLuaStack();

    string gtrackback = "\
    function __G__TRACKBACK__(errorMessage) \
    print(\"----------------------------------------\") \
    print(\"LUA ERROR: \" .. tostring(errorMessage) .. \"\\n\") \
    print(debug.traceback(\"\", 2)) \
    print(\"----------------------------------------\") \
    end";
    pEngine->executeString(gtrackback.c_str());

    // load update framework
    pStack->loadChunksFromZIP("res/lib/update.zip");

    string start_path = "require(\"update.UpdateApp\").new(\"update\"):run(true)";
    CCLOG("------------------------------------------------");
    CCLOG("EXECUTE LUA STRING: %s", start_path.c_str());
    CCLOG("------------------------------------------------");
    pEngine->executeString(start_path.c_str());

    return true;
}

原来位于 main.lua 中的 __G_TRACKBACK__ 函数(用于输出lua报错信息)直接包含在C++代码中了。那么现在 main.lua 就不再需要了。

同样的,第一个载入的模块变成了 res/lib/update.zip,这个zip也可以放在quick能找到的其它路径中,使用这个路径只是我的个人习惯。

最后,LuaStack直接执行了下面这句代码启动了 update.UpdateApp 模块:

require("update.UpdateApp").new("update"):run(true); 

4.2 update包中的模块

update包有三个子模块,每个模块是一个lua文件,分别为:

  • update.UpdateApp 检测更新,决定启动哪个模块。
  • update.updater 负责真正的更新工作,与C++通信,下载、解压、复制。
  • update.updateScene 负责在更新过程中显示界面,进度条等等。

对于不同的大小写,是因为在我的命名规则中,类用大写开头,对象是小写开头。update.UpdateApp 是一个类,其它两个是对象(table)。

下面的 4.3、4.4、4.5 将分别对这3个模块进行详细介绍。

4.3 update.UpdateApp

下面是入口模块 UpdateApp 的内容:

--- The entry of Game
-- @author zrong(zengrong.net)
-- Creation 2014-07-03

local UpdateApp = {}

UpdateApp.__cname = "UpdateApp"
UpdateApp.__index = UpdateApp
UpdateApp.__ctype = 2

local sharedDirector = CCDirector:sharedDirector()
local sharedFileUtils = CCFileUtils:sharedFileUtils()
local updater = require("update.updater")

function UpdateApp.new(...)
    local instance = setmetatable({}, UpdateApp)
    instance.class = UpdateApp
    instance:ctor(...)
    return instance
end

function UpdateApp:ctor(appName, packageRoot)
    self.name = appName
    self.packageRoot = packageRoot or appName

    print(string.format("UpdateApp.ctor, appName:%s, packageRoot:%s", appName, packageRoot))

    -- set global app
    _G[self.name] = self
end

function UpdateApp:run(checkNewUpdatePackage)
    --print("I am new update package")
    local newUpdatePackage = updater.hasNewUpdatePackage()
    print(string.format("UpdateApp.run(%s), newUpdatePackage:%s", 
        checkNewUpdatePackage, newUpdatePackage))
    if  checkNewUpdatePackage and newUpdatePackage then
        self:updateSelf(newUpdatePackage)
    elseif updater.checkUpdate() then
        self:runUpdateScene(function()
            _G["finalRes"] = updater.getResCopy()
            self:runRootScene()
        end)
    else
        _G["finalRes"] = updater.getResCopy()
        self:runRootScene()
    end
end

-- Remove update package, load new update package and run it.
function UpdateApp:updateSelf(newUpdatePackage)
    print("UpdateApp.updateSelf ", newUpdatePackage)
    local updatePackage = {
        "update.UpdateApp",
        "update.updater",
        "update.updateScene",
    }
    self:_printPackages("--before clean")
    for __,v in ipairs(updatePackage) do
        package.preload[v] = nil
        package.loaded[v] = nil
    end
    self:_printPackages("--after clean")
    _G["update"] = nil
    CCLuaLoadChunksFromZIP(newUpdatePackage)
    self:_printPackages("--after CCLuaLoadChunksForZIP")
    require("update.UpdateApp").new("update"):run(false)
    self:_printPackages("--after require and run")
end

-- Show a scene for update.
function UpdateApp:runUpdateScene(handler)
    self:enterScene(require("update.updateScene").addListener(handler))
end

-- Load all of packages(except update package, it is not in finalRes.lib)
-- and run root app.
function UpdateApp:runRootScene()
    for __, v in pairs(finalRes.lib) do
        print("runRootScene:CCLuaLoadChunksFromZip",__, v)
        CCLuaLoadChunksFromZIP(v)
    end

    require("root.RootScene").new("root"):run()
end

function UpdateApp:_printPackages(label)
    label = label or ""
    print("\npring packages "..label.."------------------")
    for __k, __v in pairs(package.preload) do
        print("package.preload:", __k, __v)
    end
    for __k, __v in pairs(package.loaded) do
        print("package.loaded:", __k, __v)
    end
    print("print packages "..label.."------------------\n")
end


function UpdateApp:exit()
    sharedDirector:endToLua()
    os.exit()
end

function UpdateApp:enterScene(__scene)
    if sharedDirector:getRunningScene() then
        sharedDirector:replaceScene(__scene)
    else
        sharedDirector:runWithScene(__scene)
    end
end

return UpdateApp

我来说几个重点。

4.3.1 没有framework

由于没有加载 framework,class当然是不能用的。所有quick framework 提供的方法都不能使用。

我借用class中的一些代码来实现 UpdateApp 的继承。其实我觉得这个UpdateApp也可以不必写成class的。

4.3.2 入口函数 update.UpdateApp:run(checkNewUpdatePackage)

run 是入口函数,同时接受一个参数,这个参数用于判断是否要检测本地有新的 update.zip 模块。

是的,run 就是那个在 AppDelegate.cpp 中第一个调用的lua函数。

这个函数接受一个参数 checkNewUpdatePackage ,在C++调用 run 的时候,传递的值是 true 。

如果这个值为真,则会检测本地是否拥有新的更新模块,这个检测通过update.updater.hasNewUpdatePackage() 方法进行,后面会说到这个方法。

本地有更新的 update 模块,则直接调用 updateSelf 来更新 update 模块自身;若无则检测是否有项目更新,下载更新的资源,解析它们,处理它们,然后启动主项目。这些工作通过update.updater.checkUpdate() 完成,后面会说到这个方法。

若没有任何内容需要更新,则直接调用 runRootScene 来显示主场景了。这后面的内容就交给住场景去做了,update 模块退出历史舞台。

从上面这个流程可以看出。在更新完成之前,主要的项目代码和资源没有进行任何载入。这也就大致达到了我们 更新一切 的需求。因为所有的东西都没有载入,也就不存在更新。只需要保证我载入的内容是最新的就行了。

因此,只要保证 update 模块能更新,就达到我们最开始的目标了。

这个流程还可以保证,如果没有更新,甚至根本就不需要载入 update 模块的场景界面,直接跳转到游戏的主场景即可。

有句代码在 run 函数中至关重要:

_G["finalRes"] = updater.getResCopy()

finalRes 这个全局变量保存了本地所有的 原始/更新 资源索引。它是一个嵌套的tabel,保存的是所有资源的名称以及它们对应的 绝对/相对 路径的对应关系。后面会详述。

4.3.3 更新自身 update.UpdateApp:updateSelf(newUpdatePackage)

这是本套机制中最重要的一环。理解了它,你就知道更新一切其实没什么秘密。Lua本来就提供了这样一套机制。

由于在 C++ 中已经将 update 模块载入了内存,那么要更新自身首先要做的是清除 Lua 的载入标记。

Lua在两个全局变量中做了标记:

  • package.preload 执行 CCLuaLoadChunksFromZIP 之后会将模块缓存在这里作为 require 的加载器;
  • package.loaded 执行 require 的时候会先查询 package.loaded,若没有则会查询 package.preload 得到加载器,利用加载器加载模块,再将加载的模块缓存到 package.loaded 中。

详细的机制可以阅读 Lua程序设计(第2版) 15.1 require 函数。

那么,要更新自己,只需要把 package.preload 和 package.loaded 清除,然后再用新的 模块填充 package.preload 即可。下面就是核心代码了:

local updatePackage = {
    "update.UpdateApp",
    "update.updater",
    "update.updateScene",
}
for __,v in ipairs(updatePackage) do
    package.preload[v] = nil
    package.loaded[v] = nil
end
_G["update"] = nil
CCLuaLoadChunksFromZIP(newUpdatePackage)
require("update.UpdateApp").new("update"):run(false)

如果不相信这么简单,可以用上面完整的 UpdateApp 模块中提供的UpdateApp:_printPackages(label) 方法来检测。

4.3.4 显示更新界面 update.UpdateApp:runUpdateScene(handler)

update.updater.checkUpdate() 的返回是异步的,下载和解压都需要时间,在这段时间里面,我们需要一个界面。runUpdateScene 方法的作用就是显示这个界面。并在更新成功之后调用handler处理函数。

4.3.5 显示主场景 update.UpdateApp:runRootScene()

到了这里,update 包就没有作用了。但由于我们先前没有载入除 update 包外的任何包,这里必须先载入它们。

我上面提到过,finalRes 这个全局变量是一个索引表,它的 lib 对象就是一个包含所有待载入的包(类似于 frameworks_precompiled.zip 这种)的列表。我们通过循环将它们载入内存。

对于 root.RootScene 这个模块来说,就是标准的quick模块了,它可以使用quick中的任何特性。

for __, v in pairs(finalRes.lib) do
    print("runRootScene:CCLuaLoadChunksFromZip",__, v)
    CCLuaLoadChunksFromZIP(v)
end

require("root.RootScene").new("root"):run()

4.3.6 怎么使用这个模块

你如果要直接拿来就用,这个模块基本上不需要修改。因为本来它就没什么特别的功能。当然,你可以看完下面两个模块再决定。

4.4 update.updateScene

这个模块用于显示更新过程的进度和一些信息。所有内容如下:

------
-- updateScene for update package.
-- This is a object, not a class.
-- In this scene, it will show download progress bar 
-- and state for uncompress.
-- @author zrong(zengrong.net)
-- Creation: 2014-07-03

local updater = require("update.updater")
local sharedDirector         = CCDirector:sharedDirector()

-- check device screen size
local glview = sharedDirector:getOpenGLView()
local size = glview:getFrameSize()
local display = {}
display.sizeInPixels = {width = size.width, height = size.height}

local w = display.sizeInPixels.width
local h = display.sizeInPixels.height

CONFIG_SCREEN_WIDTH = 1280 
CONFIG_SCREEN_HEIGHT = 800
CONFIG_SCREEN_AUTOSCALE = "FIXED_HEIGHT"

local scale, scaleX, scaleY

scaleX, scaleY = w / CONFIG_SCREEN_WIDTH, h / CONFIG_SCREEN_HEIGHT
scale = scaleY
CONFIG_SCREEN_WIDTH = w / scale

glview:setDesignResolutionSize(CONFIG_SCREEN_WIDTH, CONFIG_SCREEN_HEIGHT, kResolutionNoBorder)

local winSize = sharedDirector:getWinSize()
display.contentScaleFactor = scale
display.size               = {width = winSize.width, height = winSize.height}
display.width              = display.size.width
display.height             = display.size.height
display.cx                 = display.width / 2
display.cy                 = display.height / 2
display.c_left             = -display.width / 2
display.c_right            = display.width / 2
display.c_top              = display.height / 2
display.c_bottom           = -display.height / 2
display.left               = 0
display.right              = display.width
display.top                = display.height
display.bottom             = 0
display.widthInPixels      = display.sizeInPixels.width
display.heightInPixels     = display.sizeInPixels.height

print("# display in updateScene start")
print(string.format("# us.CONFIG_SCREEN_AUTOSCALE      = %s", CONFIG_SCREEN_AUTOSCALE))
print(string.format("# us.CONFIG_SCREEN_WIDTH          = %0.2f", CONFIG_SCREEN_WIDTH))
print(string.format("# us.CONFIG_SCREEN_HEIGHT         = %0.2f", CONFIG_SCREEN_HEIGHT))
print(string.format("# us.display.widthInPixels        = %0.2f", display.widthInPixels))
print(string.format("# us.display.heightInPixels       = %0.2f", display.heightInPixels))
print(string.format("# us.display.contentScaleFactor   = %0.2f", display.contentScaleFactor))
print(string.format("# us.display.width                = %0.2f", display.width))
print(string.format("# us.display.height               = %0.2f", display.height))
print(string.format("# us.display.cx                   = %0.2f", display.cx))
print(string.format("# us.display.cy                   = %0.2f", display.cy))
print(string.format("# us.display.left                 = %0.2f", display.left))
print(string.format("# us.display.right                = %0.2f", display.right))
print(string.format("# us.display.top                  = %0.2f", display.top))
print(string.format("# us.display.bottom               = %0.2f", display.bottom))
print(string.format("# us.display.c_left               = %0.2f", display.c_left))
print(string.format("# us.display.c_right              = %0.2f", display.c_right))
print(string.format("# us.display.c_top                = %0.2f", display.c_top))
print(string.format("# us.display.c_bottom             = %0.2f", display.c_bottom))
print("# display in updateScene done")

display.ANCHOR_POINTS = {
    CCPoint(0.5, 0.5),  -- CENTER
    CCPoint(0, 1),      -- TOP_LEFT
    CCPoint(0.5, 1),    -- TOP_CENTER
    CCPoint(1, 1),      -- TOP_RIGHT
    CCPoint(0, 0.5),    -- CENTER_LEFT
    CCPoint(1, 0.5),    -- CENTER_RIGHT
    CCPoint(0, 0),      -- BOTTOM_LEFT
    CCPoint(1, 0),      -- BOTTOM_RIGHT
    CCPoint(0.5, 0),    -- BOTTOM_CENTER
}

display.CENTER        = 1
display.LEFT_TOP      = 2; display.TOP_LEFT      = 2
display.CENTER_TOP    = 3; display.TOP_CENTER    = 3
display.RIGHT_TOP     = 4; display.TOP_RIGHT     = 4
display.CENTER_LEFT   = 5; display.LEFT_CENTER   = 5
display.CENTER_RIGHT  = 6; display.RIGHT_CENTER  = 6
display.BOTTOM_LEFT   = 7; display.LEFT_BOTTOM   = 7
display.BOTTOM_RIGHT  = 8; display.RIGHT_BOTTOM  = 8
display.BOTTOM_CENTER = 9; display.CENTER_BOTTOM = 9

function display.align(target, anchorPoint, x, y)
    target:setAnchorPoint(display.ANCHOR_POINTS[anchorPoint])
    if x and y then target:setPosition(x, y) end
end

local us = CCScene:create()
us.name = "updateScene"

local localResInfo = nil

function us._addUI()
    -- Get the newest resinfo in ures.
    local localResInfo = updater.getResCopy()

    local __bg = CCSprite:create(us._getres("res/pic/init_bg.png"))
    display.align(__bg, display.CENTER, display.cx, display.cy)
    us:addChild(__bg, 0)

    local __label = CCLabelTTF:create("Loading...", "Arial", 24)
    __label:setColor(ccc3(255, 0, 0))
    us._label = __label
    display.align(__label, display.CENTER, display.cx, display.bottom+30)
    us:addChild(__label, 10)
end

function us._getres(path)
    if not localResInfo then
        localResInfo = updater.getResCopy()
    end
    for key, value in pairs(localResInfo.oth) do
        print("us._getres:", key, value)
        local pathInIndex = string.find(key, path)
        if pathInIndex and pathInIndex >= 1 then
            print("us._getres getvalue:", path)
            res[path] = value
            return value
        end
    end
    return path
end

function us._sceneHandler(event)
    if event == "enter" then
        print(string.format("updateScene \"%s:onEnter()\"", us.name))
        us.onEnter()
    elseif event == "cleanup" then
        print(string.format("updateScene \"%s:onCleanup()\"", us.name))
        us.onCleanup()
    elseif event == "exit" then
        print(string.format("updateScene \"%s:onExit()\"", us.name))
        us.onExit()

        if DEBUG_MEM then
            print("----------------------------------------")
            print(string.format("LUA VM MEMORY USED: %0.2f KB", collectgarbage("count")))
            CCTextureCache:sharedTextureCache():dumpCachedTextureInfo()
            print("----------------------------------------")
        end
    end
end

function us._updateHandler(event, value)
    updater.state = event
    if event == "success" then
        updater.stateValue = value:getCString()
        updater.updateFinalResInfo()
        if us._succHandler then
            us._succHandler()
        end
    elseif event == "error" then
        updater.stateValue = value:getCString()
    elseif event == "progress" then
        updater.stateValue = tostring(value:getValue())
    elseif event == "state" then
        updater.stateValue = value:getCString()
    end
    us._label:setString(updater.stateValue)
    assert(event ~= "error", 
        string.format("Update error: %s !", updater.stateValue))
end

function us.addListener(handler)
    us._succHandler = handler
    return us
end

function us.onEnter()
    updater.update(us._updateHandler)
end

function us.onExit()
    updater.clean()
    us:unregisterScriptHandler()
end

function us.onCleanup()
end

us:registerScriptHandler(us._sceneHandler)
us._addUI()
return us

代码都在上面,说重点:

4.4.1 还是没有framework

这是必须一直牢记的。由于没有载入quick的 framework,所有的quick特性都不能使用。

你也许会说没有framework我怎么写界面?那么想想用C++的同学吧!那个代码怎么也比Lua多吧?

什么什么?你说有CCB和CCS?CCS你妹啊!同学我和你不是一个班的。

例如,原来在quick中这样写:

display.newSprite("res/pic/init_bg.png")
    :align(display.CENTER, display.cx, display.cy)
    :addTo(self, 0)
 

在没有quick framework的时候需要改成这样:

local __bg = CCSprite:create(us._getres("res/pic/init_bg.png"))
display.align(__bg, display.CENTER, display.cx, display.cy)
us:addChild(__bg, 0)

等等!为啥我用了 display !!!笨蛋,你不会偷quick的啊啊啊!

4.4.2 必须要偷的代码

为了方便使用,我们可以偷一部分framework的代码过来(干嘛说得那么难听嘛,程序员怎么能用偷?程序员的事,用CV啊),注意CV来的代码用local变量来保存。由于 updateScene 已经是一个可视的场景,因此quick中关于界面缩放设置的那部分代码也是必须CV过来。不多,几十行而已。

游戏产品绝大多数都不会做成横屏竖屏自动适应的(自己找SHI啊有木有),因此界面缩放的代码我也只保存了一个横屏的,这又省了不少。那CV的同学,注意自己改啊!

4.4.3 update.updateScene._getres(path)

在 update.updateScene 模块中,所有涉及到资源路径的地方,必须使用这个方法来包裹。

这个方法先从 update.updater 模块中获取最新的资源索引列表,然后根据我们传递的相对路径从索引列表中查找到资源的实际路径(可能是原包自带的资源,也可能是更新后的资源的绝对路径),然后载入它们。这能保证我们使用的是最新的资源。

4.4.4 update.updateScene._updateHandler(event, value)

这个方法已经在 上面 C++ 模块中 讲过了。注意其中的 _succHandler 是在 update.UpdateApp 中定义的匿名函数。

4.4.5 怎么使用这个模块

如果你要使用这个模块,那么可能大部分都要重写。你可以看到,我在这个模块中只有一个背景图和一个 CCLabeTTF 来显示下载进度和状态。你当然不希望你的更新界面就是这个样子。怎么也得来个妹子做封面不是?

4.5 update.updater

这是整个更新系统的核心部分了。代码更长一点,但其实很好懂。

在这个模块中,我们需要完成下面的工作:

  1. 调用C++的Updater模块来获取远程的版本号以及资源下载地址;
  2. 调用C++的Updater模块来下载解压;
  3. 合并解压后的新资源到新资源文件夹;
  4. 更新总的资源索引;
  5. 删除临时文件;
  6. 报告更新中的各种错误。

所以说,这是一个工具模块。它提供的是给更新使用的各种工具。而 UpdateApp 和 updateScene 则分别是功能和界面模块。

--- The helper for update package.
-- It can download resources and uncompress it, 
-- copy new package to res directory,
-- and remove temporery directory.
-- @author zrong(zengrong.net)
-- Creation 2014-07-03

require "lfs"
local updater = {}
updater.STATES = {
    kDownStart = "downloadStart",
    kDownDone = "downloadDone",
    kUncompressStart = "uncompressStart",
    kUncompressDone = "uncompressDone",
    unknown = "stateUnknown",
}

updater.ERRORS = {
    kCreateFile = "errorCreateFile",
    kNetwork = "errorNetwork",
    kNoNewVersion = "errorNoNewVersion",
    kUncompress = "errorUncompress",
    unknown = "errorUnknown";
}

function updater.isState(state)
    for k,v in pairs(updater.STATES) do
        if v == state then
            return true
        end
    end
    return false
end

function updater.clone(object)
    local lookup_table = {}
    local function _copy(object)
        if type(object) ~= "table" then
            return object
        elseif lookup_table[object] then
            return lookup_table[object]
        end
        local new_table = {}
        lookup_table[object] = new_table
        for key, value in pairs(object) do
            new_table[_copy(key)] = _copy(value)
        end
        return setmetatable(new_table, getmetatable(object))
    end
    return _copy(object)
end

function updater.vardump(object, label, returnTable)
    local lookupTable = {}
    local result = {}

    local function _v(v)
        if type(v) == "string" then
            v = "\"" .. v .. "\""
        end
        return tostring(v)
    end

    local function _vardump(object, label, indent, nest)
        label = label or ""
        local postfix = ""
        if nest > 1 then postfix = "," end
        if type(object) ~= "table" then
            if type(label) == "string" then
                result[#result +1] = string.format("%s[\"%s\"] = %s%s", indent, label, _v(object), postfix)
            else
                result[#result +1] = string.format("%s%s%s", indent, _v(object), postfix)
            end
        elseif not lookupTable[object] then
            lookupTable[object] = true

            if type(label) == "string" then
                result[#result +1 ] = string.format("%s%s = {", indent, label)
            else
                result[#result +1 ] = string.format("%s{", indent)
            end
            local indent2 = indent .. "    "
            local keys = {}
            local values = {}
            for k, v in pairs(object) do
                keys[#keys + 1] = k
                values[k] = v
            end
            table.sort(keys, function(a, b)
                if type(a) == "number" and type(b) == "number" then
                    return a < b
                else
                    return tostring(a) < tostring(b)
                end
            end)
            for i, k in ipairs(keys) do
                _vardump(values[k], k, indent2, nest + 1)
            end
            result[#result +1] = string.format("%s}%s", indent, postfix)
        end
    end
    _vardump(object, label, "", 1)

    if returnTable then return result end
    return table.concat(result, "\n")
end

local u  = nil
local f = CCFileUtils:sharedFileUtils()
-- The res index file in original package.
local lresinfo = "res/resinfo.lua"
local uroot = f:getWritablePath()
-- The directory for save updated files.
local ures = uroot.."res/"
-- The package zip file what download from server.
local uzip = uroot.."res.zip"
-- The directory for uncompress res.zip.
local utmp = uroot.."utmp/"
-- The res index file in zip package for update.
local zresinfo = utmp.."res/resinfo.lua"

-- The res index file for final game.
-- It combiled original lresinfo and zresinfo.
local uresinfo = ures .. "resinfo.lua"

local localResInfo = nil
local remoteResInfo = nil
local finalResInfo = nil

local function _initUpdater()
    print("initUpdater, ", u)
    if not u then u = Updater:new() end
    print("after initUpdater:", u)
end

function updater.writeFile(path, content, mode)
    mode = mode or "w+b"
    local file = io.open(path, mode)
    if file then
        if file:write(content) == nil then return false end
        io.close(file)
        return true
    else
        return false
    end
end

function updater.readFile(path)
    return f:getFileData(path)
end

function updater.exists(path)
    return f:isFileExist(path)
end

--[[
-- Departed, uses lfs instead.
function updater._mkdir(path)
    _initUpdater()
    return u:createDirectory(path)
end

-- Departed, get a warning in ios simulator
function updater._rmdir(path)
    _initUpdater()
    return u:removeDirectory(path)
end
--]]

function updater.mkdir(path)
    if not updater.exists(path) then
        return lfs.mkdir(path)
    end
    return true
end

function updater.rmdir(path)
    print("updater.rmdir:", path)
    if updater.exists(path) then
        local function _rmdir(path)
            local iter, dir_obj = lfs.dir(path)
            while true do
                local dir = iter(dir_obj)
                if dir == nil then break end
                if dir ~= "." and dir ~= ".." then
                    local curDir = path..dir
                    local mode = lfs.attributes(curDir, "mode") 
                    if mode == "directory" then
                        _rmdir(curDir.."/")
                    elseif mode == "file" then
                        os.remove(curDir)
                    end
                end
            end
            local succ, des = os.remove(path)
            if des then print(des) end
            return succ
        end
        _rmdir(path)
    end
    return true
end

-- Is there a update.zip package in ures directory?
-- If it is true, return its abstract path.
function updater.hasNewUpdatePackage()
    local newUpdater = ures.."lib/update.zip"
    if updater.exists(newUpdater) then
        return newUpdater
    end
    return nil
end

-- Check local resinfo and remote resinfo, compare their version value.
function updater.checkUpdate()
    localResInfo = updater.getLocalResInfo()
    local localVer = localResInfo.version
    print("localVer:", localVer)
    remoteResInfo = updater.getRemoteResInfo(localResInfo.update_url)
    local remoteVer = remoteResInfo.version
    print("remoteVer:", remoteVer)
    return remoteVer ~= localVer
end

-- Copy resinfo.lua from original package to update directory(ures) 
-- when it is not in ures.
function updater.getLocalResInfo()
    print(string.format("updater.getLocalResInfo, lresinfo:%s, uresinfo:%s", 
        lresinfo,uresinfo))
    local resInfoTxt = nil
    if updater.exists(uresinfo) then
        resInfoTxt = updater.readFile(uresinfo)
    else
        assert(updater.mkdir(ures), ures.." create error!")
        local info = updater.readFile(lresinfo)
        print("localResInfo:", info)
        assert(info, string.format("Can not get the constent from %s!", lresinfo))
        updater.writeFile(uresinfo, info)
        resInfoTxt = info
    end
    return assert(loadstring(resInfoTxt))()
end

function updater.getRemoteResInfo(path)
    _initUpdater()
    print("updater.getRemoteResInfo:", path)
    local resInfoTxt = u:getUpdateInfo(path)
    print("resInfoTxt:", resInfoTxt)
    return assert(loadstring(resInfoTxt))()
end

function updater.update(handler)
    assert(remoteResInfo and remoteResInfo.package, "Can not get remoteResInfo!")
    print("updater.update:", remoteResInfo.package)
    if handler then
        u:registerScriptHandler(handler)
    end
    updater.rmdir(utmp)
    u:update(remoteResInfo.package, uzip, utmp, false)
end

function updater._copyNewFile(resInZip)
    -- Create nonexistent directory in update res.
    local i,j = 1,1
    while true do
        j = string.find(resInZip, "/", i)
        if j == nil then break end
        local dir = string.sub(resInZip, 1,j)
        -- Save created directory flag to a table because
        -- the io operation is too slow.
        if not updater._dirList[dir] then
            updater._dirList[dir] = true
            local fullUDir = uroot..dir
            updater.mkdir(fullUDir)
        end
        i = j+1
    end
    local fullFileInURes = uroot..resInZip
    local fullFileInUTmp = utmp..resInZip
    print(string.format('copy %s to %s', fullFileInUTmp, fullFileInURes))
    local zipFileContent = updater.readFile(fullFileInUTmp)
    if zipFileContent then
        updater.writeFile(fullFileInURes, zipFileContent)
        return fullFileInURes
    end
    return nil
end

function updater._copyNewFilesBatch(resType, resInfoInZip)
    local resList = resInfoInZip[resType]
    if not resList then return end
    local finalRes = finalResInfo[resType]
    for __,v in ipairs(resList) do
        local fullFileInURes = updater._copyNewFile(v)
        if fullFileInURes then
            -- Update key and file in the finalResInfo
            -- Ignores the update package because it has been in memory.
            if v ~= "res/lib/update.zip" then
                finalRes[v] = fullFileInURes
            end
        else
            print(string.format("updater ERROR, copy file %s.", v))
        end
    end
end

function updater.updateFinalResInfo()
    assert(localResInfo and remoteResInfo,
        "Perform updater.checkUpdate() first!")
    if not finalResInfo then
        finalResInfo = updater.clone(localResInfo)
    end
    --do return end
    local resInfoTxt = updater.readFile(zresinfo)
    local zipResInfo = assert(loadstring(resInfoTxt))()
    if zipResInfo["version"] then
        finalResInfo.version = zipResInfo["version"]
    end
    -- Save a dir list maked.
    updater._dirList = {}
    updater._copyNewFilesBatch("lib", zipResInfo)
    updater._copyNewFilesBatch("oth", zipResInfo)
    -- Clean dir list.
    updater._dirList = nil
    updater.rmdir(utmp)
    local dumpTable = updater.vardump(finalResInfo, "local data", true)
    dumpTable[#dumpTable+1] = "return data"
    if updater.writeFile(uresinfo, table.concat(dumpTable, "\n")) then
        return true
    end
    print(string.format("updater ERROR, write file %s.", uresinfo))
    return false
end

function updater.getResCopy()
    if finalResInfo then return updater.clone(finalResInfo) end
    return updater.clone(localResInfo)
end

function updater.clean()
    if u then
        u:unregisterScriptHandler()
        u:delete()
        u = nil
    end
    updater.rmdir(utmp)
    localResInfo = nil
    remoteResInfo = nil
    finalResInfo = nil
end

return updater

代码都在上面,还是说重点:

4.5.1 就是没有framework

我嘴巴都说出茧子了,没有就是没有。

不过,我又从quick CV了几个方法过来:

  • clone 方法用来完全复制一个table,在复制文件索引列表的时候使用;
  • vardump 方法用来1持久化索引列表,使其作为一个lua文件保存在设备存储器上。有修改。
  • writeFile 和 readFile 用于把需要的文件写入设备中,也用它来复制文件(读入一个文件,在另一个地方写入来实现复制)
  • exists 这个和quick实现的不太一样,直接用 CCFileUtils 了。

4.5.2 文件操作

除了可以用 writeFile 和 readFile 来实现文件的复制操作之外,还要实现文件夹的创建和删除。

这个功能可以使用 lfs(Lua file system) 来实现,参见:在lua中递归删除一个文件夹 。

4.5.3 相关目录和变量

上面的代码中定义了几个变量,在这里进行介绍方便理解:

4.5.3.1 lres(local res)

安装包所带的res目录;

4.5.3.2 ures(updated res)

保存在设备上的res目录,用于保存从网上下载的新资源;

4.5.3.3 utmp(update temp)

临时文件夹,用于解压缩,更新后会删除;

4.5.3.4 lresinfo(本地索引文件)

安装包内自带的所有资源的索引文件,所有资源路径指向包内自带的资源。打包的时候和产品包一起提供,产品包会默认使用这个资源索引文件来查找资源。它的大概内容如下:

local data = {
    version = "1.0",
    update_url = "http://192.168.18.22:8080/updater/resinfo.lua",
    lib = {
        ["res/lib/config.zip"] = "res/lib/config.zip",
        ["res/lib/framework_precompiled.zip"] = "res/lib/framework_precompiled.zip",
        ["res/lib/root.zip"] = "res/lib/root.zip",
        ......
    },
    oth = {
        ["res/pic/init_bg.png"] = "res/pic/init_bg.png",
        ......
    },
}
return data

从它的结构可以看出,它包含了当前包的版本(version)、在哪里获取要更新的资源索引文件(update_url)、当前包中所有的lua模块的路径(lib)、当前包中所有的资源文件的路径(oth)。

4.5.3.5 uresinfo(更新索引文件)

保存在 ures 中的更新后的索引文件,没有更新的资源路径指向包内自带的资源,更新后的资源路径指向ures中的资源。它的内容大致如下:

config.zip 的路径是在 iOS 模拟器中得到的。

local data = {
    version = "1.0",
    update_url = "http://192.168.18.22:8080/updater/resinfo.lua",
    lib = {
        ["res/lib/cc.zip"] = "res/lib/cc.zip",
        ["res/lib/config.zip"] = "/Users/zrong/Library/Application Support/iPhone Simulator/7.1/Applications/2B46FAC0-C419-42B5-92B0-B06DD16E113B/Documents/res/lib/config.zip",
        ......
    },
    oth = {
        ["res/pic/init_bg.png"] = "res/pic/init_bg.png",
        ......
    },
}
return data

4.5.3.6 http://192.168.18.22:8080/updater/resinfo.lua

getRemoteResInfo 方法会读取这个文件,然后将结果解析成lua table。对比其中的version与 lrefinfo 中的区别,来决定是否需要更新。

若需要,则调用C++ Updater模块中的方法下载 package 指定的zip包并解压。

它的内容如下:

local data = {
    version = "1.0.2",
    package = "http://192.168.18.22:8080/updater/res.zip",
}
return data

4.5.3.7 http://192.168.18.22:8080/updater/res.zip

zip包的文件夹结构大致如下:

res/
res/resinfo.lua
res/lib/cc.zip
res/pic/init_bg.png
......

zip文件的下载和解压都是由C++完成的,但是下载和解压的路径需要Lua来提供。这个动作完成后,C++会通知Lua更新成功。Lua会接着进行后续操作就使用下面 4.5.4 中提到的方法来复制资源、合并 uresinfo 。

4.5.3.8 zresinfo(zip资源索引文件)

zip文件中也包含一个 resinfo.lua ,它用于指示哪些文件需要更新。内容大致如下:

local data = {
    version = "1.0.2",
    lib = {
        "res/lib/cc.zip",
        ......
    },
    oth = {
        "res/pic/init_bg",
        ......
    },
}
return data

这个文件中包含的所有文件必须能在zip解压后找到。


4.5.4 update.updater.updateFinalResInfo()

这是一个至关重要的方法,让我们代入用上面提到的变量名和目录来描述它的功能:

它实现的功能是:

  1. 读取 uresinfo,若没有,则将 lresinfo 复制成 uresinfo;
  2. 从 utmp 中读取 zresinfo,注意此时zip文件已经解压;
  3. 将需要更新的资源文件从 utmp 中复制到 ures 中;
  4. 更新 uresinfo ,使其中的资源键名指向正确的资源路径(上一步复制的目标路径);
  5. 删除 utmp;
  6. 将更新后的 uresinfo 作为lua文件写入 ures 。

4.5.5 其它方法

对 update.updater 的调用一般是这样的顺序:

  1. 调用 checkUpdat 方法检测是否需要升级;
  2. 调用 update 方法执行升级,同时注册事件管理handler;
  3. 升级成功,调用 getResCopy 方法获取最新的 uresinfo 。

5 对 framework 的修改

5.1 写一个 getres 方法

ures 中包含的就是所有素材的索引(键值对)。形式如下:

  • 键名:res/pic/init_bg.png
  • 键值(lres中): res/pic/init_bg.png
  • 键值(ures中):/Users/zrong/Library/Application Support/iPhone Simulator/7.1/Applications/2B46FAC0-C419-42B5-92B0-B06DD16E113B/Documents/res/pic/init_bg.png

在程序中,我们一般会使用这样的写法来获取资源:

display.newSprite("pic/init_bg.png")

或者干脆简化成了:

display.newSprite("init_bg.png")

要上面的代码能够工作,需要为 CCFileUtils 设置搜索路径:

CCFileUtils:sharedFileUtils:addSearchPath("res/")
CCFileUtils:sharedFileUtils:addSearchPath("res/pic/")

但是,在这套更新机制中,我不建议设置搜索路径,因为素材都是以完整路径格式保存的,这样使用起来更方便和更确定。

如果是新项目,那么挺好,我只需要保证素材路径基于 res 提供即可,类似这样:

display.newSprite("res/pic/init_bg.png")

但是对于已经开发了一段时间的项目来说,一个个改就太不专业了。这是我们需要扩展一个 io.getres 方法:

res = {}

function io.getres(path)
    print("io.getres originl:", path)
    if CCFileUtils:sharedFileUtils():isAbsolutePath(path) then
        return path
    end
    if res[path] then return res[path] end
    for key, value in pairs(finalRes.oth) do
        print(key, value)
        local pathInIndex = string.find(key, path)
        if pathInIndex and pathInIndex >= 1 then
            print("io.getres getvalue:", path)
            res[path] = value
            return value
        end
    end
    print("io.getres no get:", path)
    return path
end

然后,我们需要修改 quick framework 中的display模块让我们的旧代码不必进行任何改动就能生效。

5.2 修改 display.newSprite

找到该方法中的这个部分:

if string.byte(filename) == 35 then -- first char is #
    local frame = display.newSpriteFrame(string.sub(filename, 2))
    if frame then
        sprite = spriteClass:createWithSpriteFrame(frame)
    end
else
    if display.TEXTURES_PIXEL_FORMAT[filename] then
        CCTexture2D:setDefaultAlphaPixelFormat(display.TEXTURES_PIXEL_FORMAT[filename])
        sprite = spriteClass:create(filename)
        CCTexture2D:setDefaultAlphaPixelFormat(kCCTexture2DPixelFormat_RGBA8888)
    else
        sprite = spriteClass:create(filename)
    end
end

将其改为:

if string.byte(filename) == 35 then -- first char is #
    local frame = display.newSpriteFrame(string.sub(filename, 2))
    if frame then
        sprite = spriteClass:createWithSpriteFrame(frame)
    end
else
    local absfilename = io.getres(filename)
    if display.TEXTURES_PIXEL_FORMAT[filename] then
        CCTexture2D:setDefaultAlphaPixelFormat(display.TEXTURES_PIXEL_FORMAT[filename])
        sprite = spriteClass:create(absfilename)
        CCTexture2D:setDefaultAlphaPixelFormat(kCCTexture2DPixelFormat_RGBA8888)
    else
        sprite = spriteClass:create(absfilename)
    end
end

5.3 修改display.newTilesSprite

将其中的 local sprite = CCSprite:create(filename, rect)

改为local sprite = CCSprite:create(io.getres(filename), rect)

5.4 修改 display.newBatchNode

改法与上面相同。

6. 后记

噢!这真是一篇太长的文章了,真希望我都说清了。

其实还有一些东西在这个机制中没有涉及,例如:

6.1 更新的健壮性

  • 在更新 update.zip 模块自身的时候,如果新的update.zip有问题怎么办?
  • 如果索引文件找不到怎么办?zip文件解压失败怎么办?zresinfo 中的内容与zip文件解压后的内容不符怎么办?
  • 下载更新的时候网断了如何处理?如何处理断点续传?设备磁盘空间不够了怎么处理?

6.2 更多的更新方式

我在 需求的复杂性 里面描述了一些需求,例如:

  • 如何回滚更新?
  • 如何多个版本共存?
  • 如何对资源进行指纹码化?

这些问题都不难解决。方法自己想,我只能写到这儿了。

话说回来,实现了 更新一切 ,你还担心什么呢?

 


射手,30分钟够么?

附文中部分链接内容

语义化版本2.0.0

源:http://semver.org/lang/zh-CN/

语义化版本2.0.0

摘要

版本格式:主版本号.次版本号.修订号,版本号递增规则如下:

  1. 主版本号:当你做了不兼容的API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。

简介

在软件管理的领域里存在着被称作“依赖地狱”的死亡之谷,系统规模越大,加入的套件越多,你就越有可能在未来的某一天发现自己已深陷绝望之中。

在依赖高的系统中发布新版本套件可能很快会成为恶梦。如果依赖关系过高,可能面临版本控制被锁死的风险(必须对每一个相依套件改版才能完成某次升级)。而如果依赖关系过于松散,又将无法避免版本的混乱(假设兼容于未来的多个版本已超出了合理数量)。当你专案的进展因为版本相依被锁死或版本混乱变得不够简便和可靠,就意味着你正处于依赖地狱之中。

作为这个问题的解决方案之一,我提议用一组简单的规则及条件来约束版本号的配置和增长。这些规则是根据(但不局限于)已经被各种封闭、开放源码软件所广泛使用的惯例所设计。为了让这套理论运作,你必须先有定义好的公共API。这可以透过文件定义或代码强制要求来实现。无论如何,这套API 的清楚明了是十分重要的。一旦你定义了公共API,你就可以透过修改相应的版本号来向大家说明你的修改。考虑使用这样的版本号格式:XYZ (主版本号.次版本号.修订号)修复问题但不影响API 时,递增修订号;API 保持向下兼容的新增及修改时,递增次版本号;进行不向下兼容的修改时,递增主版本号。

我称这套系统为“语义化的版本控制”,在这套约定下,版本号及其更新方式包含了相邻版本间的底层代码和修改内容的信息。

语义化版本控制规范(SemVer)

以下关键词MUST、MUST NOT、REQUIRED、SHALL、SHALL NOT、SHOULD、SHOULD NOT、 RECOMMENDED、MAY、OPTIONAL 依照RFC 2119 的叙述解读。(译注:为了保持语句顺畅, 以下文件遇到的关键词将依照整句语义进行翻译,在此先不进行个别翻译。)

  1. 使用语义化版本控制的软件“必须MUST”定义公共API。该API可以在代码中被定义或出现于严谨的文件内。无论何种形式都应该力求精确且完整。

  2. 标准的版本号“必须MUST”采用XYZ的格式,​​ 其中X、Y和Z为非负的整数,且“禁止MUST NOT”在数字前方补零。X是主版本号、Y是次版本号、而Z为修订号。每个元素“必须MUST”以数值来递增。例如:1.9.1 -> 1.10.0 -> 1.11.0。

  3. 标记版本号的软件发行后,“禁止MUST NOT”改变该版本软件的内容。任何修改都“必须MUST”以新版本发行。

  4. 主版本号为零(0.yz)的软件处于开发初始阶段,一切都可能随时被改变。这样的公共API 不应该被视为稳定版。

  5. 1.0.0 的版本号用于界定公共API 的形成。这一版本之后所有的版本号更新都基于公共API 及其修改内容。

  6. 修订号Z(xyZ | x > 0)“必须MUST”在只做了向下兼容的修正时才递增。这里的修正指的是针对不正确结果而进行的内部修改。

  7. 次版本号Y(xYz | x > 0)“必须MUST”在有向下兼容的新功能出现时递增。在任何公共API的功能被标记为弃用时也“必须MUST”递增。也“可以MAY”在内部程序有大量新功能或改进被加入时递增,其中“可以MAY”包括修订级别的改变。每当次版本号递增时,修订号“必须MUST”归零。

  8. 主版本号X(Xyz | X > 0)“必须MUST”在有任何不兼容的修改被加入公共API时递增。其中“可以MAY”包括次版本号及修订级别的改变。每当主版本号递增时,次版本号和修订号“必须MUST”归零。

  9. 先行版本号“可以MAY”被标注在修订版之后,先加上一个连接号再加上一连串以句点分隔的标识符号来修饰。标识符号“必须MUST”由ASCII码的英数字和连接号[0-9A-Za-z-]组成,且“禁止MUST NOT”留白。数字型的标识符号“禁止MUST NOT”在前方补零。先行版的优先级低于相关联的标准版本。被标上先行版本号则表示这个版本并非稳定而且可能无法达到兼容的需求。范例:1.0​​.0-alpha、1.0.0-alpha.1、 1.0.0-0.3.7、1.0.0-x.7.z.92。

  10. 版本编译信息“可以MAY”被标注在修订版或先行版本号之后,先加上一个加号再加上一连串以句点分隔的标识符号来修饰。标识符号“必须MUST”由ASCII的英数字和连接号[0-9A-Za-z-]组成,且“禁止MUST NOT”留白。当判断版本的优先层级时,版本编译信息“可SHOULD”被忽略。因此当两个版本只有在版本编译信息有差别时,属于相同的优先层级。范例:1.0.0-alpha+001、1.0.0+20130313144700、 1.0.0-beta+exp.sha.5114f85。

  11. 版本的优先层级指的是不同版本在排序时如何比较。判断优先层级时,“必须MUST”把版本依序拆分为主版本号、次版本号、修订号及先行版本号后进行比较(版本编译信息不在这份比较的列表中)。由左到右依序比较每个标识符号,第一个差异值用来决定优先层级:主版本号、次版本号及修订号以数值比较,例如1.0.0 < 2.0.0 < 2.1.0 < 2.1.1。当主版本号、次版本号及修订号都相同时,改以优先层级比较低的先行版本号决定。例如:1.0.0-alpha < 1.0.0。有相同主版本号、次版本号及修订号的两个先行版本号,其优先层级“必须MUST”透过由左到右的每个被句点分隔的标识符号来比较,直到找到一个差异值后决定:只有数字的标识符号以数值高低比较,有字母或连接号时则逐字以ASCII的排序来比较。数字的标识符号比非数字的标识符号优先层级低。若开头的标识符号都相同时,栏 ​​位比较多的先行版本号优先层级比较高。范例:1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0- rc.1 < 1.0.0。

为什么要使用语义化的版本控制?

这并不是一个新的或者革命性的想法。实际上,你可能已经在做一些近似的事情了。问题在于只是“近似”还不够。如果没有某个正式的规范可循,版本号对于依赖的管理并无实质意义。将上述的想法命名并给予清楚的定义,让你对软件使用者传达意向变得容易。一旦这些意向变得清楚,弹性(但又不会太弹性)的依赖规范就能达成。

举个简单的例子就可以展示语义化的版本控制如何让依赖地狱成为过去。假设有个名为“救火车”的函式库,它需要另一个名为“**”并已经有使用语义化版本控制的套件。当救火车创建时,**的版本号为3.1.0。因为救火车使用了一些版本3.1.0 所新增的功能, 你可以放心地指定相依于**的版本号大等于3.1.0 但小于4.0.0。这样,当**版本3.1.1 和3.2.0 发布时,你可以将直接它们纳入你的套件管理系统,因为它们能与原有相依的软件兼容。

作为一位负责任的开发者,你理当确保每次套件升级的运作与版本号的表述一致。现实世界是复杂的,我们除了提高警觉外能做的不多。你所能做的就是让语义化的版本控制为你提供一个健全的方式来发行以及升级套件,而无需推出新的相依套件,节省你的时间及烦恼。

如果你对此认同,希望立即开始使用语义化版本控制,你只需声明你的函式库正在使用它并遵循这些规则就可以了。请在你的README 文件中保留此页连结,让别人也知道这些规则并从中受益。

FAQ

在0.y.z 初始开发阶段,我该如何进行版本控制?

最简单的做法是以0.1.0 作为你的初始化开发版本,并在后续的每次发行时递增次版本号。

如何判断发布1.0.0 版本的时机?

当你的软件被用于正式环境,它应该已经达到了1.0.0 版。如果你已经有个稳定的API 被使用者依赖,也会是1.0.0 版。如果你很担心向下兼容的问题,也应该算是1.0.0 版了。

这不会阻碍快速开发和迭代吗?

主版本号为零的时候就是为了做快速开发。如果你每天都在改变API,那么你应该仍在主版本号为零的阶段(0.yz),或是正在下个主版本的独立开发分支中。

对于公共API,若即使是最小但不向下兼容的改变都需要产生新的主版本号,岂不是很快就达到42.0.0 版?

这是开发的责任感和前瞻性的问题。不兼容的改变不应该轻易被加入到有许多依赖代码的软件中。升级所付出的代价可能是巨大的。要递增主版本号来发行不兼容的改版,意味着你必须为这些改变所带来的影响深思熟虑,并且评估所涉及的成本及效益比。

为整个公共API写文件太费事了!

为供他人使用的软件​​编写适当的文件,是你作为一名专业开发者应尽的职责。保持专案高效一个非常重要的部份是掌控软件的复杂度,如果没有人知道如何使用你的软件或不知道哪些函数的调用是可靠的,要掌控复杂度会是困难的。长远来看,使用语义化版本控制以及对于公共API 有良好规范的坚持,可以让每个人及每件事都运行顺畅。

万一不小心把一个不兼容的改版当成了次版本号发行了该怎么办?

一旦发现自己破坏了语义化版本控制的规范,就要修正这个问题,并发行一个新的次版本号来更正这个问题并且恢复向下兼容。即使是这种情况,也不能去修改已发行的版本。可以的话,将有问题的版本号记录到文件中,告诉使用者问题所在,让他们能够意识到这是有问题的版本。

如果我更新了自己的依赖但没有改变公共API 该怎么办?

由于没有影响到公共API,这可以被认定是兼容的。若某个软件和你的套件有共同依赖, 则它会有自己的依赖规范,作者也会告知可能的冲突。要判断改版是属于修订等级或是次版等级,是依据你更新的依赖关系是为了修复问题或是加入新功能。对于后者,我经常会预期伴随着更多的代码,这显然会是一个次版本号级别的递增。

如果我变更了公共API 但无意中未遵循版本号的改动怎么办呢?(意即在修订等级的发布中,误将重大且不兼容的改变加到代码之中)

自行做最佳的判断。如果你有庞大的使用者群在依照公共API 的意图而变更行为后会大受影响,那么最好做一次主版本的发布,即使严格来说这个修复仅是修订等级的发布。记住, 语义化的版本控制就是透过版本号的改变来传达意义。若这些改变对你的使用者是重要的,那就透过版本号来向他们说明。

我该如何处理即将弃用的功能?

弃用现存的功能是软件开发中的家常便饭,也通常是向前发展所必须的。当你弃用部份公共API 时,你应该做两件事:(1)更新你的文件让使用者知道这个改变,(2)在适当的时机将弃用的功能透过新的次版本号发布。在新的主版本完全移除弃用功能前,至少要有一个次版本包含这个弃用信息,这样使用者才能平顺地转移到新版API。

语义化版本对于版本的字串长度是否有限制呢?

没有,请自行做适当的判断。举例来说,长到255 个字元的版本已过度夸张。再者,特定的系统对于字串长度可能会有他们自己的限制。

关于

语义化版本控制的规范是由Gravatars创办者兼GitHub共同创办者Tom Preston-Werner所建立。

如果您有任何建议,请到GitHub上提出您的问题。

授权

创用CC 姓名标示3.0 Unported 授权条款http://creativecommons.org/licenses/by/3.0/

项目版本描述规则

源:http://zengrong.net/post/2132.htm

项目版本描述规则

  • 本站文章除注明转载外,均为本站原创或者翻译。
  • 本站文章欢迎各种形式的转载,但请18岁以上的转载者注明文章出处,尊重我的劳动,也尊重你的智商;
  • 本站部分原创和翻译文章提供markdown格式源码,欢迎使用文章源码进行转载;
  • 本文标题:项目版本描述规则
  • 本文链接:http://zengrong.net/post/2132.htm

项目版本描述规则

Version definition

1. 概述

团队内所有产品和项目的版本号均遵循此规则。

2. 原则

基于 Tom Preston-Werner 的 语义化版本2.0.0 构建本规则。

版本实现内外统一,方便宣传、开发、运营和发音。

3. 版本号

3.1 版本号组成

版本号由 X.Y.Z-P.C 五个部分组成。详解如下:

|代码|名称|递增条件|递增方式|可用字符|备注|
|—-|—-|—-|—-|—-|—-|
|X|主版本号|做了大改动的时候递增|人为递增|[0-9]||
|Y|次版本号|做了一般改动的时候递增|人为递增|[0-9]||
|Z|协议版本号|客户端-服务端协议做了变动的时候递增|机器递增|[0-9]|服务端所有协议版本相加|
|P|先行版本号|描述产品发布的方式,详见 先行版本|人为递增|[ABR][0-9]|团队内部描述和讨论仅使用此版本号|
|C|编译版本号|客户端版本编译时候的版本|机器递增|[0-9a-z]|客户端git代码库提交sha码前6位,编译当时确定|

3.2 正则表达式表达法(Perl风格)

使用下面的正则拆分版本号各部分

([0-9]+)\.([0-9]+)\.([0-9]+)-([ABR][0-9]+)\.([0-9a-z]{6})

3.3 先行版本

  • 公司内部版本Alpha,简称A。版本编号从1开始,顺序递增;
  • 局部开放的测试版本Beta,简称B。版本编号依赖Alpha;
  • 公开发行的版本Release,简称R。版本编号依赖Beta。

3.4 先行版本工作流

所有的功能增加和修改,都是从Alpha版本开始的。每次增加功能,都必须递增先行版本。先行版本必须顺序递增,不得跳过任何数字。

  1. 团队按照Ax版本的计划逐步完成、完善内部版本。在内部对每一个Ax版本进行测试。Ax版本存在的问题,记录存档后,放在Ax+1版本中解决。
  2. 根据运营需求,挑选计划中或以完成的Ax版本,封装为Bx版本,进行小范围的、面向运营的测试。Bx版本存在的问题,记录存档后,放在同版本Bx中解决,直至完善。
  3. 挑选完善的Bx版本,封装为Rx版本发行。若Rx版本存在问题,回返上一步骤直至解决,然后重新封装为Rx版本号发行。

3.5 版本号制定工作流

  1. 策划和开发部门提出增加或者修改功能的要求;
  2. 运营部门制定先行版本号P;
  3. 开发部门制定主次版本号X.Y;
  4. 编译包时自动生成协议协议版本号Z和编译版本号C。

4. 范例

|发布日期|发布用途|版本号|
|—-|—-|—-|
|1024年12月25日|内部测试演示|0.1.100-A1.679ABF|
|2048年1月6日|内部测试演示|0.1.120-A2.5114f8|
|2048年1月31日|小范围外部测试|0.2.160-A3.5617ae|
|2048年X月X日|玩家小范围外部测试|0.3.100-B4.5617ae|
|…|…|…|
|2048年X月X日|公测或封测|0.9.301-B15.5617ae|
|2048年XX月XX-1日|1服前的最后1个Beta版本|0.9.356-B29.5617ae|
|4096年XX月XX日|1服|1.0.356-R29.5617ae|

5. 使用

    1. 团队内部会议中,仅使用先行版本号。
      例如:A3版,B30版
    2. 在游戏界面中,显示完整版本号;
    3. 对外宣传时,一般使用 X.Y.Z 版本号;如有必要,可加入先行版本号。
      例如: 1.0.130 , 1.0.356-R29
posted @ 2014-11-09 15:13  bxddno1  阅读(243)  评论(0)    收藏  举报