App繪製優化

貓尾巴發表於2018-09-24

螢幕

App繪製優化

在不同解析度下,dpi將會不同,比如:

App繪製優化

根據上面的表格,我們可以發現,720P,和1080P的手機,dpi是不同的,這也就意味著,不同的解析度中,1dp對應不同數量的px(720P中,1dp=2px,1080P中1dp=3px),這就實現了,當我們使用dp來定義一個控制元件大小的時候,他在不同的手機裡表現出相應大小的畫素值。

App繪製優化

我們可以說,通過dp加上自適應佈局和weight比例佈局可以基本解決不同手機上適配的問題,這基本是最原始的Android適配方案。

App繪製優化

糗事百科適配方案:

www.jianshu.com/p/a4b8e4c5d…

位元組跳動適配方案:

mp.weixin.qq.com/s/d9QCoBP6k…

CPU 與 GPU

UI 渲染還依賴兩個核心的硬體:CPU 和 GPU。UI 元件在繪製到螢幕之前,都需要經過 Rasterization(柵格化)操作,這是一個耗時操作,而 GPU 可以加快柵格化。

App繪製優化

軟體繪製使用的是 Skia 庫,它是一款能在低端裝置如手機上呈現高質量的 2D 跨平臺圖形框架,類似 Chrome、Flutter 內部使用的都是 Skia庫。

OpenGL 與 Vulkan

App繪製優化

Android 7.0 把 OpenGL ES 升級到最新的3.2 版本同時,還新增了對Vulkan的支援。Vulkan 是用於高效能 3D圖形的低開銷、跨平臺 API。

Android 渲染的演進

App繪製優化

Image Stream Producers(影像生產者)

任何可以產生圖形資訊的元件都統稱為影像的生產者,比如OpenGL ES, Canvas 2D, 和 媒體解碼器等。

Image Stream Consumers(影像消費者)

SurfaceFlinger是最常見的影像消費者,Window Manager將圖形資訊收集起來提供給SurfaceFlinger,SurfaceFlinger接受後經過合成再把圖形資訊傳遞給顯示器。同時,SurfaceFlinger也是唯一一個能夠改變顯示器內容的服務。SurfaceFlinger使用OpenGL和Hardware Composer來生成surface. 某些OpenGL ES 應用同樣也能夠充當影像消費者,比如相機可以直接使用相機的預覽介面影像流,一些非GL應用也可以是消費者,比如ImageReader 類。

Window Manager

Window Manager是一個用於控制window的系統服務,包含一系列的View。每個Window都會有一個surface,Window Manager會監視window的許多資訊,比如生命週期、輸入和焦點事件、螢幕方向、轉換、動畫、位置、轉換、z-order等,然後將這些資訊(統稱window metadata)傳送給SurfaceFlinger,這樣,SurfaceFlinger就能將window metadata合成為顯示器上的surface。

Hardware Composer

為硬體抽象層(HAL)的子系統。SurfaceFlinger可以將某些合成工作委託給Hardware Composer,從而減輕OpenGL和GPU的工作。此時,SurfaceFlinger扮演的是另一個OpenGL ES客戶端,當SurfaceFlinger將一個緩衝區或兩個緩衝區合成到第三個緩衝區時,它使用的是OpenGL ES。這種方式會比GPU更為高效。

一般應用開發都要將UI資料使用Activity這個載體去展示,典型的Activity顯示流程為:

startActivity啟動Activity; 為Activity建立一個window(PhoneWindow),並在WindowManagerService中註冊這個window; 切換到前臺顯示時,WindowManagerService會要求SurfaceFlinger為這個window建立一個surface用來繪圖。SurfaceFlinger建立一個”layer”(surface)。(以想象一下C/S架構,SF對應Server,對應Layer;App對應Client,對應Surface),這個layer的核心即是一個BufferQueue,這時候app就可以在這個layer上render了; 將所有的layer進行合成,顯示到螢幕上。

一般app而言,在任何螢幕上起碼有三個layer:

螢幕頂端的status bar 螢幕下面的navigation bar 還有就是app的UI部分。 一些特殊情況下,app的layer可能多餘或者少於3個,例如對全屏顯示的app就沒有status bar,而對launcher,還有個為了wallpaper顯示的layer。status bar和navigation bar是由系統進行去render,因為不是普通app的組成部分嘛。而app的UI部分對應的layer當然是自己去render,所以就有了第4條中的所有layer進行“合成”。

GUI框架

App繪製優化

Hardware Composer 那麼android是如何使用這兩種合成機制的呢?這裡就是Hardware Composer的功勞。處理流程為:

SurfaceFlinger給HWC提供layer list,詢問如何處理這些layer; HWC將每個layer標記為overlay或者GLES composition,然後回饋給SurfaceFlinger; SurfaceFlinger需要去處理那些GLES的合成,而不用去管overlay的合成,最後將overlay的layer和GLES合成後的buffer傳送給HWC處理。

App繪製優化

在繪製過程中,Android 各個圖形元件的作用:

  • 畫筆:Skia 或者 OpenGL。
  • 畫紙:Surface。
  • 畫板:Graphic Buffer,緩衝用於應用程式圖形的繪製。Android 4.1 之前的雙緩衝和之後的三緩衝機制。
  • 顯示:SurfaceFlinger。將 WindowManager 提供的所有 Furface,通過硬體合成器 Hardware Composer 合成並輸出到螢幕。

Android 的硬體加速的歷史

  • Android 3.0 之前,沒有硬體加速。
  • Android 3.0 開始,Android 支援 硬體加速。
  • Android 4.0 預設開始硬體加速。

沒有開啟硬體加速時的繪製方式:

App繪製優化

  • Surface。每個 View 都由某個視窗管理,而每個視窗都關聯一個 Surface。
  • Canvas。通過 Surface 的 lock() 方法獲得一個 Canvas,Canvas 可以簡單理解為 Skia 底層介面的封裝。
  • Graphic Buffer。SurfaceFlinger 會託管一個 BufferQueue,從 BufferQueue 中拿到 Graphic Buffer,然後通過 Canvas 和 Skia 將繪製內容柵格化到上面。
  • SurfaceFlinger,通過 Swap Buffer 把 Front Graphic Buffer 的內容交給 SurfaceFinger,最後硬體合成器 Hardware Composer 合成並輸出到螢幕。

硬體加速後的繪製方式:

App繪製優化

最核心的差別是,通過 GPU 完成 Graphic Buffer 的內容繪製。此外還映入了 DisplayList 的概念,每個 View 內部都有一個 DisplayList,當某個 View 需要重繪時,將它標記為 Dirty。

