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) thenrawset(_G, name, defaultValue or false)elseprint("[Warning] The global variable " .. name .. " is already declared!")end_GlobalNames[name] = truereturn _G[name]
endlocal function __innerDeclareIndex(tbl, key)if not _GlobalNames[key] thenerror("Attempt to access an undeclared global variable : " .. key, 2)endreturn nil
endlocal function __innerDeclareNewindex(tbl, key, value)if not _GlobalNames[key] thenerror("Attempt to write an undeclared global variable : " .. key, 2)elserawset(tbl, key, value)end
endlocal function __GLDeclare(name, defaultValue)local ok, ret = pcall(__innerDeclare, name, defaultValue)if not ok then--        LogError(debug.traceback(res, 2))return nilelsereturn retend
endlocal function __isGLDeclared(name)if _GlobalNames[name] or rawget(_G, name) thenreturn trueelsereturn falseend
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)
endsetmetatable(_G,
{__index = function(tbl, key)local ok, res = pcall(__innerDeclareIndex, tbl, key)if not ok thenlogerror(debug.traceback(res, 2))endreturn nilend,__newindex = function(tbl, key, value)local ok, res = pcall(__innerDeclareNewindex, tbl, key, value)if not ok thenlogerror(debug.traceback(res, 2))endend
} )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 thencls._instance = cls.new(...)endreturn cls._instance
endlocal TypeNames = {}-- 参数含义为:
-- typeName: 字符串形式的类型名称
-- superType: 父类的类型,可以为nil
-- isSingleton: 是否是单例模式的类
local function __Class(typeName, superType, isSingleton)-- 该table为类定义对应的表local classType = { __IsClass = true }-- 类型名称classType.typeName = typeNameif TypeNames[typeName] ~= nil thenlogerror("The class name is used already!!!" .. typeName)elseTypeNames[typeName] = classTypeend-- 父类类型classType.superType = superType-- 在Class身上记录继承关系-- Todo:在修改了继承关系的情况下,Reload和Hotfix可能会存在问题classType._inheritsCount = 0if superType ~= nil thenlocal cache = {}local counter = 1local curClass = superTypewhile curClass docache[counter] = curClasscounter = counter + 1curClass = curClass.superTypeendclassType._classInherits = cacheclassType._inheritsCount = counterendclassType._IsSingleton = isSingleton or false-- 记录类的继承关系if superType thenif __InheritRelationship[superType] == nil then__InheritRelationship[superType] = {}endtable.insert(__InheritRelationship[superType], classType)else__InheritRelationship[classType] = {}endclassType.ctor = falseclassType.dtor = falselocal function objToString(self)if not self.__instanceName thenlocal str = tostring(self)local _, _, addr = string.find(str, "table%s*:%s*(0?[xX]?%x+)")self.__instanceName =  string.format("Class %s : %s", classType.typeName, addr)endreturn self.__instanceNameendlocal function objGetClass(self)return classTypeendlocal function objGetType(self)return classType.typeNameend-- 创建对象的方法。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 thenfor i = class._inheritsCount-1, 1, -1 dolocal curClass = class._classInherits[i]if curClass.ctor thencurClass.ctor(object, ...)endendendif class.ctor thenclass.ctor(object, ...)endend-- 设置对象表的metatable为虚表的索引内容setmetatable(obj, { __index = __ClassTypeList[classType]})-- 构造对象createObj(classType, obj, ...)return objend-- 类的toString方法。classType.toString = function(self)return self.typeNameendif classType._IsSingleton thenclassType.GetInstance = function(...)return __createSingletonClass(classType, ...)endendif superType then-- 有父类存在时,设置类身上的super属性classType.super = setmetatable( { },{__index = function(tbl, key)local func = __ClassTypeList[superType][key]if "function" == type(func) then-- 缓存查找结果-- Todo,要考虑reload的影响tbl[key] = funcreturn funcelseerror("Accessing super class field are not allowed!")endend} )end-- 虚表对象。local vtbl = { }__ClassTypeList[classType] = vtbl-- 类的metatable设置,属性写入虚表,setmetatable(classType,{__index = function(tbl, key)return vtbl[key]end,__newindex = function(tbl, key, value)vtbl[key] = valueend,-- 让类可以通过调用的方式构造。__call = function(self, ...)-- 处理单例的模式if classType._IsSingleton == true then return __createSingletonClass(classType, ...)elsereturn classType.new(...)endend} )-- 如果有父类存在,则设置虚表的metatable,属性从父类身上取-- 注意,此处实现了多层父类递归调用检索的功能,因为取到的父类也是一个修改过metatable的对象。if superType thensetmetatable(vtbl,{__index = function(tbl, key)local ret = __ClassTypeList[superType][key]-- Todo 缓存提高了效率,但是要考虑reload时的处理。vtbl[key] = retreturn retend} )endreturn classType
end-- 判断一个类是否是另外一个类的子类
local function __isSubClassOf(cls, otherCls)return type(otherCls) == "table" andtype(cls.superType) == "table" and( cls.superType == otherCls or __isSubClassOf(cls.superType, otherCls) )
endif (not IsGLDeclared("isSubClassOf")) or(not isSubClassOf) thenGLDeclare("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) )
endif (not IsGLDeclared("isInstanceOf")) or(not isInstanceOf) thenGLDeclare("isInstanceOf", __isInstanceOf)
endif (not IsGLDeclared("Class")) or(not Class) thenGLDeclare("Class", __Class)
endreturn __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) doif cls[name] == nil thencls[name] = attrelse-- 属性名称相同不覆盖而是给出警告。print (string.format("[WARNING] The attribute name %s is already in the Class %s!", name, cls.toString()))endend
endif (not IsGLDeclared("MixinClass")) or(not MixinClass) thenGLDeclare("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进行大型游戏的开发时,可以达到不差于任何其他语言的开发速度。

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

  1. 【游戏开发实战】Unity手游第一人称视角,双摇杆控制,FPS射击游戏Demo(教程 | 含Demo工程源码)

    文章目录 一.前言 二.实现方案 1.无主之地,第一人称视角 2.我之前做的摇杆控制 3.第一人称视角 + 摇杆控制 三.开始实战 1.资源获取:Unity AssetStore 2.Low Poly ...

  2. UWA发布 | 2017 Unity手游体检蓝皮书 — ARPG篇

    报告目录: 一.ARPG手游总体性能开销分析 二.ARPG手游CPU模块性能开销分析 三.ARPG手游内存模块性能开销分析 四.ARPG手游资源管理分析 五.UWA对于ARPG手游研发团队的建议 一. ...

  3. Unity手游性能测评报告——MOBA篇

    MOBA移动游戏性能分析报告:渲染.UI和逻辑代码是性能头号杀手! UWA曾经发布过MMORPG 和 ARPG 的Unity手游性能测评报告,延续该系列,最近UWA对提交测试(登陆 www.uwa4d ...

  4. unity手游之聊天SDK集成与使用一

    unity手游之聊天SDK集成与使用一 手游中都有聊天功能,比如公会,私聊,世界聊天,那么找一个好用,功能强大的SDK的可以节省很多精力,帮助我们提高开发速度与游戏质量. 写本篇博文是为了方便使用这个 ...

  5. 如何做Unity手游性能优化的

    Unity性能优化参考: http://gameinstitute.qq.com/article/detail/39757 https://blog.uwa4d.com/archives/allino ...

  6. UWA发布 | 2017 Unity手游体检蓝皮书 — MMORPG篇

    原文链接:https://blog.uwa4d.com/archives/1903.html 正值UWA成立两周年之际,借着China Joy蓄势待发之势,UWA发布2016-2017年Unity手游 ...

  7. 腾讯是如何做Unity手游性能优化的

    他山之石-腾讯是如何做Unity手游性能优化的 本文转载自:http://www.taidous.com/thread-44045-1-1.html?_dsign=ba1258b9 俗话说,用户体验不 ...

  8. 知物由学|游戏开发者如何从容应对Unity手游风险?

    本文由 网易云 发布 "知物由学"是网易云易盾打造的一个品牌栏目,词语出自汉·王充<论衡·实知>.人,能力有高下之分,学习才知道事物的道理,而后才有智慧,不去求问就不会 ...

  9. UWA发布 | Unity手游体检蓝皮书

    作为游戏行业的服务商,UWA不仅为游戏开发者提供高效的性能优化工具,也致力于为行业提供更全面.更具体的信息和服务.为此,UWA今天发布2017-2018年度手游蓝皮书,从总体性能数据.引擎各模块开销. ...

最新文章

  1. 利用select实现年月日三级联动的日期选择效果
  2. tkinter回调异常_Python tkinter文本修改后的回调
  3. Java解析JSON时,new JSONObject(这儿写json字符串)报错问题
  4. MATLAB数值计算与符号运算
  5. 一步步通过命令行cl.exe编译Windows程序
  6. 宏定义对调试代码的作用
  7. 转角遇上Volcano,看HPC如何应用在气象行业
  8. vuejs对象更新渲染_vue 数组和对象渲染问题
  9. 《我是一只IT小小鸟》
  10. matlab 点太多,matlab输出参数太多
  11. 算法之图解单纯形算法C++
  12. 《操作系统原理》课程中涉及的数据结构总结
  13. h5 bootstrap 小程序模板_武汉商城小程序模板
  14. 小麦苗的常用代码--常用命令(仅限自己使用)--上
  15. Linux中pid与tid的异同及top中pid字段含义
  16. ABAP ALV DATA_CHANGED 函数使用说明 (ALV备忘二)
  17. $F $ 等符号的意思
  18. 基于STM32的智能篮球测温记分记时系统
  19. linux之shell脚本
  20. 分治(二)——三分法学习笔记

热门文章

  1. cobertura的使用
  2. 百度Echart展示世界地图效果
  3. 数据湖构建DLF数据探索快速入门-淘宝用户行为分析
  4. 手持式变压器变比测试仪-产品介绍-厂家-赫兹电力
  5. 如何换算洁净室送风量及计算方法
  6. JAVA框架——struts(一)struts快速入门,struts访问流程,struts配置文件详解,动态方法调用
  7. 网页爬虫--历史上的今天
  8. 记录可控硅控制电路不能完全关闭的问题解决
  9. 北京地铁10号线地图(7月最新线路图、站名)
  10. 安卓音视频整理(一)——音频模块