Android性能優化 - 消除卡頓

性能優化系列閱讀

消除卡頓

  • 什麼是卡頓及卡頓的衡量標準
  • 產生卡頓的原因
  • 通用優化流程
  • 定位卡頓原因

什麼是卡頓

卡頓是人的一種視覺感受,比如我們滑動界面時,如果滑動不流程我們就會有卡頓的感覺,這種感覺我們需要有一個量化指標,在編程時如果開發的程序超過了這個指標我們認爲其是卡頓的。

FPS(幀率):每秒顯示幀數(Frames per Second)。表示圖形處理器每秒鐘能夠更新的次數。高的幀率可以得到更流暢、更逼真的動畫。一般來說12fps大概類似手動快速翻動書籍的幀率,這明顯是可以感知到不夠順滑的。30fps就是可以接受的,但是無法順暢表現絢麗的畫面內容。提升至60fps則可以明顯提升交互感和逼真感,但是一般來說超過75fps就不容易察覺到有明顯的流暢度提升了,如果是VR設備需要高於75fps,纔可能消除眩暈的感覺。

開發app的性能目標就是保持60fps,這意味着每一幀你只有16ms≈1000/60的時間來處理所有的任務。Android系統每隔16ms發出VSYNC信號,觸發對UI進行渲染,如果每次渲染都成功,這樣就能夠達到流暢的畫面所需要的60fps。

1495598311058

如果你的某個操作花費時間是24ms,系統在得到VSYNC信號的時候就無法進行正常渲染,這樣就發生了丟幀現象。那麼用戶在32ms內看到的會是同一幀畫面。

1495598327691

如果此時用戶在看動畫的執行或者滾動屏幕(如RecyclerView),就會感覺到界面不流暢了(卡了一下)。丟幀導致卡頓產生。

流暢的情況下:

1495598346607

出現了丟幀現象(卡頓)

1495598358290

嚴重丟幀(卡死了)

1495598368704

給我們一種感覺,如果幀率越低,卡頓就越嚴重,那麼是不是就可以使用幀率來衡量卡頓那?

如何衡量卡頓

FPS的高低不能準確的反映應用的流程度。如下圖所示,只有有更新的時候才刷新界面。

1495598311058

當界面沒有變動的時候,手機不需要對界面進行更新,所以此時的FPS會很低,如果1秒鐘內都沒有變動那麼FPS=0。所以我們需要利用其他方式來衡量應用的流程度,比如可以利用丟幀數來衡量。

單位時間內丟幀數可以反映出應用是否流程。不丟幀是終極目標,但每秒丟幀在6-7幀左右可以接受,如果丟10幀以上就需要優化了。

丟幀情況(單位時間內均勻分佈) 卡頓情況
0-10幀 流暢
10-20幀 較卡
20-40幀 很卡
40-60幀 卡死了

對於我們開發人員來說,會使用一些工具找出卡頓比較集中的地方,找出原因,消除或減弱卡頓。(測試團隊會有專門的工具去測試丟幀的情況)

卡頓產生的原因

核心:分析在16ms中我們的應用做了什麼工作,那些工作阻止我們在16ms時更新界面。

通常情況下,在16ms中我們有那些工作需要處理。

單以XML佈局被繪製出來爲例進行說明。

處理過程:

  1. CPU負責把UI組件計算成多邊形和紋理
  2. OpenGL負責繪製圖像(Display List)
  3. GPU柵格化需要顯示內容並渲染到屏幕上

而實際開發中我們還加入交互、業務處理等工作,這些工作都需要在16ms中處理完成。對於開發人員來說,需要有一個工具,很直觀的幫助我們判斷出那些工作佔用了多少時間。

Profile GPU Rendering

通過手機開發者選項中提供的Profile GPU Rendering(GPU呈現模式分析)功能,我們可以清楚的看到處理流程中各部分的耗時。手機端工具(開發助手àGPU渲染圖)。建議大家在Android6.0及以上手機測試。

打開Profile GPU Rendering操作截圖如下:

1495598620838 1495598567075 1495598518583

大家可以拿着真機配置一下。看看有什麼變化。

1495598647344 1495598656043

條形圖說明

  1. 水平方向的一根綠線代表16ms。
  2. 每條都代表一幀畫面所有工作內容
  3. 每條中不同的顏色代表不同的工作內容

