Dorothy Lua开发建议

1.禁用全局变量

  可以在zbstudio中使用Analyze(Shift+F7)功能检查全局变量的使用。但是可以通过模块做数据的共享,但是只能共享基本数据类型包括string, number, boolean, table, 没有俘获userdata类型变量的function等,共享的table中也不能存有userdata。共享数据的模块写法如下:

-- file SharedModule.lua
local node = CCNode() -- userdata
local data =
{
	sharedNumber = 998, -- ok
	sharedString = "ABC", -- ok
	add = function(a,b) -- ok
		return a+b
	end,

--[[
	getModuleNode = function() -- wrong
		return node -- userdata
	end,

	sharedNode = node, -- wrong
]]
}

return data

-- file main.lua
local SharedModule = require("SharedModule")

print(SharedModule.sharedNumber)
print(SharedModule.sharedString)
print(SharedModule.add(1,2))

禁用全局变量理由

  因为全局变量的储存空间其实是一个可以被在任意位置替换掉的table,而非一个真正全局有效的空间范围。如下所示:

-- file moduleA.lua
-- in default global table environment
myGlobalValueA = 998 -- set variable in default global table

-- file moduleB.lua
Dorothy() -- this function alter default global table to Dorothy`s global table ( used setfenv(2,{}) inside )
require("moduleA")
myGlobalValueB = "pig" -- set variable in Dorothy`s global table
print(myGlobalValueA) -- will output nil

-- file main.lua
-- in default global table environment again
require("moduleB")
print(myGlobalValueB) -- will output nil

  此外,如果自由使用全局变量,全局变量和局部变量可能的冲突问题,可能会导致比较隐蔽的程序错误,并且不容易排查(js也有类似问题)。

共享数据写法说明

  模块return返回的内容,当使用require("module")时,会把这些内容缓存在package.loaded表中,可以使用(for k,v in pairs(package.loaded) do print(k,v) end)查看其内容。
  当返回内容为userdata时,Dorothy的Lua userdata对象会引用对应的C++对象,所以缓存userdata对象会导致C++对象无法得到释放,由于C++对象系统与Lua独立的引用系统,可能会导致C++系统中产生一系列的内存泄露。
  所以Lua中若要缓存userdata时则需谨慎处理。在userdata不再被使用后,需要手动清理掉对它的引用,防止内存泄露。

2.禁止模块返回创建的对象

  用返回对象的创建函数来取代直接返回新创建的对象。示例如下:

-- file MyGameScene.lua
local function MyGameScene()
	local scene = CCScene()
	-- codes to create my game scene
	return scene
end

return MyGameScene -- ok

-- file main.lua
local MyGameScene = require("MyGameScene")
local scene = MyGameScene()
CCDirector:run(scene) -- ok

错误示例:
-- file MyGameScene.lua
local scene = CCScene()
-- codes to create my game scene
return scene -- return instance, wrong

-- file main.lua
local scene = require("MyGameScene") -- wrong
CCDirector:run(scene)
-- memory leak will happens after CCDirector:replaceScene(anotherScene)

禁止模块返回创建的对象的理由

  同用模块共享数据禁止共享userdata的理由,避免userdata中的C++对象被一起缓存。

3.正确地添加对象属性和方法

  Lua中是使用table数据结构来做定义和操作的对象的,比如新建一个person对象,并添加相关的属性和方法:

local person = {} -- table object
person.name = "Pig"
person.walk = function(self)
	print(self.name.." is walking")
end

person:walk()

  Dorothy-Lua中的对象是userdata,但是也可以像table一样添加属性和方法:

local node = CCNode()
node.name = "Pig"
node.walk = function(self)
	print(self.name.." is walking")
end

node:walk()

  给Dorothy中的对象添加属性和方法有一个重要的规则就是,不应与对象自带的属性或是方法重名。比如下面的例子里:

local node = CCNode()

node:addChild(CCNode()) -- invoke C++ binding method 'addChild' from CCNode

