Unity手游开发札记——我们是如何使用Lua来开发大型游戏的?(下)

上一部分的文章聊了一下我对于使用Lua的一些观点,但是核心的内容还是在说如何弥补Lua语言在开发中的不足。一些朋友表示对于观点不敢苟同,我也希望不赞同的朋友可以多讨论,毕竟我也是屁股决定脑袋,在用多这门语言的时候总是觉得很多东西是习惯和顺手的。从心态上说我也是很开放的,比如自己会去关注ET这样纯C#的框架,想看看他们是如何做的,比用Lua好在哪里。

本来第二部分的开始计划是想先聊聊如何划分Lua和C#这两种语言的职责,但我想还是直奔主题,来聊下那些可以佐证我前面抛出的两个观点的那些方面。

3. 让Lua代码更好调试

游戏开发过程中总会写出或者遇到各种各样的bug,如何快速地定位和修复bug,也会对于开发效率有很大的影响。针对这点,我前面提到的一个观点是:

使用Lua这样的脚本语言,调试bug的效率并不低,甚至可能比C#这样的静态语言还要高

我想从以下几个方面来聊聊这个点。

3.1 节省编译时间

像Lua这样的动态语言是解释执行的,因此和静态语言相比,它们虽然运行时效率比较低,但是不需要编译的过程。这对于需要频繁修改代码,然后运行游戏测试结果的的bug修复过程,本身就是优势。比如之前做引擎C++代码的修改,无论是使用increbuild这样分布式的编译工具也好,还是购买更强力的机器也好,一次编译链接的时间总会需要等上那么一会,修改了头文件就更加痛苦……

嗯,曾经感同身受

当然,Unity的C#语言编译通常没有这么夸张,可以通过将部分模块提前编译为dll,或者将不常用的组件(比如第三方库)放置到Unity特殊的目录下来加快编译过程。然而,到项目中后期,我们的体验是修改C#代码之后,切换到unity总需要那么几秒钟的时间来进行编译。而修改Lua代码后,是不需要这几秒钟的等待时间的,重启游戏是丝般顺滑,哈哈。

有些朋友可能会觉得这几秒钟的等待无所谓,而对于一个用惯了脚本语言进行逻辑开发的人,可能会感受到这其中的差异。当然不会有人为了节约这点时间而去在Unity中集成Lua语言,它只是使用脚本语言一个顺带的福利而已。

当然,如果有朋友知道如果减少修改C#编译时间的方法,也欢迎指教~我们项目也很需要这样的经验。

3.2 断点调试支持

前文已经说了,Lua也是支持断点调试的,有朋友评论里分享了他们的调试方法:

感谢hhy分享的调试方法

我们团队内部的是使用VS Code+luaide来进行断点调试的。使用过程中偶尔遇到过一些变量值显示不正确的异常情况,但整体上基本可以满足断点调试的需求。

在调试方面,个人体验Lua的确不如C#这样的代码方便,需要自己集成调试插件,然后启动调试的时候还要有额外的步骤,而当已经确认要使用Lua之后,尽早让团队的成员学习和熟悉断点调试的方法和工具,可以节省掉不少调试的时间。

3.3 基于Reload的调试方法

想象一下这样的调试过程——

运行游戏过程中,你发现一些问题,根据经验和代码逻辑你大概定位到问题原因,在IDE中修改一些代码,或者添加一些log,按下Ctrl+S保存代码,游戏中的逻辑自动被替换为修改后的代码逻辑, 不需要重新启动游戏,在游戏中重新触发相关的逻辑,就可以看到新的log输出,或者验证你的修改的代码是否已经修复了之前的问题。

这是我在网易时,团队内已经在大范围地使用的调试方法,而基于这种方法,很多同学都懒得去学习和部署断点调试的工具。

越是大型的游戏,启动越慢,我们团队也做了一些事情来加快编辑器下的游戏启动时间,比如:

  • 编辑器下关闭闪屏过程;
  • 提供自动登录、自动创建角色等功能;
  • 提供游戏全局加速、角色速度加速等GM指令;
  • ……

这些工作都是为了提高整个程序团队乃至整个游戏研发团队的工作效率,因为重新启动游戏是一件在游戏开发中太过频繁的操作。而当一个程序需要尝试重现并修复一个bug的时候,可能需要多次这样的过程,而这也是基于log调试最为令人诟病的地方——你可能无法一次就精准地知道要在哪些地方添加log,还要根据log的输出结果调整log的位置或者输出的信息内容,如果这一过程都需要不断地重启游戏,那调试效率之低可以想象。

