Android 之如何優化 UI 渲染(下)

UI 優化系列專題,來聊一聊 Android 渲染相關知識,主要涉及 UI 渲染背景知識如何優化 UI 渲染兩部分內容。


UI 優化系列專題
  1. UI 渲染背景知識
  1. 如何優化 UI 渲染

“學而時習之、溫故而知新”,在介紹具體的 UI 優化方法之前,我們先來回顧一下在上期介紹的 UI 卡頓的排查方法。關于 UI 卡頓,我們介紹了 6 種本地排查工具,以及多種可以在線上監控 UI 卡頓、幀率的方法。

UI 渲染也會造成卡頓,大家是否會有這樣的疑惑:UI 優化和卡頓優化兩者之間有什么關系和區別嗎?

在 Android 系統的 VSYNC 信號到達時,如果 UI 線程被某個耗時任務堵塞,長時間無法對 UI 進行渲染,這時就會出現卡頓,但它并不屬于 UI 優化的范疇。UI 優化要解決的核心是由于渲染性能本身造成用戶感知的卡頓,可以把它理解為卡頓優化的一個子集


每個開發者都希望自己的應用可以達到 60 fps “如絲般順滑”。通過上一期的學習,我們已經清楚了問題所在,那有哪些方法可以幫助我們優化 UI 渲染,并保持在 60 fps?再來回顧下 UI 渲染階段的流程圖:


通用優化策略

我們的目標是保持 60 fps,這意味著所有操作都必須在 16 ms(=1000 ms / 60 fps) 內完成。優化的手段其實就是拆解渲染各階段的耗時,找到瓶頸點再加以優化。下面我們就來聊一聊 UI 渲染的各個階段都有哪些具體的優化手段和方法?

  1. 盡量使用硬件加速

在 Android 3.0 之前(或者沒有啟動硬件加速時),UI 繪制任務完全由 CPU 完成。然而 CPU 對于圖形處理并不是那么高效,這個過程完全沒有利用到 GPU 的圖形高性能。所以 Android 從 3.0 開始加入硬件加速,但是直到 4.0 時才默認開啟。說道這,如果你對硬件加速還不熟悉的話,可以參考《關于 UI 渲染,你需要了解什么?》。

通過對硬件加速的進一步了解,相信你也發自內心地認同硬件加速繪制的性能是遠遠高于軟件繪制的。所以,UI 優化的第一個方法就是保證渲染盡量使用硬件加速

此時,大家可能會有這樣的疑問:有哪些情況是我們不能使用硬件加速?之所以不能使用硬件加速,是因為硬件加速不能支持所有的 Canvas API,具體 API 兼容列表可以參考 drawing-support 文檔。如果使用了不支持的 API,系統就需要通過 CPU 軟件模擬繪制,這也是類似漸變、磨砂和圓角等效果渲染性能比較低的原因。

SVG 就是一個非常典型的例子,SVG 有很多指令都不支持硬件加速。不過凡事都有“利弊”,SVG 在加載時間、清晰度和體積方面相比 PNG 更具有優勢。有關 SVG 在 Android 上的應用可以參考這里

其實我們還是可以用一個非常取巧的方法,提前將這些 SVG 轉換成 Bitmap 緩存起來。這樣,系統就可以更好地使用硬件加速繪制。同理對于其它圓角、漸變等場景我們也可以改為 Bitmap 實現。

public static Bitmap getBitmapFromvectorDrawable(@NonNull Context context, @DrawableRes int drawableId){
    Drawable drawable = ContextCompat.getDrawable(context, drawableId);
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP){
        drawable = (DrawableCompat.wrap(drawable)).mutate();
    }
    final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
    final Canvas canvas = new Canvas(bitmap);
    drawable.setBounds(0,0 , canvas.getWidth(), canvas.getHeight());
    drawable.draw(canvas);
    return bitmap;
}

  1. Create View 優化

觀察 Android 渲染流水線時,大家是否有注意到這里缺少了一個非常重要的環節,那就是 View 創建的耗時。不要忘了 View 的創建也是在 UI 線程里,對于一些非常復雜的界面,這部分的耗時是不容忽視的。View 創建過程可以參考《View 繪制流程之 setContentView() 到底做了什么 ?》。

在優化 View 創建耗時之前,我們先來簡單拆解下這部分內容:包括各種 XML 隨機讀取的 I/O 時間、解析 XML 的時間、生成對象的時間。需要注意 View 的創建在 Framework 層使用了大量反射。具體可以參考《Android 之 LayoutInflater 全面解析》。

相應的,我們來看看這個階段有哪些優化的方法。

使用代碼創建

使用 XML 進行 UI 界面編寫可以說是非常方便的,還可以在 Android Studio 的 Preview 中實時預覽到界面。

