一、前言
跨端、动态化等技术一直是移动端开发比较热闹的话题。网上也有不少的实践方案了,思路几乎都一样。在APP端内置一个解释器或者叫Run-Time吧。这个Run-Time能够动态执行远程下发的编译产物,我们称为离线包。具体细节就不展开了,网上很多,没有必要在重复一遍了。而我们想分享的是在动态化实践过程中遇到的问题以及如何解决的。起初我们是基于一个开源工程,想着这样能少走弯路,然后顺利完成任务。然而还是太单纯,实践的过程当中仍然遇到了不少问题。编译器和Run-Time还是一个没落下的全部都过了一遍,并添加修改了不少东西。
二、技术方案
开发语言:TypeScript
Compiler时序图:
\
三、实践过程及遇到的问题
1、第一屏渲染特别慢,且与代码量的增加成正比。整个过程包含: 字节码解码(我们下发的产物是Lua字节码)、LuaVM执行字节码指令。借助性能分析工具,分析发现pcall函数执行时间很长,展开后每个函数执行时间却很短,但嵌套层次非常深。一开始并没有怀疑是类似递归的函数调用,通过调试lua-vm执行字节码指令的整个过程,发现同一个Package会被require多次。我们在用typescript写代码的时候,一般情况一个class对应一个ts文件,同时每个class会依赖同一个Widget。例如Text Widget基本上每个页面都会使用。而typescript编译成Lua代码后,每个module就是一个Lua package。Lua要使用一个package首先需要require。导致对于同一个Text Widget会被调用多次,并且互相有依赖的话还会产生死循环错误, 可以理解为循环引用。这个用一个online lua playground就可以测试,所以同样用dart实现的VM也会遇到这个问题,以下是伪代码:
TypeScript:
import { Widget } from "runtime/flutter/widget";
import { StatelessWidget } from "runtime/flutter/widgets/statelessWidget";
import { Text } from "runtime/flutter/widgets/text";
export class DemoPage extends StatelessWidget {
public build(context: BuildContext): Widget {
return Text("演示demo")
}
}
翻译成Lua代码:
package.preload["../../DemoPage"] = (function (...)
local ____statelessWidget = require("runtime.flutter.widgets.statelessWidget")
local StatelessWidget = ____statelessWidget.StatelessWidget
local ____text = require("runtime.flutter.widgets.text")
local Text = ____text.Text
function DemoPage.prototype.____constructor(self, ...)
StatelessWidget.prototype.____constructor(self, ...)
end
function MyCommissionPage.prototype.build(self, context)
return __TS__New(Text, "演示demo")
end
end)
require Lua函数的实现:
//修复前
local package = {preload={}, loaded={}}
local function require(file)
return package.preload[file]()
end
//修复后
local package = {preload={}, loaded={}}
local function require(file)
local loadedModule = package.loaded[file]
if loadedModule == nil then
package.loaded[file] = true
loadedModule = package.preload[file]()
package.loaded[file] = loadedModule
return loadedModule
end
return loadedModule
end
对于加载过后的Package,进行缓存,下次遇到直接从loaded中取。确保只加载一次,类似Java class同样只初始化一个道理,后面想想也应该这样。对汇编或字节码指令有了解的应该知道每次函数调用都需要分配一段栈空间,并且执行对应的指令。这个开销是不可忽视的,执行一次感觉不出来,如果足够多次,就会有感觉了。这也是为什么不建议在large loop里执行函数调用或者new对象的原因。
2、实现Coroutine
前面说了我们是基于开源项目,但是存在很多问题,需要做二次开发。以Coroutine为例,如果不熟悉Coroutine是什么,可以点击以链接
Programming in Lua : 9.1www.lua.org
Coroutine涉及几个函数:create、resume、yield、status。这里最难实现的是就是resume和yield。一开始没任何思路,网上也没找到一个完整能够串起来的思路,而且我们是用dart来实现。Lua有一个用c语言的实现的VM,只好硬着头皮去看了。为什么这里说是硬着头皮?因为c语言不动态调试不打断点,纯看是很难短时间去理解整个工程运行流程的,或者只看关心的部分,其实任何一门语言如果不动态调试,我都没耐心看。如果是c/c++新手即使动态调试了,也不一定会很轻松,比如怎么单步调试宏代码?恰恰Lua-VM用c实现的使用了大量的宏。还有就是指针和链表了。我们平时调试Java或者高级语言看到都是直接内容,但是在c/c++很可能就是地址,尤其是数组指针。所以对于理解整个工程的脉络依然很困难,通lldb/gdb+python script调试起来会好一些。所以c/c++难学的不仅是复杂的语法还有动态调试。
resume实现难度在于怎么恢复函数执行Call-Stack和参数的传递。
第一次执行resume时,8传给了arg。然后执行yield时被中断,继续main流程,遇到第二个resume时,才继续执行并把值返回给左边,以下是resume实现的伪代码。
当第一次调用resume时,coroutine为normal状态因此参数被loadArgs,第二次是loadReturns因为yield函数是返回一个结果。_resumeTo是一个Frame对象,调用cont函数接着执行剩余的指令。cont()执行结束分两种情况,一种是函数正常执行完毕,一种是碰到yield函数结束,因此当success为false时,该coroutine为SUSPENDED状态。当为true的时候表示已经执行完毕,进入else语句。这里比较关键,因为这里是恢复Call Stack的过程。因为coroutine遇到yield就会interrupt执行。再次resume时Call Stack已经没有了。
理解上面的后,yield的实现很简单,当执行到yield时直接return或抛一个Exception结束cont函数的执行。Lua VM怎么实现的?包括cont loadArgs loadReturns这里就不多讲,都是参照Lua VM Instructions,以下是链接。
underpop.free.fr/l/lua/docs/…underpop.free.fr
如果理解不是很清楚,可以去阅读Lua c语言源码去理解,就差不多了。
3、支持Async-Await语法糖
我们动态化方案是用typescript进行UI和业务的开发,所以Async-Await语法糖必须要实现。这个我在
zhuanlan.zhihu.com/p/362658136zhuanlan.zhihu.com
在编译阶段语法糖替换成实际的async await函数,用Lua Coroutine代替Generator来实现这两个函数。Coroutine在上面我们已经讲过了如何去实现。
4、Async函数中支持try-catch
普通函数支持try-catch很简单,在宿主语言(如我们的Lua-VM是在dart层实现的,那Dart就是宿主语言,同样的c语言版本的是在pcall里,前面的p=protect意思是保护调用)。但是要在Async函数里使用try-catch这事情就变得复杂了。先上代码:
typescript
private tryCatchInNormalFn = () => {
try {
console.log("Normal Function")
} catch (error) {
console.log(error)
}
}
编译器翻译成Lua后
self.tryCatchInNormalFn = function()
do
local ____try, ____error = pcall( function()
print("Normal Function")
end )
if not ____try then
print(____error)
end
end
end
可以发现try block执行代码,在Lua里已经放入到pcall里执行了。代码很简单,也是基于Ast语法树来实现的。上面是常规函数,下面看看异步函数的处理。
Typescript
private tryCatchInAsyncFn = async () => {
try {
await this.asyncFn(10, 10, 10)
} catch (error) {
console.log("Catch block")
} finally {
console.log("Finally block")
}
}
Lua
self.tryCatchInAsyncFn = __TS__Async( function()
do
local ____hasReturned, ____returnValue = __TS__Unpack(
__TS__Await(
__TS__AsyncTryCatch(
__TS__Async( function()
__TS__Await(
self:asyncFn(10, 10, 10) )
end ),
__TS__Async( function(____, ____error)
print("Catch block")
end ),
__TS__Async( function()
print("Finally block")
end )
)
)
)
if ____hasReturned == true then
return ____returnValue
end
end
end
)
为什么Try block、Catch block、Finally block外层要套个__TS__Async?按照es6语法规则,这三个函数块是可以加入await语句的,而且还会有多个。__TS__AsyncTryCatch外层套了一个__TS__Await,因为__TS_AsyncTryCatch是一个异步函数,并且tryCatchInAsyncFn可能会存在多个try catch块,为了保证执行顺序自上而下,必须加入await(__TS__Await)。这样在Typescript编写try-catch和在常规函数使用的是一样的,而这些都是编译器为我们做了这些工作,可以理解为预处理。再来看看__TS_AsyncTryCatch是怎么实现的。
function pCallAsync(promise)
return promise["then"](
promise, function(____, value)
return {error = nil, data = value}
end,
function(____, ____error)
return {error = ____error, data = nil}
end
)
end
AsyncTryCatch = __TS__Async(
function(self, tryFn, errFn, finallyFn)
local result = __TS__Await( pCallAsync( tryFn() ) )
local returnInfo = result.data
if result.error and errFn then
returnInfo = __TS__Await( errFn(_G, result.error) )
end
if finallyFn then
local finallyReturn = __TS__Await( finallyFn() )
if finallyReturn then
returnInfo = finallyReturn
end
end
if returnInfo then
local hasReturned = __TS__Unpack(returnInfo)
if hasReturned then
return returnInfo
end
end
end
)
function __TS__AsyncTryCatch(tryFn, errFn, finallyFn)
return AsyncTryCatch(_G, tryFn, errFn, finallyFn)
end
所以try catch在异步函数的处理要比在常规函数的处理复杂的多。