因为 soluna 支持写 app 应用,而我最近正好打算为我父母和妻子编写一个抖音直播录制工具 app,想要试试。不过这有个问题,就是录制部分需要使用 ffmpeg 来进行,而 ltask 虽然可以 N:M 调度 lua 虚拟机,但是如果像 ffmpeg 这种阻塞调用,就会导致线程阻塞了。
当然,解决方法也是有的,比如引入事件循环,将 fork 的进程挂载到事件循环上,比如使用 bee.lua 就是个不错的选择。不过这意味着需要修改 soluna 的源码,在虚拟机注入 bee.lua。但我又不太想修改源码。首先这个功能对于 soluna 本身没有什么必要,因此我倾向于不到上游提需求;其次,我也不想修改源码,方便后续同步上游最新更新。
这时候我突然理解了 C 语言的一个优点了。
ltask 预留了一个 -DLTASK_EXTERNAL_OPENLIBS 选项,soluna 本身的库就是通过 -DLTASK_EXTERNAL_OPENLIBS=soluna_openlibs 在编译时注入符号的。
这意味着,我只需要把 soluna 设置为我的子仓库依赖,然后创建一个例如 soluna_app.c 的文件。在这个文件里面我创建一个 soluna_app_openlibs 函数,它内部除了调用 soluna_openlibs 还注入 bee.lua。最后在编译时,我把定义改成 -DLTASK_EXTERNAL_OPENLIBS=soluna_app_openlibs 从而就可以实现不更改源码,增量编译增加功能
当然,解决方法也是有的,比如引入事件循环,将 fork 的进程挂载到事件循环上,比如使用 bee.lua 就是个不错的选择。不过这意味着需要修改 soluna 的源码,在虚拟机注入 bee.lua。但我又不太想修改源码。首先这个功能对于 soluna 本身没有什么必要,因此我倾向于不到上游提需求;其次,我也不想修改源码,方便后续同步上游最新更新。
这时候我突然理解了 C 语言的一个优点了。
ltask 预留了一个 -DLTASK_EXTERNAL_OPENLIBS 选项,soluna 本身的库就是通过 -DLTASK_EXTERNAL_OPENLIBS=soluna_openlibs 在编译时注入符号的。
这意味着,我只需要把 soluna 设置为我的子仓库依赖,然后创建一个例如 soluna_app.c 的文件。在这个文件里面我创建一个 soluna_app_openlibs 函数,它内部除了调用 soluna_openlibs 还注入 bee.lua。最后在编译时,我把定义改成 -DLTASK_EXTERNAL_OPENLIBS=soluna_app_openlibs 从而就可以实现不更改源码,增量编译增加功能
yoga 在排版中的具体作用是什么呢?举个简单的例子,就拿
我们创建如下一个 hud 排版, 它是一个简单的左右结构。其中左边我们定义了宽度为 400, 右边则是填满剩下的宽度。两者都填充满高度。另外右边是上下结构,其中第一个 node 占了70%的宽度,第二个占了30%的宽度。
此时单单使用这个 hud 我们还做不了什么。
紧接着我们在脚本入参中,可以获得启动时窗口的宽高, 我们可以将其设置到 screen 上:
上面这段代码做了三件事: 加载布局到 yoga 中, 然后为 screen 设置宽高, 接着调用 calc 计算布局。
最后,假设宽是800,高是600,那么 yoga 会根据 left 的宽,计算出 right 的宽度为 400, 两者的高度都是 600; 而在 right 中第一个 node 的高 420, 第二个则是 180.
最后我们将计算结果使用 matquad 材质构建出实体:
并使用
最后在 frame 中调用该函数,也就是每一帧都绘制。
当窗口发生变化时,我们又更新宽高,然后重新计算 node 布局信息,这样就可以在下一帧及时更新整体布局。
soluna/test/layout.lua
来举例:我们创建如下一个 hud 排版, 它是一个简单的左右结构。其中左边我们定义了宽度为 400, 右边则是填满剩下的宽度。两者都填充满高度。另外右边是上下结构,其中第一个 node 占了70%的宽度,第二个占了30%的宽度。
local hud = [[
id : screen
padding : 10
direction : row
gap : 10
left :
width : 400
background : 0x40000000
right :
flex : 1
gap : 10
node :
flex : 0.7
background : 0x40ffffff
node :
flex : 0.3
background : 0x40ffffff
]]
此时单单使用这个 hud 我们还做不了什么。
紧接着我们在脚本入参中,可以获得启动时窗口的宽高, 我们可以将其设置到 screen 上:
local dom = layout.load(datalist.parse_list(hud))
local screen = dom.screen
local function calc_hub()
screen.width = args.width
screen.height = args.height
return layout.calc(dom)
end
上面这段代码做了三件事: 加载布局到 yoga 中, 然后为 screen 设置宽高, 接着调用 calc 计算布局。
最后,假设宽是800,高是600,那么 yoga 会根据 left 的宽,计算出 right 的宽度为 400, 两者的高度都是 600; 而在 right 中第一个 node 的高 420, 第二个则是 180.
最后我们将计算结果使用 matquad 材质构建出实体:
local function draw_hud()
for _, obj in ipairs(draw_list) do
args.batch:add(matquad.quad(obj.w, obj.h, obj.background), obj.x, obj.y)
end
end
并使用
batch:add
提交绘制命令,告诉框架,我要创建一个四边形(quad), 它宽w高h, 使用背景色 background, 绘制时的坐标是 x 和 y。最后在 frame 中调用该函数,也就是每一帧都绘制。
当窗口发生变化时,我们又更新宽高,然后重新计算 node 布局信息,这样就可以在下一帧及时更新整体布局。
batch 是 soluna 在调用我们编写的 main.lua 时传入的参数。我们绘制时只需调用 batch:add, 这意味着,我们可以在任意地方调用,并不是说一定要在同一个地方调用。这有助于低耦合,高内聚。当我们需要绘制多个模块的 ui 时,每个 ui 可以独立传入 batch, 在内部实现排版和绘制。一般来说最佳实践是每个模块都具有一个 draw 函数和 update 函数。这样遍历多个模块时,聚合的函数只需要先 update 再 draw, 而不需要关注具体内部实现,各个模块也是独立实现的。 deepfuture 就是如此做。
在绘制 ui 时,我们可以有很多个 node,而不仅仅是一个 node。例如,bg 是一个 node, hud 也是一个 node。每个 node 都需要通过调用 calc 方法来更新结构。因此当整个界面的不同部位更新频率不同时,我们可以将其分开成多个 node,单独 calc 其变更,避免每帧都需要对整个 ui 重新计算,减轻压力。另外一个 node 中的 id 都是唯一的,而在多个 node 中 id 是可以重复的。这个 id 只是 yoga 用来计算使用,或者我们可以通过 id 来获取和设置 yoga 中的布局信息。这是我一开始误解的一点。我使用 soluna 编写一个 todo app 时,将整个页面都描绘成一个固定的 node ,结果在更新、计算时,遇到了动态添加的困难。
当我静下心来仔细阅读 deepfuture 代码时,发现了这一点。
当我静下心来仔细阅读 deepfuture 代码时,发现了这一点。
soluna 使用 facebook/yoga 作为排版工具。这个工具是 react native 的基础设施,仅仅是负责排版,不负责渲染。在 soluna 中,我们可以用 datalist 描述排版,然后将其载入到 yoga 中,生成一个 node, 接着根据 node 结构来生成渲染指令。这里的渲染指令是指传入 batch:add 的参数。因为实际渲染都是通过 batch 来实现的。关于 batch:add 函数的 api 的函数签名描述如下:
可以说是很简单了,把材质和坐标放进去就可以绘制。
但是正因为如此简单,如果我们要直接使用它来绘制整个画面,将会较为复杂。这就是为什么我们需要一个排版工具。这个排版工具实际上就是通过一系列抽象,使得我们在设计某些具体细节时,不需要关注全局。最后通过一次 calc 计算,替我们计算某个节点的全局坐标,这样我们就可以较为轻松的绘制。当然,如果有别的排版工具,也完全可以不使用 yoga 进行排版,这不是一个必要条件。
- batch:add(obj [, x [, y]])
- obj 可以是: 正整数 sprite id;材质模块返回的 userdata(例如 matquad.quad、mattext.block(…) 的结果);或已经打包好的二进制流。附带坐标则先平移后提交。
可以说是很简单了,把材质和坐标放进去就可以绘制。
但是正因为如此简单,如果我们要直接使用它来绘制整个画面,将会较为复杂。这就是为什么我们需要一个排版工具。这个排版工具实际上就是通过一系列抽象,使得我们在设计某些具体细节时,不需要关注全局。最后通过一次 calc 计算,替我们计算某个节点的全局坐标,这样我们就可以较为轻松的绘制。当然,如果有别的排版工具,也完全可以不使用 yoga 进行排版,这不是一个必要条件。
最近开始尝试学习如何使用 soluna,但是我太过急躁了,只是弄清了一点 BATCH (批渲染) API 的用法,就急不可耐地试图编写一个 TODO APP, 而且还是让 AI 写的。其结果就是代码质量失控,我完全不知道 AI 写了什么,任凭其忽悠。于是我变得急躁,失去耐心,失去谦虚。最后搞砸了一切,从头再来
service 内置一个 plugin.lua ,专门启动插件 service。例如,我们有一个 service 叫作 rss.lua ,在插件页面点击下载后,会携带插件代码,ltask.call plugin service 请求创建 rss service,定期检查 rss ,并注册一个路由用来展示 rss 功能页面。
感觉 ltask 天然适合 dalbox。每个插件就是一个 service。前面说的订阅,下载,AI 字幕都可以是单独的线程。 http 也是多个 service 负载均衡,数据库也是一个单独 service ,模板渲染也是单独 service
我大概明白了,是检查 LTASK_EXTERNAL_OPENLIBS 是否定义了。如果没有,就默认打开标准库。所以可以通过这个方式注入。不过 ant 的实现方式更彻底。直接替换掉 luaL_openlibs 。看来我的 lua binding 实现需要做一些调整,以便于支持这种操作
在为 ltask-go 编写 examples 时我陷入了困境,无法同时成功跑通三个平台的 bee.lua 。主要是无法跑通 windows 平台的。经过研究 ejoy/ant 的代码,我终于弄清楚了。嘿嘿,果然看不懂只是暂时的,迟早会弄懂。
https://github.com/ejoy/ant/blob/master/runtime/common/modules.c
ant 采用的方式是把所有的 clibs 的 luaopen 函数统一注入到自己的入口,这样 lua 代码就可以直接使用,而不需要寻找同名的 dylib
目前我还有一个疑惑,ltask 创建多个服务时,只会打开标准库。不知道是怎么注入的?难道是宏可以重写?
https://github.com/ejoy/ant/blob/master/runtime/common/modules.c
ant 采用的方式是把所有的 clibs 的 luaopen 函数统一注入到自己的入口,这样 lua 代码就可以直接使用,而不需要寻找同名的 dylib
目前我还有一个疑惑,ltask 创建多个服务时,只会打开标准库。不知道是怎么注入的?难道是宏可以重写?
rocknix 出场不支持中文,会导致中文字符全部显示为 ??? 即使在主题中安装了中文字体支持也没用。间接原因是每个 roms/*/gamtelist.xml 显示的游戏名字就是 ??? 因此可选的解决方法就是把 gamelist 里的显示名称改为中文字符,这样就可以在游戏机列表上显示为中文。这种事情可以写一个脚本来处理和生成
这个问题找到解决方案了: 之所以发生syscall frame 的错误,本质上还是因为 lua 在遇到错误的时候会进行 longjmp,即跳回安全点,这个过程对 goroutine 的栈造成了破坏。所以需要想办法避免 longjmp。在深入研究 lua 的源码后我发现 lua_error 会检查是否设置了安全位点setjmp来决定是否进行 longjmp 。如果没有设置,则检查用户是否设置了错误处理器,最后 fallback 到 panic 上。那么安全位点是什么时候设置的呢?其实很简单,就是调用 lua_pcall 的时候。这也是为什么该方法可以安全调用任何函数的原因。所以我们只要跳过该方法的调用,改为使用 call,同时设置错误处理器,就可以将不安全的错误用 go 接管。那么用 go 接管后,我们又该怎么返回错误呢?答案是进行简单的 panic 操作。然后在 call 的调用协程上 recover 就可以捕获这个错误。进一步讲,可以自己实现一个 lua_pcall,其底层实现是通过一个 lua_call 组合 panic 和 recover 来达成由 go 端接管 lua 错误处理的效果
lua binding 遇到一个问题,当 lua 取栈取到不存在的栈时,go 直接显示 exit syscall frame no longer availabe.这和 windows 环境下使用 yield 报错一样,不知道有没有什么关联