Android6.0及以上的手機顏色對應關係如下:

1495598717920

原因分析

通用優化流程

第一步:UI層優化

1、UI問題比較容易查找
2、一旦出現問題影響範圍廣(xml、mesure、layout、draw、Display List 、柵格化……)

工具:設備過渡繪製查看功能、HierarchyViewer等

常見問題:過渡繪製、佈局複雜、層級過深……

過渡繪製

在屏幕一個像素上繪製多次(超過兩次)。如:文本框,如果設置了背景顏色(黑色),那麼顯示的文字(白色)就需要在背景之上再次繪製。

打開手機開發者中的過渡繪製區域即可查看。藍色標識這個區域繪製了兩次。

1495598808556

說明:

  1. 如果大面積都是藍色,屬於正常情況。
  2. 重點關注大面積綠色及以後的,表示存在過渡繪製。

設備中的該選項只能直觀的讓我們感受到應用的界面是否存在過渡繪製,如果存在,我們需要利用Hierarchy Viewer查找佈局中不合理的地方。

過渡繪製小案例

效果圖如下

1495599983542

大面積存在過渡繪製,文字區域最嚴重。查詢Item佈局文件找出過渡繪製的原因

1495600004253

自定義控件繪製優化

Clip Rect 與 Quick Reject

  • Clip Rect:識別可見區域
  • Quick Reject:控件所在的矩形區域是否有交集

在Canvas中有上述兩個方法,幫助我們進行判斷,避免出現過渡繪製。

我們可以通過canvas.clipRect()來幫助系統識別那些可見的區域,在這個區域之外的我們不在進行繪製。如側拉菜單,當菜單顯示的時候被菜單遮擋的部分是不用進行繪製的,一旦繪製就會出現過渡繪製現象。系統的控件會控制過渡繪製,但我們自己的控件就需要自行管理了。所以在使用側拉菜單時就需要優先考慮系統提供的了。

1495598860734

如果系統沒有提供的,我們自己編寫時也需要注意,避免出現過渡繪製。

自定義控件過渡繪製

案例效果

1495600074884

編寫自定義控件MyVIew,在佈局中引入該控件

<com.itheima.overdrawdemo.MyView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/transparent">
</com.itheima.overdrawdemo.MyView>

圖片資源數組

private int[] ids = new int[]{R.drawable.img1, R.drawable.img2, R.drawable.img3, R.drawable.img4, R.drawable.img5, R.drawable.img6};

初始化時加載圖片資源,同時對一會需要使用到的畫筆做初始化

private void init() {
    for (int i = 0; i < 6; i++) {
        imgs[i] = BitmapFactory.decodeResource(getResources(), ids[i]);
    }

    paint=new Paint();
    paint.setAntiAlias(true);
}

先將圖片擺放好

for (int i = 0; i < imgs.length; i++) {
    canvas.drawBitmap(imgs[i],i*20,0,paint);
}

通過過渡繪製的開啓,觀察結果

1495600254497

原因比較簡單,對於“大王”這張牌來說,我們不需要繪製完整的圖片,如果都繪製了就會出現上面的情況

處理思路:找出牌需要繪製的區域,讓canvas在繪製這張牌時僅僅按區域繪製一部分即可。對於“大王”這張牌來說我們僅僅繪製如下內容

1495600289745

重點來了,我們該如何劃定這個區域? 在Canvas中clipRect方法可以幫助我們劃定一個區域,進行繪製。

方法參數說明:

clipRect(int left, int top, int right, int bottom) 

canvas.clipRect(0, 0, 20, imgs[i].getHeight()); 

設置完成後,我們來繪製大王這張牌。

canvas.drawBitmap(imgs[0],0,0,paint);

再增加循環,快速繪製所有的牌。

for (int i = 0; i < imgs.length; i++) {
    canvas.clipRect(i * 20, 0, (i + 1) * 20, imgs[i].getHeight());
    canvas.drawBitmap(imgs[i],i*20,0,paint);
}

大家會發現繪製完成的結果不是我們想要的。

我們需要藉助save和restore來完成裁剪的操作。

save:用來保存Canvas的狀態。save之後,可以調用Canvas的平移、放縮、旋轉、錯切、裁剪等操作。

restore:用來恢復Canvas之前保存的狀態。防止save後對Canvas執行的操作對後續的繪製有影響。

