一、RelativeLayout的性能損耗

前面在討論動畫優化時,有提到低效率的動畫實現往往都讓代碼充滿了不確定性,比如requestLayout往往會造成一些意想不到的性能損耗。剛好最近在修改一個頁面時,發現了一個跟預期不一樣的bug,感覺可以跟大家分享一下。

我們都知道RelativeLayout在measure上是有性能損耗的,由於需要分別計運算元View在橫、豎兩個方向上的尺寸,在每次measure過程中,子View需要被measure兩次,所以在使用RelativeLayout時,一定要控制佈局的深度,減少嵌套的情況;但關於這個問題,我個人的認識一直停留在——由於View的measure是有重入保護的,對於在RelativeLayout中沒有requestLayout的子View,即使重複調用measure也會直接返回纔是。但在處理這個bug的過程中,發現實際情況跟想像中有所不同。

這個問題的場景是這樣的,在某個列表頁面,需要在頂部顯示一個item的描述,當描述過長時,需要使用marquee效果來滾動顯示;而底部有一個計數,隨著item數量變化,數字會隨之增加或減少。

寫一個demo的話,效果和對應的佈局文件如下,

圖1 demo 佈局效果圖和對應的佈局文件

這裡為了方便討論,把不必要的View都刪掉了,最少元素就是在RelativeLayout中有兩個TextView,一個大小固定,可以marquee(我們叫它headerText);另一個會通過setText改變自己的內容,且是wrap_content的(叫它changedText)。

而這個bug的現象如下,在headerText marquee的過程中,如果changedText中的內容發生變化,headerText的marquee就會被打斷。

由於在實際頁面上,右下角還有一個button,點擊button才會觸發changedText中計數變化,所以看到這個問題的第一反應是可能點擊按鈕時觸發了焦點變換,所以導致marquee停止了。

但實際調試了一下(堆棧如圖2),發現跟預期的調用並不相符——直接原因是headerText觸發了measure,才重置了marquee的狀態。

圖2 changedText marquee停止的堆棧

由於changedText的width是wrap_content,那麼修改changedText的內容會觸發requestLayout並不奇怪;但對於headerText,它在佈局中也沒有依賴changedText,為什麼也會跟著受到影響呢?

看到這裡,我們可能需要複習一下View System measure相關的一些源碼,

首先是measure的重入規則,我們知道在一個View Tree上,如果某一個View調用了requestLayout,並不會導致全部View都重新measure,一般是requestLayout所在鏈路上的View和在同一個ViewGroup中受到影響的View會被重新measure,實現上述邏輯的,主要是在View.java measure方法中的一段防重入的代碼。

圖3 measure中的重入判斷邏輯

從圖3可以看出,重入條件的主要是forceLayout和needsLayout兩個bool,按邏輯可以拆分成下面三個邏輯分支:

  • View調用requestLayout或forceLayout過,這種情況肯定需要重新measure;
  • targetSdk不高於M,此次的MeasureSpec和上次不同(specChanged),這種情況肯定也需要重新measure;
  • targetSdk高於M時,這裡新增了一個優化,如果View的MeasureSpec是EXACTLY的,且View的大小已經跟MesureSpec請求的相同了,那即使MeasureSpec發生了變化,也可以不用重新measure。

這裡先跑題一下,關於第三點優化的邏輯,剛看到這段邏輯的時候有點不太理解, 後來找到了這段代碼的原始提交:

https://android.googlesource.com/platform/frameworks/base/+/9cefbda11ee5308145d58b0b99ced0f66a0b1cf9%5E%21/#F0?

android.googlesource.com

以及後來為什麼要加targetSdk高於M這段判斷的原因:

https://android.googlesource.com/platform/frameworks/base/+/9d8a230fbd71ac57ef806326f15fa133ba125083?

android.googlesource.com

圖4 相關的ASOP提交記錄

從提交記錄可以看出,這裡對應的場景應該是,在某些Layout佈局中,我們可能需要先探測一下子View的大小,然後再決定View的真正大小(這種場景我們應該都不陌生)。那麼此時就可能需要對同一個子View measure兩次,第一次measure mode是AT_MOST或UNSPECIFIED,第二次當真正確定了View的大小時,纔是EXACTLY。雖然兩次MeasureSpec的mode不同,但size有可能是一樣的。有了這段代碼的支持,第二次measure可以直接返回,從而減少了一次measure。

這是一個非常細節的優化點,我們看一眼首次提交時間,嗯,是在4年前,向谷歌大佬低頭,惹不起惹不起。


跑題結束回到之前的討論,不考慮特殊情況,括重入邏輯就是——只要View自己沒有requestLayout或者再次measure時,MeasureSpec沒變,就不需要重新measure。

