深入探索Android佈局優化(上)

前言

成爲一名優秀的Android開發,需要一份完備的知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

Android的繪製優化其實可以分爲兩個部分,即佈局(UI)優化和卡頓優化,而佈局優化的核心問題就是要解決因佈局渲染性能不佳而導致應用卡頓的問題,所以它可以認爲是卡頓優化的一個子集。對於Android開發來說,寫佈局可以說是一個比較簡單的工作,但是如果想將寫的每一個佈局的渲染性能提升到比較好的程度,要付出的努力是要遠遠超過寫佈局所付出的。由於佈局優化這一主題包含的內容太多,因此,筆者將它分爲了上、下兩篇,本篇,即爲深入探索Android佈局優化的上篇。本篇包含的主要內容如下所示:

  • 1、繪製原理
  • 2、屏幕適配
  • 3、優化工具
  • 4、佈局加載原理
  • 5、獲取界面佈局耗時

說到Android的佈局繪製,那麼我們就不得不先從佈局的繪製原理開始說起。

一、繪製原理

Android的繪製實現主要是藉助CPU與GPU結合刷新機制共同完成的。

1、CPU與GPU

  • CPU負責計算顯示內容,包括Measure、Layout、Record、Execute等操作。在UI繪製上的缺陷在於容易顯示重複的視圖組件,這樣不僅帶來重複的計算操作,而且會佔用額外的GPU資源。
  • GPU負責柵格化(用於將UI元素繪製到屏幕上,即將UI組件拆分到不同的像素上顯示)。

這裏舉兩個栗子來講解一些CPU和GPU的作用:

  • 1、文字的顯示首先經過CPU換算成紋理,然後再傳給GPU進行渲染。
  • 2、而圖片的顯示首先是經過CPU的計算,然後加載到內存當中,最後再傳給GPU進行渲染。

那麼,軟件繪製和硬件繪製有什麼區別呢?我們先看看下圖:

 

image

 

 

這裏軟件繪製使用的是Skia庫(一款在低端設備如手機上呈現高質量的 2D 圖形的 跨平臺圖形框架)進行繪製的,而硬件繪製本質上是使用的OpenGl ES接口去利用GPU進行繪製的。OpenGL是一種跨平臺的圖形API,它爲2D/3D圖形處理硬件指定了標準的軟件接口。而OpenGL ES是用於嵌入式設備的,它是OpenGL規範的一種形式,也可稱爲其子集。

並且,由於OpenGl ES系統版本的限制,有很多 繪製API 都有相應的 Android API level 的限制,此外,在Android 7.0 把 OpenGL ES 升級到最新的 3.2 版本的時候,還添加了對Vulkan(一套適用於高性能 3D 圖形的低開銷、跨平臺 API)的支持。Vulan作爲下一代圖形API以及OpenGL的繼承者,它的優勢在於大幅優化了CPU上圖形驅動相關的性能。

2、Android 圖形系統的整體架構

Android官方的架構圖如下:

 

image

 

 

爲了比較好的描述它們之間的作用,我們可以把應用程序圖形渲染過程當作一次繪畫過程,那麼繪畫過程中 Android 的各個圖形組件的作用分別如下:

  • 畫筆:Skia 或者 OpenGL。我們可以用 Skia去繪製 2D 圖形,也可以用 OpenGL 去繪製 2D/3D 圖形。
  • 畫紙:Surface。所有的元素都在 Surface 這張畫紙上進行繪製和渲染。在 Android 中,Window 是 View 的容器,每個窗口都會關聯一個 Surface。而 WindowManager 則負責管理這些窗口,並且把它們的數據傳遞給 SurfaceFlinger。
  • 畫板:Graphic Buffer。Graphic Buffer 緩衝用於應用程序圖形的繪製,在 Android 4.1 之前使用的是雙緩衝機制,而在 Android 4.1 之後使用的是三緩衝機制。
  • 顯示:SurfaceFlinger。它將 WindowManager 提供的所有 Surface,通過硬件合成器 Hardware Composer 合成並輸出到顯示屏。

在瞭解完Android圖形系統的整體架構之後,我們還需要了解下Android系統的顯示原理,關於這塊內容可以參考我之前寫的Android性能優化之繪製優化的Android系統顯示原理一節。

3、RenderThread

在Android系統的顯示過程中,雖然我們利用了GPU的圖形高性能計算的能力,但是從計算Display到通過GPU繪製到Frame Buffer都在UI線程中完成,此時如果能讓GPU在不同的線程中進行繪製渲染圖形,那麼繪製將會更加地流暢。

於是,在Android 5.0之後,引入了RenderNode和RenderThread的概念,它們的作用如下:

  • RenderNode:進一步封裝了Display和某些View的屬性。
  • RenderThread:渲染線程,負責執行所有的OpenGl命令,其中的RenderNode保存有渲染幀的所有信息,能在主線程有耗時操作的前提下保證動畫流暢。

CPU將數據同步給GPU之後,通常不會阻塞等待RenderThread去利用GPU去渲染完視圖,而是通知結束之後就返回。加入ReaderThread之後的整個顯示調用流程圖如下圖所示:

 

image

 

 

在Android 6.0之後,其在adb shell dumpsys gxinfo命令中添加了更加詳細的信息,在優化工具一節中我將詳細分析下它的使用。

在Android 7.0之後,對HWUI進行了重構,它是用於2D硬件繪圖並負責硬件加速的主要模塊,其使用了OpenGl ES來進行GPU硬件繪圖。此外,Android 7.0還支持了Vulkan,並且,Vulkan 1.1在Android 被引入。

硬件加速存在哪些問題?

我們都知道,硬件加速的原理就是將CPU不擅長的圖形計算轉換成GPU專用指令。

  • 1、其中的OpenGl API調用和Graphic Buffer緩衝區至少會佔用幾MB以上的內存,內存消耗較大
  • 2、有些OpenGl的繪製API還沒有支持,特別是比較低的Android系統版本,並且由於Android每一個版本都會對渲染模塊進行一些重構,導致了在硬件加速繪製過程中會出現一些不可預知的Bug。如在Android 5.0~7.0機型上出現的libhwui.so崩潰問題,需要使用inline Hook、GOT Hook等native調試手段去進行分析定位,可能的原因是ReaderThread與UI線程的sync同步過程出現了差錯,而這種情況一般都是有多個相同的視圖繪製而導致的,比如View的複用、多個動畫同時播放

4、刷新機制

16ms發出VSync信號觸發UI渲染,大多數的Android設備屏幕刷新頻率爲60HZ,如果16ms內不能完成渲染過程,則會產生掉幀現象。

二、屏幕適配

我們都知道,Android手機屏幕的差異化導致了嚴重的碎片化問題,並且屏幕材質也是用戶比較關注的一個重要因素。

首先,我們來了解下主流Android屏幕材質,目前主要有兩類:

  • LCD(Liquid Crystal Display):液晶顯示器。
  • OLED(Organic Light-Emitting Diode ):有機發光二極管。

早在20世紀60年代,隨着半導體集成電路的發展,美國人成功研發出了第一塊液晶顯示屏LCD,而現在大部分最新的高端機使用的都是OLED材質,這是因爲相比於LCD屏幕,OLED屏幕在色彩、可彎曲程度、厚度和耗電等方面都有一定的優勢。正因爲如此,現在主流的全面屏、曲面屏與未來的柔性摺疊屏,使用的幾乎都是 OLED 材質。當前,好的材質,它的成本也必然會比較昂貴。

1、OLED 屏幕和 LCD 屏幕的區別

如果要明白OLED 屏幕和LCD屏幕的區別,需要了解它們的運行原理,下面,我將分別進行講解。

屏幕的成像原理

屏幕由無數個點組成,並且,每個點由紅綠藍三個子像素組成,每個像素點通過調節紅綠藍子像素的顏色配比來顯示不同的顏色,最終所有的像素點就會形成具體的畫面。

LCD背光源與OLED自發光

下面,我們來看下LCD和OLED的總體結構圖,如下所示:

 

image

 

 

LCD的發光原理主要在於背光層Back-light,它通常都會由大量的LED背光燈組成以用於顯示白光,之後,爲了顯示出彩色,在其上面加了一層有顏色的薄膜,白色的背光穿透了有顏色的薄膜後就可以顯示出彩色了。但是,爲了實現調整紅綠藍光的比例,需要在背光層和顏色薄膜之間加入一個控制閥門,即液晶層liquid crystal,它可以通過改變電壓的大小來控制開合的程度,開合大則光多,開合小則光少