save和restore要配對使用(restore可以比save少,但不能多),如果restore調用次數比save多,會引發Error。save和restore之間,往往夾雜的是對Canvas的特殊操作

代碼修改如下

for (int i = 0; i < imgs.length; i++) {
    canvas.save();
    canvas.clipRect(i * 20, 0, (i + 1) * 20, imgs[i].getHeight());
    canvas.drawBitmap(imgs[i],i*20,0,paint);
    canvas.restore();
}

效果如下

1495600425015

剩下最後一個工作,把最上面的牌繪製完整

for (int i = 0; i < imgs.length; i++) {
    canvas.save();
    if(i<imgs.length-1) {
        canvas.clipRect(i * 20, 0, (i + 1) * 20, imgs[i].getHeight());
    }else if(i==imgs.length-1){
        canvas.clipRect(i * 20, 0, i * 20+imgs[i].getWidth(), imgs[i].getHeight());
    }
    canvas.drawBitmap(imgs[i],i*20,0,paint);
    canvas.restore();
}

Hierarchy Viewer(層級查看器)工具使用

Hierarchy Viewer可以很直接的呈現佈局的層次關係,視圖組件的各種屬性。我們可以通過紅,黃,綠三種不同的顏色來區分佈局的Measure,Layout,Executive的相對性能表現如何。

打開工具

1495598877932

選擇需要查看的內容

1495598888106

查看各個節點Measure,Layout,Executive

1495598899260

三個小圓點, 依次表示Measure, Layout,Draw, 可以理解爲對應View的onMeasure, onLayout, onDraw三個方法

  • 綠色:表示該View的此項性能比該View Tree中超過50%的View都要快

  • 黃色:表示該View的此項性能比該View Tree中超過50%的View都要慢

  • 紅色:表示該View的此項性能是View Tree中最慢的

一般來說

  • Measure紅點, 可能是佈局中嵌套RelativeLayout, 或是嵌套LinearLayout都使用了weight屬性.

  • Layout紅點, 可能是佈局層級太深

  • Draw紅點, 可能是自定義View的繪製有問題, 複雜計算等

我們之前的小案例,可以進行層級優化

常規做法

  1. 沒有用的父佈局——沒有背景繪製或沒有大小限制的父佈局,不會對界面效果產生任何影響。特別是進來的佈局,很容易產生問題。可以通過標籤替代。

  2. 在佈局層次一樣的情況下,建議使用LinearLayout代替RelativeLayout。

  3. 使用LinearLayout導致的層次變深,可以使用RelativeLayout進行替換。同樣的界面我們可以使用不同的方式去實現,選擇一個層級最少的方案。

  4. 不常用的UI被設置成了GONE,嘗試使用代替。

  5. 去掉多餘的背景顏色,減少過渡繪製,對於有多層背景色的佈局來說,留最上面的一層即可。謹慎使用alpha,如果後渲染的元素有設置alpha值,那麼這個元素就會和屏幕上已經渲染好的元素做blend處理,這樣會導致不少性能問題,特別是出現在列表的Item中。

  6. 對於使用Selector當背景的佈局,可以將normal狀態的color設置爲透明。

  7. 我們不能因爲提高性能而忽略了界面需要達到的效果(平衡Design與Performance)

第二步:代碼問題查找

工具:Lint

常見問題:我們重點關注Performance和Xml中的一些建議

  • 在繪製時實例化對象(onDraw)
  • 手機不能進入休眠狀態(Wake lock)
  • 資源忘記回收
  • Handler使用不當倒置內存泄漏
  • 沒有使用SparseArray代替HashMap
  • 未被使用的資源
  • 佈局中無用的參數
  • 可優化佈局(如:ImageView與TextView的組合是否可以使用TextView獨立完成)
  • 效率低下的 無用的命名空間等

Lint工具使用

Android Studio中開啓Lint工具

選中需要分析的Module,點擊工具欄中Analyze中的Inspect Code選項。

1495598952323

選擇需要分析的Module或整個項目

1495598962260

我們可以逐一閱讀一下,但是重點關注性能問題,xml中的一些問題也儘可能進行修復。

1495598972840

問題處理

1、案例中性能問題處理

1495598994803

其他的一些性能問題

1495599006190

建議使用concate方法進行連接字符串,會比append的方式性能好。

2、案例中xml提到的內容如下

1495599017421

其他問題:

無效的命名空間