脚本语言提供的Reload功能,可以帮我们实现无需重启进程就可以更新代码的效果。在我们工程中使用了Tango这个非常古老的库来做进程间的跨Lua虚拟机访问,它的底层也是基于Socket来实现的,整个更新流程的结构示意图如下:

基于Tango的代码自动Reload结构示意图

在这个流程中,需要自己开发一个简单的IDE插件,或者把IDE的快捷键映射到本地的一个执行程序上。在需要reload的时候,获取要reload的文件,比如是当前IDE打开的文件,然后通过Tango的客户端尝试连接本地的Tango服务器,如果连接成功,就将reload的请求发送过去,游戏进程中开启的Tango服务器收到请求之后,执行reload操作,代码就被更新了。

需要说明的是,这个流程看起来并不复杂,但是也经历过几个步骤的演化过程:

  1. 首先在最初的时候,只提供了reload模块的功能,IDE中修改了代码之后,需要手动在游戏内通过GM指令或者Telnet上去的Shell控制台执行Reload操作;
  2. 后来引入了rpyc和Tango这样的跨进程通讯的模块之后,在外部制作了一个简单的ui工具,可以记录之前操作过的指令,方便快速reload;
  3. 最后才引入了IDE插件,将reload功能直接集成到保存操作中,实现自动Reload。

Tango虽然远没有Python的rpyc好用,经过简单的改造之后也基本满足我们的需求。如果了解有更好用库的朋友非常欢迎推荐~具体的实现细节不做过多的讨论,这里只聊一下reload的实现。

在Python中原生就有reload函数,Lua中的实现要通过loadfile或者loadstring这样的函数来实现。当然,你有可以暴力地删除掉原来已经require过的模块,然后重新require它,但这可能只能够正确处理非常少的情况,毕竟其他模块可能已经保留的对于原模块的引用。一个完备的reload过程需要保留之前模块中的上下文数据,只替换对应的逻辑和需要添加的数据内容,这样才能能够保证进程不重启的条件下,下次执行的正确性。

这里截取部分代码来说明这个流程的复杂性和基本原理:

-- Reload.lua
-- 并没有给出完整代码,仅供参考

    local Old = _ImportModule[PathFile]
    if Old and not Reload then
        return Old
    end

    -- 先loadfile再clear环境
    local func, err = loadfile(PathFile)
    if not func then
        logerror(func, err)
        return func, err
    end

    -- 第一次载入,不存在更新的问题
    if not Old then
        _ImportModule[PathFile] = {}
        local New = _ImportModule[PathFile]
        -- 设置原始环境
        setmetatable(New, {__index = _G})
        local ret = setfenv(func, New)()
        _ImportResult[PathFile] = ret
        return New
    end

    -- 先缓存原来的旧内容
    local OldCache = {}
    for k,v in pairs(Old) do
        OldCache[k] = v
        Old[k] = nil
    end

    -- 使用原来的module作为fenv,可以保证之前的引用可以更新到
    local ret = setfenv(func, Old)()
    _ImportResult[PathFile] = ret

    -- 更新以后的模块, 里面的table的reference将不再有效,需要还原.
    local New = Old

    -- 还原table(copy by value)
    for k,v in pairs(OldCache) do
        local TmpNewData = New[k]
        -- 默认不更新
        New[k] = v
        if TmpNewData then
            if type(v) == "table" then
                if type(TmpNewData) == "table" then
                    -- 如果是一个class则需要全部更新,其他则可能只是一些数据,不需要更新
                    if v.__IsClass then
                        local mt = getmetatable(v)
                        if rawget(v,"__IsClass") then
                            -- 是class要更新其mt
                            local old_mt = v.mt
                            local index = old_mt.__index
                            ReplaceTbl(v, TmpNewData)
                            v.mt = old_mt
                            old_mt.__index = index
                        end
                    end
                    local mt = getmetatable(TmpNewData)
                    if mt then setmetatable(v, mt) end
                end
            -- 函数段必须用新的
            elseif type(v) == "function" then
                New[k] = TmpNewData
            end
        end
    end

整个Reload的过程中需要考虑的内容比较多,但是即便如此,对于那些比如local func = xxx.foo这样被缓存的函数,依然可能存在更新不到的情况,对于某些被缓存在闭包中的函数,也有类似的问题。

