Lua中优雅的异步封装

发布时间 2024-01-08 00:24:24作者: 小小钊

注:下面应用场景主要针对Unity引擎 + Lua方案。其他问题可以参考类似思想进行解决

问题

在我们日常使用异步的一些接口进行编码的时候,经常会遇到这样的问题:

  • 使用异步回调的方式,当有复杂的嵌套业务,使得回调内需要嵌套回调,导致这个业务的逻辑无法像同步业务那样清晰直观的展现
  • 异步编码方式对业务人员要求较高,需要考虑的情况比较复杂,容易出现Bug

解决方案

这里记录两种解决方案:

1. Lua协程

  • 首先我们需要了解C#的IEnumerator,以及yield return语法。通过官方文档,我们可以简单的理解,C#在IEnumerator中,通过yield的关键字,把我们的代码分为了上下两块,通过MoveNext进行两块代码的执行。
    img
  • 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酱油回来后,加两勺子酱油!!
    img

  • 类似上面的方式,我们可以将业务需要的资源对象进行封装,并暴露对应的接口给业务。业务在调用接口的时候是不知道资源是否已经加载完成的,只需要向写同步业务那样进行编写处理。这个第三者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
      

总结

两种方案的使用场景都不相同,面对复杂、层级较深的业务,推荐使用协程来组织业务。而组织和编码上比较简单的可以使用方案二。每个方案也都有各自的缺点:方案一没办法一次性发起多个异步操作,而方案二需要约束业务只能调用封装对象的方法,不能取内部的字段或者属性。