看到這裡答案已經呼之欲出,應該是RelativeLayout在兩次measure時,使用的MeasureSpec不同,打破了View measure的重入條件,導致在measure時,不相關的View也會被重複measure。

我們可以觀察一下源碼,首先是RelativeLayout橫向measure的邏輯,

圖5 RelativeLayout橫向measure child的邏輯

可以看到這裡對於子View的height MeasureSpec的處理比較奇怪,為什麼除了MATCH_PARENT,都處理成AT_MOST了?

縱向measure的MesureSpec計算邏輯如下,組裝MeasureSpec邏輯基本就跟我們熟悉的邏輯差不多了。

圖6 RelativeLayout縱向measure child的邏輯

而導致這種差異的原因是,在第一次measure的時候,我們並沒有掌握child在縱向上的任意數據(也就是沒有調用applyVerticalSizeRules這個函數,關於RelativeLayout measure的整個邏輯,這裡就不再贅述了),所以在measureChildHorizontal的過程中,實際是不關心vertical方向的尺寸的。

換句話說,圖5標註部分的邏輯實際也沒有什麼意義,但如果拼裝出的MeasureSpec恰好能跟圖6邏輯拼裝出的保持一致,那麼那些在縱向上沒有依賴的View就可能可以減少一次measure(後續requestLayout觸發的時候,也不會被重複measure)。

我們可以找到RelativeLayout這段邏輯比較原始的提交,

https://android.googlesource.com/platform/frameworks/base/+/f782e60efc09f210643432f31b4c18026d7716d6%5E%21/#F0?

android.googlesource.com

圖7 RelativeLayout比較早期的邏輯

從最初的邏輯可以看到,第一次measure確實是不必關心vertical方向的尺寸的,在早期的邏輯中,對height MeasureSpec會一視同仁,全部設置成UNSPECIFIED。只能說,可能最初的設計就是這樣,不排除google也並沒有考慮減少重複measure的問題;另外,從上面的提交我們也可以看出,RelativeLayout第一次measure,對vertical方向的measure也不是完全沒有作用,上面的提交就是基於修改bug才引入的(這裡想像不出來對應的場景是什麼,也不排除是早期Android版本纔有的問題)。

不過至少從我們的分析,結合View.java measure()中的重入保護邏輯可以看出,如果在第一次measure是vertical方向的MeasureSpec能恰好跟第二次measure一致,那麼就可以減少不必要的measure,特別對於那些在佈局中不存在依賴的child,實際ConstraintLayout就是用的這種思路。而現存的這種邏輯,就會導致RelativeLayout measure的過程有一定的隨機性,就像這個問題一樣,一旦子View的MeasureSpec定義的不合適,就會產生被重複measure的問題。

所以我們還是抱著試一試的心態,建了一個google issue(issue id:117577891,歡迎跟帖版聊),萬一谷歌大佬回復了呢(雖然沒有搜索到類似的issue,不過應該早就是已知問題了)。

二、問題的修復

在源碼的海洋中帶薪遨遊了一番,我們還是得回歸現實繼續修bug。從前面分析的結論可以看到,這裡主要是headerText在measure過程中,重入條件被打破了,導致每次有其他child requestLayout,headerText都會跟著一起重新measure,觸發onMeasure,從而重置了marquee的狀態。所以修改方法主要是保證headerText在被measure時,重入條件不要被隨便打破就可以了。

主要改法有兩種,一種是調整一下headerText的height參數來配合RelativeLayout的源碼,比如把headerText的height改為wrap_content就可以規避重複measure的問題。

但這種改法並不是很理想,首先它依賴了RelativeLayout內部的邏輯,如果google後續調整了RelativeLayout生成MeasureSpec的邏輯,那該問題很可能故態復萌;其次wrap_content也不太符合我們的需求,我們可能還需要增加一些View和邏輯,來處理視覺上的差異;最後這樣可維護性也很差,後續其他同事修改這個頁面的時候,很可能修改了headerText的參數,那問題又會復現。

而第二種改法是,我們可以嵌套一層ViewGroup,來保證headerText在佈局上的穩定。比如在headerText上包一層FrameLayout(如圖8)。

圖8 調整後的佈局

這種改法的原理也不複雜。調整佈局之後,RelativeLayout佈局依然不穩定,FrameLayout還是有可能會被measure兩次,但是在FrameLayout繼續measure headerText的時候,只要保證它傳遞的MeasureSpec是穩定的,headerText就可以觸發重入保護,onMeasure不會被調用,marquee也就不會被打斷了。