相比于客户端调试用的Reload逻辑,如果是使用Lua语言实现逻辑的服务端,当需要Refresh逻辑的时候,需要更加完备的更新考虑,所以如果对这块感兴趣的朋友,可以找一些开源的Lua服务端框架来看下,看看是否有可以参考的代码。而如果仅仅是调试使用,则可以使用相对少的精力实现最为核心的基础功能,做到对于大部分函数的重新加载就够用了。

不仅仅针对Lua语言,在使用任何语言进行游戏开发的过程中,善用语言的特性,在加上不断改进的心,就可以做出很多提升团队效率的工具。

3.4 Lua内存数据的查看和修改

在开发和调试过程中,经常会遇到需要查看内存数据的需求,一方面Unity在编辑器模式下提供了非常便利的场景数据查看的方式,在设备上也可以集成之前推荐过很多次的插件Hdg Remote Debug,另外一方面C#和Lua的内存通过断点调试工具来进行查看。

在我们游戏的开发中,基于Tango制作了另外的内存查看和修改工具。原理非常简单,基于Tango跨Lua进程的特性,配合一个基于pyQT的gui界面,就可以做到:

  1. 直接输入代码输出和修改游戏内的数据;
  2. 可以直接指定游戏内的一个table获取代码,然后查看其所有内容。
  3. 通过ip访问可以直接连接移动设备进行操作。

我们在用的工具截图如下:

Lua内存查看和修改工具截图

截图中左侧是内存对象的逐层展示功能,可以看到当前内存中通过代码获取的某个table中的具体信息,比如QA要验证角色数值计算的正确性,就会使用这个工具来进行查看。右侧更多的提供给程序,用于执行一些代码和逻辑,直接查看游戏进程中的Lua数据,并可以进行实时的修改。同时右侧的功能还有一个单纯的Shell版本,基于iLua可以做补全等操作,方便很多。

3.5 小结

上述的这些工具的开发部署的确会花费团队一些时间和精力,但是有了这些工具,不断根据需求进行完善和改进,可以让程序团队可以更加高效地进行错误的调试和修复,提高整个团队的工作效率。

4. 更快修复线上问题

对于线上问题的修复,Patch的部分其实很多项目大同小异,不过这里面细节也有很多,有时间的时候可以整理和分享一下我们在这部分做的工作。这篇文章的线上问题修复我们来着重聊聊Hotfix

前文描述观点的时候已经说了Hotfix可以实现的效果,我其实不知道业内使用这一方式进行线上问题修复的普及程度是怎么样的。在网易的时候因为大家都用脚本,Hotfix是游戏上线的标配功能,出来创业之后,大家在聊热更新什么的,从来不会单独提这块,所以我并不知道这种修复方式在行业内是正在被广泛应用呢,还是并不常用的一种方式。

4.1 Hotfix的优势

现在手游开发的周期越来越短,开发速度要求越来越快,往往会在上线之后遇到一些影响了玩家体验或者阻碍了玩家流程的客户端bug需要线上修复,这时候修改代码制作patch然后放出去,对于已经在线的玩家,如果是强制patch的方式,要把玩家踢掉线让其重启客户端或者到Patch更新界面去进行Patch的下载操作,这其实对于玩家的体验非常不好。而Hotfix的优势正是在玩家无感知的情况下修复紧急的bug。

4.2 基本原理

Hotfix的基本原理依然是基于动态语言的Reload功能,更加准确的说是Function Reload。下图简单描述了整个Hotfix的流程:

Hotfix的应用流程

更加具体地可以描述为:

  1. 程序发现要修复的bug,编写特殊的Hotfix代码进行修复,测试通过后上传到svn服务器;
  2. 通过发布指令,将svn上更新后的Hotfix代码同步到服务器上;
  3. 服务器发现Hotfix代码有更新,则将其压缩序列化后通过socket发送给所有在线的客户端,同时带上字符串的MD5值供客户端验证;
  4. 客户端收到Hofix消息之后,首先反序列化数据得到代码内容,校验MD5值之后,如果和本地已经执行过的Hotfix的MD5值不同,则执行替换逻辑,并记录当前已经执行过Hotfix的MD5值,如果相同则不再执行;
  5. 客户端连接服务器的时候会主动请求一次Hofix。

4.3 实现方式

执行Hotfix执行的代码非常简单,基于loadstring函数即可:

local f = loadstring(GameContext.HotfixData)
if f then
    ClientUtils.trycall(f)
end

这里的实现就没有reload那么复杂,但是也是有一定的限制,比如local的函数或者在闭包内的函数依然很难做正确的hotfix,需要编写特殊的Hotfix代码。