但是,如果我們需要對某個界面進行極致的優化,就可以使用代碼進行編寫界面。不過這種方式對于開發效率來說簡直是災難,此時我們可能會考慮一些取巧的方法,例如開源的 X2C 工具能夠將 XML 轉換為 Java 代碼。但它還是有一些情況是不支持直接轉換的。

  • <Merge/> 標簽,由于在編譯期間無法確定 XML 的 parent,所以無法支持。
  • 系統 style,在編譯期間只能查到應用的 style 列表,無法查詢系統 style,所以只支持應用內 style。

使用 XML 或 Java 兩者在編寫 UI 方面的綜合對比(性能 & 效率):

實際開發過程中,我們需要兼容性能與開發效率,建議只在對性能要求非常高,但修改又不非常頻繁的場景下才考慮使用代碼創建


異步創建

此時,聰明的你肯定會想到,能不能把 View 的創建工作放到其他線程,實現 UI 的預加載呢?這里需要特別注意, View 的繪制任務最終還是要回到 UI 線程,否則系統會拋出如下異常:

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}
  • View 的繪制流程不一定非要在主線程,但它要求一定是在原線程。絕大多數操作系統 UI 框架都是單線程的,這主要是因為多線程的 UI 框架在設計上會非常復雜,而且由于線程同步等問題并不一定比單線程性能更好。

可能還有朋友反映會出現如下報錯異常:

throw new RuntimeException( "Can't create handler inside thread 
                                                that has not called Looper.prepare()")

事實上,該異常的報錯點發生在 Handler,在 Handler 的構造方法內會嘗試獲取當前線程的 Looper 對象,我們知道 UI 主線程的 Looper 對象是在 ActivityThread 的 main 方法中創建的,所以我們在 UI 主線程的任意位置進行 new Handler() 都沒有問題,但是子線程默認是不存在 Looper 對象的,簡單看下這一過程。

public Handler(Callback callback, boolean async) {
    // 獲取當前線程的Looper對象,通過ThreadLocal
    mLooper = Looper.myLooper();
    if (mLooper == null) {
         // 非UI線程默認不存在Looper對象,此時便會拋出異常
        throw new RuntimeException(
            "Can't create handler inside thread that has not called Looper.prepare()");
    }
    // Looper中的MessageQueue,消息對壘
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}
  • Handler 必須在具有 Looper 對象的線程中工作,此時我們需要手動為當前線程開啟消息隊列。
@Override
public void run() {
    // 為當前線程創建Looper對象,Looper是線程單例(原理 ThreadLocal)
    Looper.prepare();
    final Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
           // do somethings
        }
     }
     // 開啟出隊,不斷的從MessageQueue中next
     Looper.loop();
}

View 重用

正常來說,View 會隨著 Activity 或 Fragment 的銷毀而同時銷毀。系統的 ListView、RecycleView 通過 View 的緩存與重用大大提升了渲染性能。因此我們可以參考它們的思想,實現一套可以在不同 Activity 或 Fragment 使用的 View 緩存機制。

但是這里需要注意,所有進入緩存池的 View 都已經“凈身出戶”,不會保留之前的狀態。


3. measure/layout 優化

渲染流程中 measure 和 layout 也是需要 CPU 在主線程執行的,大家網上查詢關于 UI 優化的內容,絕大多數都是關于這塊兒的優化建議,這里也簡單做下總結:

  • 減少 UI 布局層次。例如盡量扁平化布局,使用<ViewStub/> <Merge/>等優化。

  • 優化 layout 的開銷。盡量不再使用 RelativeLayout 或者基于 weight LinearLayout,它們 layout 的開銷非常巨大。現在更加推薦使用 ConstraintLayout 替代 RelativeLayout 或者 weight LinearLayout。

  • 背景優化。盡量不要重復去設置背景,這里需要注意的是主題背景(theme),theme 默認會是一個純色背景,如果我們自定義了界面的背景,那么主題的背景對我們來說是無用的,但是由于主題的背景設置在 DecorView 中,所以這里會帶來 Overdraw,也就是無謂的繪制性能損耗。如果需要去掉 theme 背景色可以通過如下方式:

// 必須在 setContentView() 之前
getWindow().setBackgroundDrawable(null);

利用系統新特性

Android 渲染框架非常龐大,并且演進的非常快,接下來我們就一起看下系統又提供了哪些新的特性可以更好的幫助我們優化 UI 渲染。

1. RenderThread

Android 在 4.0 默認開啟硬件加速,但是真正較大改善 UI 渲染性能的卻是在 Android 4.1 的 Project Butter 黃油計劃之后。雖然從此開始利用 GPU 的圖形高性能運算,但是從計算 DisplayList,到通過 GPU 完成 Graphic Buffer 繪制,再到 Frame Buffer 整個計算和繪制都在 UI 主線程中完成。