需要重繪時,也是區域性重繪,只會繪製一個 View 的 DisplayList,而不是像軟體繪製那也需要向上遞迴。

App繪製優化

Android 4.1:Project Butter

單層緩衝引發“畫面撕裂”問題

單層緩衝引發“畫面撕裂”問題

App繪製優化

如上圖,CPU/GPU 向 Buffer 中生成影像,螢幕從 Buffer 中取影像、重新整理後顯示。這是一個典型的生產者——消費者模型。理想的情況是幀率和重新整理頻率相等,每繪製一幀,螢幕顯示一幀。而實際情況是,二者之間沒有必然的大小關係,如果沒有鎖來控制同步,很容易出現問題。 所謂”撕裂”就是一種畫面分離的現象,這樣得到的畫像雖然相似但是上半部和下半部確實明顯的不同。這種情況是由於幀繪製的頻率和螢幕顯示頻率不同步導致的,比如顯示器的重新整理率是75Hz,而某個遊戲的FPS是100. 這就意味著顯示器每秒更新75次畫面,而顯示卡每秒更新100次,比你的顯示器快33%。

雙緩衝

App繪製優化

兩個快取區分別為 Back Buffer 和 Frame Buffer。GPU 向 Back Buffer 中寫資料,螢幕從 Frame Buffer 中讀資料。VSync 訊號負責排程從 Back Buffer 到 Frame Buffer 的複製操作,可認為該複製操作在瞬間完成。

雙緩衝的模型下,工作流程這樣的:

在某個時間點,一個螢幕重新整理週期完成,進入短暫的重新整理空白期。此時,VSync 訊號產生,先完成複製操作,然後通知 CPU/GPU 繪製下一幀影像。複製操作完成後螢幕開始下一個重新整理週期,即將剛複製到 Frame Buffer 的資料顯示到螢幕上。 在這種模型下,只有當 VSync 訊號產生時,CPU/GPU 才會開始繪製。這樣,當幀率大於重新整理頻率時,幀率就會被迫跟重新整理頻率保持同步,從而避免“tearing”現象。

VSYNC 偏移

應用和SurfaceFlinger的渲染迴路必須同步到硬體的VSYNC,在一個VSYNC事件中,顯示器將顯示第N幀,SurfaceFlinger合成第N+1幀,app合成第N+2幀。 使用VSYNC同步可以保證延遲的一致性,減少了app和SurfaceFlinger的錯誤,以及顯示在各個階段之間的偏移。然而,前提是app和SurfaceFlinger每幀時間的變化並不大。因此,從輸入到顯示的延遲至少有兩幀。 為了解決這個問題,您可以使用VSYNC偏移量來減少輸入到顯示的延遲,其方法為將app和SurfaceFlinger的合成訊號與硬體的VSYNC關聯起來。因為通常app的合成耗時是小於兩幀的(33ms左右)。 VSYNC偏移訊號細分為以下3種,它們都保持相同的週期和偏移向量:

HW_VSYNC_0:顯示器開始顯示下一幀。 VSYNC:app讀取輸入並生成下一幀。 SF VSYNC:SurfaceFlinger合成下一幀的。 收到VSYNC偏移訊號之後, SurfaceFlinger 才開始接收緩衝區的資料進行幀的合成,而app才處理輸入並渲染幀,這些操作都將在16.7ms完成。

Jank 掉幀

注意,當 VSync 訊號發出時,如果 GPU/CPU 正在生產幀資料,此時不會發生複製操作。螢幕進入下一個重新整理週期時,從 Frame Buffer 中取出的是“老”資料,而非正在產生的幀資料,即兩個重新整理週期顯示的是同一幀資料。這是我們稱發生了“掉幀”(Dropped Frame,Skipped Frame,Jank)現象。

流暢性解決方案思路

從dumpsys SurfaceFlinger --latency中獲取127幀的資料 上面的命令返回的第一行為裝置本身固有的幀耗時,單位為ns,通常在16.7ms左右 從第二行開始,分為3列,一共有127行,代表每一幀的幾個關鍵時刻,單位也為ns

第一列t1: when the app started to draw (開始繪製影像的瞬時時間) 第二列t2: the vsync immediately preceding SF submitting the frame to the h/w (VSYNC信令將軟體SF幀傳遞給硬體HW之前的垂直同步時間),也就是對應上面所說的軟體Vsync 第三列t3: timestamp immediately after SF submitted that frame to the h/w (SF將幀傳遞給HW的瞬時時間,及完成繪製的瞬時時間)

將第i行和第i-1行t2相減,即可得到第i幀的繪製耗時,提取出每一幀不斷地dump出幀資訊,計算出

一些計算規則

計算fps: 每dumpsys SurfaceFlinger一次計算彙總出一個fps,計算規則為: frame的總數N:127行中的非0行數 繪製的時間T:設t=當前行t2 - 上一行的t2,求出所有行的和∑t fps=N/T (要注意時間轉化為秒) 計算中一些細節問題 一次dumpsys SurfaceFlinger會輸出127幀的資訊,但是這127幀可能是這個樣子:

...
0               0               0
0               0               0
0               0               0
575271438588    575276081296    575275172129
575305169681    575309795514    575309142441
580245208898    580250445565    580249372231
580279290043    580284176346    580284812908
580330468482    580334851815    580333739054 
0               0               0
0               0               0
...
575271438588    575276081296    575275172129
575305169681    575309795514    575309142441
複製程式碼

出現0的地方是由於buffer中沒有資料,而非0的地方為繪製幀的時刻,因此僅計算非0的部分資料 觀察127行資料,會發現偶爾會出現9223372036854775808這種數字,這是由於字元溢位導致的,因此這一行資料也不能加入計算 不能單純的dump一次計算出一個fps,舉個例子,如果A時刻操作了手機,停留3s後,B時刻再次操作手機,按照上面的計算方式,則t>3s,並且也會參與到fps的計算去,從而造成了fps不準確,因此,需要加入一個閥值判斷,當t大於某個值時,就計算一次fps,並且把相關資料重新初始化,這個值一般取500ms 如果t<16.7ms,則直接按16.7ms算,同樣的總耗時T加上的也是16.7

計算jank的次數: 如果t3-t1>16.7ms,則認為發生一次卡頓 流暢度得分計算公式 設目標fps為target_fps,目標每幀耗時為target_ftime=1000/target_fps 從以下幾個維度衡量流暢度:

fps: 越接近target_fps越好,權重分配為40% 掉幀數:越少越好,權重分配為40% 超時幀:拆分成以下兩個維度

超時幀的個數,越少越好,權重分配為5% 最大超時幀的耗時,越接近target_ftime越好,權重分配為15%

end_time = round(last_frame_time / 1000000000, 2)
T = utils.get_current_time()
fps = round(frame_counts * 1000 / total_time, 2)

# 計算得分
g = fps / target
if g > 1:
  g = 1
if max_frame_time - kpi <= 1:
       max_frame_time = kpi
h = kpi / max_frame_time
 score = round((g * 50 + h * 10 + (1 - over_kpi_counts / frame_counts) * 40), 2)
複製程式碼

2012 年 I/O 大會上宣佈 Project Butter 黃油計劃,在 4.1 中正式開啟這個機制。

Project Butter 主要包含:VSYNC 和 Triple Buffering(三快取機制)。

VSYNC 類似時鐘中斷。每次收到 VSYNC 中斷,CPU 會立即準備 Buffer 資料,業內標準重新整理頻率是 60Hz(每秒重新整理 60次),也就是一幀資料的準備時間要在 16ms 內完成。

App繪製優化

Android 4.1 之前,Android 使用雙緩衝機制,不同的 View 或者 Activity 它們都會共用一個 Window,也就是共用同一個 Surface。

每個 Surface 都會有一個 BufferQueue 緩衝佇列,這個佇列會由 SurfaceFlinger 管理,通過匿名共享記憶體機制與 App 應用層互動。

App繪製優化

App繪製優化

安卓系統中有 2 種 VSync 訊號:

螢幕產生的硬體 VSync: 硬體 VSync 是一個脈衝訊號,起到開關或觸發某種操作的作用。 由 SurfaceFlinger 將其轉成的軟體 Vsync 訊號:經由 Binder 傳遞給 Choreographer。

如何理解 Triple Buffering(三快取機制)?

雙緩衝只有 A 和 B 兩個緩衝區,如果 CPU/GPU 繪製時間較長,超過一個 VSYNC 訊號週期,因為緩衝區 B 中的資料沒有準備好,只能繼續展示 A 緩衝區的內容,這樣緩衝區 A 和 B 都分別被顯示裝置和 GPU 佔用,CPU 無法準備下一幀的資料。

App繪製優化

增加一個緩衝區,CPU、GPU 和顯示裝置都有各自的緩衝區,互不影響。

簡單來說,三快取機制就是在雙緩衝機制的基礎上,增加一個 Graphic Buffer 緩衝區,這樣可以最大限度的利用空閒時間,帶來的壞處是多私用了一個 Graphic Buffer 所佔用的記憶體。

App繪製優化

檢測工具:

Systrace,Android 4.1 新增的新能資料取樣和分析工具。 Tracer for OpenGL ES,Android 4.1 新增的工具,可以逐幀、逐函式的記錄 App 用 OpenGL ES 的繪製過程。 過度繪製工具,Android 4.2 新增,參考《檢查 GPU 渲染速度和繪製過度》

60 fps

手機螢幕是由許多的畫素點組成的,每個畫素點通過顯示不同的顏色最終螢幕呈現各種各樣的影像。手機系統的型別和手機硬體的不同導致UI的流暢性體驗個不一致。

螢幕展示的顏色資料

  • 在GPU中有一塊緩衝區叫做 Frame Buffer ,這個幀緩衝區可以認為是儲存畫素值的二位陣列。
  • 陣列中的每一個值就對應了手機螢幕的畫素點需要顯示的顏色。
  • 由於這個幀緩衝區的數值是在不斷變化的,所以只要完成對螢幕的重新整理就可以顯示不同的影像了。
  • 至於重新整理工作的邏輯電路會定期的重新整理 Frame Buffer的。 目前主流的重新整理頻率為60次/秒 折算出來就是16ms重新整理一次。
  • GPU 除了幀緩衝區用以交給手機螢幕進行繪製外. 還有一個緩衝區 Back Buffer 這個用以交給應用的,讓CPU往裡面填充資料。
  • GPU會定期交換 Back Buffer 和 Frame Buffer ,也就是對Back Buffer中的資料進行柵格化後將其轉到 Frame Buffer 然後交給螢幕進行顯示繪製,同時讓原先的Frame Buffer 變成 Back Buffer 讓程式處理。

Android的16ms

在Choreographer類中我們有一個方法獲取螢幕重新整理速率:

public final class Choreographer {
	private static float getRefreshRate() {
        DisplayInfo di = DisplayManagerGlobal.getInstance().getDisplayInfo(
                Display.DEFAULT_DISPLAY);
        return di.refreshRate;
    }
}

public final class DisplayInfo implements Parcelable {
    public float refreshRate;
}

final class VirtualDisplayAdapter extends DisplayAdapter {
	private final class VirtualDisplayDevice extends DisplayDevice implements DeathRecipient {
		@Override
        public DisplayDeviceInfo getDisplayDeviceInfoLocked() {
            if (mInfo == null) {
                mInfo = new DisplayDeviceInfo();
                mInfo.refreshRate = 60;
            }
            return mInfo;
        }
	}
}
複製程式碼

VSYNC

VSYNC是Vertical Synchronization(垂直同步)的縮寫,是一種在PC上已經很早就廣泛使用的技術。 可簡單的把它認為是一種定時中斷。

App繪製優化

由上圖可知

1.時間從0開始,進入第一個16ms:Display顯示第0幀,CPU處理完第一幀後,GPU緊接其後處理繼續第一幀。三者互不干擾,一切正常。 2.時間進入第二個16ms:因為早在上一個16ms時間內,第1幀已經由CPU,GPU處理完畢。故Display可以直接顯示第1幀。顯示沒有問題。但在本16ms期間,CPU和GPU 卻並未及時去繪製第2幀資料(注意前面的空白區),而是在本週期快結束時,CPU/GPU才去處理第2幀資料。 3.時間進入第3個16ms,此時Display應該顯示第2幀資料,但由於CPU和GPU還沒有處理完第2幀資料,故Display只能繼續顯示第一幀的資料,結果使得第1 幀多畫了一次(對應時間段上標註了一個Jank)。 4.通過上述分析可知,此處發生Jank的關鍵問題在於,為何第1個16ms段內,CPU/GPU沒有及時處理第2幀資料?原因很簡單,CPU可能是在忙別的事情(比如某個應用通過sleep 固定時間來實現動畫的逐幀顯示),不知道該到處理UI繪製的時間了。可CPU一旦想起來要去處理第2幀資料,時間又錯過了!