1495599035966

無效的佈局參數

比如在線性佈局中的控件使用到了相對佈局中的屬性,運行時需要處理,影響代碼的執行效率。

3、案例中關於定義聲明變量的警告

1495599048742

意見或建議

  • 不斷關注Lint中提到的問題,將公司中命名規範中沒有提到的內容逐一補全。
  • Lint不是萬能的。

第三步:優化App的邏輯層

工具:Traceview

常見問題:主線程耗時大的函數、滑動過程中的CPU工作問題,工具可以提供每個函數的耗時和調用次數,我們重點關注兩種類型的函數:

  1. 主線程裏佔用CUP時間很長的函數,特別關注IO操作(文件IO、網絡IO、數據庫操作等)
  2. 主線程調用次數多的函數

使用Traceview找出卡住主線程的地方

Traceview工具使用

通過Android Studio打開裏面的Android Device Monitor,切換到DDMS窗口,點擊左邊欄上面想要跟蹤的進程,再點擊上面的Start Method Profiling的按鈕,如下圖所示:

1495599073133

啓動跟蹤之後,再操控app,做一些你想要跟蹤的事件,例如滑動RecyclerView,點擊某些視圖進入另外一個頁面等等。操作完之後,回到Android Device Monitor,再次點擊相同的按鈕停止跟蹤。此時工具會爲剛纔的操作生成TraceView的詳細視圖。

1495599083984

重點關注Incl Cpu Time、Call+Recur Calls/Total、Real Time/Call

通過降序排序,我們可以分別找到這兩列中數值比較大的內容。

指標說明:

指標 說明
Incl(Inclusive) Cpu Time 方法本身和其調用的所有子方法佔用CPU時間
Excl(Exclusive) Cpu Time 方法本身佔用CPU時間
Incl Real Time 方法(包含子方法)開始到結束用時
Excl Real Time 方法本身開始到結束用時
Call + Recursion Calls/Total 方法被調用次數 + 方法被遞歸調用次數
Cpu Time/Call 方法調用一次佔用CPU時間. 方法實際執行時間(不包括io等待時間)
Real Time/Call 方法調用一次實際執行時間. 方法開始結束時間差(包括等待時間)

小案例:

我們可以在ViewHolder的設置數據中做點手腳,比如睡幾毫秒(8ms),通過監控滾動,我們是否可以定位到問題代碼。

好的做法

  1. 不要阻塞UI線程,佔用CUP較多的工作儘可能放在子線程中執行。
  2. 需要結合使用場景選擇不同的線程處理方案

AsyncTask:爲UI線程與工作線程之間進行快速的切換提供一種簡單便捷的機制。適用於當下立即需要啓動,但是異步執行的生命週期短暫的使用場景。

HandlerThread: 爲某些回調方法或者等待某些任務的執行設置一個專屬的線程,並提供線程任務的調度機制。

ThreadPool: 把任務分解成不同的單元,分發到各個不同的線程上,進行同時併發處理。

IntentService: 適合於執行由UI觸發的後臺Service任務,並可以把後臺任務執行的情況通過一定的機制反饋給UI。

如果大量操作數據庫數據時建議使用批處理操作。如:批量添加數據。

綜合案例

應用啓動性能優化。

使用NoHttp獲取應用列表

問題表現:通常從用戶點擊到應用完全展示完首頁,需要用戶等待一段時間。我們如何縮短時間並提高用戶體驗。

分析:應用在啓動的過程中我們的代碼能夠影響啓動速度的地方如下

  • Application的onCreate
  • 首屏Activity的渲染

步驟:

  1. 利用Traceview工具觀察啓動過程方法耗時情況,重點關注onCreate方法(自定義Application和首頁Activity)。問題:Traceview工具如何在應用啓動時監控數據?

  2. 分析自定義Application耗時操作,判斷onCreate方法中的內容(如:第三方的工具是否可以不佔用主線程進行初始化)。

  3. 查看界面是否存在過渡繪製。

  4. 利用Hierarchy Viewer工具查看界面需要優化的點。

  5. 啓動過程中的白屏優化。

第一步:觀察耗時情況

1、在onCreate開始和結尾打上trace

Debug.startMethodTracing("POApp");
Debug.stopMethodTracing();

運行程序, 會在sdcard上生成一個”POApp.trace”的文件.

