Android架構元件實現MVVM模式的應用
Image元件
weex的list元件和image元件非常容易出問題,企鵝電競本身又存在很多無限列表的weex頁面,list和image的組合爆發的記憶體問題,導致接入weex後app的記憶體問題導致的crash一直居高不下。
list元件問題
首先來說一下list,list對應的實現是WXListComponent,對應的view是BounceRecyclerView。RecyclerView應該大家都很熟悉,android support庫裡面提供的高效能的替代ListView的控制元件,它的存在就是為了列表中元素複用。本來weex使用了RecyclerView作為list的實現,是一件皆大歡喜的事情,但是RecyclerView中有一種使用不當的情況,會導致view不可複用。
下圖描述了RecyclerView的複用流程:

image
[ RecyclerView複用 ]
weex中的RecyclerView並沒有設定stableId,所以RecyclerView的所有複用都依賴於ViewHolder的ViewType,Weex的ViewType生成見下圖:
private int generateViewType(WXComponent component) {long id;try {id = Integer.parseInt(component.getRef());String type = component.getAttrs().getScope();if (!TextUtils.isEmpty(type)) {if (mRefToViewType == null) {mRefToViewType = new ArrayMap<>();}if (!mRefToViewType.containsKey(type)) {mRefToViewType.put(type, id);}id = mRefToViewType.get(type);}} catch (RuntimeException e) {WXLogUtils.eTag(TAG, e);id = RecyclerView.NO_ID;WXLogUtils.e(TAG, "getItemViewType: NO ID, this will crash the whole render system of WXListRecyclerView");}return (int) id; }
在沒有設定scope的情況下,viewHolder的component的ref就是viewType,即所有的ViewHolder都是不同且不可複用的,此時的RecyclerView也就退化成了一個稍微複雜一點的ScrollView。
如果設定了scope屬性,但你絕對想不到,scope本身也是一個坑。下面直接上程式碼:
// BasicListComponent.onBindViewHolder() public void onBindViewHolder(final ListBaseViewHolder holder, int position) {...if (holder.getComponent() != null && holder.getComponent() instanceof WXCell) {if(holder.isRecycled()) {holder.bindData(component);component.onRenderFinish(STATE_UI_FINISH);}...} } // ListBaseViewHolder.bindData() public void bindData(WXComponent component) {if (mComponent != null && mComponent.get() != null) {mComponent.get().bindData(component);isRecycled = false;} }
上面程式碼中,可以看到,使用了scope,當複用Holder時,會把需要展示的component的資料繫結到複用的component中。那麼問題來了,如果我不是隻是想修改部分屬性,而是需要改變component的層級關係呢?例如從a->b->c修改成a->c->b,那麼是不是隻能用不同的viewType或者是說變成下面的結構:a->b a->c b->b1 b->c1 c->c2 c->b2這樣的結構,但是view的例項多了,必然又會導致記憶體等各種問題。最為致命的問題是,createViewHolder的時候,傳給ViewHolder的component例項就是原件,而非拷貝,當bindData執行了以後,就等用於你複用的那個component的資料被修改了,當你再滑回去的時候,GG。
所以scope屬性基本不可用,留給我們的只有相當於scrollView的list。
還好,為了解決list這麼戳的效能,有了recyclerList,從vue的語法層,支援了模板的複用。但是坑爹的是,0.17 、 0.18 版本recyclerList都有這樣那樣的問題,重構同學覺得使用起來效率較低。0.19版本weex團隊fix了這些問題後,企鵝電競的前端同學也正在嘗試往recyclerList去切換。
image元件問題
相信android開發們都清楚,圖片的問題永遠是大問題。OOM、GC等效能問題,經常就是伴隨著圖片操作。
在0.17版本以前,WXImageView中bitmap的釋放都是在component的recycle中執行,0.17版本之後,在detach時也會執行recycle,但是WXImageView的recycle只是把ImageView的drawable設定為null,並沒有實際呼叫bitmap的recycle。
而企鵝電競在版本執行過程中發現,僅僅把bitmapDrawable設定為null,不去呼叫bitmap的recycle,部分機型上面的oom問題非常突出(這裡一直沒想明白,為啥這部分機型會出現這個問題,後面替換成fresco去管理就沒這個問題了)。當然,如果直接recycle bitmap,不設定bitmapDrawable,會直接導致crash。
回到企鵝電競本身,企鵝電競中的圖片管理使用了fresco,在接入weex以前,我們已經針對fresco載入圖片做了一系列優化,而且fresco本身已經包含了三級快取等功能。
接入weex後,首先想到的就是使用fresco的管線加載出bitmap後給WXImage使用。在這個過程中,先是遇到了對CloseableReference管理不恰當導致bitmap 還在使用卻被recycle 掉了,然後又遇到了沒有執行recycle導致bitmap無法釋放的坑。在長列表中,圖片無法釋放的問題被無限放大,經常出現快速滑動幾屏就oom的問題。而且隨著業務發展使用WXImage無法播放gif和webp圖片也成為瓶頸。
後續版本中,企鵝電競直接重寫了image和img標籤,使用Fresco的SimpleDraweeView替換了ImageView。該方案帶來的收益是bitmap不在需要自己管理,即oom問題和bitmap recycle之後導致的crash問題會大大減少,且fresco預設就支援gif和webp圖片。但是,這個方案也有個致命的問題:圓角。
圓角問題得先從fresco和weex各自的圓角方案說起。
weex圓角(盒模型-border): https://weex.apache.org/cn/wiki/common-styles.html#shi-li
fresco圓角: https://www.fresco-cn.org/docs/rounded-corners-and-circles.html
fresco圓角方案具體可見RoundedBitmapDrawable,RoundedColorDrawable,RoundedCornersDrawable這3個類,fresco圓角屬性的改變最終都只是修改這3個類的屬性,圓角也是基於draw時候修改canvas畫布內容實現,BtimapDrawable的裁減以及邊框的繪製都是在draw的時候繪製上去。
weex圓角方案具體可見ImageDrawable,實現方案為藉助android的PaintDrawable,通過設定shader實現bitmapDrawable的裁減,但是邊框的繪製則依賴於backgroundDrawable。
而且在fresco中,封裝了多層的drawable,較難修改drawabl的 draw的邏輯,而且邊框引數的設定也不如weex眾多樣化。
針對兩者的差異性,企鵝電競的解決方案是放棄fresco的圓角方案,通過fresco的後處理器裁減bitmap達到圓角的效果,邊框複用weex的background的方案。這個方案唯一的問題後處理器中必須建立一份新的bitmap,但是通過複用fresco的bitmapPool,並不會導致記憶體有過多的問題。
下面貼一下後處理器處理圓角的關鍵程式碼:
public CloseableReference<Bitmap> process(Bitmap sourceBitmap, PlatformBitmapFactory bitmapFactory) {CloseableReference<Bitmap> bitmapRef = null;try {if (mInnerImageView instanceof FrescoImageView && sourceBitmap != null && !sourceBitmap.isRecycled()&& sourceBitmap.getWidth() > 0 && sourceBitmap.getHeight() > 0) {...// 解決Bitmap繪製尺寸上限問題,比如:Bitmap too large to be uploaded into a texture (1302x9325, max=8192x8192)int maxSize = EGLUtil.getGLESTextureLimit();int resizeWidth = mWidth;int resizeHeight = mHeight;float ratio = 0;if (maxSize > 0 && (mWidth > maxSize || mHeight > maxSize)) {ratio = Math.max((float) mWidth / maxSize, (float) mHeight / maxSize);resizeWidth = (int) (mWidth / ratio);resizeHeight = (int) (mHeight / ratio);}float[] borderRadius = ((FrescoImageView) mInnerImageView).getBorderRadius();if (checkBorderRadiusValid(borderRadius)) {Drawable imageDrawable = ImageDrawable.createImageDrawable(sourceBitmap, mInnerImageView.getScaleType(), borderRadius, resizeWidth, resizeHeight, false);imageDrawable.setBounds(0, 0, resizeWidth, resizeHeight);CloseableReference<Bitmap> tmpBitmapRef = bitmapFactory.createBitmap(resizeWidth, resizeHeight, sourceBitmap.getConfig());Canvas canvas = new Canvas(tmpBitmapRef.get());imageDrawable.draw(canvas);bitmapRef = tmpBitmapRef;} else if (ratio != 0) {bitmapRef = bitmapFactory.createBitmap(sourceBitmap, 0, 0, resizeWidth, resizeHeight, sourceBitmap.getConfig());}}if (bitmapRef == null) {bitmapRef = bitmapFactory.createBitmap(sourceBitmap);}} catch (Throwable e) {WeexLog.e(TAG, "process image error:" + e.toString());}return bitmapRef;}
當list和image組合在一起的時候,由於weex的image並沒有recycle掉bitmap,而且沒有bitmapPool的使用,會導致長列表weex頁面佔用記憶體特別高。而替換為fresco的bitmap記憶體管理模式後,由於weex導致的記憶體crash問題佔比明顯從最開始版本的2%下降到了0.1%-0.2%。
預載入
當踩完大大小小的坑,緩解了記憶體和crash問題之後,企鵝電競在weex使用上又遇到了2大難題:
-
除錯困難
-
頁面載入慢
除錯困難
weex的頁面並不能給前端的開發同學絲滑的除錯體驗。最開始前端同學是採用終端日誌或者彈框的方式除錯(心疼前端同學就這麼學會了看android日誌),後面通過再三跟weex團隊的溝通,終於確定了weex和weex_debuger對應的版本,前端同學可以在chrome上面除錯weex頁面。
然而weex_deubgger並不是完美的解決方案,weex本身是jscore核心,而weex_debugger只是通過chrome除錯協議開了個服務,等同於使用的是chrome的核心,核心的不一致性無法保證除錯的準確性。連weex的開發同學自己都說了會遇到debug環境和正式環境結果不一致的情況。
解決方案也很簡單,那就是可以在mac的xcode和safari上面除錯。當時由於替換mac的成功過高,就將就使用了weex_debugger的方案,後面怎麼解決了相信大家心裡有數。
頁面載入速度慢
隨著企鵝電競業務的發展,很快前端同學就反饋過來,怎麼weex頁面開啟的速度這麼慢,這個菊花轉了這麼久。當時的內心是崩潰的,明明接入的時候好好的,一個頁面輕輕鬆鬆500-600ms就載入回來了,哪裡會有問題?
業務的發展速度永遠是你想象不到的,2個版本不到的時間,企鵝電競中的weex頁面輕輕鬆鬆從個位數突破到兩位數,bundle大小也輕輕鬆鬆從幾十kb突破到了上百kb,由此帶來的問題是開啟weex頁面後能明顯看到菊花轉動了,甚至開啟速度上還不如直出的web頁面。
首先從資料報表中發現,頁面開啟速度中,1s中有300-400ms是bundle從網路下載的時間,那是不是把這段時間省了,頁面有輕輕鬆鬆回到毫秒級別開啟速度了。
下圖展示了預載入的整體流程。

image
[ 預載入流程 ]
預載入方案上線後,頁面成功節省了將近200ms的耗時。20M的LRUCache大小也是參考了http cache的預設大小值,頁面開啟的預載入率在75%-80%。
預渲染
做了預載入之後,很快又發現,就算沒有網路請求,頁面開啟耗時還是超過了1s。這種情況下,現有的方案已經無法繼續優化頁面。這個時候突然有了個想法,weex本身是把前端的虛擬dom轉化為終端的各種view控制元件,那麼為什麼weex頁面的開啟會慢終端頁面開啟這麼多呢?
定義問題
解決問題之前,先來定義一下問題具體是什麼。針對渲染速度慢,企鵝電競對weex渲染的耗時定義如下:
· renderStart = 呼叫WXSdkInstance.render()的時間點
· httpFinish = httpAdapter請求回來之後呼叫WXSdkInstance.onHttpFinish()的時間點
· renderFinish = 回撥 IWXRenderListener.onRenderSuccess()的時間點
· 頁面開啟耗時 = renderFinish - renderStart
· 網路耗時 = httpFinish - renderStart
· 渲染耗時 = renderFinish - httpFinish
所以之前的預載入,已經優化了網路耗時,但是渲染耗時在頁面大了之後,依舊會有很大的效能問題。
為了揭開這個問題的本質,先來看一下weex整體的框架:

image
[ weex框架圖: ]
JSFrameWork
提供給前端的sdk,對vue的dom操作做了各種封裝,JSFrameWork單獨打包到apk包中。
JavaScriptCore
使用與safari的JavaScript引擎,專門處理JavaScript的虛擬機器,對應chrome的v8,功能可以大體聯想成java的jvm。
JSS
weex core的server端,封裝了對JavaScripteCore的呼叫,封裝了instance的沙盒,多程序實現中,JSS和JavaScriptCore的執行在另外的程序,防止JS執行異常導致主程序崩潰。
JSC
weex core的client端,作為WeexFrameWork和JSS橋接層,另外從0.18版本開始,cssLayout也下沉到了這一層。
WeexFrameWork
提供各種sdk介面的java呼叫,虛擬dom和Android控制元件樹的轉換,控制元件管理等。
瞭解完了weex框架,再把關注點轉移到js build之後生成的jsBundle,細心的同學肯定能夠發現,生成的jsBundle本質上就是一個js方法,所以weex頁面render的過程本質上是 執行一個js方法。
針對企鵝電競關注的遊戲首頁,對整個weex框架加了完整的打點,看到在nexus 6上面,對應的耗時以及整體流程如下圖:

image
[ weex執行流程以及耗時 ]
可以看到效能的熱點主要在執行js方法以及虛擬dom的執行這兩個關鍵步驟上,根據打點來看,單個js方法和單個虛擬dom的執行,耗時都很低。企鵝電競抓了多次打點,看到啟動時候執行js最慢的也僅僅是3ms,大多數執行都在0.1ms - 0 ms這個區間。但是,再快的執行耗時,也架不住量多,同樣以企鵝電競遊戲首頁為例,啟動的時候該頁面執行的js方法多大2000+個,這2000+個方法執行再加上方法排程的耗時,能成為效能熱點一點也不意外。而虛擬dom的執行也同理,單次執行經過weex團隊的優化,執行耗時基本在1ms-3ms之間,但是同樣的架不住量多以及執行緒排程的時間問題。
預渲染方案
瞭解RN的同學應該也知道,js方法的執行和虛擬dom的執行是這種框架的核心所在,想要撬動整個核心,基本上難度等同於重寫一個了。那麼剩下的方案也就只有一個:提前渲染。

image
[ 預渲染 ]
預渲染的方案修改了WeexFrameWork虛擬dom和Android控制元件樹轉換的部分,在預渲染時,不生成真正的component和view結構,用抽象出來的ComponentNode儲存虛擬dom的操作,並在RealRender的時候將node轉換成一個個component以及View。
這個方案的基本原理就是典型的以提前消費的空間換取時間,不去轉換真正的component和View原因是view在不同context中的不可複用性以及view本身會佔用大部分記憶體。
預渲染優化資料
記憶體消耗
提前渲染必然導致類記憶體的提前消耗,在huawei nove3上測試得到,預渲染遊戲首頁時的峰值記憶體會去到10M,但是在最後預渲染完成後GC會釋放這部分記憶體,最終常駐記憶體為0.3M。 真正渲染遊戲首頁的記憶體峰值會去到20M,最後的常駐記憶體為5.6M。
可以看到預渲染對常駐記憶體的消耗極少,但是由於虛擬dom執行,導致峰值記憶體偏高,在某些記憶體敏感場景下,還是會有一定風險。
頁面開啟耗時
實驗室中游戲首頁的正常載入資料為900ms(已經預載入,無網路耗時),經過預渲染,頁面開啟僅需要150ms。
現網資料:

image
[ 預渲染頁面開啟上報 ]
最後,來兩張優化前後的對比圖:

image
[ 預渲染: ]

image
[ 非預渲染: ]
“深度相容測試”現已對外,騰訊專家為您定製自動化測試指令碼,覆蓋應用核心場景,對上百款主流機型進行適配相容測試,提供詳細測試報告。****
另有客戶端效能測試,一網打盡FPS、CPU等基礎效能資料,詳細展示各類渲染資料,極速定位效能問題。
小編幫大家整理了一些資料,需要的加QQ群:4112676