NSYNC的出現

App繪製優化

由圖可知,每收到VSYNC中斷,CPU就開始處理各幀資料。整個過程非常完美。 不過,仔細琢磨圖2卻會發現一個新問題:圖2中,CPU和GPU處理資料的速度似乎都能在16ms內完成,而且還有時間空餘,也就是說,CPU/GPU的FPS(幀率,Frames Per Second)要高於Display的FPS。確實如此。由於CPU/GPU只在收到VSYNC時才開始資料處理,故它們的FPS被拉低到與Display的FPS相同。但這種處理並沒有什麼問題,因為Android裝置的Display FPS一般是60,其對應的顯示效果非常平滑。 如果CPU/GPU的FPS小於Display的FPS,會是什麼情況呢?請看下圖:

App繪製優化

由圖可知: 1.在第二個16ms時間段,Display本應顯示B幀,但卻因為GPU還在處理B幀,導致A幀被重複顯示。 2.同理,在第二個16ms時間段內,CPU無所事事,因為A Buffer被Display在使用。B Buffer被GPU在使用。注意,一旦過了VSYNC時間點, CPU就不能被觸發以處理繪製工作了。

Triple Buffer

為什麼CPU不能在第二個16ms處開始繪製工作呢?原因就是隻有兩個Buffer。如果有第三個Buffer的存在,CPU就能直接使用它, 而不至於空閒。出於這一思路就引出了Triple Buffer。結果如圖所示:

App繪製優化

由圖可知: 第二個16ms時間段,CPU使用C Buffer繪圖。雖然還是會多顯示A幀一次,但後續顯示就比較順暢了。 是不是Buffer越多越好呢?回答是否定的。由圖4可知,在第二個時間段內,CPU繪製的第C幀資料要到第四個16ms才能顯示, 這比雙Buffer情況多了16ms延遲。所以,Buffer最好還是兩個,三個足矣。

以上對VSYNC進行了理論分析,其實也引出了Project Buffer的三個關鍵點: 核心關鍵:需要VSYNC定時中斷。 Triple Buffer:當雙Buffer不夠使用時,該系統可分配第三塊Buffer。 另外,還有一個非常隱祕的關鍵點:即將繪製工作都統一到VSYNC時間點上。這就是Choreographer的作用。在它的統一指揮下,應用的繪製工作都將變得井井有條。

Android 5.0:RenderThread

App繪製優化

5.0 之前,GPU 的高效能運算,都是在 UI 執行緒完成的,5.0 之後引入了兩個重大改變,一個是引入 RenderNode 的概念,它對 DisplayList 及一些 View 顯示屬性做了進一步封裝;另一個是引入 RenderThread,所有 GL 命令執行都放在這個單獨的執行緒上,渲染執行緒在 RenderNode 中存有渲染幀的所有資訊,可以做一些屬性動畫。

此處還可以開啟 Profile GPU Rendering 檢查,6.0 之後,會輸出下面的計算和繪製每個階段的耗時。

App繪製優化

App繪製優化

UI 渲染測量的兩種工具:

測試工具:Profile GPU Rendering 和 Show GPU Overdraw。參考:《檢查 GPU 渲染速度和繪製過度》

Layout Inspector

用於分析手機上正在執行的呃App的檢視佈局結構。

GPU呈現模式分析工具簡介

Profile GPU Rendering工具的使用很簡單,就是直觀上看一幀的耗時有多長,綠線是16ms的閾值,超過了,可能會導致掉幀,這個跟VSYNC垂直同步訊號有關係,當然,這個圖表並不是絕對嚴謹的(後文會說原因)。每個顏色的方塊代表不同的處理階段,先看下官方文件給的對映表:

App繪製優化

想要完全理解各個階段,要對硬體加速及GPU渲染有一定的瞭解,不過,有一點,必須先記心裡:雖名為 Profile GPU Rendering,但圖示中所有階段都發生在CPU中,不是GPU 。最終CPU將命令提交到 GPU 後觸發GPU非同步渲染螢幕,之後CPU會處理下一幀,而GPU並行處理渲染,兩者硬體上算是並行。 不過,有些時候,GPU可能過於繁忙,不能跟上CPU的步伐,這個時候,CPU必須等待,也就是最終的swapbuffer部分,主要是最後的紅色及黃色部分(同步上傳的部分不會有問題,個人認為是因為在Android GPU與CPU是共享記憶體區域的),在等待時,將看到橙色條和紅色條中出現峰值,且命令提交將被阻止,直到 GPU 命令佇列騰出更多空間。

App繪製優化

穩定定位工具:Systrace 和 Tracer for OpenGL ES,參考《Slow rendering》,Android 3.1 之後,推薦使用 Graphics API Debugger(GAPID)來替代 Tracer for OpenGL ES 工具。

有哪些自動化測量 UI 渲染效能的工具?

使用dumpsys gfxinfo 測UI效能

dumpsys是一款執行在裝置上的Android工具,將 gfxinfo命令傳遞給dumpsys可在logcat中提供輸出,其中包含各階段發生的動畫以及幀相關的效能資訊。

adb shell dumpsys gfxinfo < PACKAGE_NAME >
複製程式碼

該命令可用於蒐集幀的耗時資料。執行該命令後,可以等到如下的 結果:

Applications Graphics Acceleration Info:
Uptime: 102809662 Realtime: 196891968

** Graphics info for pid 31148 [com.android.settings] **

Stats since: 102794621664587ns
Total frames rendered: 105
Janky frames: 2 (1.90%)
50th percentile: 5ms
90th percentile: 7ms
95th percentile: 9ms
99th percentile: 19ms
Number Missed Vsync: 0
Number High input latency: 0
Number Slow UI thread: 2
Number Slow bitmap uploads: 0
Number Slow issue draw commands: 1
HISTOGRAM: 5ms=78 6ms=16 7ms=4 8ms=1 9ms=2 10ms=0 11ms=0 12ms=0 13ms=2 14ms=0 15ms=0 16ms=0 17ms=0 18ms=0 19ms=1 20ms=0 21ms=0 22ms=0 23ms=0 24ms=0 25ms=0 26ms=0 27ms=0 
...
...
複製程式碼