對於OLED來說,它不需要LCD屏幕的背光層和用於控制出光量的液晶層,它就像一個有着無數個小的彩色燈泡組成的屏幕,只需要給它通電就能發光。

LCD的致命缺陷

它的液晶層不能完全關合,如果LCD顯示黑色,會有部分光穿過顏色層,所以LCD的黑色實際上是白色和黑色混合而成的灰色。而OLED不一樣,OLED顯示黑色的時候可以直接關閉區域的像素點。

此外,由於背光層的存在,所以LCD顯示器的背光非常容易從屏幕與邊框之間的縫隙泄漏出去,即會產生顯示器漏光現象。

OLED屏幕的優勢

  • 1、由於沒有有背光層和液晶層的存在,所以它的厚度更薄,其彎曲程度可以達到180%
  • 2、對比度(白色比黑色的比值)更高,使其畫面顏色越濃;相較於LCD來說,OLED是油畫,色彩純而細膩,而LCD是水彩筆畫,色彩朦朧且淡
  • 3、OLED每個像素點都是獨立的,所以OLED可以單獨點亮某些像素點,即能實現單獨點亮。而LCD只能控制整個背光層的開關。並且,由於OLED單獨點亮的功能,使其耗電程度大大降低
  • 4、OLED的屏幕響應時間很快,不會造成畫面殘留以致造成視覺上的拖影現象。而LCD則會有嚴重的拖影現象。

OLED屏幕的劣勢

  • 1、由於OLED是有機材料,導致其壽命是不如LCD的 有機材料的。並且,由於OLED單獨點亮的功能,會使每個像素點工作的時間不一樣,這樣,在屏幕老化時就會導致色彩顯示不均勻,即產生燒屏現象。
  • 2、由於OLED就不能採取控制電壓的方式去調整亮度,所以目前只能通過不斷的開關開關開關去進行調光。
  • 3、OLED的屏幕像素點排列方式不如LCD的緊湊,所以在分辨率相同的情況下,OLED的屏幕是不如LCD清楚的。即OLED的像素密度較低

2、屏幕適配方案

我們都知道,Android 的 系統碎片化、機型以及屏幕尺寸碎片化、屏幕分辨率碎片化非常地嚴重。所以,一個好的屏幕適配方案是很重要的。接下來,我將介紹目前主流的屏幕適配方案。

1、最原始的Android適配方案:dp + 自適應佈局或weight比例佈局

首先,我們來回顧一下px、dp、dpi、ppi、density等概念:

  • px:像素點,px = density * dp。
  • ppi:像素密度,每英寸所包含的像素數目,屏幕物理參數,不可調整,dpi沒有人爲調整時 = ppi。
  • dpi:像素密度,在系統軟件上指定的單位尺寸的像素數量,可人爲調整,dpi沒有人爲調整時 = ppi。
  • dp:density-independent pixels,即密度無關像素,基於屏幕物理分辨率的一個抽象的單位,以dp爲尺寸單位的控件,在不同分辨率和尺寸的手機上代表了不同的真實像素,比如在分辨率較低的手機中,可能1dp = 1px,而在分辨率較高的手機中,可能1dp=2px,這樣的話,一個64*64dp的控件,在不同的手機中就能表現出差不多的大小了,px = dp * (dpi / 160)。
  • denstiy:密度,屏幕上每平方英寸所包含的像素點個數,density = dpi / 160。

通常情況下,我們只需要使用dp + 自適應佈局(如鴻神的AutoLayout、ConstraintLayout等等)或weight比例佈局即可基本解決碎片化問題,當然,這種方式也存在一些問題,比如dpi和ppi的差異所導致在同一分辨率手機上控件大小的不同

2、寬高限定符適配方案

它就是窮舉市面上所有的Android手機的寬高像素值,通過設立一個基準的分辨率,其他分辨率都根據這個基準分辨率來計算,在不同的尺寸文件夾內部,根據該尺寸編寫對應的dimens文件,如下圖所示:

 

image

 

 

比如以480x320爲基準分辨率:

  • 寬度爲320,將任何分辨率的寬度整分爲320份,取值爲x1-x320。
  • 高度爲480,將任何分辨率的高度整分爲480份,取值爲y1-y480。

那麼對於800*480的分辨率的dimens文件來說:

  • x1=(480/320)*1=1.5px
  • x2=(480/320)*2=3px

 

image

 

 

此時,如果UI設計界面使用的就是基準分辨率,那麼我們就可以按照設計稿上的尺寸填寫相對應的dimens去引用,而當APP運行在不同分辨率的手機中時,系統會根據這些dimens去引用該分辨率對應的文件夾下面去尋找對應的值。但是這個方案由一個缺點,就是無法做到向下兼容去使用更小的dimens,比如說800x480的手機就一定要找到800x480的限定符,否則就只能用統一默認的dimens文件了。

3、UI適配框架AndroidAutoLayout的適配方案

因寬高限定符方案的啓發,鴻神出品了一款能使用UI適配更加開發高效和適配精準的項目。

項目地址

基本使用步驟如下:

第一步:在你的項目的AndroidManifest中註明你的設計稿的尺寸:

<meta-data android:name="design_width" android:value="768">
</meta-data>
<meta-data android:name="design_height" android:value="1280">
</meta-data>

第二步:讓你的Activity繼承自AutoLayoutActivity。如果你不希望繼承AutoLayoutActivity,可以在編寫佈局文件時,直接使用AutoLinearLayout、Auto***等適配佈局即可。

接下來,直接在佈局文件裏面使用具體的像素值就可以了,因爲在APP運行時,AndroidAutoLayout會幫助我們根據不同手機的具體尺寸按比例伸縮。

AndroidAutoLayout在寬高限定符適配的基礎上,解決了其dimens不能向下兼容的問題,但是它在運行時會在onMeasure裏面對dimens去做變換,所以對於自定義控件或者某些特定的控件需要進行單獨適配;並且,整個UI的適配過程都是由框架完成的,以後想替換成別的UI適配方案成本會比較高,而且,不幸的是,項目已經停止維護了。

4、smallestWidth適配方案(sw限定符適配)

smallestWidth即最小寬度,系統會根據當前設備屏幕的 最小寬度 來匹配 values-swdp。

我們都知道,移動設備都是允許屏幕可以旋轉的,當屏幕旋轉時,屏幕的高寬就會互換,加上 最小 這兩個字,是因爲這個方案是不區分屏幕方向的,它只會把屏幕的高度和寬度中值最小的一方認爲是 最小寬度。

並且它跟寬高限定符適配原理上是一樣,都是系統通過特定的規則來選擇對應的文件。它與AndroidAutoLayout一樣,同樣解決了其dimens不能向下兼容的問題,如果該屏幕的最小寬度是360dp,但是項目中沒有values-sw360dp文件夾的話,它就可能找到values-sw320dp這個文件夾,其尺寸規則命名如下圖所示:

 

image

 

 

假如加入我們的設計稿的像素寬度是375,那麼其對應的values-sw360dp和values-sw400dp寬度如下所示:

 

image

 

 

 

image

 

 

smallestWidth的適配機制由系統保證,我們只需要針對這套規則生成對應的資源文件即可,即使對應的smallestWidth值沒有找到完全對應的資源文件,它也能向下兼容,尋找最接近的資源文件。雖然多個dimens文件可能導致apk變大,但是其增加大小範圍也只是在300kb-800kb這個區間,這還是可以接受的。這套方案唯一的變數就是選擇需要適配哪些最小寬度限定符的文件,如果您生成的 values-swdp 與設備實際的 最小寬度 差別不大,那誤差也就在能接受的範圍內,如果差別很大,那效果就會很差。最後,總結一下這套方案的優缺點:

優點:

  • 1、穩定且無性能損耗。
  • 2、可通過選擇需要哪些最小寬度限定符文件去控制適配範圍。
  • 3、在自動生成values-sw的插件基礎下,學習成本較低。

插件地址爲自動生成values-sw的項目代碼。生成需要的values-swdp文件夾的步驟如下:

  • 1、clone該項目到本地,以Android項目打開。
  • 2、DimenTypes文件中寫入你希望適配的sw尺寸,默認的這些尺寸能夠覆蓋幾乎所有手機適配需求。
  • 3、DimenGenerator文件中填寫設計稿的尺寸(DESIGN_WIDTH是設計稿寬度,DESIGN_HEIGHT是設計稿高度)。
  • 4、執行lib module中的DimenGenerator.main()方法,當前地址下會生成相應的適配文件,把相應的文件連帶文件夾拷貝到正在開發的項目中。