UI 主線程 “既當爹又當媽”,任務過于繁重。所以在 Android 5.0 又引入了兩個比較大的變化,一個是 RenderNode,它對 DisplayList 及一些 View 顯示屬性做了進一步封裝;另一個是引入了 RenderThread,所有的 GL 命令執行都放到這個線程上,怎么理解它呢?

在 Android 5.0 之前,Android 的主線程同時也是 Open GL 線程,在此之后,Android 應用的 Open GL 線程就獨立出來了,稱之為 RenderThread。渲染線程在 RenderNode 中存有渲染幀的所有信息,對于 ViewPropertyAnimator 和 CircularReveal 動畫,我們可以使用 RenderThread 實現動畫的異步渲染,這樣即便主線程發生阻塞的時候也可以保證動畫的流暢。

下面我以 ViewPropertyAnimator 動畫與普通屬性動畫為例:

  • 普通屬性動畫
private void startAnim(View v) {
    final ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(v, "scaleY", 1, 2);
    objectAnimator.setDuration(2000);
    objectAnimator.start();
    // 模擬主線程發生卡頓
    delaySleep(1000, 2000);
}
  • 利用 RenderThread 異步動畫渲染
private void startPropertyAnim(View v) {
    v.setScaleY(1);
    final ViewPropertyAnimator animator = v.animate().scaleY(2).setDuration(2000);
    // 必須在start之前調用
    AsyncAnimHelper.onStartBefore(animator, v);
    animator.start();
    // 模擬主線程發生卡頓
    delaySleep(1000, 2000);
}

設置兩種動畫分別在執行 1s 后,讓主線程休眠 2s(模擬主線程卡頓)。可以很明顯看到普通屬性動畫,在主線程阻塞的時候,會出現丟幀卡頓現象。而使用 RenderThread 渲染的動畫即使阻塞了主線程仍然不受影響,如下圖所示(上面控件為普通屬性動畫):

需要注意,目前支持 RenderThread 完全渲染的動畫僅有兩種:ViewPropertyAnimator 和 CircularReveal(揭露動畫),不過它們在支持異步渲染的前提下還存在一些限制,具體可以參考這里


2. RenderScript

現在越來越多的應用會使用一些高級圖片或者視頻剪輯功能,例如圖片的高斯模糊、放大、銳角等。拿日常我們使用最多的“掃一掃”這個場景,這里涉及大量的圖片變化操作,例如縮放、剪裁、二值化以及降噪等。

經過上一期的介紹,圖片的變換涉及大量的計算任務,這個時候使用 GPU 是更好的選擇。此時我們便可以通過 RenderScirpt,它是 Android 系統上的一套 API,專門用于高性能地運行計算密集型任務,它允許開發者以較少的代碼實現功能復雜且性能優越的應用程序。

以 RenderScript 提供的 ScriptIntrinsicBlur 實現圖片的模糊效果為例,原圖如下:

模糊后效果如下:

相比較基于 Java 實現的 fastblur ,RenderScript 在處理時間和內存占用都更具有優勢。如何將 RenderScript 應用到我們的項目中?可以參考下面的一些實踐方案:


UI 優化的進階

在通用優化策略中,我們介紹了 View 的創建耗時也是不能忽視的,對于復雜的 UI 可以采用異步預加載的方式。在 Android 5.0 之后,系統更是將 View 的繪制任務抽離到 RenderThread 線程,那能不能更進一步,將 View 的 measure 和 layout 的計算任務也像 Create View 一樣實現線程的預加載呢?這樣可以極大地提升首次顯示的性能。

TextView 是 Android 系統控件中非常強大也非常重要的一個控件,強大的背后就意味著需要做很多計算工作。在 2018 年的 Google I/O 大會,發布了 PrecomputedText 并已經集成在 Jetpack 中,它為我們提供了進行異步 measure 和 layout 的接口,進一步減輕 UI 線程的計算量。

那對于其他控件我們有沒有可能采用類似的方式?接下來一起看下最近兩年業界都有哪些優秀的方案。

1. Litho:異步布局

Litho 是 Facebook 開源的聲明式 Android UI 渲染框架,它是基于另外一個 Facebook 開源的布局引擎 Yoga 開發的。Litho 本身非常強大,內部做了很多非常不錯的優化,下面就來簡單介紹一下它是如何優化 UI 的。

異步布局

一般來說,Android 所有的控件繪制都要遵守 measure -> layout -> draw 的流水線,并且這些都發生在主線程中。