node.addChild = function(self,node) -- assign action to alter function
	print("override")
end
node:addChild(CCNode()) -- invoke the new method and print override

node.anchor = function(self,anchor) -- raise error, 'anchor' is a CCNode property,
												  -- only accept value of oVec2 type, 
												   -- and setter function invoked here which causes error
	print("can`t override")
end

  上面的anchor是CCNode的一个oVec2类型的属性,它的赋值行为,会触发内部setter函数执行数据类型验证,然后调用底层C++对象的操作。上面的addChild是CCNode中一个由C++绑定而来的方法,它的赋值行为将会覆盖该node对象在Lua中调用该C++方法的接口,并且把这个接口覆盖成自定的一个lua函数,但是底层C++中的addChild方法并不会因此被实际替换掉。所以根据上述情况,给Dorothy中的一个对象添加属性和方法,如果不是特殊需要,添加的属性不应与自带的属性和方法名称重名。

4.维护对自定义对象的引用

  自定义对象包括自己用table创建的新对象,或是给Dorothy对象添加属性而来的对象。在Lua中,没有被引用的对象会被清理掉是很自然的事。但是对于Dorothy的对象,这个概念稍有点复杂。比如下面的代码:

Dorothy()

local function f()
	local node = CCNode() -- local lua item not referenced
	node.tag = 998 -- set tag property of CCNode class
	node.flag = 233 -- add new field to node object
	local scene = CCScene()
	scene:addChild(node) -- node is referenced in C++ system
	CCDirector:run(scene)
end

f()
collectgarbage() -- clear unreferenced local lua items

print(CCDirector.currentScene.children[1].tag) -- get the property, print 998
print(CCDirector.currentScene.children[1].flag) -- lost new field, print nil

  会发现其中的node对象作为Lua对象,没有在Lua中被引用时会被销毁,包括对象上添加的属性。但是node作为游戏底层的C++对象,会被保持引用而一直存在,当再次获取这个对象的时候,会被创建为新的Lua对象而重新在Lua中被使用。就是说,一个Dorothy对象包含C++部分和Lua部分,如果我们要新建一个有自定义方法和属性的Dorothy对象,为了避免自定义的方法和属性被销毁,我们需要在Lua代码中保持对这个对象的引用。比如把上述代码改成这样:

Dorothy()

local function f()
	local node = CCNode()
	node.tag = 998 -- set tag property of CCNode class
	node.flag = 233 -- add new field to node object
	CCDirector.node = node -- lua item referenced by global object CCDirector
	local scene = CCScene()
	scene:addChild(node) -- node is referenced in C++ system
	CCDirector:run(scene)
end

f()
collectgarbage() -- clear unreferenced local lua items

print(CCDirector.currentScene.children[1].tag) -- print 998
print(CCDirector.currentScene.children[1].flag) -- print 233

5.使用消息系统做模块间的通讯

  为了减少模块之间的耦合度,非组合关系的模块使用消息系统做通讯。示例:

-- file Candle.lua
...
local function Candle()
	local candle = CCNode()
	candle.color = ccColor3(0x000000)
	...
	candle.light = function(self)
		self.color = ccColor3(0xffffff)
	end
	return candle
end

return Candle

-- file Cake.lua
local Candle = require("Candle")
...
local function Cake()
	local cake = CCNode()
	...
	local candle = Candle()
	cake.candle = candle -- use composition
	cake:addChild(candle)

	cake:gslot("Cake.Light", function() -- register listener for light event
		cake.candle:light()
	end)
	return cake
end

return Cake

-- file GameUI.lua
...
local button = oButton("Click to light a cake", function()
	emit("Cake.Light") -- send light event
end)
menu:addChild(button)
...

-- file main.lua
...
local Cake = require("Cake")
local GameUI = require("GameUI")

local cake = Cake()
scene:addChild(cake)

local ui = GameUI()
scene:addChild(ui)