缺點:

  • 1、侵入性高,後續切換其他屏幕適配方案需修改大量 dimens 引用。
  • 2、覆蓋更多不同屏幕的機型需要生成更多的資源文件,使APK體積變大。
  • 3、不能自動支持橫豎屏切換時的適配,如要支持需使用 values-wdp 或 屏幕方向限定符 再生成一套資源文件,又使APK體積變大。

如果想讓屏幕寬度隨着屏幕的旋轉而做出改變該怎麼辦呢?

此時根據 values-wdp (去掉 sw 中的 s) 去生成一套資源文件即可。

如果想區分屏幕的方向來做適配該怎麼辦呢?

去根據 屏幕方向限定符 生成一套資源文件,後綴加上 -land 或 -port 即可,如:values-sw360dp-land (最小寬度 360 dp 橫向),values-sw400dp-port (最小寬度 720 dp 縱向)。

注意:

如果UI設計上明顯更適合使用wrap_content,match_parent,layout_weight等,我們就要毫不猶豫的使用,畢竟,上述都是僅僅針對不得不使用固定寬高的情況,我相信基礎的UI適配知識大部分開發者還是具備的。如果不具備的話,請看下方:

5、今日頭條適配方案

它的原理是根據屏幕的寬度或高度動態調整每個設備的 density (每 dp 佔當前設備屏幕多少像素),通過修改density值的方式,強行把所有不同尺寸分辨率的手機的寬度dp值改成一個統一的值,這樣就可以解決所有的適配問題。其對應的重要公式如下:

當前設備屏幕總寬度(單位爲像素)/  設計圖總寬度(單位爲 dp) = density

今日頭條適配方案默認項目中只能以高或寬中的一個作爲基準來進行適配,並不像 AndroidAutoLayout 一樣,高以高爲基準,寬以寬爲基準,來同時進行適配,爲什麼?

因爲,現在中國大部分市面上的 Android 設備的屏幕高寬比都不一致,特別是現在的全面屏、劉海屏、彈性摺疊屏,使這個問題更加嚴重,不同廠商推出的手機的屏幕高寬比都可能不一致。所以,我們只能以高或寬其中的一個作爲基準進行適配,以此避免佈局在高寬比不一致的屏幕上出現變形。

它有以下優勢:

  • 1、使用成本低,操作簡單,使用該方案後在頁面佈局時不需要額外的代碼和操作。
  • 2、侵入性低,和項目完全解耦,在項目佈局時不會依賴哪怕一行該方案的代碼,而且使用的還是 Android 官方的 API,意味着當你遇到什麼問題無法解決,想切換爲其他屏幕適配方案時,基本不需要更改之前的代碼,整個切換過程幾乎在瞬間完成,試錯成本接近於 0。
  • 3、可適配三方庫的控件和系統的控件(不止是是 Activity 和 Fragment,Dialog、Toast 等所有系統控件都可以適配),由於修改的 density 在整個項目中是全局的,所以只要一次修改,項目中的所有地方都會受益。
  • 4、不會有任何性能的損耗。
  • 5、不涉及私有API。

它的缺點如下所示:

  • 1、適配範圍不可控,只能一刀切的將整個項目進行適配,這種將所有控件都強行使用我們項目自身的設計圖尺寸進行適配的方案會有問題:當某個系統控件或三方庫控件的設計圖尺寸和和我們項目自身的設計圖尺寸差距越大時,該系統控件或三方庫控件的適配效果就越差。比較好的解決方案就是按 Activity 爲單位,取消當前 Activity 的適配效果,改用其他的適配方案。
  • 2、對舊項目的UI適配兼容性不夠。

注意:

千萬不要在此方案上使用smallestWidth適配方案中直接填寫設計圖上標註的 px 值的做法,這樣會使項目強耦合於這個方案,後續切換其它方案都不得不將所有的 layout 文件都改一遍。

這裏推薦一下JessYanCoding的AndroidAutoSize項目,用法如下:

1、首先在項目的build.gradle中添加該庫的依賴:

 implementation 'me.jessyan:autosize:1.1.2'

2、接着 AndroidManifest 中填寫全局設計圖尺寸 (單位 dp),如果使用副單位,則可以直接填寫像素尺寸,不需要再將像素轉化爲 dp:

<manifest>
    <application>            
        <meta-data
            android:name="design_width_in_dp"
            android:value="360"/>
        <meta-data
            android:name="design_height_in_dp"
            android:value="640"/>           
    </application>           
</manifest>

爲什麼只需在AndroidManifest.xml 中填寫一下 meta-data 標籤就可實現自動運行?

在 App 啓動時,系統會在 App 的主進程中自動實例化聲明的 ContentProvider,並調用它的 onCreate 方法,執行時機比 Application#onCreate 還靠前,可以做一些初始化的工作,這個時候我們就可以利用它的 onCreate 方法在其中啓動框架。如果項目使用了多進程,調用Application#onCreate 中調用下 ContentProvider#query 就能夠使用 ContentProvider 在當前進程中進行實例化。

小結

上述介紹的所有方案並沒有哪一個是十分完美的,但我們能清晰的認識到不同方案的優缺點,並將它們的優點相結合,這樣才能應付更加複雜的開發需求,創造出最卓越的產品。比如SmallestWidth 限定符適配方案 主打的是穩定性,在運行過程中極少會出現安全隱患,適配範圍也可控,不會產生其他未知的影響,而 今日頭條適配方案 主打的是降低開發成本、提高開發效率,使用上更靈活,也能滿足更多的擴展需求。所以,具體情況具體分析,到底選擇哪一個屏幕適配方案還是需要去根據我們項目自身的需求去選擇。

三、優化工具

1、Systrace

早在深入探索Android啓動速度優化一文中我們就瞭解過Systrace的使用、原理及它作爲啓動速度分析的用法。而它其實主要是用來分析繪製性能方面的問題。下面我就詳細介紹下Systrace作爲繪製優化工具有哪些必須關注的點。

1、關注Frames

首先,先在左邊欄選中我們當前的應用進程,在應用進程一欄下面有一欄Frames,我們可以看到有綠、黃、紅三種不同的小圓圈,如下圖所示:

 

image

 

 

圖中每一個小圓圈代表着當前幀的狀態,大致的對應關係如下:

  • 正常:綠色。
  • 丟幀:黃色。
  • 嚴重丟幀:紅色。

並且,選中其中某一幀,我們還可以在視圖最下方的詳情框看到該幀對應的相關的Alerts報警信息,以幫助我們去排查問題;此外,如果是大於等於Android 5.0的設備(即API Level21),創建幀的工作工作分爲UI線程和render線程。而在Android 5.0之前的版本中,創建幀的所有工作都是在UI線程上完成的。接下來,我們看看該幀對應的詳情圖,如下所示:

 

image

 

 

對應到此幀,我們發現這裏可能有兩個繪製問題:Bitmap過大、佈局嵌套層級過多導致的measure和layout次數過多,這就需要我們去在項目中找到該幀對應的Bitmap進行相應的優化,針對佈局嵌套層級過多的問題去選擇更高效的佈局方式,這塊後面我們會詳細介紹。

2、關注Alerts欄

此外,Systrace的顯示界面還在在右邊側欄提供了一欄Alert框去顯示出它所檢測出所有可能有繪製性能問題的地方及對應的數量,如下圖所示:

 

 

image

 

 

在這裏,我們可以將Alert框看做是一個是待修復的Bug列表,通常一個區域的改進可以消除應用程序中的所有類中該類型的警報,所以,不要爲這裏的警報數量所擔憂。

2、Layout Inspector

Layout Inspector是AndroidStudio自帶的工具,它的主要作用就是用來查看視圖層級結構的。

具體的操作路徑爲:

點擊Tools工具欄 ->第三欄的Layout Inspector -> 選中當前的進程

下面爲操作之後打開的Awesome-WanAndroid首頁圖,如下所示:

 

image

 

 

其中,最左側的View Tree就是用來查看視圖的層級結構的,非常方便,這是它最主要的功能,中間的是一個屏幕截圖,最右邊的是一個屬性表格,比如我在截圖中選中某一個TextView(Kotlin/入門及知識點一欄),在屬性表格的text中就可以顯示相關的信息,如下圖所示:

 

image

 

 

3、Choreographer

