cocos2d-x+lua代码热加载(Hot Swap)的研究

来源:互联网 发布:梅西最新数据 编辑:程序博客网 时间:2024/04/28 17:44

 代码热加载跟自动更新无关,主要目的是在程序运行的时候动态的替换代码,从而实现不重启程序而更新代码的目的。最理想的情况当然是我修改完代码并保存,然后就可以直接在游戏中看到修改后的效果,这个在实际开发过程中会大大提高效率。 即便达不到理想情况,我们也希望可以实现部分热加载,从而简化操作。例如我们可以仅仅对配置文件、消息文件、界面文件实现热加载,这样策划更新数据后可以直接在游戏中看结果,而不需要重新打开客户端去跑任务。

        热加载主要原理其实很简单,lua require文件都会缓存在package.loaded里面,当重新加载文件的时候,把这个置空,然后重新require对应文件就可以了。

        实际应用中会有更多需要考虑的因素,所以完全的代码热加载很复杂(原理很简单,但是实现很复杂,需要关注的因素很多)。

        CocosIDE展示了代码热加载的效果:编辑场景中图片的位置并保存,然后图片自动放置到新的位置上面了。 这个效果看着非常神奇,但是实际上并没有什么实用价值。因为它的热加载,其实就是重新require文件(基于上面提到的原理)的过程,这个过程中会重新require 'main.lua',从而整个游戏都会被重新启动。当我们只有一个简单的场景的时候,就可以实现看起来很完美的热加载。然而,由于实际游戏客户端项目会比这个复杂很多,我们会涉及到多场景、多界面、多状态的维护,所以想实现没有Bug的热加载是很困难的。

        现在只研究了一部分,初步可行,后期完善了会更加实用。

1、按R键重新加载所有的lua脚本。这个后面可以做很多优化。比如windows下检测文件变化,而不需要手动按键。只重新加载改变的文件而不是所有文件都遍历一遍。

[python] view plaincopy
  1. local listener = cc.EventListenerKeyboard:create();  
  2.   
  3.             listener:registerScriptHandler(function(keycode, evt)  
  4.                 --print(keycode)  
  5.                 if keycode == 138 then  
  6.                     -- 按R重新加载代码  
  7.                     reload_script_files();  
  8.   
  9.                     -- 逻辑代码  重新加载所有的配置  
[python] view plaincopy
  1. -- 逻辑代码 关闭并重新打开当前已打开的窗口  
[python] view plaincopy在CODE上查看代码片派生到我的代码片
  1.     end  
  2. end, cc.Handler.EVENT_KEYBOARD_RELEASED);  
  3.   
  4. local eventDispatcher = cc.Director:getInstance():getEventDispatcher();  
  5. eventDispatcher:addEventListenerWithSceneGraphPriority(listener, scene);  

2、重新加载脚本的实现,这个会递归遍历这个脚本所有依赖的子脚本。所以一般情况下我们只需要加载一个main.lua就足够了。当然后面优化后就可以加载特定的文件而无需从main.lua一直遍历下去

