原文发表于2017-03-09 的 淘宝技术 微信公众号
https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ==&mid=2650400342&idx=1&sn=fa60e7c0240e60a8dddf3ed1bf065e55&chksm=83952e4eb4e2a758174bc29c9f00365d976ad1caf3de0ecf680ee15993faf6e538f83b7a8577&mpshare=1&scene=1&srcid=0102wZfFc4p48EHBpNrCpH9k&pass_ticket=abmlg4D9GfUaydfP1K0GJOIgxwaEa%2BCIvr3liUacTac%3D#rd
Weex是手机淘宝推出的轻量级跨平台UI解决方案,它在iOS、Android和H5三端提供了一致的渲染能力。 众所周知,Weex的用户有很多是前端开发者,一直以来他们都希望Weex能直接支持canvas标签,这样可以利用他们熟悉的canvas API来绘制图形,图表,制作动画。
为了给Weex提供canvas 2d/3d绘制和动画交互的能力,我们在Weex市场上推出了weex-gcanvas插件,帮助广大开发者实现使用canvas API的需求。
weex-gcanvas是一款支持Weex页面中 canvas 标签的weex插件,它使用OpenGL ES模拟canvas的操作,支持文字渲染,图片加载,图形绘制, 动画等canvas常用功能。
为了方便大家在Weex上绘制图表,我们为支付宝的G2可视化引擎的移动版G2-mobile提供了for Weex的封装weex-chart,可以直接在Weex上上运行G2-mobile的大部分示例demo。
以下是G2-moibile的示例demo在weex-gcanvas上的运行效果截图。
实现了canvas 2d的绘制功能后,这只是万里长征迈开了第一步。画静态图表只是weex-gcanvas的一个基本功能,我们的目标是让Weex满足canvas 2d/3d动画交互的需求,并且支持一些已有的成熟动画框架。
众所周知,我们的业务有大量的动画和小游戏的业务场景,之前Weex无法提供功能支撑。很多业务同学只能将页面全部用H5实现,甚至在同一页面中混用Weex和H5。开发起来不太方便,不同体系之间的状态同步也不好操作。
为此,我们和天猫的同学合作,尝试在weex-gcanvas上直接运行Hilo HTML5游戏引擎。在天猫同学的帮助下,我们为hilojs编写了for Weex的封装,直接将hilo的demo在weex上运行了起来。
这时,一个意想不到的情况出现了。
我们以典型的小鱼游动的case来测试Hilo的运行效率, 这个demo在PC和移动端浏览器上运行非常流畅,但是在weex-gcanvas上的帧率只有可怜的5,6帧。。。
而且,我使用的测试机 LG G5 拥有2016年上半年的旗舰级配置: 骁龙820/4G/2k屏幕。在这种比较高端的机型上都只有这个帧率,那广大的千元机型上的帧率就不敢想像了。
如果只能提供这样的性能的话,使用Weex的业务同学是肯定不会接受的。
G-Canvas还有另外一个版本的windvane插件,提供了浏览器之外的canvas绘制能力,而且据测试性能并不弱,跑各种游戏引擎demo和canvas benchmark的表现都是不错的。那么,问题到底出在哪里呢?
问题出在哪里(上)
优化的第一步
写过Weex module的同学们都知道,若想自定义一个module扩充Weex的功能,需要在module中写一些Native的方法然后暴露给JS端调用。
对于GcanvasModule来说,它的底层是使用OpenGL ES来实现canvas绘制操作的,举例来说,对于下面的canvas代码:
我们会在JS层将其转换为类似下面的私有协议的绘制命令,然后通过GcanvasModule暴露的render()方法发给底层渲染模块,命令中首位的"d"代表drawImage
现在问题来了,对于这张图片,我们会将其加载到OpenGL ES的纹理中,之后涉及到这张图的操作都使用纹理id,而不是文件的真实URL,因此底层渲染模块期待的drawImage命令其实是
在将文件URL直接放到绘制命令中有以下两个问题:
文件URL往往非常长,而且下发的频率很高,这增加了JS端和Native端callNative的通信开销
在GcanvasModule中,首先我们需要从多种命令混杂的长字符串中利用字符串匹配精确地找出d打头的drawImage命令;其次,找到后面跟的文件URL,根据文件URL查找到真实纹理id,然后将文件URL替换为真实纹理id,并生成正确的drawImage命令格式。大量的字符串匹配工作有相当大的开销。
基于以上的考虑,我们在图片被OpenGL ES加载完毕,纹理id生成之后,将纹理id和文件URL的映射callback到JS端记录下来; 以后每次下发drawImage命令就直接使用包含了纹理id的命令,从而将上述两个问题的影响降低到最低。
添加了这个修改之后, 帧率果然有了明显改善,从5帧左右提高到了十几帧。
优化的第二步
我发现gcanvas的辅助JS库中存在大量的调试信息。
将这些log全部注释掉之后,帧率又提高了几帧,达到二十帧左右了。
为什么区区几行log会造成几帧的帧率损失? 这个我们以后再提。
到现在为止, 典型的小鱼游动的Hilo demo能跑20帧左右了,看似性能提高明显,但是仍然是不可接受的。
把这个解决方案提供给用户,我们很难回答这样的问题:
**浏览器上可以跑满60帧,为什么Weex上只能跑二十多帧?**
既然现在的成果无法拿出手,我们只能继续优化。
问题出在哪里(下)
通过调试神器Weex-devtool查看log时,我无意中发现控制台中每秒钟打印数十次类似
的log,也不清楚是哪一个模块打印的。但是直觉告诉我这里肯定有问题。
在通读hilojs, Weex的JS framework和Weex Android SDK源码之后,我有了这样的发现。
以下用一个简化版的UML时序图简要说明在JS代码中设置一个setTimeout之后发生了哪些调用序列:
在使用Hilo的JS业务代码中,hilojs中通过这样的代码来开始动画循环:
即通过串行调用setTimeout来实现动画循环,其中interval是根据初始化时设定的期望帧率计算得到的。 若设定Hilo运行在60帧,则interval等于1000/60, 大约是每16ms运行一次循环。
我们都知道,Javascript标准并没有定义setTimeout和setInterval,这两个函数的的功能是由浏览器的BOM实现或者其它JS宿主环境如Node.js自行提供的。那Weex中的定时器又是怎么实现的呢?
在Weex的JS framework中,setTimeout的调用会被注册到Weex SDKEngine的timer模块处理,最终会通过callNative调用到Java层的WXTimerModule
而Weex sdk中,WXTimerModule设置定时器是通过Handler的sendMessageDelayed()来实现的。
当倒计时结束之后,WXTimerModule又通过WxBridgeManager的JNI native方法execJS 去请求v8去执行setTimeout的第一个参数,即JS回调。
也就是说,在Weex里面每调用一次setTimeout(func, delay),需要通过在v8中注册的C++全局函数callNative反射找到Java层的WXBridge,将调用转发到WXTimerModule; 然后借助Android Handler实现倒计时delay,倒计时结束后又通过JNI调用到v8中的execJS函数,请求v8帮忙执行func这个回调JS函数。。。
现在问题已经很明显了,若想跑到60帧,Hilo需要每16ms调用一次setTimeout, 导致JS频繁地从V8代码中反射调用Java实现的WXTimerModule; 同时也会频繁地从Java使用JNI调用v8,如此高频率的反射和JNI调用极大地影响了动画的性能。
基于现有的Weex TimerModule实现做小修小补看来是不可能从根本上解决帧率问题了,所以我们准备采用如下的解决方案:
解决思路
为weex_v8core提供c++的定时器实现,可以直接从JS中调用,无需跳转到Java代码, 功能和接口与现有的WXTimerModule保持一致。
设计目标
尽可能地不修改v8本身。首先,若修改了v8,即使达到了性能优化的目的,还需要做大量的回归测试,这个代价我们承受不了;其次,未来Weex sdk若是要升级自带的v8版本,我们还得跟着适配v8新版本。
修改代价尽可能地小。比如我们可以把Node.js的定时器实现移植到Android,然后集成到Weex体系,可是那么做会引入不必要的复杂性,体积和时间成本也不可接受。
最终,我们决定在当前的weex_v8core JNI代码中添加setTimeout的C++实现,可以被JS直接调用。当定时器到时之后,将待执行的JS函数加入v8的待执行task队列。
由于之前没有接触过v8,我通过各种资料恶补了一下v8的基本知识。我遇到的
困难主要是没有全面且权威的文档; 而且由于v8引擎升级频率很快,你找到的资料要么太老,要么太新,可能并不适用于Weex sdk当前使用的v8版本3.17.12。
在v8中注册全局函数
在v8中,可以注册一个C++函数到JS的Global对象供JS代码调用,所以我们首先编写了一个setTimeout()函数,通过v8的API注册到Global。
在setTimeoutWeex的代码中,为了测试整条链路是否OK,我首先简单地usleep若干毫秒来模拟定时器,然后直接执行JS层传下来的回调函数。
这里构造callJS的参数obj时遇到了麻烦,我们可以看一下Java版setTimeout的原型
也就是说v8拿到的是一个JS callback函数Id而不是函数本体,在这里找到JS callback函数本体和函数id的对应关系费了我很大功夫,有兴趣的同学可以读一下Weex JS framework的源码, 在此不再赘述。
最后,修改Weex的JS framework, 不去调用Timer的SetTimeout功能,而是去调用C++全局函数SetTimeoutWeex。
将以上调用流程厘清之后,我编写了简单的demo测试了setTimeout,虽然会阻塞住Weex WeexJSBridgeThread的执行,但是确实把整个流程走通了。
在v8的c++代码中注册全局函数供JS调用,以及在v8的c++代码中直接调用Weex JS framework的JS函数这两个问题解决之后,接下来我们就要想办法编写自己的setTimeout和setInterval实现了。
当JS端调用C++的setTimeoutWeex并设置好定时器之后,我们的需求是: ”运行在新线程的timer到时间后,执行回调将待执行的JS callback函数加入v8的待执行队列"。
这会带来一个多线程访问v8冲突的问题。
我们来看一下Weex的架构图:
从图中可以看出,在Weex执行时,Weex的JS framework在不停地通过callNative功能调用Weex sdk的各种module和component; 而Weex sdk则不停地使用callJS功能调用Weex JS framework中的各种JS函数; 而执行callJS的线程,就叫做WeexJSBridgeThread。
众所周知,v8以及其它JS引擎都是单线程的,所以我们需要新线程的timer到时间后,返回到WeexJSBridgeThread线程,然后执行回调,否则会引发v8 crash。
我陆续在Android上尝试了select,timerfd,libuv,boost::asio等方案, 始终没办法稳定地同时达到如下两个目标:
设置定时器时不阻塞WeexJSBridgeThread。
定时器到时之后, callback能回到WeexJSBridgeThread。
这时候,@永伯加入了对多线程下的定时器的研究。 在永伯的帮助下,我们对定时器做了如下的尝试。
第一版定时器
第一版定时器的实现,是通过create一个新的C++的线程,在新线程里直接sleep指定的timeout,然后callback调用方即可。(当然,也有其他的方式,比如通过C++11的std::condition_variable也可以实现类似sleep的功能)
定时器实现相对简单,难点在于如何能够回调回WeexJSBridgeThread上。Linux系线程间通信,比较常见的就是通过signal或者mutex,但是mutex显然不适合我们目前的应用场景,不可能让V8主线程阻塞,所以signal成为我们的不二选择。
但是,用什么signal实现线程间通信呢,显然,用系统的signal是不可取,因为会把系统signal对应的语义覆盖,可能会引起各种未知问题。但是天无绝人之路,系统预留了SIGRTMIN -- SIGRTMAX之间的信号供开发者使用,所以我们可以通过自定义信号实现线程间通信。当然,此处也有坑,SIGRTMIN附近的几个信号(SIGRTMIN +1 —- SIGRTMIN + 3)最好不要用,可能被系统thread自己留用了,如果自定义这几个信号,在注册signal的时候就会报错。
signal实现线程间通信的第二个问题是:怎么发送signal。可能对Linux系统熟悉的人,会第一时间想到kill,但是很可惜,kill的应用域是进程间通信,给线程发signal,还得交给他的小弟pthread_kill,pthread_kill可以给指定的线程发送signal。
方案已经成型,但是实现方案之后,发现该方案不大稳定,在频繁调用定时器时,有时候pthread_kill发出的signal不能被成功接收到。 Google一遍之后,发现这个问题确实存在,但是没有一个令人信服的答案,此处也欢迎广大同学不吝赐教,为什么有时候会收不到signal消息,请随时联系我们,@永伯、@凯冯。
基于以上的现象,使得我们不得不放弃该实现方案。才有了我们的第二版定时器。
第二版定时器
既然自定义的signal会有signal不能接收到的弊端,那系统预留的signal会不会也有类似问题呢,因此我们决定用posix提供的setitimer来实现定时器。
setitimer在定时器到期之后,由系统发出SIGALRM signal,SIGALRM signal是系统的原生signal。但是很不幸,这个版本的定时器,在长时间运行的时候,也出现了问题。SIGALRM signal有时会被子线程接收到,而不是WeexJSBridgeThread。究其原因,是因为WeexJSBridgeThread在处理其他任务,在signal发出的时候,主线程没有时间处理,只能交给子线程处理。这又违背了我们的初衷,所以该方案也只能夭折。
以上两版的定时器都是在iOS和macOS上测试过,可以长时间稳定运行,通过在网络上搜索,我们发现了大量关于Android pthread线程库的bug报告。 举例如下
因此,我怀疑Android pthread并不是标准的实现,永伯的Timer可能并没有问题,是Android自身导致该Timer无法稳定运行。此处仅仅是根据目前观察到的现象做出的猜测,如果有哪位同学也遇到过类似问题,或者有解决此类问题的经验,请联系@永伯、@凯冯。
既然“回调到指定线程”的规划解决不了,那我们只能接受这个现实,从不同的线程去调用v8了。
v8上的多线程调用
虽然一般概念里的JS引擎都是单线程的,但是并不是说它不能被多线程调用。请看以下的v8源码注释。
这一段注释的重点在这一句
**An isolate can be entered by at most one thread at any given time. The Locker/Unlocker API can be used to synchronize.**
其中,**Isolate**代表一个独立的v8 VM。对应一个或多个线程。但同一时刻只能被一个线程进入。
所有的Isolate彼此之间是完全隔离的,它们不能够有任何共享的资源。
所以,我们的定时器线程只能与WeexJSBridgeThread共享同一个Isolate,同时又做到互斥访问。
根据注释中的说明,从不同的线程调用一个v8实例来执行JS函数时,需要使用Locker API来同步,就是在合适的地方加上:
最后,我们又编写了第三版的定时器。
第三版定时器
最后的一版定时器,把线程间通信的代码全部去掉,只保留了定时器的基础实现。即通过create一个新的C++的线程,在新线程里直接sleep指定的timeout,然后callback调用方。在callback的实现里,调用v8::Locker,保证在调用JS函数时不会crash。(当然,我们也遇到了一些C++线程和JVM交互的问题,在Android某些版本的机器上会出现crash,因为C++线程并没有attach到JVM上)
Android不同版本上到处是坑。
事实证明,我们最后采取的方案性能不如前两版,但是可以长时间稳定运行。我使用三星note3运行同一个小鱼游动的Hilo demo,25个小时之后小鱼仍然在坚强地游动。
setTimeout的实现原理大致就是如此,我们还同步提供了setInterval,clearTimeout和clearInterval的C++实现并测试通过。
优化结果
我们使用的测试机器是LG G5和Moto Z,它们的配置都是骁龙820/4G内存/2k屏幕。
还是运行同一个小鱼游动的Hilo demo。
未优化版本的gcanvas JS库配合WxTimerModule, 在LG G5上只有5帧。 优化版本的gcanvas JS库配合WxTimerModule,在LG G5上可以跑20帧左右。 优化版本的gcanvas JS库配合C++定时器, 在LG G5上可以跑接近60帧。
下面是使用同一台手机Moto Z, 分别使用WxTimerModule和C++定时器渲染五十条鱼的对比结果。
未优化版本的gcanvas JS库配合WxTimerModule渲染50条鱼,0帧 优化版本的gcanvas JS库配合WxTimerModule渲染50条鱼,只能跑到5帧左右。
https://v.qq.com/x/page/n0380rlf4w6.html
优化版本的gcanvas JS库配合C++定时器渲染50条鱼,可以跑到35帧以上。
下面是运行canvas guimark1 benchmark,使用老Timer和新Timer的对比测试
新定时器带来的影响
首先,通过推出weex-gcanvas插件,我们提供了canvas 2d图形绘制和动画交互的能力;C++实现的新定时器的完成,则使得在Weex页面中直接编写高性能canvas动画由不可能成为可能。
其次,C++实现的setTimeout可能会对Weex的整体性能带来一定的提升,因为Virtual DOM Diff里也用到了定时器的功能, 他是先调用C++全局函数setTimeoutNative,然后反射调用Java层的同名方法。
可以尝试将两个版本的setTimeout统一起来,避免反射调用Java,从而提高Weex页面重绘的性能。
再次,当前我们对定时器所做的尝试,将为JavaScript动画的持续优化打下坚实的基础。例如,我们可能会在当前工作的基础上,尝试实现requestAnimationFrame。
未来的改进思路
在WeexJSBridgeThread线程和定时器线程上加锁毕竟还是对性能有影响,我们仍然会持续寻找在Android上能长时间稳定运行的“回调到指定线程”的定时器实现,避免加锁,也希望大家给出宝贵的建议。
若H5游戏运行在60帧,则意味着在当前的实现中,定时器每16ms就要启动和终止一个新的定时器线程,我们考虑在底层定时器中引入线程池,减少线程分配的开销。
同样的,为了回调到JS,定时器每16ms都会分配一些v8对象。这些对象存在时间非常短,会被很快回收。我们会尝试在定时器中预先分配一些v8对象并复用,减少频繁分配v8对象导致频繁GC的现象。
当前我们在Android上的工作是可以移植到iOS端的,同样可以提高iOS端的canvas动画性能。
console.log带来的性能影响
现在终于可以讲一讲为何console.log()对动画帧率的影响相当大了。
在Weex Android中,所有的console.log()实际上是通过v8调用到C++的nativeLog(),然后通过反射找到Java层的WXLogUtils类的d()方法,最终通过Java层的Log.d()将字符串打印出来。
在动画运行中,如果你每帧都通过console.log()打印信息,实际上会频繁的引入反射操作,从而影响了动画的性能。
那么为何要舍近求远,绕一大圈从Java层来打印出log呢?其实把上面的十几行代码换成下面这句一样可以完成打印log的功能。
这里是为了提供对weex-devtool debug功能的支持而特意这么写的。 如果开启了weex-devtool的debug功能,Java层的WXLogUtils会把log信息发给weex-devtool,从而在Weex开发者工具的控制台中同步打印手机端的Log信息。
按我个人理解,此处仍然是有优化空间的。
可以在nativeLog中做判断,只有在开启了debug时反射调用WXLogUtils,未开启时直接使用__android_log_print()
即使是使用反射操作,可以把首次调用FindClass, GetStaticMethodID,NewStringUTF的结果缓存下来,后续调用时直接CallStaticVoidMethod,减少反射的性能开销。
经过我的测试,注释掉反射调用Java层log的代码,全部直接使用__android_log_print后,动画帧率有明显的提高,50条小鱼游动的case
以LG G5为例
Log调用形式帧率
使用反射调用Java层log35到40帧
__android_log_print40到45帧
意外之喜
之前我们提过,我们的目标是让weex-gcanvas满足2d/3d动画交互的需求,在canvas 2d支持已经日趋成熟的背景下,3d功能的规划已经提上议事日程。
显而易见的是,3d动画对定时器的性能提出了更高的要求。因此,我们的C++ setTimeout和setInteval实现的诞生恰逢其时。
在新一代高性能定时器加持之下,weex-gcanvas的webGL支持已经呼之欲出, 敬请期待。