Choreographer是用來獲取FPS的,並且可以用於線上使用,具備實時性,但是僅能在Api 16之後使用,具體的調用代碼如下:

Choreographer.getInstance().postFrameCallback();

使用Choreographer獲取FPS的完整代碼如下所示:

 

private long mStartFrameTime = 0;
private int mFrameCount = 0;

/**
 * 單次計算FPS使用160毫秒
 */
private static final long MONITOR_INTERVAL = 160L; 
private static final long MONITOR_INTERVAL_NANOS = MONITOR_INTERVAL * 1000L * 1000L;

/**
 * 設置計算fps的單位時間間隔1000ms,即fps/s
 */
private static final long MAX_INTERVAL = 1000L; 

@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void getFPS() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
        return;
    }
    Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
        @Override
        public void doFrame(long frameTimeNanos) {
            if (mStartFrameTime == 0) {
                mStartFrameTime = frameTimeNanos;
            }
            long interval = frameTimeNanos - mStartFrameTime;
            if (interval > MONITOR_INTERVAL_NANOS) {
                double fps = (((double) (mFrameCount * 1000L * 1000L)) / interval) * MAX_INTERVAL;
                // log輸出fps
                LogUtils.i("當前實時fps值爲: " + fps);
                mFrameCount = 0;
                mStartFrameTime = 0;
            } else {
                ++mFrameCount;
            }

            Choreographer.getInstance().postFrameCallback(this);
        }
    });
}

通過以上方式我們就可以實現實時獲取應用的界面的FPS了。但是我們需要排除掉頁面沒有操作的情況,即只在界面存在繪製的時候才做統計。我們可以通過 addOnDrawListener 去監聽界面是否存在繪製行爲,代碼如下所示:

getWindow().getDecorView().getViewTreeObserver().addOnDrawListener

當出現丟幀的時候,我們可以獲取應用當前的頁面信息、View 信息和操作路徑上報至 APM後臺,以降低二次排查的難度。此外,我們將連續丟幀超過 700 毫秒定義爲凍幀,也就是連續丟幀 42 幀以上。這時用戶會感受到比較明顯的卡頓現象,因此,我們可以統計更有價值的凍幀率。凍幀率就是計算髮生凍幀時間在所有時間的佔比。通過解決應用中發生凍幀的地方我們就可以大大提升應用的流暢度。

4、Tracer for OpenGL ES 與 GAPID(Graphics API Debugger)

Tracer for OpenGL ES 是 Android 4.1 新增加的工具,它可逐幀、逐函數的記錄 App 使用 OpenGL ES 的繪製過程,並且,它可以記錄每個 OpenGL 函數調用的消耗時間。當使用Systrace還找不到渲染問題時,就可以去嘗試使用它。

而GAPID是 Android Studio 3.1 推出的工具,可以認爲是Tracer for OpenGL ES的進化版,它不僅實現了跨平臺,而且支持Vulkan與回放。由於它們主要是用於OpenGL相關開發的使用,這裏我就不多介紹了。

5、自動化測量 UI 渲染性能的方式

在自動化測試中,我們通常希望通過執行性能測試的自動化腳本來進行線下的自動化檢測,那麼,有哪些命令可以用於測量UI渲染的性能呢?

我們都知道,dumpsys是一款輸出有關係統服務狀態信息的Android工具,利用它我們可以獲取當前設備的UI渲染性能信息,目前常用的有如下兩種命令:

1、gfxinfo

gfxinfo的主要作用是輸出各階段發生的動畫與幀相關的信息,命令格式如下:

adb shell dumpsys gfxinfo <PackageName>

這裏我以Awesome-WanAndroid項目爲例,輸出其對應的gfxinfo信息如下所示:

quchao@quchaodeMacBook-Pro ~ % adb shell dumpsys gfxinfo json.chao.com.wanandroid
Applications Graphics Acceleration Info:
Uptime: 549887348 Realtime: 549887348

** Graphics info for pid 1722     [json.chao.com.wanandroid] **

Stats since: 549356564232951ns
Total frames rendered: 5210
Janky frames: 193 (3.70%)
50th percentile: 5ms
90th percentile: 9ms
95th percentile: 13ms
99th percentile: 34ms
Number Missed Vsync: 31
Number High input latency: 0
Number Slow UI thread: 153
Number Slow bitmap uploads: 6
Number Slow issue draw commands: 51
HISTOGRAM: 5ms=4254 6ms=131 7ms=144 8ms=87 9ms=80 10ms=83 11ms=108 12ms=57 13ms=29 14ms=17 15ms=17 16ms=14 17ms=20 18ms=15 19ms=15 20ms=17 21ms=9 22ms=14 23ms=8 24ms=9 25ms=4 26ms=5 27ms=4 28ms=4 29ms=1 30ms=2 31ms=4 32ms=3 34ms=6 36ms=5 38ms=7 40ms=8 42ms=0 44ms=3 46ms=3 48ms=5 53ms=2 57ms=0 61ms=3 65ms=0 69ms=1 73ms=1 77ms=0 81ms=0 85ms=0 89ms=1 93ms=1 97ms=0 101ms=0 105ms=0 109ms=0 113ms=1 117ms=0 121ms=0 125ms=0 129ms=0 133ms=0 150ms=2 200ms=0 250ms=2 300ms=1 350ms=1 400ms=0 450ms=1 500ms=0 550ms=1 600ms=0 650ms=0 700ms=0 750ms=0 800ms=0 850ms=0 900ms=0 950ms=0 1000ms=0 1050ms=0 1100ms=0 1150ms=0 1200ms=0 1250ms=0 1300ms=0 1350ms=0 1400ms=0 1450ms=0 1500ms=0 1550ms=0 1600ms=0 1650ms=0 1700ms=0 1750ms=0 1800ms=0 1850ms=0 1900ms=0 1950ms=0 2000ms=0 2050ms=0 2100ms=0 2150ms=0 2200ms=0 2250ms=0 2300ms=0 2350ms=0 2400ms=0 2450ms=0 2500ms=0 2550ms=0 2600ms=0 2650ms=0 2700ms=0 2750ms=0 2800ms=0 2850ms=0 2900ms=0 2950ms=0 3000ms=0 3050ms=0 3100ms=0 3150ms=0 3200ms=0 3250ms=0 3300ms=0 3350ms=0 3400ms=0 3450ms=0 3500ms=0 3550ms=0 3600ms=0 3650ms=0 3700ms=0 3750ms=0 3800ms=0 3850ms=0 3900ms=0 3950ms=0 4000ms=0 4050ms=0 4100ms=0 4150ms=0 4200ms=0 4250ms=0 4300ms=0 4350ms=0 4400ms=0 4450ms=0 4500ms=0 4550ms=0 4600ms=0 4650ms=0 4700ms=0 4750ms=0 4800ms=0 4850ms=0 4900ms=0 4950ms=0
Caches:
Current memory usage / total memory usage (bytes):
TextureCache          5087048 / 59097600
Layers total          0 (numLayers = 0)
RenderBufferCache           0 /  4924800
GradientCache           20480 /  1048576
PathCache                   0 /  9849600
TessellationCache           0 /  1048576
TextDropShadowCache         0 /  4924800
PatchCache                  0 /   131072
FontRenderer A8        184219 /  1478656
    A8   texture 0       184219 /  1478656
FontRenderer RGBA           0 /        0
FontRenderer total     184219 /  1478656
Other:
FboCache                    0 /        0
Total memory usage:
6586184 bytes, 6.28 MB


Pipeline=FrameBuilder
Profile data in ms:

    json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity/android.view.ViewRootImpl@4a2142e (visibility=8)
    json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.ArticleDetailActivity/android.view.ViewRootImpl@4bccbcf (visibility=8)
View hierarchy:

json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity/android.view.ViewRootImpl@4a2142e
151 views, 154.02 kB of display lists

json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.ArticleDetailActivity/android.view.ViewRootImpl@4bccbcf
19 views, 18.70 kB of display lists


Total ViewRootImpl: 2
Total Views:        170
Total DisplayList:  172.73 kB

下面,我將對其中的關鍵信息進行分析。

幀的聚合分析數據