[python] view plaincopy在CODE上查看代码片派生到我的代码片
  1. -- 外部库 登记  
  2. local package_list = package_list or {  
  3.     bit = true,  
  4.     lfs = true,  
  5.     cjson = true,  
  6.     pb = true,  
  7.     socket = true,  
  8. }  
  9.   
  10. -- 全局性质类/或禁止重新加载的文件记录  
  11. local ignored_file_list = ignored_file_list or {  
  12.     global = true ,  
  13. }  
  14.   
  15. --已重新加载的文件记录  
  16. local loaded_file_list = loaded_file_list or {}  
  17.   
  18. --视图排版控制  
  19. function leading_tag( indent )  
  20.     -- body  
  21.     if indent < 1 then  
  22.         return ''  
  23.     else  
  24.         return string.rep( '    |',  indent - 1  ) .. '    '  
  25.     end  
  26. end  
  27.   
  28. --关键递归重新加载函数  
  29. --filename 文件名  
  30. --indent   递归深度, 用于控制排版显示  
  31. function recursive_reload( filename, indent )  
  32.     -- body  
  33.     if package_list[ filename] then   
  34.         --对于 外部库, 只进行重新加载, 不做递归子文件  
  35.         --卸载旧文件  
  36.         package.loaded[ filename] = nil  
  37.   
  38.         --装载信文件  
  39.         require( filename )  
  40.   
  41.         --标记"已被重新加载"  
  42.         loaded_file_list[ filename] = true  
  43.   
  44.         --print( leading_tag(indent) .. filename .. "... done" )  
  45.         return true  
  46.     end  
  47.   
  48.     --普通文件  
  49.     --进行 "已被重新加载" 检测  
  50.     if loaded_file_list[ filename] then   
  51.         --print( leading_tag(indent) .. filename .. "...already been reloaded IGNORED" )  
  52.         return true  
  53.     end  
  54.   
  55.     local fullPath = cc.FileUtils:getInstance():fullPathForFilename(string.gsub(filename, '%.''/') .. '.lua');  
  56.     --print(fullPath)  
  57.     --读取当前文件内容, 以进行子文件递归重新加载  
  58.     local file, err = io.open( fullPath )  
  59.     if file == nil then   
  60.         print( string.format( "failed to reaload file(%s), with error:%s", fullPath, err or "unknown" ) )  
  61.         return false  
  62.     end  
  63.   
  64.     print( leading_tag(indent) .. filename)  
  65.   
  66.     -- 缓存文件内容,及时关闭文件,否则文件不可写入  
  67.     local data = {}  
  68.     local comment = false  
  69.     for line in file:lines() do  
  70.         line = string.trim(line);  
  71.         if string.find(line, '%-%-%[%[%-%-') ~= nil then  
  72.             comment = true;  
  73.         end  
  74.   
  75.         if comment and (string.find(line, '%]%]') ~= nil or string.find(line, '%-%-%]%]%-%-') ~= nil) then  
  76.             comment = false;  
  77.         end  
  78.   
  79.         -- 被注释掉的,和持有特殊标志的require文件不重新加载  
  80.         local linecomment = (line[1] == '-' and line[2] == '-')  
  81.         if not comment and not linecomment and string.find(line, '%-%- Ignore Reload') == nil  then  
  82.             table.insert(data, line);  
  83.         end  
  84.     end  
  85.   
  86.     io.close(file)  
  87.   
  88.     local function getFileName(line)  
  89.         local begIndex = string.find(line, "'""'"); 
  90.         local endIndex = string.find(line, "'", (begIndex or 1) + 1)  
  91.         if begIndex == nil or endIndex == nil then  
  92.             begIndex = string.find(line, '"''"'); 
  93.             endIndex = string.find(line, '"', (begIndex or 1) + 1)  
  94.         end  
  95.   
  96.         if begIndex == nil or endIndex == nil then  
  97.             return nil;  
  98.         end  
  99.   
  100.         return string.sub(line, begIndex + 1, endIndex - 1)  
  101.     end  
  102.   
  103.     -- 先解析文件,加载里面的子文件  
  104.     for _,line in ipairs(data) do   
  105.         -- 去除空白符  
  106.         --line = string.gsub( line, '%s''' )  
  107.         local subFileName = nil   
  108.         if string.find(line, 'require') ~= nil then  
  109.             subFileName = getFileName(line);  
  110.         elseif string.find(line, 'import') ~= nil then  
  111.             -- TODO 兼容import 通过fullPath进行解析  
  112.             subFileName = nil  
  113.         end  
  114.   
  115.         if subFileName then  
  116.             --printInfo('file: %s     subFile: %s', line, subFileName)  
  117.             --进行递归   
  118.             local success = recursive_reload( subFileName, indent + 1 )  
  119.             if not success then   
  120.                 print( string.format( "failed to reload sub file of (%s)", filename ) )  
  121.                 return false   
  122.             end  
  123.   
  124.         end  
  125.           
  126.     end      
  127.   
  128.   
  129.     -- "后序" 处理当前文件...  
  130.     if ignored_file_list[ filename] then  
  131.         --忽略 "禁止被重新加载"的文件  
  132.         print( leading_tag(indent) .. filename .. "... IGNORED" )  
  133.         return true  
  134.     else  
  135.   
  136.         --卸载旧文件  
  137.         package.loaded[ filename] = nil  
  138.   
  139.         --装载新文件  
  140.         require( filename )  
  141.   
  142.         --设置"已被重新加载" 标记  
  143.         loaded_file_list[ filename] = true  
  144.         --print( leading_tag(indent) .. filename .. "... done" )  
  145.         return true  
  146.     end  
  147. end  
  148.   
  149. --主入口函数  
  150. function reload_script_files()  
  151.       
  152.     print"[reload_script_files...]")  
  153.   
  154.     loaded_file_list = {}  
  155.   
  156.     --本项目是以 main.lua 为主文件  
  157.     recursive_reload( "MainController"0 )  
  158.       
  159.     print"[reload_script_files...done]")  
  160.   
  161.     return "reload ok"  
  162. end  

3、具体逻辑层面的处理

      lua的热加载主要麻烦的地方其实在逻辑层面的处理上面,一开始写代码的时候就要注意一些问题。比如:

      a、全局变量这样创建     test = test or {}    这样重新加载文件的时候就不会初始化全局变量了。同理,lua文件作用域内的函数调用也需要类似的判定防止重复运行。

      b、重新加载配置和重新打开当前窗口都需要针对自己的逻辑特殊处理。 

      c、理论上我们希望的热加载是对函数实现的替换。所以肯定不会实时的反应修改,比如npc的位置不会因为重新加载脚本而实时改变,这个是我们加载场景的时候就创建好的,如果需要npc站在新的位置上,需要重新加载场景或者运行刷新npc位置的函数。 同理,窗口中控件的位置也不会实时改变,需要我们重新打开窗口。 不过如果我们可以通过代码自动执行相关的刷新操作,其实对最终用户来说是没有什么区别的。同样是可以达到所见即所得的效果。

       d、当我们重新加载脚本后,所有的脚本内容都会自动更新。但是注册给cocos2d-x的函数不会,估计是因为tolua已经缓存了对应的函数体。这个暂时想不到好的解决方法,因为即便我能够清空tolua中的缓存,也无法找到对应的lua中的新函数。 除非重新注册函数,而重新注册函数其实就相当于重新打开窗口这个过程。


4、实际应用

      这里描述的是相对理想的情况,而且由于肯定是热加载功能为游戏框架服务,而不是反过来游戏框架去适应热加载。所以最终达不到真正理想的无缝加载。不过即便如此,通过上面三步操作也可以大大提高游戏开发效率。

      当我们写了部分界面的功能后,运行程序,查看结果。发现界面光效位置有偏移,在脚本中修改光效的位置,保存。这个时候光效自动在新的位置出现(依赖于自动重新打开窗口或自动刷新窗口功能,如果没有这个功能,则需要手动点按钮打开窗口)。我们可以继续添加新的功能,比如给按钮绑定函数,保存一下,点击窗口中的按钮看看效果,发现函数实现有错误,修改之,然后再保存,再点击按钮看下效果,运行正常,继续开发后续功能。

0 0