而如果使用了类似于我们这样复杂的Class结构,有大量Function的缓存的话,需要额外的处理函数来保证这些缓存的函数对象被正确替换,比如针对我前文提供的Class方式,需要这样的代码来执行Class级别的函数替换:

-- 类的继承关系数据,用于处理Hotfix等逻辑。
-- 数据形式:key为ClassType,value为继承自它的子类列表。
local __InheritRelationship = {}

local function __getInheritChildren( classType, output )
    if output[classType] then
        return
    else
        output[classType] = true
        if __InheritRelationship[classType] then
            for index, childType in pairs(__InheritRelationship[classType]) do
                __getInheritChildren(childType, output)
            end
        end
    end
end

local function __HotfixClassFunction(classType, funcName, newFunc)
    local classVtbl = __ClassTypeList[classType]
    if classVtbl and funcName and newFunc then
        local preFunc = classVtbl[funcName]
        classVtbl[funcName] = newFunc
        local children = {}
        __getInheritChildren(classType, children)
        for replaceClass, value in pairs(children) do
            local vtbl = __ClassTypeList[replaceClass]
            if rawget(vtbl, funcName) == preFunc then
                vtbl[funcName] = newFunc
            end
            if replaceClass ~= classType then
                local super = replaceClass.super
                if rawget(super, funcName) == preFunc then
                    super[funcName] = newFunc
                end
            end
        end
    end
end

if (not IsGLDeclared("HotfixClassFunction")) or(not HotfixClassFunction) then
    GLDeclare("HotfixClassFunction", __HotfixClassFunction)
end

给出一段简单的Hotfix代码示例如下:

-- hotfix

local function WorldEntity_destroy(self)
    -- New Code Here
end

-- 替换函数
local function ReplaceFunc( )
    HotfixClassFunction( WorldEntity, "destroy", WorldEntity_destroy)
end

-- 替换数据
local function ReplaceData( )
    ResDungeon[1011]['member_num'] = 1
    ResDungeon[1012]['member_num'] = 1
end

ReplaceFunc()
ReplaceData()
注意:如果你像我们一样在战斗中使用了帧同步的方式,对于战斗中逻辑或者数据的Hotfix一定要非常小心,一场战斗中的玩家,无论是开始就进入的还是在断线重连上的时候,都 必须使用同样的Hotfix代码,否则不同客户端帧同步计算的结果就不同了。

4.4 小结

如果你们团队在外放前拥有更长的测试周期,拥有更加专业的团队成员,可以尽量减少线上问题出现的概率,那大家都会非常开心,也就可能不会需要我们正在使用的这种Hofix修复线上问题的方法。如果你们在使用Lua或者其他的脚本语言,具有动态reload代码的特性,你也可以实现一下这种fix方式,以备不时之需。当然,还是建议对于这套东西多加测试,更加希望所有团队都不需要修复这么紧急的线上问题~

无痛的人流可能并不存在,但是对于玩家无感的bug修复,是可以存在的。

5. 分享经验,而不争辩好坏

回头来看,写这篇文章最初的心态带着那么一点点为Lua“正名”的意思,想告诉大家其实Lua虽然的确有些难用的地方,但是经过一些工具构建,加上对于动态语言特性的善用,可以做很多事情,在某些方面甚至可以比静态语言做得更好。

回头看这个想法有些可笑,其实一个语言真正好用与否,完全取决于用的人。团队的历史经验、团队的整体实力、所要做的游戏类型等等不同,都会有完全不同的结论。无论什么样的框架,什么样的语言,什么样的技术,都只有最合适的,没有最好的。又或者换个角度,只有经过项目和团队的磨砺,技术这把双刃剑,朝向问题的那面才能更锋利,朝向自己的那面才会更加圆润。

而我能做的,是把我觉得我们项目中对于Lua的使用较好的部分分享给你,如果你必须使用Lua,我希望你能用得舒服一点,如果你依然觉得它是“狗屎”,那就选择合适你的。

之前我习惯使用Python,出来创业之后要使用Lua开始还有些胆怯,花了一个多月的时间和团队一起构建上述的这些基础框架、调试工具以及维护流程,并用一个项目的时间来改进磨砺它们。现在,一年多之后,我觉得我们把它用得挺顺手了。但我依然告诉自己要保持开放的态度,下一个项目,我们可能继续使用Lua,也可能会尝试ILRuntime,又或者其他什么新鲜的东西。我相信,对于之前经验的总结、工具的改进让我们走得更稳,对于不熟悉的技术的学习和讨论让我们跑得更快。

猜你喜欢

转载自blog.csdn.net/qq_14914623/article/details/80944701
今日推荐