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

0. 照旧的碎碎念

转眼间已经三月了,2月份的博客因为过年的懒惰和开年之后的忙碌而没有写……第二个月就打破了去年总结时对于2018年的愿望,真是羞耻呢……

年后在准备新的测试版本,断断续续做了一些优化,更多的精力放在团队的绩效评估、沟通这样偏管理的事物上,说实话技术上可以聊的东西不多。近期看到UWA群里和问答上聊Lua的使用之类的话题比较多,也在看ET这套完全基于C#进行游戏开发的框架中提到——

“在发布的时候,定义预编译指令ILRuntime就可以无缝切换成使用ILRuntime加载热更新动态库。这样开发起来及其方便,再也不用使用狗屎lua了。”

Lua是门小而精的语言,它的确很多地方像狗屎一样……比如只提供table这样一种数据结构,而且基于数组域和哈希域的封装让#这样的操作符号可以坑死不少新手甚至老司机,一个哈希表要取长度还要自己封装一个遍历函数等等诸多不便的地方。

我们项目深度使用了Lua,原因其实在1年多前的一篇文章里已经有聊过——《Unity手游开发札记——Lua语言集成》,有兴趣的朋友可以再去看看。那篇文章也聊了最初对于一些框架上的改造,而今天这篇文章我想聊聊我们团队是如何使用Lua来开发大型游戏的。一方面让大家看看我们是如何把Lua这个“狗屎”,捏成巧克力的形状甚至做出一点点巧克力的味道;另外一方面,也想为纠结是否使用Lua来做Unity的代码更新方案的朋友提供一些做决策的参考。

1. 我的观点

在聊一些更加具体的经验之前,我想先把我自己的观点抛出来,这也是我花时间写这篇文章最想表达的两点内容:

  1. 使用Lua这样的脚本语言,目的不仅仅在于让代码可以被Patch更新,而且让游戏逻辑可以被Hotfix更新
  2. 使用Lua这样的脚本语言,调试bug的效率并不低,甚至可能比C#这样的静态语言还要高

先聊下第一点,我看很多朋友在聊的时候不断提到客户端的热更新,可能每个人或者公司有自己不同的叫法,在我的观点里,通过在游戏启动的时候下载新的资源文件替换之前的文件,让游戏不需要重新安装就可以更新内容的方式叫做“Patch更新”,而不是热更新(Hotfix)

在我的理解中,热更新(Hotfix)的概念从服务端来讲,是指不停止服务的情况下进行的更新,此时如果玩家正在进行游戏,玩家是无感知的,最多感觉到一点顿卡之类的。而对于客户端来说,玩家正在进行游戏,这时候如果需要玩家退出到登陆界面重新下载Patch内容再进入游戏,打断了玩家的游戏体验,根本就不能称之为“热”更新,虽不至于是冷更,最多是“温”更新……

脚本语言让游戏逻辑和数据可以做到玩家无感知的情况下进行错误的修复,比如有一个trace导致了玩家某个系统的界面打开后内容显示错误,Hotfix应用之后,玩家下次打开这个界面的时候,trace就已经被修复了,内容显示正确,而玩家完全没有任何更新的感知,这种才能叫做真正的客户端热更新

第二点,有些朋友认为脚本语言只能通过打log进行调试,是一件非常痛苦的事情。首先,Python和Lua这样的脚本语言都有各自的调试工具,可能没有那么便利,但基本功能是够用的;其次,在移动网络游戏的开发中,有网络因素、异步逻辑、设备上运行等存在的情况下,有些bug是很难单步调试来进行重现和分析的,这种情况下log调试必不可少,而且我认为通过分析代码逻辑精准地添加log快速定位问题并修复问题的能力,是每一个程序员应该掌握的基本技巧;最后,结合动态语言的reload功能,即使是使用log调试,也有很高效的方法,在加上内存查看工具,可以做到很高效的bug定位和修复。

这里只是先阐述一下我个人的观点,下面我将根据实际的项目经验来聊聊我们使用Lua的一些方面。

2. 让Lua代码更好写

Lua自身提供的功能很精简,精简也意味着它在很多方面会有些“残疾”……这会导致团队的开发效率比较低,因此必须通过一些基础内容的构建来让团队更好地使用Lua语言。需要注意的是,天下没有免费的午餐,更快的开发效率有很多时候意味着更慢的运行效率

2.1 全局变量访问控制

Lua的设计中有一个特点就是:

当你不在变量前使用local关键字的时候,这个变量会被放在_G这个全局表中。

我在最初学习Lua的时候也很难理解这个设计,这和之前我使用的编程语言中作用域的概念是相违背的,但是当你理解函数的env概念之后,就很容易理解为什么在Lua语言中,这样的设计反而是最为合理和自洽的。

