原文發表于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支持已經呼之欲出, 敬請期待。