開始的一欄是統計的當前界面所有幀的聚合分析數據,主要作用是綜合查看App的渲染性能以及幀的穩定性。

  • Graphics info for pid 1722 [json.chao.com.wanandroid] -> 說明了當前提供的是Awesome-WanAndroid應用界面的幀信息,對應的進程id爲1722。
  • Total frames rendered 5210 -> 本次dump的數據蒐集了5210幀的信息。
  • Janky frames: 193 (3.70%) -> 5210幀中有193幀發生了Jank,即單幀耗時時間超過了16ms,卡頓的概率爲3.70%。
  • 50th percentile: 5ms -> 所有幀耗時排序後,其中前50%最大的耗時幀的耗時爲5ms。
  • 90th percentile: 9ms -> 同上,依次類推。
  • 95th percentile: 13ms -> 同上,依次類推。
  • 99th percentile: 34ms -> 同上,依次類推。
  • Number Missed Vsync: 31 -> 垂直同步失敗的幀數爲31。
  • Number High input latency: 0 -> 處理input耗時的幀數爲0。
  • Number Slow UI thread: 153 -> 因UI線程的工作而導致耗時的幀數爲153。
  • Number Slow bitmap uploads: 6 -> 因bitmap加載導致耗時的幀數爲6。
  • Number Slow issue draw commands: 51 -> 因繪製問題導致耗時的幀數爲51。
  • HISTOGRAM: 5ms=4254 6ms=131 7ms=144 8ms=87... -> 直方圖數據列表,說明了耗時0~5ms的幀數爲4254,耗時5~6ms的幀數爲131,後續的數據依次類推即可。

後續的log數據表明了不同組件的緩存佔用信息,幀的建立路徑信息以及總覽信息等等,參考意義不大。

可以看到,上述的數據只能讓我們總體感受到繪製性能的好壞,並不能去定位具體幀的問題,那麼,還有更好的方式去獲取具體幀的信息嗎?

添加framestats去獲取最後120幀的詳細信息

該命令的格式如下:

adb shell dumpsys gfxinfo <PackageName> framestats

這裏還是以Awesome-WanAndroid項目爲例,輸出項目標籤頁的幀詳細信息:

quchao@quchaodeMacBook-Pro ~ % adb shell dumpsys gfxinfo json.chao.com.wanandroid framestats
Applications Graphics Acceleration Info:
Uptime: 603118462 Realtime: 603118462

...

Window: json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity
Stats since: 603011709157414ns
Total frames rendered: 3295
Janky frames: 117 (3.55%)
50th percentile: 5ms
90th percentile: 9ms
95th percentile: 14ms
99th percentile: 32ms
Number Missed Vsync: 17
Number High input latency: 3
Number Slow UI thread: 97
Number Slow bitmap uploads: 13
Number Slow issue draw commands: 20
HISTOGRAM: 5ms=2710 6ms=75 7ms=81 8ms=70...

---PROFILEDATA---
Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,
0,603111579233508,603111579233508,9223372036854775807,0,603111580203105,603111580207688,603111580417688,603111580651698,603111580981282,603111581033157,603111581263417,603111583942011,603111584638678,1590000,259000,
0,603111595904553,603111595904553,9223372036854775807,0,603111596650344,603111596692428,603111596828678,603111597073261,603111597301386,603111597362376,603111597600292,603111600584667,603111601288261,1838000,278000,
...,
---PROFILEDATA---

...

這裏我們只需關注其中的PROFILEDATA一欄,因爲它表明了最近120幀每個幀的狀態信息。

因爲其中的數據是以csv格式顯示的,我們將PROFILEDATA中的數據全部拷貝過來,然後放入一個txt文件中,接着,把.txt後綴改爲.csv,使用WPS表格工具打開,如下圖所示:

 

image

 

 

從上圖中,我們看到輸出的第一行是對應的輸出數據列的格式,下面我將詳細進行分析。

Flags:

  • Flags爲0則可計算得出該幀耗時:FrameCompleted - IntendedVsync。
  • Flags爲非0則表示繪製時間超過16ms,爲異常幀。

IntendedVsync:

  • 幀的預期Vsync時刻,如果預期的Vsync時刻與現實的Vsync時刻不一致,則表明UI線程中有耗時工作導致其無法響應Vsync信號。

Vsync:

  • 花費在Vsync監聽器和幀繪製的時間,比如Choreographer frame回調、動畫、View.getDrawingTime等待。
  • 理解Vsync:Vsync避免了在屏幕刷新時,把數據從後臺緩衝區複製到幀緩衝區所消耗的時間。

OldestInputEvent:

  • 輸入隊列中最舊輸入事件的時間戳,如果沒有輸入事件,則此列數據都爲Long.MAX_VALUE。
  • 通常用於framework層開發。

NewestInputEvent:

  • 輸入隊列中最新輸入時間的時間戳,如果沒有輸入事件,則此列數據都爲0。
  • 計算App大致的延遲添加時間:FrameCompleted - NewestInputEvent。
  • 通常用於framework層開發。

HandleInputStart:

  • 將輸入事件分發給App對應的時間戳時刻。
  • 用於測量App處理輸入事件的時間:AnimationStart - HandleInputStart。當值大於2ms時,說明程序花費了很長的時間來處理輸入事件,比如View.onTouchEvent等事件。注意在Activity切換或產生點擊事件時此值一般都比較大,此時是可以接受的。

AnimationStart:

  • 運行Choreographer(舞蹈編排者)註冊動畫的時間戳。
  • 用來評估所有運行的所有動畫器(ObjectAnimator、ViewPropertyAnimator、常用轉換器)需要多長時間:AnimationStart - PerformTraversalsStart。當值大於2ms時,請查看此時是否執行的是自定義動畫且動畫是否有耗時操作。

PerformTraversalsStart:

  • 執行佈局遞歸遍歷開始的時間戳。
  • 用於獲取measure、layout的時間:DrawStart - PerformTraversalsStart。(注意滾動或動畫期間此值應接近於0)。

DrawStart:

  • draw階段開始的時間戳,它記錄了任何無效視圖的DisplayList的起點。
  • 用於獲取視圖數中所有無效視圖調用View.draw方法所需的時間:SyncStart - DrawStart。
  • 在此過程中,硬件加速模塊中的DisplayList發揮了重要作用,Android系統仍然使用invalidate()調用draw()方法請求屏幕更新和渲染視圖,但是對實際圖形的處理方式有所不同。Android系統並沒有立即執行繪圖命令,而是將它們記錄在DisplayList中,該列表包含視圖層次結構繪圖所需的所有信息。相對於軟件渲染的另一個優化是,Android系統僅需要記錄和更新DispalyList,以顯示被invalidate() 標記爲dirty的視圖。只需重新發布先前記錄的Displaylist,即可重新繪製尚未失效的視圖。此時的硬件繪製模型主要包括三個過程:刷新視圖層級、記錄和更新DisplayList、繪製DisplayList。相對於軟件繪製模型的刷新視圖層級、然後直接去繪製視圖層級的兩個步驟,雖然多了一個步驟,但是節省了很多不必要的繪製開銷。

SyncQueued:

  • sync請求發送到RenderThread線程的時間戳。
  • 獲取sync就緒所花費的時間:SyncStart - SyncQueued。如果值大於0.1ms,則說明RenderThread正在忙於處理不同的幀。

SyncStart:

  • 繪圖的sync階段開始的時間戳。
  • IssueDrawCommandsStart - SyncStart > 0.4ms左右則表明有許多新的位圖需要上傳至GPU。

IssueDrawCommandsStart:

  • 硬件渲染器開始GPU發出繪圖命令的時間戳。
  • 用於觀察App此時繪製時消耗了多少GPU:FrameCompleted - IssueDrawCommandsStart。

SwapBuffers:

  • eglSwapBuffers被調用時的時間戳。
  • 通常用於Framework層開發。

FrameCompleted:

  • 當前幀完成繪製的時間戳。
  • 獲取當前幀繪製的總時間:FrameCompleted - IntendedVsync。

綜上,我們可以利用這些數據計算獲取我們在自動化測試中想關注的因素,比如幀耗時、該幀調用View.draw方法所消耗的時間。framestats和幀耗時信息等一般2s收集一次,即一次120幀。爲了精確控制收集數據的時間窗口,如將數據限制爲特定的動畫,可以重置計數器,重新聚合統計的信息,對應命令如下:

adb shell dumpsys gfxinfo <PackageName> reset

2、SurfaceFlinger

我們都知道,在Android 4.1以後,系統使用了三級緩衝機制,即此時有三個Graphic Buffer,那麼如何查看每個Graphic Buffer佔用的內存呢?

答案是使用SurfaceFlinger,命令如下所示:

adb shell dumpsys SurfaceFlinger