对于Lua语言自身来说,这种合理和自洽是美的,但是它会给使用的人带来困惑和难以排查的bug,因为你非常可能因为遗漏的local声明,导致污染了_G,甚至修改到了了你不想修改的变量,或者你的某个变量被别处的代码不小心修改了。因此在我们的工程中,去掉了Lua的这一特性,当期望使用一个局部变量但是没有写local变量的时候,使用error报出错误,所有的全局变量必须显示地进行声明

实现方法很简单,重写_G的__index方法和__newindex方法:

-- Global.lua
-- 辅助记录全局变量的名称是否被使用过
local _GlobalNames = { }

local function __innerDeclare(name, defaultValue)
    if not rawget(_G, name) then
        rawset(_G, name, defaultValue or false)
    else
        print("[Warning] The global variable " .. name .. " is already declared!")
    end
    _GlobalNames[name] = true
    return _G[name]
end

local function __innerDeclareIndex(tbl, key)
    if not _GlobalNames[key] then
        error("Attempt to access an undeclared global variable : " .. key, 2)
    end

    return nil
end

local function __innerDeclareNewindex(tbl, key, value)
    if not _GlobalNames[key] then
        error("Attempt to write an undeclared global variable : " .. key, 2)
    else
        rawset(tbl, key, value)
    end
end

local function __GLDeclare(name, defaultValue)
    local ok, ret = pcall(__innerDeclare, name, defaultValue)
    if not ok then
        --        LogError(debug.traceback(res, 2))
        return nil
    else
        return ret
    end
end

local function __isGLDeclared(name)
    if _GlobalNames[name] or rawget(_G, name) then
        return true
    else
        return false
    end
end

-- Set "GLDeclare" into global.
if (not __isGLDeclared("GLDeclare")) or (not GLDeclare) then
    __GLDeclare("GLDeclare", __GLDeclare)
end

-- Set "IsGLDeclared" into global.
if (not __isGLDeclared("IsGLDeclared")) or(not IsGLDeclared) then
    __GLDeclare("IsGLDeclared", __isGLDeclared)
end

setmetatable(_G,
{
    __index = function(tbl, key)
        local ok, res = pcall(__innerDeclareIndex, tbl, key)
        if not ok then
            logerror(debug.traceback(res, 2))
        end

        return nil
    end,

    __newindex = function(tbl, key, value)
        local ok, res = pcall(__innerDeclareNewindex, tbl, key, value)
        if not ok then
            logerror(debug.traceback(res, 2))
        end
    end
} )

return __GLDeclare

我相信这种强制报错的设定可以帮助很多刚刚上手Lua的朋友避免一些错误。上述的代码也是参考网上的开源工程,需要用的朋友可以直接拿去。

2.2 Class的设计

虽然面向对象的设计在很多帖子的讨论中已经过时的,面向切面编程等等新概念不断被提出,但是对于一个需要团队协作的游戏项目来说,面向对象的设计依然是目前最为常用的逻辑实现方式。Lua自身没有Class的概念,提供了metatable来做继承,但很弱。我们在项目最初的时候就构建了Class的机制,来方便代码的编写。虽然和原生支持Class的Python和C#这样的语言相比易用性和功能上还都有差距,但是基本够用了。

直接提供核心代码如下:

-- Class.lua
-- 类定义,不支持多重继承

local GLDeclare = require "Framework/Global"

-- 所有定义过的类列表,key为类的类型名称,value为对应的虚表
local __ClassTypeList = { }

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

local function __createSingletonClass(cls, ...)
    if cls._instance == nil then
        cls._instance = cls.new(...)
    end
    return cls._instance
end

local TypeNames = {}