Graphics info for pid 31148 [com.android.settings]: 表明當前dump的為設定介面的幀資訊,pid為31148 Total frames rendered: 105 本次dump蒐集了105幀的資訊 Janky frames: 2 (1.90%) 105幀中有2幀的耗時超過了16ms,卡頓概率為1.9% Number Missed Vsync: 0 垂直同步失敗的幀 Number High input latency: 0 處理input時間超時的幀數 Number Slow UI thread: 2 因UI執行緒上的工作導致超時的幀數 Number Slow bitmap uploads: 0 因bitmap的載入耗時的幀數 Number Slow issue draw commands: 1 因繪製導致耗時的幀數 HISTOGRAM: 5ms=78 6ms=16 7ms=4 ... 直方圖資料,表面耗時為0-5ms的幀數為78,耗時為5-6ms的幀數為16,同理類推。

在Android 6.0以後為gfxinfo 提供了一個新的引數framestats,其作用可以從最近的幀中提供非常詳細的幀資訊,以便您可以更準確地跟蹤和除錯問題。

> adb shell dumpsys gfxinfo < PACKAGE_NAME > framestats
複製程式碼

此命令將應用程式生成的最後120幀資訊列印出,其中包含納秒時間戳。以下是來自adb dumpsys gfxinfo <PACKAGE_NAME>的示例原始輸出framestats:

0 ,27965466202353 ,27965466202353 ,27965449758000 ,27965461202353 ,27965467153286 ,27965471442505 ,27965471925682 ,27965474025318 ,27965474588547 ,27965474860786 ,27965475078599 ,27965479796151 ,27965480589068 ,0 ,27965482993342 ,27965482993342 ,27965465835000 ,27965477993342 ,27965483807401 ,27965486875630 ,
27965487288443 ,27965489520682 ,27965490184380 ,27965490568703 ,27965491408078 ,27965496119641 ,27965496619641 ,0 ,27965499784331 ,27965499784331 ,27965481404000 ,27965494784331 ,27965500785318 ,27965503736099 ,27965504201151 ,27965506776568 ,27965507298443 ,27965507515005 ,27965508405474 ,27965513495318 ,27965514061984 ,

0,27965516575320,27965516575320,27965497155000,27965511575320,27965517697349,27965521276151,27965521734797,27965524350474,27965524884536,27965525160578,27965526020891,27965531371203,27965532114484,
複製程式碼

此輸出的每一行代表應用程式生成的一幀。每一行的列數都相同,每列對應描述幀在不同的時間段的耗時情況。

Framestats資料格式

由於資料塊以CSV格式輸出,因此將其貼上電子表格工具中非常簡單,或者通過指令碼進行收集和分析。下表說明了輸出資料列的格式。所有的時間戳都是納秒。

  • FLAGS

FLAGS列為'0'的行可以通過從FRAME_COMPLETED列中減去INTENDED_VSYNC列計算其總幀時間。

如果非零,則該行應該被忽略,因為該幀的預期佈局和繪製時間超過16ms,為異常幀。

  • INTENDED_VSYNC

幀的的預期起點。如果此值與VSYNC不同,是由於 UI 執行緒中的工作使其無法及時響應垂直同步訊號所造成的。

  • VSYNC

花費在vsync監聽器和幀繪製的時間(Choreographer frame回撥,動畫,View.getDrawingTime()等)

  • OLDEST_INPUT_EVENT

輸入佇列中最舊輸入事件的時間戳,如果沒有輸入事件,則輸入Long.MAX_VALUE。

此值主要用於平臺工作,對應用程式開發人員的用處有限。

  • NEWEST_INPUT_EVENT

輸入佇列中最新輸入事件的時間戳,如果幀沒有輸入事件,則為0。

此值主要用於平臺工作,對應用程式開發人員的用處有限。

然而,通過檢視(FRAME_COMPLETED - NEWEST_INPUT_EVENT),可以大致瞭解應用程式新增的延遲時間。

  • HANDLE_INPUT_START

將輸入事件分派給應用程式的時間戳。

通過檢視這段時間和ANIMATION_START之間的時間,可以測量應用程式處理輸入事件的時間。

如果這個數字很高(> 2ms),這表明程式花費了非常長的時間來處理輸入事件,例如View.onTouchEvent(),也就是說此工作需要優化,或者分發到不同的執行緒。請注意,某些情況下這是可以接受的,例如發起新活動或類似活動的點選事件,並且此數字很大。

  • ANIMATION_START

執行Choreographer註冊動畫的時間戳。

通過檢視這段時間和PERFORM_TRANVERSALS_START之間的時間,可以確定評估執行的所有動畫器(ObjectAnimator,ViewPropertyAnimator和常用轉換器)需要多長時間。

如果此數字很高(> 2ms),請檢查您的應用是否編寫了自定義動畫以確保它們適用於動畫。

  • PERFORM_TRAVERSALS_START

PERFORM_TRAVERSALS_STAR-DRAW_START,則可以提取佈局和度量階段完成的時間。(注意,在滾動或動畫期間,你會希望這應該接近於零..)

  • DRAW_START

performTraversals的繪製階段開始的時間。這是錄製任何無效檢視的顯示列表的起點。

這和SYNC_START之間的時間是在樹中所有無效檢視上呼叫View.draw()所花費的時間。

  • SYNC_QUEUED

同步請求傳送到RenderThread的時間。

這標誌著開始同步階段的訊息被髮送到RenderThread的時刻。如果此時間和SYNC_START之間的時間很長(> 0.1ms左右),則意味著RenderThread忙於處理不同的幀。在內部,這被用來區分幀做了太多的工作,超過了16ms的預算,由於前一幀超過了16ms的預算,幀被停止了。

  • SYNC_START

繪圖的同步階段開始的時間。

如果此時間與ISSUE_DRAW_COMMANDS_START之間的時間很長(> 0.4ms左右),則通常表示有許多新的點陣圖必須上傳到GPU。

  • ISSUE_DRAW_COMMANDS_START

硬體渲染器開始向GPU發出繪圖命令的時間。

這段時間和FRAME_COMPLETED之間的時間間隔顯示了應用程式正在生產多少GPU。像這樣出現太多透支或低效率渲染效果的問題。

  • SWAP_BUFFERS

eglSwapBuffers被呼叫的時間。

  • FRAME_COMPLETED

幀的完整時間。花在這個幀上的總時間可以通過FRAME_COMPLETED - INTENDED_VSYNC來計算。

你可以用不同的方式使用這些資料。例如下面的直方圖,顯示不同幀時間的分佈(FRAME_COMPLETED - INTENDED_VSYNC),如下圖所示。

App繪製優化

這張圖一目瞭然地告訴我們,大多數的幀耗時都遠低於16ms(用紅色表示),但幾幀明顯超過了16ms。隨著時間的推移,我們可以檢視此直方圖中的變化,以檢視批量變化或新建立的異常值。您還可以根據資料中的許多時間戳來繪製出輸入延遲,佈局花費的時間或其他類似的感興趣度量。