輸出的結果非常多,因爲包含很多系統應用和界面的相關信息,這裏我們僅過濾出Awesome-WanAndroid應用對應的信息:

+ Layer 0x7f5a92f000 (json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0)
  layerStack=   0, z=    21050, pos=(0,0), size=(1080,2280), crop=(   0,   0,1080,2280), finalCrop=(   0,   0,  -1,  -1), isOpaque=1, invalidate=0, dataspace=(deprecated) sRGB Linear Full range, pixelformat=RGBA_8888 alpha=0.000, flags=0x00000002, tr=[1.00, 0.00][0.00, 1.00]
  client=0x7f5dc23600
  format= 1, activeBuffer=[1080x2280:1088,  1], queued-frames=0, mRefreshPending=0
        mTexName=386 mCurrentTexture=0
        mCurrentCrop=[0,0,0,0] mCurrentTransform=0
        mAbandoned=0
        - BufferQueue mMaxAcquiredBufferCount=1 mMaxDequeuedBufferCount=2
          mDequeueBufferCannotBlock=0 mAsyncMode=0
          default-size=[1080x2280] default-format=1 transform-hint=00 frame-counter=51
        FIFO(0):
        Slots:
          // 序號           // 表明是否使用的狀態 // 對象地址 // 當前負責第幾幀 // 手機屏幕分辨率大小
         >[00:0x7f5e05a5c0] state=ACQUIRED 0x7f5b1ca580 frame=51 [1080x2280:1088,  1]
          [02:0x7f5e05a860] state=FREE     0x7f5b1ca880 frame=49 [1080x2280:1088,  1]
          [01:0x7f5e05a780] state=FREE     0x7f5b052a00 frame=50 [1080x2280:1088,  1]

在Slots中,顯示的是緩衝區相關的信息,可以看到,此時App使用的是00號緩衝區,即第一個緩衝區。

接着,在SurfaceFlinger命令輸出log的最下方有一欄Allocated buffers,這這裏可以使用當前緩衝區對應的對象地址去查詢其佔用的內存大小。具體對應到我們這裏的是0x7f5b1ca580,匹配到的結果如下所示:

0x7f5b052a00: 9690.00 KiB | 1080 (1088) x 2280 |    1 |        1 | 0x10000900 | json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0
0x7f5b1ca580: 9690.00 KiB | 1080 (1088) x 2280 |    1 |        1 | 0x10000900 | json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0
0x7f5b1ca880: 9690.00 KiB | 1080 (1088) x 2280 |    1 |        1 | 0x10000900 | json.chao.com.wanandroid/json.chao.com.wanandroid.ui.main.activity.MainActivity#0

可以看到,這裏每一個Graphic Buffer都佔用了9MB多的內存,通常分辨率越大,單個Graphic Buffer佔用的內存就越多,如1080 x 1920的手機屏幕,一般佔用8160kb的內存大小。此外,如果應用使用了其它的Surface,如SurfaceView或TextureView(兩者一般用在opengl進行圖像處理或視頻處理的過程中),這個值會更大。如果當App退到後臺,系統就會將這部分內存回收。

瞭解了常用佈局優化常用的工具與命令之後,我們就應該開始着手進行優化了,但在開始之前,我們還得對Android的佈局加載原理有比較深入的瞭解。

四、佈局加載原理

1、爲什麼要了解Android佈局加載原理?

知其然知其所以然,不僅要明白在平時開發過程中是怎樣對佈局API進行調用,還要知道它內部的實現原理是什麼。明白具體的實現原理與流程之後,我們可能會發現更多可優化的點。

2、佈局加載源碼分析

我們都知道,Android的佈局都是通過setContentView()這個方法進行設置的,那麼它的內部肯定實現了佈局的加載,接下來,我們就詳細分析下它內部的實現原理與流程。

Awesome-WanAndroid項目爲例,我們在通用Activity基類的onCreate方法中進行了佈局的設置:

setContentView(getLayoutId());

點進去,發現是調用了AppCompatActivity的setContentView方法:

@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

這裏的setContentView其實是AppCompatDelegate這個代理類的抽象方法:

 /**
 * Should be called instead of {@link Activity#setContentView(int)}}
 */
public abstract void setContentView(@LayoutRes int resId);

在這個抽象方法的左邊,會有一個綠色的小圓圈,點擊它就可以查看到對應的實現類與方法,這裏的實現類是AppCompatDelegateImplV9,實現方法如下所示:

 @Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mOriginalWindowCallback.onContentChanged();
}

setContentView方法中主要是獲取到了content父佈局,移除其內部所有視圖之後並最終調用了LayoutInflater對象的inflate去加載對應的佈局。接下來,我們關注inflate內部的實現:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
    return inflate(resource, root, root != null);
}

這裏只是調用了inflate另一個的重載方法:

 

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                + Integer.toHexString(resource) + ")");
    }

    // 1
    final XmlResourceParser parser = res.getLayout(resource);
    try {
        // 2
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

在註釋1處,通過Resources的getLayout方法獲取到了一個XmlResourceParser對象,繼續跟蹤下getLayout方法:

public XmlResourceParser getLayout(@LayoutRes int id) throws NotFoundException {
    return loadXmlResourceParser(id, "layout");
}

這裏繼續調用了loadXmlResourceParser方法,注意第二個參數傳入的爲layout,說明此時加載的是一個Xml資源佈局解析器。我們繼續跟蹤loadXmlResourceParse方法:

@NonNull
XmlResourceParser loadXmlResourceParser(@AnyRes int id, @NonNull String type)
        throws NotFoundException {
    final TypedValue value = obtainTempTypedValue();
    try {
        final ResourcesImpl impl = mResourcesImpl;
        impl.getValue(id, value, true);
        if (value.type == TypedValue.TYPE_STRING) {
            // 1
            return impl.loadXmlResourceParser(value.string.toString(), id,
                    value.assetCookie, type);
        }
        throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)
                + " type #0x" + Integer.toHexString(value.type) + " is not valid");
    } finally {
        releaseTempTypedValue(value);
    }
}

在註釋1處,如果值類型爲字符串的話,則調用了ResourcesImpl實例的loadXmlResourceParser方法。我們首先看看這個方法的註釋:

/**
 * Loads an XML parser for the specified file.
 *
 * @param file the path for the XML file to parse
 * @param id the resource identifier for the file
 * @param assetCookie the asset cookie for the file
 * @param type the type of resource (used for logging)
 * @return a parser for the specified XML file
 * @throws NotFoundException if the file could not be loaded
 */
@NonNull
XmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,
        @NonNull String type)
        throws NotFoundException {
        
        ...
        
        final XmlBlock block = mAssets.openXmlBlockAsset(assetCookie, file);
        
        ...
        
        return block.newParser();
        
        ...
}

註釋的意思說明了這個方法是用於加載指定文件的Xml解析器,這裏我們之間查看關鍵的mAssets.openXmlBlockAsset方法,這裏的mAssets對象是AssetManager類型的,看看AssetManager實例的openXmlBlockAsset方法做了什麼處理:

/**
 * {@hide}
 * Retrieve a non-asset as a compiled XML file.  Not for use by
 * applications.
 * 
 * @param cookie Identifier of the package to be opened.
 * @param fileName Name of the asset to retrieve.
 */
/*package*/ final XmlBlock openXmlBlockAsset(int cookie, String fileName)
    throws IOException {
    synchronized (this) {
        if (!mOpen) {
            throw new RuntimeException("Assetmanager has been closed");
        }
        // 1
        long xmlBlock = openXmlAssetNative(cookie, fileName);
        if (xmlBlock != 0) {
            XmlBlock res = new XmlBlock(this, xmlBlock);
            incRefsLocked(res.hashCode());
            return res;
        }
    }
    throw new FileNotFoundException("Asset XML file: " + fileName);
}

可以看到,最終是調用了註釋1處的openXmlAssetNative方法,這是定義在AssetManager中的一個Native方法:

    private native final long openXmlAssetNative(int cookie, String fileName);

與此同時,我們可以猜到讀取Xml文件肯定是通過IO流的方式進行的,而openXmlBlockAsset方法後拋出的IOException異常也驗證了我們的想法。因爲涉及到IO流的讀取,所以這裏是Android佈局加載流程一個耗時點 ,也有可能是我們後續優化的一個方向。