但 Litho 如前面提到 PrecomputedText 一樣,把 measure 和 layout 都放到了后臺線程,只留下必須要在主線程完成的 draw,這有效降低了 UI 線程的負載。它的渲染流水變化如下:

極致扁平化

前面也有提到,降低 UI 的層級是一個非常通用的優化方法。Litho 給了我們一種在不用改變代碼的情況下直接降低 UI 的層級。由于 Litho 使用了自有的布局引擎(Yoga),在布局階段就可以檢測不必要的層級,減少 ViewGroups 來實現 UI 扁平化。

如下圖,使用 Litho 布局,我們可以得到一個極致扁平的視圖效果,這對減少渲染時遞歸調用,加快渲染速度起到極大的幫助。上半部分是我們一般編寫界面的方法,下半部分是 Litho 編寫的界面,可以看到只有一層層級。

優化 RecycleView

Litho 還優化了 RecylerView 中 UI 組件的緩存和回收方法。原生的 RecycleView 或者 ListView 是按照 viewType 來進行緩存和回收,但如果一個 RecyclerView / ListView 中出現 viewType 過多,會使緩存形同虛設。

而 Litho 中的所有組件都可以被回收,它是按照 text、image 和 video 獨立回收的,并在任意位置進行復用。這種細粒度的復用方式尤其適合復雜的滑動列表,可以極大地提高內存命中率,提高滾動幀率,內存優化非常明顯。

Litho 還對類似 RecycleView 等可以提前知道下一個視圖“長什么樣子”的場景,利用 CPU 的閑置時間提前在異步線程中完成 Measure / Layout 過程。

Litho 雖然強大,但也有自己的缺點,它為了實現 measure/layout 異步化,使用了類似 react 單向數據流設計,這一定程度上加大了 UI 開發的復雜性。Litho 還拋棄了原生的布局方式,通過組件方式使用 Java/Kotlin 來編寫 UI,無法做到在 AS 中預覽。

public class MainActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ComponentContext context = new ComponentContext(this);
        final Text.Builder builder = Text.create(context);
        final Component = builder.text("Hello World")
                .textSizeDip(40)
                .textColor(Color.parseColor("#666666"))
                .textAlignment(Layout.Alignment.ALIGN_CENTER)
                .build();
        final LithoView view = LithoView.create(context, component);
        setContentView(view);
    }
}

有關 Litho 的更多實踐內容可以參照下面資料:


2. Flutter:自己的布局 + 渲染引擎

Litho 雖然通過使用自家的的布局引擎 Yoga,一定程度上突破了系統的某些限制,但是在 draw 之后依然走的系統的渲染機制。如下圖所示:

那能不能把系統的渲染也同時接管過來?Flutter 正式這樣的框架,它也是最近十分火爆的一個新框架。

Flutter 是 Google 推出并開源的移動應用開發框架,開發者可以通過 Dart 語言開發 App,一套代碼同時運行在 iOS 和 Android 平臺。并且它也是 Google 未來的新操作系統 Fuchsia 的默認開發套件。

Flutter 在 Android 上完全沒有基于系統的渲染引擎,而是把 Skia 引擎直接集成進了 App 中,并且直接使用了 Dart 虛擬機,可以說是一套跳出 Android 的方案,所以 Flutter 可以很容易實現跨平臺。下圖為 Flutter 的整體框架:

總的來說 Flutter 吸取了各優秀前端框架的精髓,加上強大的 Dart 虛擬機和 Skia 渲染引擎,特別適合在追求 iOS 和 Android 跨平臺的一致性,高性能的 UI 交互體驗場景。可以說是一個非常優秀的框架,美團、今日頭條等很多應用部分功能已經使用 Flutter 開發。

有關 Flutter 更多原理和實踐資料可以參考:


最后

回顧一下 UI 渲染優化整個過程,從問題排查到定位,再到具體的優化手段,我們不難發現它們存在這樣一個特征:

1. 在系統的框架內優化 。使用代碼創建、布局優化、 View 緩存等都是這個思路,我們希望盡可能提高甚至省下渲染流水線里某個階段的耗時。

2. 利用系統新特性。使用硬件加速、RenderThread、RenderScript 都是這個思路,通過系統一些新的特性,最大限度的提高應用性能。

3. 突破系統的限制。例如 Litho 突破了布局,Flutter 則更近一步,把渲染也接管過來了。這正是由于 Android 系統碎片化嚴重,很多好的特性可能低版本系統并不支持。而且系統需要支持所有的場景,在一些特定場景下無法實現最優解。這個時候就希望突破系統的條條框框。


大家平時在工作中,都做過哪些關于 UI 優化的工作,相信你肯定也有屬于自己的優化殺手锏。歡迎大家留言分享或指正。

文章如果對你有幫助,請留個贊吧。


擴展閱讀

其他系列專題

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373