如果在開發者選項中的CPU呈現模式分析中選擇adb shell dumpsys gfxinfo,則adb shell dumpsys gfxinfo命令將輸出最近120幀的時間資訊,並將其分成幾個不同的類別,可以直觀的顯示各部分的快慢。

與上面的framestats類似,將它貼上到您選擇的電子表格工具中非常簡單,或者通過指令碼進行收集和解析。下圖顯示了應用程式生成的幀每個階段的詳細耗時。

App繪製優化

執行gfxinfo,複製輸出,將其貼上到電子表格應用程式中,並將資料繪製為直方圖的結果。

每個垂直條代表一幀動畫; 其高度表示計算該動畫幀所用的毫秒數。條形圖中的每個彩色段表示渲染管道的不同階段,以便您可以看到應用程式的哪些部分可能會造成瓶頸。

framestats資訊和frame耗時資訊通常為2s收集一次(一次120幀,一幀16ms,耗時約2s)。為了精確控制時間視窗,例如,將資料限制為特定的動畫 ,可以重置所有計數器,並重新收集的資料。

> adb shell dumpsys gfxinfo < PACKAGE_NAME > reset
複製程式碼

同樣 也適用於需要捕獲小於2s的資料。

dumpsys是能發現問題或者判斷問題的嚴重性,但無法定位真正的原因。如果要定位原因,應當配合systrace工具使用。

SurfaceFlinger。三快取機制,在 4.1 之後,每個 Surface 都會有三個 Graphic Buffer,這部分可以才看到到當前渲染所佔用的記憶體資訊。對於這部分記憶體,當應用退到後臺的時候,系統會將這些記憶體回收,不會記入應用的記憶體佔用中。

UI 優化有哪些常用手段?

  • 儘量使用硬體加速。

有些 Convas API 不能完美支援硬體加速,參考 drawing-support 文件。SVG 對很多指令硬體加速也不支援。

  • Create View 優化。

View 的建立在 UI 執行緒,複雜介面會引起耗時,耗時分析可以分解成:XML 的隨機讀 的 I/O 時間、解析 XML 時間、生成物件的時間等。可以使用的優化方式有:使用程式碼建立,例如使用 XML 轉換為 Java 程式碼的工具,例如 X2C;

非同步建立,在子執行緒建立 View 會出現異常,可以先把子執行緒的 Looper 的 MessageQueue 替換成 UI 執行緒 Looper 的 Queue;

App繪製優化

View 重用。

App繪製優化

  • measure/layout 優化。渲染流程中 measure 和 layout 也需要 CPU 在主執行緒執行。優化的方法有:減少 UI 佈局層次,儘量扁平化,<ViewStub>,<merge>、優化 layout 開銷,避免使用 RelativeLayout 或者 基於 weighted 的 LinearLayout。使用ConstraintLayout;背景優化,不要重複設定背景。

TextView 是系統空間中,非常複雜的一個控制元件,強大的背後就代表了很多計算,2018 年 Google I/O 釋出了 PercomputedText 並已經整合在 Jetpack 中,它提供了介面,可以非同步進行 measure 和 layout,不必在主執行緒中執行。

UI 優化的進階手段有哪些?

  • Litho:非同步佈局。這是 Facebook 開源的宣告式 Android UI 渲染框架,基於另外一個 Facebook 開源的佈局引擎 Yoga 開發的。Litho 的缺點很明顯,太重了。
  • Flutter:自己的佈局 + 渲染引擎。Flutter 使用 Skia 引擎渲染 UI,直接使用 Dart 虛擬機器,跳出了 Android 原有的方案。參考:《Flutter 原理和實踐
  • RenderThread 和 RenderScript。5.0 開始,系統增加了 RenderThread,當主執行緒阻塞時,普通動畫會出現丟幀卡頓,而使用 RenderThread 渲染的動畫即使阻塞了主執行緒,仍然不受影響。參考《RenderThread實現動畫非同步渲染》

RenderScript 參考:

RenderScript 渲染利器

RenderScript:簡單而快速的影像處理 Android RenderScript 簡單實現圖片的高斯模糊效果

佈局優化

在編寫Android的佈局時總會遇到這樣或者那樣的痛點,比如:

  1. 有些佈局的在很多頁面都用到了,而且樣式都一樣,每次用到都要複製貼上一大段,有沒有辦法可以複用呢?
  2. 解決了1中的問題之後,發現複用的佈局外面總要額外套上一層佈局,要知道佈局巢狀是會影響效能的吶;
  3. 有些佈局只有用到時才會顯示,但是必須提前寫好,設定雖然為了invisible或gone,還是多多少少會佔用記憶體的。

include

include的中文意思是“包含”,“包括”,你當一個在主頁面裡使用include標籤時,就表示當前的主佈局包含標籤中的佈局,這樣一來,就能很好地起到複用佈局的效果了在那些常用的佈局比如標題欄和分割線等上面用上它可以極大地減少程式碼量的它有兩個主要的屬性。:

  • layout:必填屬性,為你需要插入當前主佈局的佈局名稱,通過R.layout.xx的方式引用;
  • id:當你想給通過包括新增進來的佈局設定一個ID的時候就可以使用這個屬性,它可以重寫插入主佈局的佈局ID。

常規使用

我們先建立一個ViewOptimizationActivity,然後再建立一個layout_include.xml佈局檔案,它的內容非常簡單,就一個TextView的:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:gravity="center_vertical"
    android:textSize="14sp"
    android:background="@android:color/holo_red_light"
    android:layout_height="40dp">
</TextView>
複製程式碼

現在我們就用include標籤,將其新增到ViewOptimizationActivity的佈局中:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ViewOptimizationActivity">

    <!--include標籤的使用-->
    <TextView
        android:textSize="18sp"
        android:text="1、include標籤的使用"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <include
        android:id="@+id/tv_include1"
        layout="@layout/layout_include"/>

</LinearLayout>
複製程式碼

為了驗證它就是layout_include.xml的根佈局的TextView的ID,我們在ViewOptimizationActivity中初始化的TextView,並給它設定文字:

TextView tvInclude1 = findViewById(R.id.tv_include1);
tvInclude1.setText("1.1 常規下的include佈局");
複製程式碼

App繪製優化