注意:需要給程序加上寫存儲的權限

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

Android6.0以後的模擬器需要爲應用打開讀寫權限

2、通過adb pull將其導出到本地

adb pull /sdcard/ POApp.trace 存放文件路徑

第二步:分析數據

通過DDMS的FileàOpen File

1495599251273

查詢結果如下

1495599262659

說明:我們使用Real Time/Call進行排序可以得到上圖內容

大家可以發現在Application中阻塞主線程乾的工作都是NoHttp的初始化工作。爲了提高應用的啓動速度,我們可以將這個工作放到子線程中完成,通常我們會使用IntentService來處理這個工作。

代碼如下:

/**
 * Created by itheima.
 */

public class MyApplication extends Application {
    @Override
    public void onCreate() {

        // 如果沒有辦法手動操作監控,可以使用如下代碼重點關注我們感興趣的方法
        // 監控的結果會生成文件存儲SDCard上
//        Debug.startMethodTracing("AppStartupDemo");// 文件的名稱
        super.onCreate();

        InitService.start(this);

        SystemClock.sleep(1000);

//        Debug.stopMethodTracing();
    }
}
/**
 * 將MyApplication中onCreate方法內容耗時的初始化工作移動到該類中
 */

public class InitService extends IntentService {

    // 問題:由於將NoHttp的初始化工作移動到了子線程,當主線程使用NoHttp發現沒有初始化完成,報異常了。

    // 方案一:使用boolean值進行初始化工作的標記,如果完成boolean爲true,可以在使用該工具的地方每隔一個時間段判斷一下。
    // 方案二:當初始化工作完成後,發出一個通知,如果有觀察者,則進行後續工作的處理

    public static boolean isInit=false;// 標記是否初始化完成

    public InitService() {
        super("init");
    }

    @Override
    protected void onHandleIntent(@Nullable Intent intent) {
        // 耗時操作

        Logger.setTag("NoHttp");
        Logger.setDebug(true);

        NoHttp.initialize(this, new NoHttp.Config()
                .setConnectTimeout(30 * 1000)
                .setReadTimeout(30 * 1000)
        );

        isInit=true;
    }

    /**
     * 啓動service
     * @param myApplication
     */
    public static void start(MyApplication myApplication) {
        Intent intent = new Intent(myApplication, InitService.class);
        myApplication.startService(intent);
    }
}

修改完成後,會引發一個問題,及在首頁訪問網絡時,由於NoHttp的初始化還沒有完成會報出如下異常:

1495599312787

如果我們在首頁就需要立即訪問網絡,就需要對初始化進行監控,可以簡單的使用一個boolean值,進行判斷,當初始化完成後boolean值修改爲true。我們在MainActivity中可以使用Handler間隔一段時間就檢查一下boolean即可。

第三步:過渡繪製

進入首頁後,應用的啓動速度限制就集中在首頁的界面渲染上了。因此我們開始對界面進行優化處理。

過渡繪製查看結果。

1495599339554

表現還好,我們可以檢查一下Item,看看是否可以優化掉一次繪製。

1495599354266

第四步:優化界面佈局

Hierarchy Viewer工具派上用場了,我們可以檢查一下佈局是否合理。

1495599366449

重點觀察其中一個條目

1495599375273

優化完成後的結構圖

我們先優化掉兩個用處不大的LinearLayout,然後在考慮是否可以繼續優化掉條目中的LinearLayout。

1495599387551

第五步:Launch screens設置

兩種處理方案:

方案一:設置一個背景圖

<item name="android:windowBackground">@drawable/splash</item>
<item name="android:windowNoTitle">true</item>

注意:當界面加載完成後需要將背景改成白色。

方案二:設置成透明的界面,製造延時啓動效果

<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>

AndroidPerformanceMonitor

GitHub BlockCanary — 輕鬆找出Android App界面卡頓元兇

BlockCanary是一個Android平臺的一個非侵入式的性能監控組件,應用只需要實現一個抽象類,提供一些該組件需要的上下文環境,就可以在平時使用應用的時候檢測主線程上的各種卡慢問題,並通過組件提供的各種信息分析出原因並進行修復。

取名爲BlockCanary則是爲了向LeakCanary致敬,順便本庫的UI部分是從LeakCanary改來的,之後可能會做一些調整。

發佈了273 篇原創文章 · 獲贊 266 · 訪問量 123萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章