-- 参数含义为:
-- typeName: 字符串形式的类型名称
-- superType: 父类的类型,可以为nil
-- isSingleton: 是否是单例模式的类
local function __Class(typeName, superType, isSingleton)
    -- 该table为类定义对应的表
    local classType = { __IsClass = true }

    -- 类型名称
    classType.typeName = typeName
    if TypeNames[typeName] ~= nil then
        logerror("The class name is used already!!!" .. typeName)
    else
        TypeNames[typeName] = classType
    end

    -- 父类类型
    classType.superType = superType

    -- 在Class身上记录继承关系
    -- Todo:在修改了继承关系的情况下,Reload和Hotfix可能会存在问题
    classType._inheritsCount = 0
    if superType ~= nil then
        local cache = {}
        local counter = 1
        local curClass = superType
        while curClass do
            cache[counter] = curClass
            counter = counter + 1
            curClass = curClass.superType
        end
        classType._classInherits = cache
        classType._inheritsCount = counter
    end

    classType._IsSingleton = isSingleton or false

    -- 记录类的继承关系
    if superType then
        if __InheritRelationship[superType] == nil then
            __InheritRelationship[superType] = {}
        end
        table.insert(__InheritRelationship[superType], classType)
    else
        __InheritRelationship[classType] = {}
    end

    classType.ctor = false
    classType.dtor = false

    local function objToString(self)
        if not self.__instanceName then
            local str = tostring(self)
            local _, _, addr = string.find(str, "table%s*:%s*(0?[xX]?%x+)")
            self.__instanceName =  string.format("Class %s : %s", classType.typeName, addr)
        end

        return self.__instanceName
    end

    local function objGetClass(self)
        return classType
    end

    local function objGetType(self)
        return classType.typeName
    end

    -- 创建对象的方法。
    classType.new = function(...)
        -- 该table为对象对应的表
        local obj = { }

        -- 对象的toString方法,输出结果为类型名称 内存地址。
        obj.toString = objToString

        -- 获取类
        obj.getClass = objGetClass

        -- 获取类型名称的方法。
        obj.getType = objGetType

        -- 递归的构造过程
        local createObj = function(class, object, ...)
            -- 优化递归过程中的函数调用
            if class.superType ~= nil then
                for i = class._inheritsCount-1, 1, -1 do
                    local curClass = class._classInherits[i]
                    if curClass.ctor then
                        curClass.ctor(object, ...)
                    end
                end
            end

            if class.ctor then
                class.ctor(object, ...)
            end
        end

        -- 设置对象表的metatable为虚表的索引内容
        setmetatable(obj, { __index = __ClassTypeList[classType]})

        -- 构造对象
        createObj(classType, obj, ...)
        return obj
    end

    -- 类的toString方法。
    classType.toString = function(self)
        return self.typeName
    end

    if classType._IsSingleton then
        classType.GetInstance = function(...)
            return __createSingletonClass(classType, ...)
        end
    end

    if superType then
        -- 有父类存在时,设置类身上的super属性
        classType.super = setmetatable( { },
        {
            __index = function(tbl, key)
                local func = __ClassTypeList[superType][key]
                if "function" == type(func) then
                    -- 缓存查找结果
                    -- Todo,要考虑reload的影响
                    tbl[key] = func
                    return func
                else
                    error("Accessing super class field are not allowed!")
                end
            end
        } )
    end

    -- 虚表对象。
    local vtbl = { }
    __ClassTypeList[classType] = vtbl

    -- 类的metatable设置,属性写入虚表,
    setmetatable(classType,
    {
        __index = function(tbl, key)
            return vtbl[key]
        end,

        __newindex = function(tbl, key, value)
            vtbl[key] = value
        end,

        -- 让类可以通过调用的方式构造。
        __call = function(self, ...)
            -- 处理单例的模式
            if classType._IsSingleton == true then 
                return __createSingletonClass(classType, ...)
            else
                return classType.new(...)
            end
        end
    } )

    -- 如果有父类存在,则设置虚表的metatable,属性从父类身上取
    -- 注意,此处实现了多层父类递归调用检索的功能,因为取到的父类也是一个修改过metatable的对象。
    if superType then
        setmetatable(vtbl,
        {
            __index = function(tbl, key)
                local ret = __ClassTypeList[superType][key]
                -- Todo 缓存提高了效率,但是要考虑reload时的处理。
                vtbl[key] = ret
                return ret
            end
        } )
    end

    return classType
end

-- 判断一个类是否是另外一个类的子类
local function __isSubClassOf(cls, otherCls)
    return type(otherCls) == "table" and
             type(cls.superType) == "table" and
           ( cls.superType == otherCls or __isSubClassOf(cls.superType, otherCls) )
end

if (not IsGLDeclared("isSubClassOf")) or(not isSubClassOf) then
    GLDeclare("isSubClassOf", __isSubClassOf)
end

-- 判断一个对象是否是一个类的实例(包含子类)
local function __isInstanceOf(obj, cls)
    local objClass = obj:getClass()
    return objClass ~= nil and type(cls) == 'table' and (cls == objClass or __isSubClassOf(objClass, cls) )
end

if (not IsGLDeclared("isInstanceOf")) or(not isInstanceOf) then
    GLDeclare("isInstanceOf", __isInstanceOf)
end

if (not IsGLDeclared("Class")) or(not Class) then
    GLDeclare("Class", __Class)
end

return __Class