說明我們設定的佈局和標識都是成功的不過你可能會對ID這個屬性有疑問:?ID我可以直接在的TextView中設定啊,為什麼重寫它呢別忘了我們的目的是複用,當在你主一個佈局中使用include標籤新增兩個以上的相同佈局時,ID相同就會衝突了,所以重寫它可以讓我們更好地呼叫它和它裡面的控制元件。還有一種情況,假如你的主佈局是RelateLayout,這時為了設定相對位置,你也需要給它們設定不同的ID。

重寫根佈局的佈局屬性

除了id之外,我們還可以重寫寬高,邊距和可見性(visibility)這些佈局屬性。但是一定要注意,單單重寫android:layout_height或者android:layout_width是不行,必須兩個同時重寫才起作用。包括邊距也是這樣,如果我們想給一個包括進來的佈局新增右邊距的話的完整寫法是這樣的:

<include
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:layout_marginEnd="40dp"
        android:id="@+id/tv_include2"
        layout="@layout/layout_include"/>
複製程式碼

App繪製優化

控制元件ID相同時的處理

在1.1中我們知道了ID可以屬性重寫include佈局的根佈局ID,但對於根佈局裡面的佈局和控制元件是無能為力的,如果這時一個佈局在主佈局中包括了多次,那怎麼區別裡面的控制元件呢?

我們先建立一個layout_include2.xml的佈局,它的根佈局是FrameLayout,裡面有一個TextView,它的ID是tv_same:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:background="@android:color/holo_orange_light"
    android:layout_height="wrap_content">

    <TextView
        android:gravity="center_vertical"
        android:id="@+id/tv_same"
        android:layout_width="match_parent"
        android:layout_height="50dp" />

</FrameLayout>
複製程式碼

在主佈局中新增進去:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ViewOptimizationActivity">

    <!--include標籤的使用-->
    ……

    <include layout="@layout/layout_include2"/>

    <include
        android:id="@+id/view_same"
        layout="@layout/layout_include2"/>

</LinearLayout>
複製程式碼

為了區分,這裡給第二個layout_include2設定了ID也許你已經反應過來了,沒錯,我們就是要建立根佈局的物件,然後再去初始化裡面的控制元件:

TextView tvSame = findViewById(R.id.tv_same);
tvSame.setText("1.3 這裡的TextView的ID是tv_same");
FrameLayout viewSame = findViewById(R.id.view_same);
TextView tvSame2 = viewSame.findViewById(R.id.tv_same);
tvSame2.setText("1.3 這裡的TextView的ID也是tv_same");
複製程式碼

App繪製優化

merge

include標籤雖然解決了佈局重用的問題,卻也帶來了另外一個問題:佈局巢狀因為把需要重用的佈局放到一個子佈局之後就必須加一個根佈局,如果你的主佈局的根佈局和你需要包括的根佈局都是一樣的(比如都是LinearLayout),那麼就相當於在中間多加了一層多餘的佈局了。有那麼沒有辦法可以在使用include時不增加布局層級呢?答案當然是有的,就是那使用merge標籤。

使用merge標籤要注意一點一:必須是一個佈局檔案中的根節點,看起來跟其他佈局沒什麼區別,但它的特別之處在於頁面載入時它的不會繪製,它就像是佈局或者控制元件的搬運工,把“貨物”搬到主佈局之後就會功成身退,不會佔用任何空間,因此也就不會增加布局層級了。這正如它的名字一樣,只起“合併”作用。

常規使用

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/tv_merge1"
        android:text="我是merge中的TextView1"
        android:background="@android:color/holo_green_light"
        android:gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="40dp" />

    <TextView
        android:layout_toEndOf="@+id/tv_merge1"
        android:id="@+id/tv_merge2"
        android:text="我是merge中的TextView2"
        android:background="@android:color/holo_blue_light"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="40dp" />
</merge>
複製程式碼

這裡我使用了一些相對佈局的屬性,原因後面你就知道了我們接著在ViewOptimizationActivity的佈局新增RelativeLayout的,然後使用包括標籤將layout_merge.xml新增進去:

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <include
        android:id="@+id/view_merge"
        layout="@layout/layout_merge"/>
</RelativeLayout>
複製程式碼

App繪製優化

對佈局層級的影響

在layout_merge.xml中,使用我們相對佈局的屬性android:layout_toEndOf將藍色的TextView設定到了綠色的TextView的右邊,而layout_merge.xml的父佈局是RelativeLayout,所以這個屬性是起了作用了,merge標籤不會影響裡面的控制元件,也不會增加布局層級。

App繪製優化

看到可以RelativeLayout下面直接就是兩個TextView的了,merge標籤並沒有增加布局層級。可以看出merge的侷限性,即需要你明確將merge裡面的佈局控制元件狀語從句:include到什麼型別的佈局中,提前設定merge裡面的佈局和控制元件的位置。

合併的ID

學習在include標籤時我們知道,android:id屬性可以重寫被包括的根佈局ID,但如果根節點merge呢?說前面了merge並不會作為一個佈局繪製出來,所以這裡給它設定ID是不起作用的。我們在它的父佈局RelativeLayout中再加一個TextView的,使用android:layout_below屬性把設定到layout_merge下面:

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <include
        android:id="@+id/view_merge"
        layout="@layout/layout_merge"/>

    <TextView
        android:text="我不是merge中的佈局"
        android:layout_below="@+id/view_merge"
        android:background="@android:color/holo_purple"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="40dp"/>
</RelativeLayout>
複製程式碼

執行之後你會發現新加的TextView中會把合併佈局蓋住,沒有像預期那樣在其下方。把如果android:layout_below中的ID改為layout_merge.xml中任一的TextView的ID(比如tv_merge1),執行之後就可以看到如下效果:

App繪製優化

即佈局父RelativeLayout下級佈局就是包括進去的TextView的了。

ViewStub

你一定遇到這樣的情況:頁面中有些佈局在初始化時沒必要顯示,但是又不得不事先在佈局檔案中寫好,設定雖然成了invisible或gone,但是在初始化時還是會載入,這無疑會影響頁面載入速度。針對這一情況,Android為我們提供了一個利器---- ViewStub。這是一個不可見的,大小為0的檢視,具有懶載入的功能,它存在於檢視層級中,但只會在setVisibility()狀語從句:inflate()方法呼叫只會才會填充檢視,所以不會影響初始化載入速度它有以下三個重要屬性:

  • android:layout:ViewStub需要填充的檢視名稱,為“R.layout.xx”的形式;
  • android:inflateId:重寫被填充的檢視的父佈局ID。

