Lua中优雅的异步封装
转载或者引用本文内容请注明来源及原作者
问题
在我们日常使用异步的一些接口进行编码的时候,经常会遇到这样的问题:
- 使用异步回调的方式,当有复杂的嵌套业务,使得回调内需要嵌套回调,导致这个业务的逻辑无法像同步业务那样清晰直观的展现
- 异步编码方式对业务人员要求较高,需要考虑的情况比较复杂,容易出现Bug
解决方案
注:下面应用场景主要针对Unity引擎 + Lua方案。其他问题可以参考类似思想进行解决
这里记录两种解决方案:
1. Lua协程
- 首先我们需要了解C#的IEnumerator,以及yield return语法。通过官方文档,我们可以简单的理解,C#在IEnumerator中,通过yield的关键字,把我们的代码分为了上下两块,通过MoveNext进行两块代码的执行。
- Unity的协程技术就是借用了C#迭代器的方法,通过主线程的Update进行遍历执行,实现协程。同样的,我们在Lua端也可以构造一个IEnumerator,实现对应的接口,然后透传到我们的Unity协程进行驱动,即可实现协程的功能。
- 代码块:
-
构建Lua的IEnumerator
---@class IEnumerator IEnumerator实现 ---@field Current object c#对象 IEnumerator = Class.Define("IEnumerator") ---协和退出标记 local moveEnd = {} ---构造函数 ---@private ---@param func fun(...) function IEnumerator:_Ctor(func, ...) local params = { ... } self.wrapFunc = function() local b, err = pcall(func, unpack(params)) if not b then Log.Err(err) end return moveEnd end self:Reset() end --region -------------公开函数------------- --- IEnumerator实现接口:跳转到下一个代码块 ---@return boolean function IEnumerator:MoveNext() self.Current = self.coWrap() if self.Current == moveEnd then self.Current = nil return false else return true end end --- IEnumerator实现接口:重置代码块到初始,目前用于初始化 function IEnumerator:Reset() self.coWrap = coroutine.wrap(self.wrapFunc) end --endregion
-
构建迭代器并传到C#的协程
function XLuaCoroutine.StartCoroutine(mono, fun, ...) local iter = IEnumerator(fun, ...) return CoroutineUtil.StartCoroutine(iter, mono) end
-
同时你可以通过继承Unity提供的CustomYieldInstruction,进行对应逻辑的封装,通过Lua的coroutine.yield来进行异步逻辑的组织。
--这个loader继承于CustomYieldInstruction local loader = XLuaCoroutine.CoroutineLoader(...) coroutine.yield(loader)
-
2. 操作指令缓存技术
-
逻辑和视图分离是我们常用的一种技术手段,将我们的视图逻辑和真正的业务逻辑区分开来,达到可移植、高内聚的目的。这里的操作指令缓存技术,启发的来源也是基于这一点。
-
假设有这么一个应用场景:有两个人A、B,A需要做馅饼但是刚好没酱油了,想叫B去帮忙买酱油(这里假设除了B,谁都没办法去买酱油),但是B刚好出去玩了。所以A需要等B回来,并等B买回来酱油才能继续做馅饼。但是如果我们引入了第三个人C,A吩咐C说:你等B回来,叫他去买酱油,我先去搅肉了。这样A就不需要等B回来,可以先去做自己的事情。并且当A把肉馅搅完后,也能够叫C:等B酱油回来后,加两勺子酱油!!
-
类似上面的方式,我们可以将业务需要的资源对象进行封装,并暴露对应的接口给业务。业务在调用接口的时候是不知道资源是否已经加载完成的,只需要向写同步业务那样进行编写处理。这个第三者C,把业务需要做的事情以指令的形式缓存在自己身上。当资源还没加载完成,则等资源加载完后进行指令的逐条处理;或者资源已经加载完了,那就直接进行指令处理。避免了业务需要使用回调等待的方式进行编写业务逻辑。
-
lua中如何实现这样的机制呢?我们借用了lua元表的__index和__newindex
- 元表核心逻辑
---默认值,无效的module local _moduleFlag = {} local loaderMetable = { __call = function(wrapper, ...) local tb = wrapper._innerData table.insert(tb.data, { key = tb.key, params = table.pack2(...) }) tb.key = "" end, __index = function(wrapper, key) local tb = wrapper._innerData --已加载完 if tb.module ~= _moduleFlag and not tb.keepWaiting then return tb.module[key] else --Log.Err(key, debug.traceback()) if tb.key ~= "" then error(key, "请勿访问内部变量") end tb.key = key return wrapper end end, __newindex = function(wrapper, k, v) local tb = wrapper._innerData if tb.module ~= _moduleFlag then tb.module[k] = v else error(k, "请勿访问内部变量") end end }
- 通过指定创建的LuaTable对象的元表关联
local function CreateNodeLoader() local tb = { _innerData = { data = {}, key = "", keepWaiting = false, module = _moduleFlag, }, } setmetatable(tb, loaderMetable) return tb end
- 最后在异步完成后记得回调指令列表,进行指令调用
local function OnComplete(wrapper, md) local tb = wrapper._innerData tb.module = md --getmetatable(wrapper).__newindex = md local index = -1 for i, v in ipairs(tb.data) do local fun = md[v.key] if fun and type(fun) == "function" then fun(table.unpack2(v.params)) index = i if tb.keepWaiting then break end end end if index > 0 then for i = 1, index do table.remove(tb.data, i) end tb.key = nil else tb.data = {} tb.key = nil end end
- 元表核心逻辑
总结
两种方案的使用场景都不相同,面对复杂、层级较深的业务,推荐使用协程来组织业务。而组织和编码上比较简单的可以使用方案二。每个方案也都有各自的缺点:方案一没办法一次性发起多个异步操作,而方案二需要约束业务只能调用封装对象的方法,不能取内部的字段或者属性。