天天看點

cocos2d-x+lua代碼熱加載(Hot Swap)的研究

        代碼熱加載跟自動更新無關,主要目的是在程式運作的時候動态的替換代碼,進而實作不重新開機程式而更新代碼的目的。最理想的情況當然是我修改完代碼并儲存,然後就可以直接在遊戲中看到修改後的效果,這個在實際開發過程中會大大提高效率。 即便達不到理想情況,我們也希望可以實作部分熱加載,進而簡化操作。例如我們可以僅僅對配置檔案、消息檔案、界面檔案實作熱加載,這樣策劃更新資料後可以直接在遊戲中看結果,而不需要重新打開用戶端去跑任務。

        熱加載主要原理其實很簡單,lua require檔案都會緩存在package.loaded裡面,當重新加載檔案的時候,把這個置空,然後重新require對應檔案就可以了。

        實際應用中會有更多需要考慮的因素,是以完全的代碼熱加載很複雜(原理很簡單,但是實作很複雜,需要關注的因素很多)。

        CocosIDE展示了代碼熱加載的效果:編輯場景中圖檔的位置并儲存,然後圖檔自動放置到新的位置上面了。 這個效果看着非常神奇,但是實際上并沒有什麼實用價值。因為它的熱加載,其實就是重新require檔案(基于上面提到的原理)的過程,這個過程中會重新require 'main.lua',進而整個遊戲都會被重新啟動。當我們隻有一個簡單的場景的時候,就可以實作看起來很完美的熱加載。然而,由于實際遊戲用戶端項目會比這個複雜很多,我們會涉及到多場景、多界面、多狀态的維護,是以想實作沒有Bug的熱加載是很困難的。

        現在隻研究了一部分,初步可行,後期完善了會更加實用。

1、按R鍵重新加載所有的lua腳本。這個後面可以做很多優化。比如windows下檢測檔案變化,而不需要手動按鍵。隻重新加載改變的檔案而不是所有檔案都周遊一遍。

local listener = cc.EventListenerKeyboard:create();

            listener:registerScriptHandler(function(keycode, evt)
            	--print(keycode)
                if keycode == 138 then
                    -- 按R重新加載代碼
                    reload_script_files();

                    -- 邏輯代碼  重新加載所有的配置
           
-- 邏輯代碼 關閉并重新打開目前已打開的視窗
           
end
            end, cc.Handler.EVENT_KEYBOARD_RELEASED);

            local eventDispatcher = cc.Director:getInstance():getEventDispatcher();
            eventDispatcher:addEventListenerWithSceneGraphPriority(listener, scene);
           

2、重新加載腳本的實作,這個會遞歸周遊這個腳本所有依賴的子腳本。是以一般情況下我們隻需要加載一個main.lua就足夠了。當然後面優化後就可以加載特定的檔案而無需從main.lua一直周遊下去

-- 外部庫 登記
local package_list = package_list or {
    bit = true,
    lfs = true,
    cjson = true,
    pb = true,
    socket = true,
}

-- 全局性質類/或禁止重新加載的檔案記錄
local ignored_file_list = ignored_file_list or {
    global = true ,
}

--已重新加載的檔案記錄
local loaded_file_list = loaded_file_list or {}

--視圖排版控制
function leading_tag( indent )
    -- body
    if indent < 1 then
        return ''
    else
        return string.rep( '    |',  indent - 1  ) .. '    '
    end
end