这个Lua的Class实现也有参考网上的开源代码,做了一些自己的改进,主要功能有:

  1. 只支持单继承;
  2. 原生支持单例,但注意,对于不需要继承的单例,比如一些常用的Manager,其实不推荐使用Class的方式,而是直接使用Lua的Table的形式来做效率更高;
  3. 支持super来调用父类的方法,但是调用的时候必须使用 ClassName.super(self, ...) 这样的方式来显示地把self传递给父类,否则父类拿到的self会是错误的对象;
  4. 支持构造函数ctor,但是这在某些想自动控制构造的情况下也是一把双刃剑……

对于多重集成没有提供原生支持,本来是可以的,但是多重集成有自身的问题,我们提供了一种基于Mixin 的思路来处理,类似于Interface,核心目标功能是合并一些函数到一个Class中,提供一些大类的模块拆分,避免出现一个几千甚至上万行代码的类文件。(之前端游项目中,几万行的py文件都有遇到……当时eclipse这样的IDE打开这样的py文件都要好久……)

-- 将一个table中所有的属性和方法合并到一个class中,用于处理一个类比较大的设计
-- 注意,合并的方法的reload需要单独处理
local function __MixinClass(cls, mixin)
    assert(type(mixin) == 'table', "mixin must be a table")
    for name, attr in pairs(mixin) do
        if cls[name] == nil then
            cls[name] = attr
        else
            -- 属性名称相同不覆盖而是给出警告。
            print (string.format("[WARNING] The attribute name %s is already in the Class %s!", name, cls.toString()))
        end
    end
end

if (not IsGLDeclared("MixinClass")) or(not MixinClass) then
    GLDeclare("MixinClass", __MixinClass)
end

2.3 常用函数库的补充

这一部分是自己来弥补Lua语言函数库不丰富的问题,当然也要看项目需求,我们引入的主要有:

  1. table相关的一些操作函数,包括长度获取、dump为字符串、深浅拷贝、深度对比、根据值获得索引等等;
  2. json库;
  3. int64库(用的是Lua 5.1);
  4. bit操作库;
  5. Lua socket库;
  6. ……

这部分跟项目具体需求相关,就不一一列举和给出代码了。

2.4 IDE

IDE的部分也只说几句,我们团队目前用的比较多的是Sublime Text 3和VS Code,最初我个人还在使用VS+插件的形式,后来也转向了VS Code阵营。

个人体验VS Code还是比较不错的,加上一些自动补全和基于LuaChecker的语法检查插件,基本能够保证避免开发中一些很蠢的bug。

如果需要,可以自己导出一下Unity的接口为一个Lua的文件,提升自动补全的体验,比如我们最初导出的一份U3DAPI.lua的部分内容截取示例如下:

--- <summary>
--- 全名:UnityEngine.Camera.depthTextureMode [读写] 
--- 返回值 : DepthTextureMode
--- </summary>
--- <returns type="DepthTextureMode"></returns>
Camera.depthTextureMode = function() end
--- <summary>
--- 全名:UnityEngine.Camera.clearStencilAfterLightingPass [读写] 
--- 返回值 : Boolean
--- </summary>
--- <returns type="Boolean"></returns>
Camera.clearStencilAfterLightingPass = function() end
--- <summary>
--- 全名:UnityEngine.Camera.commandBufferCount [读写] 
--- 返回值 : Int32
--- </summary>
--- <returns type="Int32"></returns>
Camera.commandBufferCount = function() end

2.5 培训和分享

我们团队的同学大都有多年使用Python的经验,但是对于Lua还是需要上手时间,所以在最初的时候就组织了程序内部的Lua培训和分享,把比如对于table和string使用的坑、元表、Lua的GC基本原理、错误处理等等方面在团队内部进行了统一的学习和讨论,整体的收获还是比较大的。在开发过程中发现的代码上的问题,也及时在群内进行讨论,这些都逐步提高了整个团队使用Lua进行游戏开发的能力和效率。

2.6 小结

Lua语言自身的确是有很多易用性上的问题,前文提到的库不够丰富之类的,通过在项目初期添加一些基础的结构和库,再加上一些提前规避错误的强制手段,可以一定程度上改善易用性的问题。然而,即使到现在,使用Lua有一年多的时候,我们团队中还是偶尔有同学出现.和:用错导致bug的现象。用好一门语言总是需要一个不断踩坑不断成长的过程,C#也好,Python也好,Lua也好,都需要不断地学习和改进,希望我们的一些经验和教训可以帮助刚刚上手Lua的团队提前规避一些坑,也期望更多已经熟练使用Lua的团队可以分享你们经验和方法~

总是,Lua这门小而精的语言,在提供了脚本语言中几乎最快的运行效率的同时,也有着开发效率方面的各种问题,这些问题需要整个团队的力量去弥补和改进。 我相信,经过积淀的团队,在使用Lua进行大型游戏的开发时,可以达到不差于任何其他语言的开发速度。

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值