而如何保證FrameLayout傳遞的MeasureSpec是穩定的呢?因為FrameLayout拼裝MeasureSpec的邏輯是直接使用的ViewGroup.java中經典的拼裝方式——靜態函數getChildMeasureSpec(),拼裝規則如下。

表1 ViewGroup默認的MeasureSpec拼裝邏輯

從上圖可以看出,只要保證子View是EXACTLY,無論ViewGroup本身的MeasureSpec是什麼,都不會影響到子View——通過這種方式,我們就可以利用FrameLayout給headerText創造一個穩定的佈局了。

唯一比較傷心的是,這樣修改會導致佈局增加一層,我們也只能安慰自己——FrameLayout不能算佈局嵌套,FrameLayout的事,能算嵌套麼?

當然,還有更多可能的修改方式,比如我們可以自己重載headerText的onMeasure,在其中插入控制重入的邏輯,防止marquee被打斷;或者在父ViewGroup中重載requestLayout,適時的打斷requestLayout的調用鏈。更多的改法可能就要根據實際應用場景來選擇了,但整體的思路基本都是類似的——把佈局中不穩定的部分隔離開,從而減少measure和layout帶來的損耗。

三、穩定的佈局

從前文的分析可以發現,不穩定的佈局除了容易導致一些出乎意料的視覺bug,更多的時候會引入性能問題,而後者往往是很難察覺的。以前文討論的佈局為例,如果我們在實時刷新changedText時,RelativeLayout中同時存在一些動畫的元素,那麼丟幀幾乎不可避免。

構建一個穩定的佈局,大致也有兩個方向,一方面就是像本文討論的一樣,儘可能構造一個相對穩定的佈局,通過合理的佈局方式,把動態、不穩定的內容和靜態、穩定的內容隔離開,減少每次measure、layout帶來的性能損耗,這一點在之前動畫優化的文章中也有所提及;而另一方面就是從源頭出發,減少或合理控制佈局中動態的內容,減少觸發measure、layout的次數,常規來說基本就是減少requestLayout的次數。

關於第二點,回到本文討論的問題,為什麼setText會觸發requestLayout?這裡毫無疑問是由於changedText的width是wrap_ content。那麼如果changedText的width不是wrap_content就一定可以避免觸發requestLayout麼?還是直接從源碼中尋找答案吧。

圖9 TextView setText相關的源碼

從源碼中可以看出,除了要求width不能是wrap_content,對height的變化也有要求,同時Ellipsize不能是marquee。

width不能是wrap_content比較好理解,如果width是EXACTLY或者AT_MOST,那麼在第一次measure之後,TextView的寬度實際就是固定的了,後續調用setText,實際只是根據available width結合Ellipsize對文字進行截取就可以了。另外,也可以注意到,這裡有另一個條件mMaxWidth == mMinWidth,在這種情況下,如果設置了maxWidth和minWidth,且兩者相等,TextView的寬度實際也是固定的。

高度不能發生變化,在這裡應該是對應富文本的情況,如果在富文本中包含了影響高度的Span,且當前height是wrap_content,那肯定也是需要重新requestLayout的。同時也可以看到,setText對高度是不敏感的(畢竟文字是橫向展示的)。

setText作為一個最常見的會隱式觸發requestLayout的API,對佈局穩定充滿了不可預知的影響。而在實際使用場景中,很多時候我們都可以用match_parent來替代wrap_content(因為一般我們都會有個container來承載TextView),所以又回到了前面的觀點,優化佈局往往只是舉手之勞,謹慎使用TextView wrap_content。

四、最後的總結

首先,requestLayout果然會造成很多意想不到的性能損耗,這個問題如果不是影響到了TextView的marquee,真的很容易被忽略。同時,理論上參考表1的邏輯,View的MeasureSpec如果都是EXACTLY,理論上應該是穩定的,但實際在很多佈局中都是不能保證的。因此我們也藉機分析了一下RelativeLayout的源碼,明確了RelativeLayout在measure時會產生性能損耗的原因。

其次,我們在寫自定義ViewGroup或者相對複雜的佈局時,除了完成需求,也可以考慮一下佈局穩定的問題,優化佈局性能可能就在舉手之勞間;同時,我們分析了一個會隱式觸發requestLayout的API,我們會傾向於對TextView width設置match_parent,而不是wrap_content,來增加布局的穩定性。

另外,利用好ASOP的開源特性,畢竟google對於源碼的認識,一般是要超前於普通開發者數年的。從源碼和源碼的提交歷史中追溯邏輯,會讓我們事半功倍。

最後,從前面討論的題外話我們可以看出,google對代碼細節的把控和優化是超乎想像的,既然我們都已經「站在巨人的肩膀上」了,為何不能跟著更進一步呢。

推薦閱讀:

相關文章