用消息系统做模块通讯的说明

  上面示例中的Candle类和Cake类是组合关系的类,所以Cake模块可以直接依赖于Candle模块。但是GameUI类和Cake类只是很弱的关联关系,所以他们之间更适于使用消息系统来做通讯,而避免建立直接的依赖或是对象引用的关系。Cake给外部调用的接口通过使用oListener()监听消息的方式来提供。需要调用Cake的接口时,通过oEvent:send()来发送消息触发Cake的接口功能。
使用消息系统的好处是,可以极大的降低模块之间的耦合程度,把模块做得非常内聚,系统更容易扩展,模块代码也更容易进行复用,也方便进行模块的测试。
  消息系统的使用,用类似C#委托的方式也可以实现,但用委托的缺点是,当模块之间嵌套的层级比较多的时候,我们会需要把事件逐级地进行传递。特别是在UI的模块容易遇到这种情况,比如下面这个例子:

-- file Menu.lua
...
menu.menuClicked = nil

local button = Button()
button.clicked = function()
	if menu.menuClicked then
		menu.menuClicked() -- button click event trigger menu click event
	end
end
menu:addChild(button)
return menu
...

-- file Panel.lua
...
panel.panelClicked = nil

local menu = Menu()
menu.menuClicked = function()
	if panel.panelClicked then
		panel.panelClicked() -- menu click event trigger panel click event
	end
end
panel:addChild(menu)
return panel
...

-- file main.lua
...
local panel = Panel()
panel.panelClicked = function()
	print("panel clicked!") -- but sometimes what we really want is just a panel click event when we click on a button from the panel
end
scene:addChild(panel)
CCDirector:run(scene)
...

  因为模块的嵌套和每个模块内聚性的需要,我们只好逐级给每个模块定义自己范围的事件,然后通过外部的连接代码把事件传递出来。如果直接写跨层级的事件传递,可能就会破坏模块的封装性。硬要跨模块层级去使用事件的话,就得向上级模块暴露底层模块的细节,比如像这样:

-- file main.lua
...
local panel = Panel()
panel.menu.button.clicked = function() -- expose submodule details
	print("panel clicked!")
end
scene:addChild(panel)
CCDirector:run(scene)
...

  代码耦合度也提高了,没有达到很好的封装效果。
  而实际上,当我们在写游戏的时候,往往很少复用同一段业务逻辑的代码,很多创建型的代码在整个游戏中也只是一次性执行的代码。同一个游戏的各种UI界面往往也没有固定的模式。如果费很大的力气去总结和抽象出多种场景适用的界面模块来复用,效果可能会很差。倒不如准备几套不同交互方式的界面基础代码模版,然后写新界面的时候,复制粘贴模版代码作为基础,然后再开始进行改造就行了。因为脚本代码本身就是一种游戏数据,本来就该设计为应对最多变的业务逻辑来使用。本来就是用来应对变化的东西,应对不变的模式化的内容就不适合脚本来做了。所以我认为把脚本当写框架的编程语言来用并不能发挥它的最大的作用,需要写成框架的逻辑一般更应该放到底层的语言来写。
  所以,在Lua中使用消息系统的终极目标,就是为了不用去做刻意的框架设计和封装,同时也能降低模块的耦合度。
  之前UI界面用消息系统来写的话,就要这样写:

-- file Menu.lua
...
local button = Button()
button.clicked = function()
	emit("EventToPrint") -- just focus on business
end
menu:addChild(button)
return menu
...

-- file Panel.lua
...
local menu = Menu()
panel:addChild(menu)
return panel
...

-- file main.lua
...
local panel = Panel()
scene:addChild(panel)
scene:slot("EventToPrint", function()
	print("panel clicked!")
end)
CCDirector:run(scene)
...

  我们把注意的焦点完全只放在代码的业务逻辑上。在这段代码中,点击按钮就是为了调用一段打印文字的代码。所以直接使用消息系统把两个模块中的逻辑连接起来就简单完事,然后其他设计方面的工作就不用特别去在意了。接着可以在Panel.lua模块的最开始写一个说明性的注释,可以写上该模块将要监听和发送的消息定义,供该模块的其他使用者进行了解和使用。