分析完Resources實例的getLayout方法的實現之後,我們繼續跟蹤inflate方法的註釋2處:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                + Integer.toHexString(resource) + ")");
    }

    // 1
    final XmlResourceParser parser = res.getLayout(resource);
    try {
        // 2
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

infalte的實現代碼如下:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        
        ...

        try {
            // Look for the root node.
            int type;
            while ((type = parser.next()) != XmlPullParser.START_TAG &&
                    type != XmlPullParser.END_DOCUMENT) {
                // Empty
            }

            if (type != XmlPullParser.START_TAG) {
                throw new InflateException(parser.getPositionDescription()
                        + ": No start tag found!");
            }

            final String name = parser.getName();

            ...
            
            // 1
            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }

                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // Temp is the root view that was found in the xml
                // 2
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                
                ...
            }
            ...
        }
        ...
    }
    ...
}

可以看到,infalte內部是通過XmlPull解析的方式對佈局的每一個節點進行創建對應的視圖的。首先,在註釋1處會判斷節點是否是merge標籤,如果是,則對merge標籤進行校驗,如果merge節點不是當前佈局的父節點,則拋出異常。然後,在註釋2處,通過createViewFromTag方法去根據每一個標籤創建對應的View視圖。我們繼續跟蹤下createViewFromTag方法的實現:

private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) {
    return createViewFromTag(parent, name, context, attrs, false);
}

 View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
   
    ...
   
    try {
        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }

        if (view == null && mPrivateFactory != null) {
            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
        }

        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(parent, name, attrs);
                } else {
                    view = createView(name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    } 
    ...
}

在createViewFromTag方法中,首先會判斷mFactory2是否存在,存在就會使用mFactory2的onCreateView方法區創建視圖,否則就會調用mFactory的onCreateView方法,接下來,如果此時的tag是一個Fragment,則會調用mPrivateFactory的onCreateView方法,否則的話,最終都會調用LayoutInflater實例的createView方法:

 

 public final View createView(String name, String prefix, AttributeSet attrs)
        throws ClassNotFoundException, InflateException {
   
   ...

    try {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);

        if (constructor == null) {
            // Class not found in the cache, see if it's real, and try to add it
            // 1
            clazz = mContext.getClassLoader().loadClass(
                    prefix != null ? (prefix + name) : name).asSubclass(View.class);

            if (mFilter != null && clazz != null) {
                boolean allowed = mFilter.onLoadClass(clazz);
                if (!allowed) {
                    failNotAllowed(name, prefix, attrs);
                }
            }
            // 2
            constructor = clazz.getConstructor(mConstructorSignature);
            constructor.setAccessible(true);
            sConstructorMap.put(name, constructor);
        } else {
            ...
        }

        ...

        // 3
        final View view = constructor.newInstance(args);
        if (view instanceof ViewStub) {
            // Use the same context when inflating ViewStub later.
            final ViewStub viewStub = (ViewStub) view;
            viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
        }
        mConstructorArgs[0] = lastContext;
        return view;

    } 
   ...
}

LayoutInflater的createView方法中,首先,在註釋1處,使用類加載器創建了對應的Class實例,然後在註釋2處根據Class實例獲取到了對應的構造器實例,並最終在註釋3處通過構造器實例constructor的newInstance方法創建了對應的View對象。可以看到,在視圖節點的創建過程中採用到了反射,我們都知道反射是比較耗性能的,過多的反射可能會導致佈局加載過程變慢,這個點可能是後續優化的一個方向。

最後,我們來總結下Android中的佈局加載流程:

  • 1、在setContentView方法中,會通過LayoutInflater的inflate方法去加載對應的佈局。
  • 2、inflate方法中首先會調用Resources的getLayout方法去通過IO的方式去加載對應的Xml佈局解析器到內存中。
  • 3、接着,會通過createViewFromTag根據每一個tag創建具體的View對象。
  • 4、它內部主要是按優先順序爲Factory2和Factory的onCreatView、createView方法進行View的創建,而createView方法內部採用了構造器反射的方式實現。

從以上分析可知,在Android的佈局加載流程中,性能瓶頸主要存在兩個地方:

  • 1、佈局文件解析中的IO過程。
  • 2、創建View對象時的反射過程。

3、LayoutInflater.Factory分析

在前面分析的View的創建過程中,我們明白系統會優先使用Factory2和Factory去創建對應的View,那麼它們究竟是幹什麼的呢?

其實LayoutInflater.Factory是layoutInflater中創建View的一個Hook,Hook即掛鉤,我們可以利用它在創建View的過程中加入一些日誌或進行其它更高級的定製化處理:比如可以全局替換自定義的TextView等等

接下來,我們查看下Factory2的實現:

 public interface Factory2 extends Factory {
    /**
     * Version of {@link #onCreateView(String, Context, AttributeSet)}
     * that also supplies the parent that the view created view will be
     * placed in.
     *
     * @param parent The parent that the created view will be placed
     * in; <em>note that this may be null</em>.
     * @param name Tag name to be inflated.
     * @param context The context the view is being created in.
     * @param attrs Inflation attributes as specified in XML file.
     *
     * @return View Newly created view. Return null for the default
     *         behavior.
     */
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}

可以看到,Factory2是直接繼承於Factory,繼續跟蹤下Factory的源碼:

 public interface Factory {
    /**
     * Hook you can supply that is called when inflating from a LayoutInflater.
     * You can use this to customize the tag names available in your XML
     * layout files.
     *
     * <p>
     * Note that it is good practice to prefix these custom names with your
     * package (i.e., com.coolcompany.apps) to avoid conflicts with system
     * names.
     *
     * @param name Tag name to be inflated.
     * @param context The context the view is being created in.
     * @param attrs Inflation attributes as specified in XML file.
     *
     * @return View Newly created view. Return null for the default
     *         behavior.
     */
    public View onCreateView(String name, Context context, AttributeSet attrs);
}

onCreateView方法中的第一個參數就是指的tag名字,比如TextView等等,我們還注意到Factory2比Factory的onCreateView方法多一個parent的參數,這是當前創建的View的父View。看來,Factory2比Factory功能要更強大一些。

最後,我們總結下Factory與Factory2的區別:

  • 1、Factory2繼承與Factory。
  • 2、Factory2比Factory的onCreateView方法多一個parent的參數,即當前創建View的父View。

五、獲取界面佈局耗時

1、常規方式

如果要獲取每個界面的加載耗時,我們就必需在setContentView方法前後進行手動埋點。但是它有如下缺點:

  • 1、不夠優雅。
  • 2、代碼有侵入性。

2、AOP

關於AOP的使用,我在《深入探索Android啓動速度優化》一文的AOP(Aspect Oriented Programming)打點部分已經詳細講解過了,這裏就不再贅述,還不瞭解的同學可以點擊上面的鏈接先去學習下AOP的使用。

我們要使用AOP去獲取界面佈局的耗時,那麼我們的切入點就是setContentView方法,聲明一個@Aspect註解的PerformanceAop類,然後,我們就可以在裏面實現對setContentView進行切面的方法,如下所示:

@Around("execution(* android.app.Activity.setContentView(..))")
public void getSetContentViewTime(ProceedingJoinPoint joinPoint) {
    Signature signature = joinPoint.getSignature();
    String name = signature.toShortString();
    long time = System.currentTimeMillis();
    try {
        joinPoint.proceed();
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
    LogHelper.i(name + " cost " + (System.currentTimeMillis() - time));
}

爲了獲取方法的耗時,我們必須使用@Around註解,這樣第一個參數ProceedingJoinPoint就可以提供proceed方法去執行我們的setContentView方法,在此方法的前後就可以獲取setContentView方法的耗時。後面的execution表明了在setContentView方法執行內部去調用我們寫好的getSetContentViewTime方法,後面括號內的*是通配符,表示匹配任何Activity的setContentView方法,並且方法參數的個數和類型不做限定。

完成AOP獲取界面佈局耗時的方法之後,重裝應用,打開幾個Activity界面,就可以看到如下的界面佈局加載耗時日誌:

2020-01-01 12:20:17.605 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 174
2020-01-01 12:20:58.010 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 13
2020-01-01 12:21:27.058 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 44
2020-01-01 12:21:31.128 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 61
2020-01-01 12:23:09.805 12297-12297/json.chao.com.wanandroid I/WanAndroid-PEGASILOG: │ [PerformanceAop.java | 36 | getSetContentViewTime] AppCompatActivity.setContentView(..) cost 22

可以看到,Awesome-WanAndroid項目裏面各個界面的加載耗時一般都在幾十毫秒作用,加載慢的界面可能會達到100多ms,當然,不同手機的配置不一樣,但是,這足夠讓我們發現哪些界面佈局的加載比較慢

3、LayoutInflaterCompat.setFactory2

上面我們使用了AOP的方式監控了Activity的佈局加載耗時,那麼,如果我們需要監控每一個控件的加載耗時,該怎麼實現呢?

答案是使用LayoutInflater.Factory2,我們在基類Activity的onCreate方法中直接使用LayoutInflaterCompat.setFactory2方法對Factory2的onCreateView方法進行重寫,代碼如下所示:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {

    // 使用LayoutInflaterCompat.Factory2全局監控Activity界面每一個控件的加載耗時,
    // 也可以做全局的自定義控件替換處理,比如:將TextView全局替換爲自定義的TextView。
    LayoutInflaterCompat.setFactory2(getLayoutInflater(), new LayoutInflater.Factory2() {
        @Override
        public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

            if (TextUtils.equals(name, "TextView")) {
                // 生成自定義TextView
            }
            long time = System.currentTimeMillis();
            // 1
            View view = getDelegate().createView(parent, name, context, attrs);
            LogHelper.i(name + " cost " + (System.currentTimeMillis() - time));
            return view;
        }

        @Override
        public View onCreateView(String name, Context context, AttributeSet attrs) {
            return null;
        }
    });

    // 2、setFactory2方法需在super.onCreate方法前調用,否則無效        
    super.onCreate(savedInstanceState);
    setContentView(getLayoutId());
    unBinder = ButterKnife.bind(this);
    mActivity = this;
    ActivityCollector.getInstance().addActivity(this);
    onViewCreated();
    initToolbar();
    initEventAndData();
}