與include標籤不同,ViewStub的android:id屬性的英文設定ViewStub本身ID的,而不是重寫佈局ID,這一點可不要搞錯了。另外,ViewStub還提供了OnInflateListener介面,用於監聽佈局是否已經載入了。

填充佈局的正確方式

我們先建立一個layout_view_stub.xml,放置裡面一個Switch開關:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:background="@android:color/holo_blue_dark"
    android:layout_height="100dp">
    <Switch
        android:id="@+id/sw"
        android:layout_gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</FrameLayout>
複製程式碼
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ViewOptimizationActivity">

    <!--ViewStub標籤的使用-->
    <TextView
        android:textSize="18sp"
        android:text="3、ViewStub標籤的使用"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <ViewStub
        android:id="@+id/view_stub"
        android:inflatedId="@+id/view_inflate"
        android:layout="@layout/layout_view_stub"
        android:layout_width="match_parent"
        android:layout_height="100dp" />
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <Button
            android:text="顯示"
            android:id="@+id/btn_show"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content" />

        <Button
            android:text="隱藏"
            android:id="@+id/btn_hide"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content" />

        <Button
            android:text="操作父佈局控制元件"
            android:id="@+id/btn_control"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    </LinearLayout>
</LinearLayout>
複製程式碼

在ViewOptimizationActivity中監聽ViewStub的填充事件:

viewStub.setOnInflateListener(new ViewStub.OnInflateListener() {
            @Override
            public void onInflate(ViewStub viewStub, View view) {
                Toast.makeText(ViewOptimizationActivity.this, "ViewStub載入了", Toast.LENGTH_SHORT).show();
            }
        });
複製程式碼

然後通過按鈕事件來填充和顯示layout_view_stub:

@Override
public void onClick(View view) {
    switch (view.getId()) {
        case R.id.btn_show:
            viewStub.inflate();
            break;
        case R.id.btn_hide:
            viewStub.setVisibility(View.GONE);
            break;
        default:
            break;
    }
}
複製程式碼

執行之後,點選“顯示”按鈕,layout_view_stub顯示了,並彈出 “ViewStub載入了” 的吐司;點選“隱藏”按鈕,佈局又隱藏掉了,但是再點選一下“顯示”按鈕,頁面居然卻閃退了,檢視日誌,發現丟擲了一個異常:

java.lang.IllegalStateException: ViewStub must have a non-null ViewGroup viewParent
複製程式碼

我們開啟ViewStub的原始碼

public View inflate() {
        final ViewParent viewParent = getParent();

        if (viewParent != null && viewParent instanceof ViewGroup) {
            if (mLayoutResource != 0) {
                final ViewGroup parent = (ViewGroup) viewParent;
                final View view = inflateViewNoAdd(parent);
                replaceSelfWithView(view, parent);

                mInflatedViewRef = new WeakReference<>(view);
                if (mInflateListener != null) {
                    mInflateListener.onInflate(this, view);
                }

                return view;
            } else {
                throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
            }
        } else {
            throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
        }
    }
複製程式碼
private void replaceSelfWithView(View view, ViewGroup parent) {
        final int index = parent.indexOfChild(this);
        parent.removeViewInLayout(this);

        final ViewGroup.LayoutParams layoutParams = getLayoutParams();
        if (layoutParams != null) {
            parent.addView(view, index, layoutParams);
        } else {
            parent.addView(view, index);
        }
    }
複製程式碼

果然,ViewStub在這裡呼叫了removeViewInLayout()方法把自己從佈局移除了。到這裡我們就明白了,ViewStub在填充佈局成功之後就會自我銷燬,再次呼叫inflate()方法就會丟擲IllegalStateException異常異常了。此時如果想要再次顯示佈局,可以呼叫setVisibility()方法。

為了避免inflate()方法多次呼叫,我們可以採用如下三種方式:

try {
    viewStub.inflate();
} catch (IllegalStateException e) {
    Log.e("Tag",e.toString());
    view.setVisibility(View.VISIBLE);
}
複製程式碼
if (isViewStubShow){
    viewStub.setVisibility(View.VISIBLE);
}else {
    viewStub.inflate();
}
複製程式碼
public void setVisibility(int visibility) {
    if (mInflatedViewRef != null) {
        View view = mInflatedViewRef.get();
        if (view != null) {
            view.setVisibility(visibility);
        } else {
            throw new IllegalStateException("setVisibility called on un-referenced view");
        }
    } else {
        super.setVisibility(visibility);
        if (visibility == VISIBLE || visibility == INVISIBLE) {
            inflate();
        }
    }
}
複製程式碼

viewStub.getVisibility()為何總是等於0?

在顯示ViewStub中的佈局時,你可能會採取如下的寫法:

if (viewStub.getVisibility() == View.GONE){
    viewStub.setVisibility(View.VISIBLE);
}else {
    viewStub.setVisibility(View.GONE);
}
複製程式碼

如果你將viewStub.getVisibility()的值列印出來,就會看到它始終為0,這恰恰是View.VISIBLE的值。奇怪,我們明明寫了viewStub.setVisibility(View.GONE),layout_view_stub也隱藏了,為什麼ViewStub的狀態還是可見呢?

重新回到3.1.3,看看ViewStub中的setVisibility()原始碼,首先判斷弱引用物件mInflatedViewRef是否為空,不為空則取出存放進去的物件,也就是我們ViewStub中的檢視中,然後呼叫了檢視的setVisibility()方法,mInflatedViewRef為空時,則判斷能見度為VISIBLE或無形時呼叫充氣()方法填充佈局,如果為GONE的話則不予處理。這樣一來,在mInflatedViewRef不為空,也就是已經填充了佈局的情況下,ViewStub中的setVisibility()方法實際上是在設定內部檢視的可見性,而不是ViewStub本身。這樣的設計其實也符合ViewStub的特性,即填充佈局之後就自我銷燬了,給其設定可見性是沒有意義的。

仔細比較一下,其實ViewStub就像是一個懶惰的包含,我們需要它載入時才載入。要操作佈局裡面的控制元件也跟包一樣,你可以先初始化ViewStub中的佈局中再初始化控制元件:

//1、初始化被inflate的佈局後再初始化其中的控制元件,
FrameLayout frameLayout = findViewById(R.id.view_inflate);//android:inflatedId設定的id
Switch sw = frameLayout.findViewById(R.id.sw);
sw.toggle();
複製程式碼

如果主佈局中控制元件的ID沒有衝突,可以直接初始化控制元件使用:

//2、直接初始化控制元件
Switch sw = findViewById(R.id.sw);
sw.toggle();
複製程式碼

相關文章