--關鍵遞歸重新加載函數
--filename 檔案名
--indent   遞歸深度, 用于控制排版顯示
function recursive_reload( filename, indent )
    -- body
    if package_list[ filename] then 
        --對于 外部庫, 隻進行重新加載, 不做遞歸子檔案
        --解除安裝舊檔案
        package.loaded[ filename] = nil

        --裝載信檔案
        require( filename )

        --标記"已被重新加載"
        loaded_file_list[ filename] = true

        --print( leading_tag(indent) .. filename .. "... done" )
        return true
    end

    --普通檔案
    --進行 "已被重新加載" 檢測
    if loaded_file_list[ filename] then 
        --print( leading_tag(indent) .. filename .. "...already been reloaded IGNORED" )
        return true
    end

    local fullPath = cc.FileUtils:getInstance():fullPathForFilename(string.gsub(filename, '%.', '/') .. '.lua');
    --print(fullPath)
    --讀取目前檔案内容, 以進行子檔案遞歸重新加載
    local file, err = io.open( fullPath )
    if file == nil then 
        print( string.format( "failed to reaload file(%s), with error:%s", fullPath, err or "unknown" ) )
        return false
    end

    print( leading_tag(indent) .. filename)

    -- 緩存檔案内容,及時關閉檔案,否則檔案不可寫入
    local data = {}
    local comment = false
    for line in file:lines() do
        line = string.trim(line);
        if string.find(line, '%-%-%[%[%-%-') ~= nil then
            comment = true;
        end

        if comment and (string.find(line, '%]%]') ~= nil or string.find(line, '%-%-%]%]%-%-') ~= nil) then
            comment = false;
        end

        -- 被注釋掉的,和持有特殊标志的require檔案不重新加載
        local linecomment = (line[1] == '-' and line[2] == '-')
        if not comment and not linecomment and string.find(line, '%-%- Ignore Reload') == nil  then
            table.insert(data, line);
        end
    end

    io.close(file)

    local function getFileName(line)
        local begIndex = string.find(line, "'");
        local endIndex = string.find(line, "'", (begIndex or 1) + 1)
        if begIndex == nil or endIndex == nil then
            begIndex = string.find(line, '"');
            endIndex = string.find(line, '"', (begIndex or 1) + 1)
        end

        if begIndex == nil or endIndex == nil then
            return nil;
        end

        return string.sub(line, begIndex + 1, endIndex - 1)
    end

    -- 先解析檔案,加載裡面的子檔案
    for _,line in ipairs(data) do 
        -- 去除空白符
        --line = string.gsub( line, '%s', '' )
        local subFileName = nil 
        if string.find(line, 'require') ~= nil then
            subFileName = getFileName(line);
        elseif string.find(line, 'import') ~= nil then
            -- TODO 相容import 通過fullPath進行解析
            subFileName = nil
        end

        if subFileName then
            --printInfo('file: %s     subFile: %s', line, subFileName)
            --進行遞歸 
            local success = recursive_reload( subFileName, indent + 1 )
            if not success then 
                print( string.format( "failed to reload sub file of (%s)", filename ) )
                return false 
            end

        end
        
    end    


    -- "後序" 處理目前檔案...
    if ignored_file_list[ filename] then
        --忽略 "禁止被重新加載"的檔案
        print( leading_tag(indent) .. filename .. "... IGNORED" )
        return true
    else

        --解除安裝舊檔案
        package.loaded[ filename] = nil

        --裝載新檔案
        require( filename )

        --設定"已被重新加載" 标記
        loaded_file_list[ filename] = true
        --print( leading_tag(indent) .. filename .. "... done" )
        return true
    end
end

--主入口函數
function reload_script_files()
    
    print( "[reload_script_files...]")

    loaded_file_list = {}

    --本項目是以 main.lua 為主檔案
    recursive_reload( "MainController", 0 )
    
    print( "[reload_script_files...done]")

    return "reload ok"
end
           

3、具體邏輯層面的處理

      lua的熱加載主要麻煩的地方其實在邏輯層面的處理上面,一開始寫代碼的時候就要注意一些問題。比如:

      a、全局變量這樣建立     test = test or {}    這樣重新加載檔案的時候就不會初始化全局變量了。同理,lua檔案作用域内的函數調用也需要類似的判定防止重複運作。

      b、重新加載配置和重新打開目前視窗都需要針對自己的邏輯特殊處理。 

      c、理論上我們希望的熱加載是對函數實作的替換。是以肯定不會實時的反應修改,比如npc的位置不會因為重新加載腳本而實時改變,這個是我們加載場景的時候就建立好的,如果需要npc站在新的位置上,需要重新加載場景或者運作重新整理npc位置的函數。 同理,視窗中控件的位置也不會實時改變,需要我們重新打開視窗。 不過如果我們可以通過代碼自動執行相關的重新整理操作,其實對最終使用者來說是沒有什麼差別的。同樣是可以達到所見即所得的效果。

       d、當我們重新加載腳本後,所有的腳本内容都會自動更新。但是注冊給cocos2d-x的函數不會,估計是因為tolua已經緩存了對應的函數體。這個暫時想不到好的解決方法,因為即便我能夠清空tolua中的緩存,也無法找到對應的lua中的新函數。 除非重新注冊函數,而重新注冊函數其實就相當于重新打開視窗這個過程。

4、實際應用

      這裡描述的是相對理想的情況,而且由于肯定是熱加載功能為遊戲架構服務,而不是反過來遊戲架構去适應熱加載。是以最終達不到真正理想的無縫加載。不過即便如此,通過上面三步操作也可以大大提高遊戲開發效率。

      當我們寫了部分界面的功能後,運作程式,檢視結果。發現界面光效位置有偏移,在腳本中修改光效的位置,儲存。這個時候光效自動在新的位置出現(依賴于自動重新打開視窗或自動重新整理視窗功能,如果沒有這個功能,則需要手動點按鈕打開視窗)。我們可以繼續添加新的功能,比如給按鈕綁定函數,儲存一下,點選視窗中的按鈕看看效果,發現函數實作有錯誤,修改之,然後再儲存,再點選按鈕看下效果,運作正常,繼續開發後續功能。