這樣我們就實現了利用LayoutInflaterCompat.Factory2全局監控Activity界面每一個控件加載耗時的處理,後續我們可以將這些數據上傳到我們自己的APM服務端,作爲監控數據可以分析出哪些控件加載比較耗時。當然,這裏我們也可以做全局的自定義控件替換處理,比如在上述代碼中,我們可以將TextView全局替換爲自定義的TextView。

然後,我們注意到這裏我們使用getDelegate().createView方法來創建對應的View實例,跟蹤進去發現這裏的createView是一個抽象方法:

 public abstract View createView(@Nullable View parent, String name, @NonNull Context context,
        @NonNull AttributeSet attrs);

它對應的實現方法爲AppCompatDelegateImplV9對象的createView方法,代碼如下所示:

@Override
public View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs) {
    
    ...

    return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
            IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
            true, /* Read read app:theme as a fallback at all times for legacy reasons */
            VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
    );
}

這裏最終又調用了AppCompatViewInflater對象的createView方法:

 public final View createView(View parent, final String name, @NonNull Context context,
        @NonNull AttributeSet attrs, boolean inheritContext,
        boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
    
    ...

    // We need to 'inject' our tint aware Views in place of the standard framework versions
    switch (name) {
        case "TextView":
            view = new AppCompatTextView(context, attrs);
            break;
        case "ImageView":
            view = new AppCompatImageView(context, attrs);
            break;
        case "Button":
            view = new AppCompatButton(context, attrs);
            break;
        case "EditText":
            view = new AppCompatEditText(context, attrs);
            break;
        case "Spinner":
            view = new AppCompatSpinner(context, attrs);
            break;
        case "ImageButton":
            view = new AppCompatImageButton(context, attrs);
            break;
        case "CheckBox":
            view = new AppCompatCheckBox(context, attrs);
            break;
        case "RadioButton":
            view = new AppCompatRadioButton(context, attrs);
            break;
        case "CheckedTextView":
            view = new AppCompatCheckedTextView(context, attrs);
            break;
        case "AutoCompleteTextView":
            view = new AppCompatAutoCompleteTextView(context, attrs);
            break;
        case "MultiAutoCompleteTextView":
            view = new AppCompatMultiAutoCompleteTextView(context, attrs);
            break;
        case "RatingBar":
            view = new AppCompatRatingBar(context, attrs);
            break;
        case "SeekBar":
            view = new AppCompatSeekBar(context, attrs);
            break;
    }

    if (view == null && originalContext != context) {
        // If the original context does not equal our themed context, then we need to manually
        // inflate it using the name so that android:theme takes effect.
        view = createViewFromTag(context, name, attrs);
    }

    if (view != null) {
        // If we have created a view, check its android:onClick
        checkOnClickListener(view, attrs);
    }

    return view;
}

在AppCompatViewInflater對象的createView方法中系統根據不同的tag名字創建出了對應的AppCompat兼容控件。看到這裏,我們明白了Android系統是使用了LayoutInflater的Factor2/Factory結合了AppCompat兼容類來進行高級版本控件的兼容適配的。

接下來,我們注意到註釋1處,setFactory2方法需在super.onCreate方法前調用,否則無效,這是爲什麼呢?

這裏可以先大膽猜測一下,可能是因爲在super.onCreate()方法中就需要將Factory2實例存儲到內存中以便後續使用。下面,我們就跟蹤一下super.onCreate()的源碼,看看是否如我們所假設的一樣。AppCompatActivity的onCreate方法如下所示:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    final AppCompatDelegate delegate = getDelegate();
    delegate.installViewFactory();
    delegate.onCreate(savedInstanceState);
    if (delegate.applyDayNight() && mThemeId != 0) {
        // If DayNight has been applied, we need to re-apply the theme for
        // the changes to take effect. On API 23+, we should bypass
        // setTheme(), which will no-op if the theme ID is identical to the
        // current theme ID.
        if (Build.VERSION.SDK_INT >= 23) {
            onApplyThemeResource(getTheme(), mThemeId, false);
        } else {
            setTheme(mThemeId);
        }
    }
    super.onCreate(savedInstanceState);
}

第一行的delegate實例的installViewFactory()方法就吸引了我們的注意,因爲它包含了一個敏感的關鍵字“Factory“,這裏我們繼續跟蹤進installViewFactory()方法:

public abstract void installViewFactory();

這裏一個是抽象方法,點擊左邊綠色圓圈,可以看到這裏具體的實現類爲AppCompatDelegateImplV9,其實現的installViewFactory()方法如下所示:

@Override
public void installViewFactory() {
    LayoutInflater layoutInflater = LayoutInflater.from(mContext);
    if (layoutInflater.getFactory() == null) {
        LayoutInflaterCompat.setFactory2(layoutInflater, this);
    } else {
        if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImplV9)) {
            Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                    + " so we can not install AppCompat's");
        }
    }
}

可以看到,如果我們在super.onCreate()方法前沒有設置LayoutInflater的Factory2實例的話,這裏就會設置一個默認的Factory2。最後,我們再來看下默認Factory2的onCreateView方法的實現:

/**
 * From {@link LayoutInflater.Factory2}.
 */
@Override
public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
    // 1、First let the Activity's Factory try and inflate the view
    final View view = callActivityOnCreateView(parent, name, context, attrs);
    if (view != null) {
        return view;
    }

    // 2、If the Factory didn't handle it, let our createView() method try
    return createView(parent, name, context, attrs);
}

在註釋1處,我們首先會嘗試讓Activity的Facotry實例去加載對應的View實例,如果Factory不能夠處理它,在註釋2處,就會調用createView方法去創建對應的View,AppCompatDelegateImplV9類的createView方法的實現上面我們已經分析過了,此處就不再贅述了。

總結(上)

在本篇文章中,我們主要對Android的佈局繪製以及加載原理、優化工具、全局監控佈局和控件的加載耗時進行了全面的講解,這爲大家學習《深入探索Android佈局優化(下)》打下了良好的基礎。下面,總結一下本篇文章涉及的五大主題:

  • 1、繪製原理:CPU\GPU、Android圖形系統的整體架構、繪製線程、刷新機制。
  • 2、屏幕適配:OLED 屏幕和 LCD 屏幕的區別、屏幕適配方案。
  • 3、優化工具:使用Systrace來進行佈局優化、利用Layout Inspector來查看視圖層級結構、採用Choreographer來獲取FPS以及自動化測量 UI 渲染性能的方式(gfxinfo、SurfaceFlinger等dumpsys命令)。
  • 4、佈局加載原理:佈局加載源碼分析、LayoutInflater.Factory分析。
  • 5、獲取界面佈局耗時:使用AOP的方式去獲取界面加載的耗時、利用LayoutInflaterCompat.setFactory2去監控每一個控件加載的耗時。

下篇,我們將進入佈局優化的實戰環節,敬請期待~

深入探索Android佈局優化(下)

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章