2015 年 2 月 10 日
教程:在 Corona 中使用协程
今天的客座教程由 Xibalba Studios 的技术总监兼研发主管 Steven Johnson 提供。Steven 自 2003 年以来一直在使用 Lua,他曾使用自定义引擎、Vision SDK、LÖVE、HTML5 模拟以及 C++ 和 LuaJIT 中的 SDL 绑定项目。当他不忙于自己的爱好项目时,他喜欢研究数学和图形概念。
协程,在 Lua 5.0 中引入,是该语言的关键功能之一,但尽管从一开始就已在 Corona 中提供,但它们似乎很少受到关注。这很不幸,因为协程非常强大,使您能够根据需要启动和停止代码块。无论您是想进行高级计时器操作还是创建状态机,协程都可以让您更好地控制代码部分的执行时间。本教程将仅介绍它们的一些用途,特别是那些发挥 Corona 自身优势的用途。
基本概念
让我们从 API 的快速入门开始。所有函数都位于 coroutine 表中。在撰写本文时,这些尚未包含在 Corona 的 SDK API 参考中,但可以在 Lua 手册中找到。
创建一个协程很简单
1 2 3 4 5 6 7 8 |
-- 这是协程的“例程”部分,即要运行的代码... local function Body() print( "在协程中!" ) end -- ...这是我们需要运行它的对象。 local co = coroutine.create( Body ) |
这给了我们对协程的引用,co
。
我们可以检查它的类型
1 2 |
print( type( co ) ) -- 输出 "thread" |
我们发现 co
是一种独特的对象,而不是我们可能预期的表或 userdata。“线程”这个术语不必过于担心;在 Lua 中,它和“协程”在很大程度上是可以互换的[1]。此外,这些不是操作系统线程,操作系统线程会抢占正在运行的代码以切换任务。相反,协程是协作的;协程本身决定何时放弃控制。
我们还可以询问协程的状态
1 2 |
print( coroutine.status( co ) ) -- 输出 "suspended" |
正如我们所看到的,仅仅创建协程并不会运行它。这不应该太令人惊讶。例如,创建函数与调用函数是不同的。要运行协程,我们必须执行此操作
1 2 |
coroutine.resume( co ) -- 输出 "在协程中!" |
正文执行,并且按预期打印内部的消息。
协程现在的状态是什么?让我们检查一下
1 2 |
print( coroutine.status( co ) ) -- 输出 "dead" |
正文已经运行完毕,因此协程无事可做,它会进入死亡状态。
一旦协程死亡,我们能做的就是询问它的状态,它将始终为 "dead"
。如果我们尝试再次恢复它,则不会发生任何事情。或者,正如我们稍后将看到的,它会静默失败。
此时,协程看起来只不过是调用函数一次(且仅一次)的复杂方式!
谜题中缺失的部分是 yield 的能力
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
local function Body() print( "首次恢复" ) coroutine.yield() print( "第二次恢复" ) coroutine.yield() print( "最终恢复" ) end local co = coroutine.create( Body ) coroutine.resume( co ) -- 输出 "首次恢复" print( "首次 yield 后" ) coroutine.resume( co ) -- 输出 "第二次恢复" print( "第二次 yield 后" ) coroutine.resume( co ) -- 输出 "最终恢复" |
第一个 coroutine.resume
启动协程。正文开头的代码执行,并打印一条消息。到目前为止,没什么新奇的。
当 coroutine.yield
触发时,我们突然跳出协程的正文,回到创建它的代码中。我们看到的不是消息 “第二次恢复”,而是 “首次 yield 后”。
协程再次处于暂停状态,就像创建后一样。它不是死状态;仍有代码需要执行。但是,在显式恢复协程之前,它不会再次运行。
当我们这样做时,执行会在它中断的地方继续执行,紧接着 yield 之后,我们得到预期的 “第二次恢复”。另一个 yield 和最终的恢复完善了这段代码,给我们 “第二次 yield 后”,然后是 “最终恢复”。
在每次 yield 之后,状态将为 "suspended"
。在最终恢复之后,我们的协程再次为 "dead"
。
使用数据
可以在协程之间传递数据。我们可以通过将值作为参数传递给 coroutine.resume
来将值发送到协程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
local function Body( a, b, c ) DoSomething() local d, e = coroutine.yield() DoSomethingElse() local f, g, h = coroutine.yield() DoOneLastThing() end local co = coroutine.create( Body ) coroutine.resume( co, "one", 2 ) -- a = "one", b = 2, c = nil coroutine.resume( co, 8, {} ) -- d = 8, e = 该表 coroutine.resume( co, 42, 9 ) -- f = 42, g = 9, h = nil |
如我们所见,在第一次恢复(即在创建协程后启动协程)时,数据最终会出现在参数中。在随后的每次恢复中,它们则会作为最近一次 coroutine.yield
的返回值出现。
接收数据没有太大区别,但我们是通过 coroutine.yield
来完成的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
local function Body() DoSomething() coroutine.yield( 1, "3" ) DoSomethingElse() coroutine.yield( { n = 6 }, 7, 4 ) DoOneLastThing() return "Data", 1 end local co = coroutine.create( Body ) print( coroutine.resume( co ) ) -- 打印 true, 1, "3" print( coroutine.resume( co ) ) -- 打印 true, 关于表的某些信息, 7, 4 print( coroutine.resume( co ) ) -- 打印 true, "Data", 1 |
这里我们看到,传递给 coroutine.yield
的任何参数最终都会成为最近一次 coroutine.resume
的返回值,协程体返回的任何值也是如此。
(Lua 程序设计 包含一个 数据传递的优秀示例。)
现在,关于 yield 代码片段有一些奇怪之处。在打印的结果中,true
是什么意思?它表示恢复成功。如果在过程中发生错误,则该 true
将变为 false
,唯一其他的返回值是错误消息。当我之前提到恢复已死的协程会静默失败时,这就是正在发生的事情。
说到已死的协程,这就是我们在发生错误后所处的状态。
总结
我们通常只需要创建、恢复和 yield 一个协程。这种情况很常见,因此提供了一个方便的函数 coroutine.wrap
,以便更方便地使用协程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
local function Body() print( "首次恢复" ) coroutine.yield() print( "第二次恢复" ) coroutine.yield() print( "最终恢复" ) end local wrapped = coroutine.wrap( Body ) wrapped() -- 打印 "First resume" wrapped() -- 打印 "Second resume" wrapped() -- 打印 "Final resume" |
这与我们之前的示例相似,只是恢复协程的行为似乎类似于常规的函数调用。实际上,包装器只是一个函数;coroutine.resume
在幕后处理,同时处理协程引用本身。
传递数据的方式与之前大致相同,只是与 coroutine.resume
不同,包装器不返回成功布尔值。那么,如果它失败了怎么办?例如,在最后一个示例中,如果我们再次调用包装器,而此时协程已经完成怎么办?
“砰!”
当然,在 Corona 中开发时,这通常是我们想要的。它就像其他任何错误一样。掌握了这一切之后,我们就可以继续前进了。
协程-计时器团队
coroutine.wrap
提供了一些有趣的可能性。特别是,包装器只是一个函数,这为我们打开了一些大门。许多 Corona API,例如各种事件侦听器,都接受函数参数,并且会很乐意接受我们伪装的协程。
现在,大概我们会倾向于使用协程而不是常规函数,因为我们想要 yield 功能。但是,一旦我们 yield,除非再次调用包装器,否则协程的其余部分不会发生(请记住,这样做只是在底层执行恢复)。因此,我们通常希望在期望多次触发的逻辑中使用协程。[2]
碰巧的是,我们在 Corona 中有很多这样的机制。例如,计时器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
local function Body() print( "首次恢复" ) coroutine.yield() print( "第二次恢复" ) coroutine.yield() print( "最终恢复" ) end timer.performWithDelay( 1000, coroutine.wrap( Body ), 3 ) |
正如你可能想象的那样,这会每秒左右触发一次,每次打印一条我们现在熟悉的消息。这都很好,但请注意迭代计数:三次迭代。两次用于 yield,另一次用于运行最后一段。如果我们高估了这个计数,例如指定四次迭代,最终会尝试恢复一个已死的协程。上面的代码很简单,所以这不是一个大问题。
随着协程体的增长,维护正确的计数变得越来越困难,尤其是当 yield 位于循环中或函数调用后面时。一旦我们将 if
语句引入混合,我们甚至无法依赖固定数字,那么我们就完全束手无策了。
我们真正想要的是“开箱即用”的东西。幸运的是,"timer"
事件附带了一个引用,source,该引用可用于取消相应的计时器。我们可以简单地让计时器无限期运行,而不是硬编码一些迭代次数,只在完成任务后才取消它。
第一次尝试可能如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 |
local function Body( event ) DoSomething() coroutine.yield() DoSomethingElse() timer.cancel( event.source ) end timer.performWithDelay( 50, coroutine.wrap( Body ), 0 ) |
这会起作用……直到它不起作用。不幸的是,协程暴露了一个 泄漏的抽象:Corona 正在回收事件表。大概这是为了避免垃圾收集器的峰值,这是一个好主意。对于普通函数,我们永远不会知道其中的区别。但我们正在调皮。通过 yield,我们最终会跨多个帧保留该事件表。同时,Corona 一直在传递该表,交换进出计时器引用。当需要处理“我们的”计时器时,我们可能会完全取消另一个计时器!
我们可以通过提前保存引用来表现得好一点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
local function Body( event ) local source = event.source DoSomething() coroutine.yield() DoSomethingElse() timer.cancel( source ) end timer.performWithDelay( 50, coroutine.wrap( Body ), 0 ) |
这确实有效。但是,"timer"
事件还包含 count 和 time 字段,并且会出现类似的问题。
我们也可以考虑这些。我们的代码可能不会关心事件表来自哪里(正如我们刚刚看到的,它也不应该关心),如果我们给它一个“影子”表,它也不会注意到。[3] 我们更新这个影子表,一切又恢复正常了。
计时器实用工具
每次都弄清楚这些事情会变得很麻烦。就此而言,大多数基于协程的计时器一旦我们把这些细节理顺,看起来都会很相似。我们应该把这些都汇总起来以便重用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function CoroPerformWithDelay( delay, func, n ) local wrapped = coroutine.wrap( function( event ) func( event ) -- 执行操作... return "cancel" -- ...然后在我们完成时告诉计时器停止。 end ) local event2 -- 我们的“影子”事件。 return timer.performWithDelay( delay, function( event ) event2 = event2 or { source = event.source } -- 首次运行时,保存源... event2.count = event.count -- ...每次都更新这些。 event2.time = event.time local result = wrapped( event2 ) -- 更新协程。它会在第一次运行时获取事件。 if result == "cancel" then timer.cancel( event2.source ) -- 在函数完成后,或在取消请求时,终止计时器。 end end, n or 0 ) end |
现在,我们一直在研究计时器,但同样的想法也适用于 "enterFrame"
监听器,甚至适用于重复的过渡效果。我倾向于使用计时器,因为它可以自定义延迟,并且觉得取消它们更自然一些。但是,如果协程无论如何都会永远运行下去,那么用哪种方式就相当随意了。
既然我们提到了取消,请注意,提前取消计时器是完全可以的。尽管如此,重要的是要认识到计时器和协程是两个不同的事物,因此我们仍然需要挂起协程。我们的辅助函数允许我们同时执行这两个操作(当然,在协程内部),通过调用 coroutine.yield("cancel")
。
等待
因此,我们的协程运行在计时器之上。我们接下来该做什么呢?仅仅使用计时器会鼓励我们将协程中的各个步骤视为在时间上发生。如果我们把这种观点应用于之前的示例,首先我们执行 DoSomething()
,然后稍后执行 DoSomethingElse()
。从那里到想要更明确地按时间顺序排列的东西,比如“从现在起五秒后执行DoSomethingElse()
”,只需一小步。我们能实现这个目标吗?
嗯,我们确实知道“现在”是何时:我们只需询问 system.getTimer
。“未来”只是“现在”加上 5000 毫秒。一旦“未来”到来,我们就知道我们的五秒钟已经到了。最直接的方法是循环直到那时,并在每次迭代时挂起。[4]
考虑以下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function WaitMS ( duration, update ) local now = system.getTimer() local ends_at = now + duration while now < ends_at do if update then -- 调用任何每帧行为。不建议挂起,因为它会打乱我们的记账。 update() end coroutine.yield() now = system.getTimer() end end |
然后,瞧,我们可以这样做
1 2 3 4 |
DoSomething() WaitMS( 5000 ) DoSomethingElse() |
相对而言,等待五秒可能是一段很长的时间。可选的 update
参数允许我们在必要时偷偷地执行一些小批量的工作。
显然,我们可以等待除了时间之外的其他东西。也许必须满足一些条件
1 2 3 4 5 6 7 8 9 10 |
function WaitUntilTrue( func, update ) while not func() do if update then update() end coroutine.yield() end end |
我们来尝试一下!在这里,我们启动一个过渡效果,然后等待它完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
local pos = { x = 20 } local done = false -- 我们甚至还没开始。当然我们还没完成! transition.to( pos, { x = 50, onComplete = function() done = true -- 现在我们完成了。 end }) WaitUntilTrue( function() return done -- 我们完成了吗? end ) DoSomethingElse() |
同样,我们可以等待某个属性为真
1 2 3 4 5 6 7 8 9 10 |
function WaitUntilPropertyTrue( object, name, update ) while not object[name] do if update then update() end coroutine.yield() end end |
让我们用它来等待直到一个对象变得可见
1 2 3 4 5 6 7 8 9 10 11 |
local rect = display.newRect( 100, 100, 50, 50 ) rect.isVisible = false timer.performWithDelay( 5000, function() rect.isVisible = true end ) WaitUntilPropertyTrue( rect, "isVisible" ) DoSomethingElse() |
可以探索更多想法。显然,“等待直到 X 为假”的变体有其用武之地。我们甚至可以监视多个状态,为此,我们将有“等待直到 X 中的所有状态为真”、“等待直到 X 中的任何状态为真”等等。
这些辅助函数中的每一个都相当通用,但没有任何东西阻止我们创建更具体的函数。复合操作,例如“等待直到对象可见,然后等待十秒”,也可能派上用场。
正如最后的示例所示,协程可以很好地与计时器和过渡配合使用。这只是我们工具包中的又一个工具。
状态机
有时我们会遇到类似以下的代码
1 2 3 4 5 6 |
if state == 1 then DoState1() elseif state == 2 then DoState2() -- 等等... |
或者,使用字符串
1 2 3 4 5 6 7 8 |
if state == "starting" then Start() elseif state == "walking" then Walk() elseif state == "waiting" then Wait() -- 等等... |
这在一定程度上可以正常工作,但通常是过度使用的,尤其是在我们仅仅执行一系列操作时。除此之外,这种模式本身也存在一些内在的危险。当使用整数状态时,我们必须记住哪个状态属于哪个操作。很快我们就会迷失方向。如果我们切换到错误的状态,例如输入错误的整数,我们很容易就会遇到调试噩梦。
当然,使用字符串则没有这样的问题,但我们确实有为想出好名字的麻烦。这比看起来要复杂。事情一开始很容易。我们有 "starting"
、"walking"
和 "waiting"
状态……到目前为止一切顺利!然后我们又开始走路了。嗯。"walking2"
?当然,为什么不呢。接下来又是等待。(叹气) "waiting2"
是它。
最后,我们遇到了真正令人尴尬的事情。我们如何称呼“更新 x、选择填充颜色和清空数组”?我们最终可能会创建诸如上一个示例中的 Start
、Walk
和 Wait
之类的小函数,而不是内联代码,只是为了减少一些视觉上的混乱。命名问题再次出现!这也降低了代码的局部性:我们需要去寻找那些函数,看看其中有什么。此外,如果我们有多个这样的函数,那么我们实际上只是将混乱转移了。
如果能将最后一个代码段写成如下形式,那就更好了
1 2 3 4 |
Start() Walk() Wait() |
这很明显要走向哪里。将我们的逻辑移动到协程中,这种风格会很自然地出现。
作为一个更广泛的示例,我们可以为某种体育游戏编写高级游戏循环
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
CoroPerformWithDelay( 30, function() OpeningSequence() for period = 1, NumberOfPeriods do WaitMS( 15000, PanCamera ) -- 显示观众、体育场等。 StartTheClock() repeat Play() -- 基本上,发生在“发球”和吹哨之间的任何事情。 until OutOfTime() if period < NumberOfPeriods then BetweenPeriodsSequence() -- 国歌、听取教练的意见等。 else EndingSequence() -- 烟花! end end end) |
状态机,第二版
现在,我们可能想要“传统的”状态机。例如,角色 AI 通常由几个独立的、每个都相当重要的行为组成,角色会循环执行这些行为。值得庆幸的是,我们也可以适应这种情况。
考虑一个非常简单的 AI,用于我们的体育游戏中的一个玩家
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
local States = {} function States.Defensive() repeat GetCloserTo( Ball ) if CloseTo( Ball ) then TryToGrabIt() end until GainedPossession() return States.Offensive() end function States.Offensive() repeat GetCloserTo( Goal ) if CloseTo(Goal) then TryToScore() end until LostPossession() return States.Defensive() end local player = CoroPerformWithDelay( 30, States.Defensive ) |
玩家一开始是防守队员。防守策略完全是从对手手中夺回球(这不是一项非常复杂的运动)。考虑到这个目标,玩家试图进入射程并抢断球。如果成功,或者对手以某种其他方式失去了球,则玩家将控制球并转为进攻。
进攻也是类似的情况。玩家试图靠近球门并得分。如果对手重新获得控球权(球被抢断,玩家得分等),则返回防守。
切换状态很简单,只需调用相应的函数即可。我倾向于将所有状态集中到一个表格中,在进行此类操作时。状态通常没有任何明显的正确顺序,因此前向声明最终会变得过于麻烦,尤其是在开关变得越来越复杂的情况下。
return state()
语法是关键。这被称为尾调用。每当我们正常调用一个函数时,Lua 必须留下一些信息,以便在函数完成时,执行可以从它离开的地方继续。问题是,当我们切换状态时,我们无意返回!虽然我们可以使用标准的 state()
形式来回跳转,但最终会积累大量的簿记工作,导致堆栈溢出并崩溃。另一方面,尾调用实际上声明“我在这里完成了”。Lua 会遵守这一点(通过不执行任何操作),问题就消失了。
在游戏循环和玩家 AI 之间,我们有两个协程在运行。没有什么能阻止我们进一步发展,例如创建两个完整的玩家 AI 团队。最终,这取决于什么有效。
长时间运行的进程
当涉及到跨越时间的事件时,基于计时器的协程非常有用。事实证明,它们也非常适合解决另一类问题:需要一段时间才能完成的动作!
“一段时间”可能意味着一两分钟,但即使是 50 毫秒的操作也会导致我们的帧率出现抖动,如果它不允许程序的其余部分有机会运行。
一个很好的例子是加载游戏关卡。我们可能会发现自己执行一些相当耗时的步骤,例如解压缩大型文件或下载图像。资源数量也可能很大。每个资源都需要时间,并且会累积起来。
对于困境本身,我们通常无能为力。我们可能可以做的是在这些众多操作之间或期间采取一些行动。如果长时间运行的进程嵌入在协程中,我们可以调用 coroutine.yield
来暂时放弃控制权。
在两者之间让步可能看起来像这样
1 2 3 4 5 6 |
DoHeavywork() coroutine.yield() DoMoreHeavyWork() |
在期间让步
1 2 3 4 5 6 7 8 9 |
local function DoHeavyWork() for i = 1, NumberOfThingsToProcess do DoSomeWork(i) coroutine.yield() end end |
yields 会不时地暂停操作,将时间还给 Corona。
与 Corona 中的任何长时间运行的活动一样,最好有一些视觉效果,无论是活动指示器、进度视图,甚至是简单的动画。对于特别长的加载,我们甚至可以添加一个小游戏作为叠加层,以消磨时间。
一些 Yield 助手
直接使用 coroutine.yield
的一个缺点是,当我们让步时,即使仍然有时间可以工作,我们也完成了该帧的工作。我们可以通过参数化 yield 操作来在某种程度上缓解这种情况。这样,我们可以尝试不同的交错 yield 策略,直到我们确定一个最佳策略。然后,最后一个代码段变为
1 2 3 4 5 6 7 8 |
local function DoHeavyWork( yfunc ) for i = 1, NumberOfThingsToProcess do DoSomeWork(i) yfunc() end end |
例如,我们可以使用以下例程。调用时,它会尝试让步,但仅当经过一定时间后才实际这样做
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
do local since function YieldOnTimeout() local now = system.getTimer() if not since then since = now -- 首次尝试,只需同步。 elseif now - since >= 15 then -- 15 毫秒,但可以自定义或作为参数。 since = now coroutine.yield() end end end |
(do
–end
结构限制了 since
的作用域,使其仅对 YieldOnTimeout
可见。)
在使用中,它可能看起来像这样
1 2 |
DoHeavyWork( YieldOnTimeout ) |
然而,这并非万能药。有时我们会很倒霉,例如,当只有几毫秒的空闲时间时,我们再运行一个操作,最终却需要五毫秒。为了解决这个问题,最好低估超时时间。
另一种可能性是每隔几次调用就 yield 一次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
do local calls = 0 function YieldEveryFewCalls() calls = calls + 1 if calls == 4 then -- 同样,可自定义,并且可以是参数。 calls = 0 coroutine.yield() end end end |
另一个想法是随机让步,比如 25% 的时间
1 2 3 4 5 6 |
function YieldOccasionally() if math.random( 100 ) <= 25 then -- 同上。 coroutine.yield() end end |
将协程用作调试工具
print()
语句是跨各种编程语言的调试标志。有时,这归结为方便,例如,当配置调试器、然后放置和监视断点需要花费太多精力时。在极少数情况下,集成调试器甚至似乎可以使问题消失:一种可怕的 海森堡 Bug!常见的策略是在代码中可疑的点周围散布 print()
语句,然后将输出与我们的期望进行比较。如果未出现消息,则表示从未访问过相关代码,或者程序在途中崩溃。当我们缩小问题范围时,我们可以删除不再需要的 print()
实例。
在正常代码中,一个问题是大多数(或全部)这些 print()
语句都将执行,因此我们最终会收到一大堆消息。我们也可能需要了解一段代码是何时执行的,以及当时程序的状况。很难猜测过程在哪里崩溃。
输入协程。如果我们可以大致隔离有问题的代码,则可以将其临时嵌入到协程中。然后,通过在每个 print()
后面加上一个 yield,我们可以在那一刻检查世界的状态。请考虑以下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
DoSomething() -- 我们对从此处开始的代码段感到好奇... local red = display.newRect( 100, 100, 30, 70 ) red:setFillColor( 1, 0, 0 ) print( "添加了红色矩形" ) local blue = display.newCircle( 150, 30, 10 ) blue:setFillColor( 0, 0, 1 ) print( "添加了蓝色圆形" ) local green = display.newRect( 200, 100, 60, 20 ) green:setFillColor( 0, 1, 0 ) print( "添加了绿色矩形" ) -- ...并且在这里结束。 DoSomethingElse() |
我们所有的消息和显示对象会立刻显示出来。
使用协程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
DoSomething() -- 开始临时调试代码... local function print2( ... ) print( ... ) coroutine.yield() end CoroPerformWithDelay( 2000, function() -- 在每个打印语句后等待 2 秒。 -- ...临时调试代码结束 local red = display.newRect( 100, 100, 30, 70 ) red:setFillColor( 1, 0, 0 ) print2( "添加了红色矩形" ) local blue = display.newCircle( 150, 30, 10 ) blue:setFillColor( 0, 0, 1 ) print2( "添加了蓝色圆形" ) local green = display.newRect( 200, 100, 60, 20 ) green:setFillColor( 0, 1, 0 ) print2( "添加了绿色矩形" ) -- 开始临时调试代码... end ) -- ...临时调试代码结束 DoSomethingElse() |
通过使用协程,我们可以观看动作的展开。在上面的示例中,现在我们可以在每个 print2
之后花几秒钟来检查情况,而无需显着更改代码结构。如果只需要进行一些视觉检查,甚至可以直接使用 coroutine.yield
。
我最近使用了这项技术,为了测试一些布局例程。由于这些主要涉及显示对象,因此 print()
只能帮我到这里。许多操作包括一个对象相对于另一个对象进行定位,因此如果在中间发生错误,整个布局就会崩溃。通过一次查看一个步骤,我能够确定何时何地出现了问题。
请记住,此方法由于 yield 的存在,会稍微改变程序流程。因此,我们必须将其包含在我们的隔离概念中。例如,在前面的示例中,DoSomethingElse
不应依赖于包装代码内部发生的事情。
神奇的触摸
定时器并不总是最适合调试。如果延迟很短,步骤可能会过快。如果延迟时间太长,我们可能会感到厌倦等待,特别是当问题往往在很晚的时候才出现时。
幸运的是,时间不是驱动协程的唯一方式。我们之前暗示过将包装的协程用作事件侦听器。 "touch"
侦听器就是这样一种选择,实际上它可以给我们直接控制:在每次触摸时,我们都会恢复包装的代码。
使用 Corona,很容易创建一个虚拟的显示对象并为其分配这样一个侦听器。然后,我们不需要等待定时器,只需快速点击我们想要忽略的任何步骤即可。一旦我们发现可疑的东西,就可以慢慢来。
那么前面的例子就变成了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
DoSomething() -- 开始临时调试代码... local function print2( ... ) print( ... ) coroutine.yield() end local co = coroutine.create( function() -- 等待下一次触摸事件。 -- ...临时调试代码结束 local red = display.newRect( 100, 100, 30, 70 ) red:setFillColor( 1, 0, 0 ) print2( "添加了红色矩形" ) local blue = display.newCircle( 150, 30, 10 ) blue:setFillColor( 0, 0, 1 ) print2( "添加了蓝色圆形" ) local green = display.newRect( 200, 100, 60, 20 ) green:setFillColor( 0, 1, 0 ) print2( "添加了绿色矩形" ) -- 开始临时调试代码... end ) local button = display.newCircle( 20, 20, 10 ) button:setFillColor( .7 ) button:addEventListener( "touch", function( event ) if event.phase == "began" then local ok, err = coroutine.resume( co ) if not ok then print( err ) -- 哪里出错了? end end return true end) -- ...临时调试代码结束 -- DoSomethingElse() |
点击一次后,我们看到
(灰色圆圈是我们的“按钮”,即虚拟的显示对象。)
再次点击后
最后
这实际上是更适合使用 coroutine.create
和 coroutine.resume
的情况。我们不希望仅仅因为点击次数过多并运行了死协程而导致程序崩溃。我们最终还会得到一个基本的沙箱,其中一个错误 可以发生而不会导致整个程序崩溃(当然,协程随后将变为死协程)。如果我们的代码片段被正确隔离,那么 这应该可以正常工作。
一切就绪后,我们可以删除所有“临时调试代码”并继续前进。
陷阱
在一些特定情况下,协程会中断,这主要是由于 Lua 和 C 之间交互的一些怪癖。由于代码库中的一些重新设计,这些问题已在 Lua 5.2+ 中得到修复。但是,Corona 基于 5.1,因此目前这些问题是事实。
Peter “Corsix” Cawley 在他的博客中指出(参见第 2 点),其中两个问题领域涉及受保护的调用和元方法。请注意,该文章本身主要关注 5.2,而我们只关心与 5.1 相关的错误行为。有一些尝试处理受保护的调用,例如 Coxpcall,它是专门为了在 Copas 中允许它们而创建的(一个在协程之上构建 TCP/IP 服务器的库)。
迭代器 是另一个问题点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
local wrapped = coroutine.wrap( function() for _ in function() -- 一个(显然没用的!)迭代器主体。 print( "之前" ) coroutine.yield() -- 在 5.1 中,会给出错误:尝试跨元方法/ C 调用边界 yield。 print( "之后" ) end do print( "循环!" ) end end ) wrapped() |
这是一个相当糟糕的迭代器(它甚至没有尝试迭代!),但它演示了这个问题。在 Lua 5.1 中,尝试直接从所谓的迭代器函数中 yield 会导致错误。我们会看到消息 “之前”,然后我们的程序就会崩溃。请注意,这与使用协程作为迭代器不是同一回事(一个非常有用的功能,但不幸的是,它本身就是一个完整的主题!)。
值得庆幸的是,这些往往是罕见的情况,但了解它们是好的。
保存
最后一个陷阱来自设计的角度。能够 yield 为我们提供了很大的灵活性,但另一方面,我们确实需要遍历协程主体才能到达代码中的给定点,并具有所有局部变量和程序状态。通常,我们不能直接跳到中间的某个位置。如果应用程序必须能够保存并在以后恢复到它离开的位置,这会带来问题。这不是无法克服的,但确实需要尽早加以考虑。这几乎肯定很困难,可能不值得麻烦。
如果应用程序只需要不时保存(例如在游戏中,在关卡之间或在检查点),或者在恢复内容方面有一定的回旋余地,那么这问题就小得多。
例子
本文中的大部分代码都改编自 此处的 示例代码仓库。可以从 此处 下载。这些示例是为了在 Corona Geek 聚会 上展示而制作的,因此我采用了一种 可以快速打开和关闭程序整个段落的风格。特别是,代码被阻止在 长注释 中。这样做可以让我们从一个注释掉的部分开始:
1 2 3 4 |
--[[ DoSomething() -- 已注释,不会运行。 --]] |
然后,我们可以通过在注释的开头添加另一个连字符来一次启用所有代码。
1 2 3 4 |
---[[ DoSomething() -- 不再注释,将会运行。 --]] |
要再次禁用它,只需删除连字符即可。我在 main.lua
中使用了这种阻止方法,来 require()
每个示例,以及在模块本身中。
此外,为了减少录制时在模拟器和控制台之间的切换,我在 main.lua
中覆盖了 print()
,使得消息显示在屏幕上(作为文本对象)而不是控制台上。要禁用此功能,只需删除或注释掉对 print()
的赋值即可。
总结
协程是 Lua 的一个真正强大的功能,当与 Corona 的计时器和事件监听器等机制结合使用时,为我们提供了一种新颖且有用的方法来解决许多难题。我们只是触及了皮毛。《Lua 程序设计》的 第 9 章 涵盖了一些这里只是 略有提及的主题。探索吧,并享受乐趣!
[1] 协程是一个线程。反之亦然,但有一个非常重要的例外:主线程。这是程序的“正常”部分,当代码不在协程内部运行时,我们的代码会在其中运行。
[2] 通常,但不总是这样。例如,我们可以将同一个包装器分配给多个监听器。然后,当这些监听器零星触发时,协程会逐渐向前推进。
另一个用例是中止复杂的代码。我们可能会发现自己处于某个繁重的操作中间,深入到十个函数调用中,当我们意识到我们根本无法处理它时。从一个函数中 return
出来很容易。从另外九个函数中出来就不是那么容易了!另一方面,如果这一切都在一个协程内部,我们可以直接 yield。突然之间,我们回到了 主线程,并且可以直接丢弃协程。另一种实现此目的的方法是 error()
输出,尽管如果我们实际上没有错误,这似乎有点不礼貌。
俗话说,“宁可请求原谅,也不要请求许可。” 本质上,如果预先弄清楚某个操作是否有成功的机会太麻烦,那么最好的主意可能是直接去做。当我们将此方法与一组选择结合使用时,我们就会得到一种称为回溯的技术,可以用另一种表达方式来概括,“如果第一次不成功,请再试一次。”
我们可能想在协程中进行正常的 yield。通过 coroutine.yield
发送数据的能力在这里可以帮助我们。首先,我们保留几个值,可能是“success” 和 “failure”,然后恢复执行,直到我们遇到其中一个。
[3] 当然,创建影子表会重新引入垃圾。但是,基于协程的计时器总体上创建的频率远低于普通的计时器,而且运行时间更长,因此这在实践中不太可能成为问题。
[4] 另一种可能性是在 yield 时将协程的控制权传递给调度器,调度器稍后会在协程准备就绪后恢复执行。这有利有弊。由于我们不再在一个循环中旋转,它可能会更有效率,尤其是在我们有多个协程正在运行时。与此同时,如果我们需要进行更新,这些成本只是以不同的形式重新出现,例如 "enterFrame"
监听器开销。
调度器的实现方式有很多。一些纯 Lua 的示例是 Lumen 和 此 Gist。其他一些部分用 C 和 C++ 编写的代码包括 ConcurrentLua,luasched 和 Nylon。不幸的是,我们不能简单地将最后几个放入 Corona 代码库中。也就是说,它们的代码中有足够多的部分是用 Lua 编写的,值得我们研究一下。
Andrzej // Futuretro Studios
发表于 03:27h, 2 月 11 日感谢 Steven 的精彩教程。我一直在寻找一种使用协程的方法来防止在繁重负载操作时出现帧卡顿,而您最终提供了一种我可以理解并适应我自身用途的方法。太棒了!
Thomas Vanden Abeele
发表于 09:45h, 2 月 11 日请让我知道这是否解决了繁重负载操作的问题——因为据我所知,协程与线程(协程不是线程)不同,它不会帮助您解决这个问题。但我希望我在这里是错的!
Steven Johnson (Star Crunch)
发表于 17:22h, 2 月 11 日感谢大家的赞美之词!
Thomas,太糟糕了,当您在其他线程中提到图像加载时,这篇文章已经被排队了,否则我可能会在这里加入一些警告。
最终,这将取决于瓶颈是否足够细粒度,以至于您可以适应它们。不幸的是,像 newImageSheet 这样的阻塞 API 调用(以及可能大多数,如果不是全部的其他资源)不会是这样的。另一方面,如果有一种异步或增量加载方式,它可以很容易地利用“长时间运行的进程”方法。
也就是说,如果“繁重负载操作”只是几十个或数百个小型或中型资源,那么我们就有所进展了!
Thomas Vanden Abeele
发表于 04:23h, 2 月 11 日好的!很高兴在这里看到一些关于 Lua 编码的深入信息!请多提供这类内容!!!
JCH_APPLE
发表于 05:02h, 2 月 11 日真的非常有趣,感谢您提供的这个精彩的“不止是教程”
Rachel
发表于 09:09h, 2 月 11 日喜欢它!像这样深入的 Lua 代码真的对社区有帮助。请继续提供!
Conor O'Nolan
发表于 07:00h, 2 月 12 日我希望这可能有助于在响应按钮单击时产生加速的错觉。我尝试在 “began” 阶段触发脚本的第一部分,在 “ended” 阶段触发第二部分。
似乎不起作用。
在我的代码中,我弹出一个对话框,然后加载一个大的图形。
通常,在文件加载完毕之前,对话框不会显示。所以这里没有错觉。
如果协程像我预期的那样工作,对话框会立即显示,然后在用户注意力分散的情况下加载文件。
还尝试用一个函数触发两次,但这也不起作用。
有什么想法吗?
Star Crunch
发表于 14:32h, 2 月 12 日嗨,Conor。
在一般情况下,
"began"
和"ended"
之间的间隔可能太短,不会产生太大差异。当您按住触摸一会儿时,它的行为如何?如果我看到一些代码,我可能会说更多。但是,在这种情况下,最好在论坛上进行,因为我会看到回复。
Conor O'Nolan
发表于 13:59h, 2 月 15 日在 Lua 论坛中开始讨论
http://forums.coronalabs.com/topic/54565-coroutine-issue/
Michael
发表于 14:41h, 3 月 2 日精彩的文章。非常详细,并且包含一些易于遵循且非常有用的代码。感谢分享!
Jens-Christian Finnerup
发表于 01:09h, 3 月 3 日感谢这篇很棒的文章!
很高兴看到一些以 Lua 为中心的教程,
期待未来的教程
Stephen
发表于 16:38h, 3 月 21 日无法使其工作……嗯
这是我的代码
———— 代码开始 ————
local done = false
local function DoSomething()
print("Did Something")
end
local function DoSomethingElse()
print ("Did Something Else")
end
local function WaitUntilTrue( func, update )
while not func() do
if update then
update()
end
coroutine.yield() -- 尝试跨元方法/C 调用边界 yield
end
end
local pos = { x = 20 }
DoSomething()
transition.to( pos, {
x = 50,
onComplete = function()
done = true -- 现在我们完成了。
end
})
WaitUntilTrue( function()
return done -- 我们完成了吗?
end )
DoSomethingElse()
—————— 代码结束 —————
在 coroutine.yield() 处出现错误,提示“尝试跨元方法/C 调用边界 yield”
我应该在某处放置 coroutine.create 或包装吗?
救命,我一定是太蠢了。 🙁
Ben
发表于 10:50h, 8 月 9 日这是我的问题,想象一下以下场景:启动多个访问同一个全局变量的协程。这会产生竞争条件并导致数据丢失甚至更糟吗?
Rob Miracle
发表于 14:31h, 8 月 9 日在任何多线程系统(或在本例中为协程)中,当访问公共数据时都会出现竞争条件,并且程序员有责任防止这种情况发生。