6.尽量使用require加载模块

  Lua的几个代码加载的函数的关系是这样的:

local result = dofile(filename)
等价于
local func = loadfile(filename)
local result = func()

local module = require(moduleName)
等价于
local moduleFunc = loadfile(moduleName)
local module = moduleFunc()
package.loaded[moduleName] = module or true

  用loadfile每次都会重新加载Lua代码,用require会在加载执行后将结果做缓存,或是记录下该模块已经被加载过的信息。所以为了避免模块代码被重复加载应该使用require,只有当需要手动控制代码加载细节的时候才使用loadfile或是dofile。

7.延迟加载和及时清理模块

  当我们的模块依赖关系很强的时候,可能会在每个模块的开始使用require把其它模块加载进来,就像是这样:

-- file moduleA.lua
...

-- file moduleB.lua
local moduleA = require("moduleA")
...

-- file moduleC.lua
local moduleA = require("moduleA")
...

-- file moduleD.lua
local moduleB = require("moduleB")
local moduleC = require("moduleC")
...

-- file main.lua
local moduleD = require("moduleD")
...

  在这样较强的依赖关系下,在main运行的开始,执行local moduleD = require("moduleD")这一行代码的同时,所有在依赖链上的模块就要同时也完成加载,然后后续的逻辑代码才能执行。这样带来的问题是当项目代码量很大的时候,较强的模块依赖关系可能会导致大量的代码在一瞬间同时全部被加载。而事实上项目中的很多模块代码可能要随着程序的运行,在后期才被执行和调用。这样的预先加载除了满足模块的依赖关系以外没有实际的作用。
  一种解决办法是把模块的引入代码放到代码逻辑中,比如这样来组织代码,由普通写法:

-- file moduleX.lua
local moduleA = require("moduleA")

local function moduleX()
	...
	moduleA:invoke()
	...
end

  改写成

-- file moduleX.lua

local function moduleX()
	...
	local moduleA = require("moduleA")
	moduleA:invoke()
	...
end

  但是这样的写法会让代码的组织稍微变得更复杂。另一个升级版的办法是在设计时尽量降低模块之间的依赖度,然后手动控制模块的加载,比如这样:

-- file moduleA.lua
local function moduleA()
	...
	node:gslot("EventA", function()
		...
	end)
	...
	return node
end
return moduleA

-- file moduleB.lua
local function moduleB()
	...
	node.func = function(self)
		emit("EventA")
		emit("EventC",998)
	end
	...
	return node
end
return moduleB

-- file moduleC.lua
local function moduleC()
	...
	node:gslot("EventC",function(n)
		...
	end)
	...
	return node
end
return moduleC

-- file main.lua
local modules =
{
	"moduleA",
	"moduleB",
	"moduleC",
}

run(function() -- control how modules load in a routine
	local scene = CCScene()
	for i = 1, #modules do
		local module = require(modules[i])
		coroutine.yield() -- provide one game update to load module
		local node = module()
		scene:addChild(node)
		coroutine.yield() -- provide one game update to create node
	end
end)
...

  上面的代码使用消息系统拆解了模块之间依赖,使模块之间不再需要互相require,并在main中经过多个游戏更新逐步加载和执行模块内容。用协程做逐步加载,将Lua代码的加载时间分摊到多个游戏帧中。
  适时地清理已加载的游戏模块也是必要的,比如做了一个闯关的RPG游戏,每进入一个关卡的时候可以加载该关卡的模块,当过关以后,为之前关卡加载的代码就没用了。要清理这些无效的模块,唯一要手动处理的部分是模块使用require加载以后在package.loaded表里缓存的内容。package.loaded是一个全局空间所以不会自动释放。简单用package.loaded[ModuleName] = nil就可以了。

标题目录