Android App 優化之 ANR 詳解

anly_jun發表於2016-11-16

為了便於閱讀, 應邀將Android App效能優化系列, 轉移到掘金原創上來.
掘金的新出的"收藏集"功能可以用來做系列文集了.

今天先來聊聊ANR.

1, 你碰到ANR了嗎

在App使用過程中, 你可能遇到過這樣的情況:

Android App 優化之 ANR 詳解

恭喜你, 這就是傳說中的ANR.

1.1 何為ANR

ANR全名Application Not Responding, 也就是"應用無響應". 當操作在一段時間內系統無法處理時, 系統層面會彈出上圖那樣的ANR對話方塊.

1.2 為什麼會產生ANR

在Android裡, App的響應能力是由Activity Manager和Window Manager系統服務來監控的. 通常在如下兩種情況下會彈出ANR對話方塊:

  • 5s內無法響應使用者輸入事件(例如鍵盤輸入, 觸控螢幕等).
  • BroadcastReceiver在10s內無法結束.

造成以上兩種情況的首要原因就是在主執行緒(UI執行緒)裡面做了太多的阻塞耗時操作, 例如檔案讀寫, 資料庫讀寫, 網路查詢等等.

1.3 如何避免ANR

知道了ANR產生的原因, 那麼想要避免ANR, 也就很簡單了, 就一條規則:

不要在主執行緒(UI執行緒)裡面做繁重的操作.

這裡面實際上涉及到兩個問題:

  1. 哪些地方是執行在主執行緒的?
  2. 不在主執行緒做, 在哪兒做?

稍後解答.

2, ANR分析

2.1 獲取ANR產生的trace檔案

ANR產生時, 系統會生成一個traces.txt的檔案放在/data/anr/下. 可以通過adb命令將其匯出到本地:

$adb pull data/anr/traces.txt .複製程式碼

2.2 分析traces.txt

2.2.1 普通阻塞導致的ANR

獲取到的tracs.txt檔案一般如下:

如下以GithubApp程式碼為例, 強行sleep thread產生的一個ANR.

----- pid 2976 at 2016-09-08 23:02:47 -----
Cmd line: com.anly.githubapp  // 最新的ANR發生的程式(包名)

...

DALVIK THREADS (41):
"main" prio=5 tid=1 Sleeping
  | group="main" sCount=1 dsCount=0 obj=0x73467fa8 self=0x7fbf66c95000
  | sysTid=2976 nice=0 cgrp=default sched=0/0 handle=0x7fbf6a8953e0
  | state=S schedstat=( 0 0 0 ) utm=60 stm=37 core=1 HZ=100
  | stack=0x7ffff4ffd000-0x7ffff4fff000 stackSize=8MB
  | held mutexes=
  at java.lang.Thread.sleep!(Native method)
  - sleeping on <0x35fc9e33> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:1031)
  - locked <0x35fc9e33> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:985) // 主執行緒中sleep過長時間, 阻塞導致無響應.
  at com.tencent.bugly.crashreport.crash.c.l(BUGLY:258)
  - locked  (a com.tencent.bugly.crashreport.crash.c)
  at com.tencent.bugly.crashreport.CrashReport.testANRCrash(BUGLY:166)  // 產生ANR的那個函式呼叫
  - locked  (a java.lang.Class)
  at com.anly.githubapp.common.wrapper.CrashHelper.testAnr(CrashHelper.java:23)
  at com.anly.githubapp.ui.module.main.MineFragment.onClick(MineFragment.java:80) // ANR的起點
  at com.anly.githubapp.ui.module.main.MineFragment_ViewBinding$2.doClick(MineFragment_ViewBinding.java:47)
  at butterknife.internal.DebouncingOnClickListener.onClick(DebouncingOnClickListener.java:22)
  at android.view.View.performClick(View.java:4780)
  at android.view.View$PerformClick.run(View.java:19866)
  at android.os.Handler.handleCallback(Handler.java:739)
  at android.os.Handler.dispatchMessage(Handler.java:95)
  at android.os.Looper.loop(Looper.java:135)
  at android.app.ActivityThread.main(ActivityThread.java:5254)
  at java.lang.reflect.Method.invoke!(Native method)
  at java.lang.reflect.Method.invoke(Method.java:372)
  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:903)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:698)@addr=0x12d1e840>@addr=0x12dadc70>0x35fc9e33>0x35fc9e33>複製程式碼

拿到trace資訊, 一切好說.
如上trace資訊中的新增的中文註釋已基本說明了trace檔案該怎麼分析:

  1. 檔案最上的即為最新產生的ANR的trace資訊.
  2. 前面兩行表明ANR發生的程式pid, 時間, 以及程式名字(包名).
  3. 尋找我們的程式碼點, 然後往前推, 看方法呼叫棧, 追溯到問題產生的根源.

以上的ANR trace是屬於相對簡單, 還有可能你並沒有在主執行緒中做過於耗時的操作, 然而還是ANR了. 這就有可能是如下兩種情況了:

2.2.2 CPU滿負荷

這個時候你看到的trace資訊可能會包含這樣的資訊:

Process:com.anly.githubapp
...
CPU usage from 3330ms to 814ms ago:
6% 178/system_server: 3.5% user + 1.4% kernel / faults: 86 minor 20 major
4.6% 2976/com.anly.githubapp: 0.7% user + 3.7% kernel /faults: 52 minor 19 major
0.9% 252/com.android.systemui: 0.9% user + 0% kernel
...

100%TOTAL: 5.9% user + 4.1% kernel + 89% iowait複製程式碼

最後一句表明了:

  1. 當是CPU佔用100%, 滿負荷了.
  2. 其中絕大數是被iowait即I/O操作佔用了.

此時分析方法呼叫棧, 一般來說會發現是方法中有頻繁的檔案讀寫或是資料庫讀寫操作放在主執行緒來做了.

2.2.3 記憶體原因

其實記憶體原因有可能會導致ANR, 例如如果由於記憶體洩露, App可使用記憶體所剩無幾, 我們點選按鈕啟動一個大圖片作為背景的activity, 就可能會產生ANR, 這時trace資訊可能是這樣的:

// 以下trace資訊來自網路, 用來做個示例
Cmdline: android.process.acore

DALVIK THREADS:
"main"prio=5 tid=3 VMWAIT
|group="main" sCount=1 dsCount=0 s=N obj=0x40026240self=0xbda8
| sysTid=1815 nice=0 sched=0/0 cgrp=unknownhandle=-1344001376
atdalvik.system.VMRuntime.trackExternalAllocation(NativeMethod)
atandroid.graphics.Bitmap.nativeCreate(Native Method)
atandroid.graphics.Bitmap.createBitmap(Bitmap.java:468)
atandroid.view.View.buildDrawingCache(View.java:6324)
atandroid.view.View.getDrawingCache(View.java:6178)

...

MEMINFO in pid 1360 [android.process.acore] **
native dalvik other total
size: 17036 23111 N/A 40147
allocated: 16484 20675 N/A 37159
free: 296 2436 N/A 2732複製程式碼

可以看到free的記憶體已所剩無幾.

當然這種情況可能更多的是會產生OOM的異常...

2.2 ANR的處理

針對三種不同的情況, 一般的處理情況如下

  1. 主執行緒阻塞的
    開闢單獨的子執行緒來處理耗時阻塞事務.

  2. CPU滿負荷, I/O阻塞的
    I/O阻塞一般來說就是檔案讀寫或資料庫操作執行在主執行緒了, 也可以通過開闢子執行緒的方式非同步執行.

  3. 記憶體不夠用的
    增大VM記憶體, 使用largeHeap屬性, 排查記憶體洩露(這個在記憶體優化那篇細說吧)等.

3, 深入一點

沒有人願意在出問題之後去解決問題.
高手和新手的區別是, 高手知道怎麼在一開始就避免問題的發生. 那麼針對ANR這個問題, 我們需要做哪些層次的工作來避免其發生呢?

3.1 哪些地方是執行在主執行緒的

  1. Activity的所有生命週期回撥都是執行在主執行緒的.
  2. Service預設是執行在主執行緒的.
  3. BroadcastReceiver的onReceive回撥是執行在主執行緒的.
  4. 沒有使用子執行緒的looper的Handler的handleMessage, post(Runnable)是執行在主執行緒的.
  5. AsyncTask的回撥中除了doInBackground, 其他都是執行在主執行緒的.
  6. View的post(Runnable)是執行在主執行緒的.

3.2 使用子執行緒的方式有哪些

上面我們幾乎一直在說, 避免ANR的方法就是在子執行緒中執行耗時阻塞操作. 那麼在Android中有哪些方式可以讓我們實現這一點呢.

3.2.1 啟Thread方式

這個其實也是Java實現多執行緒的方式. 有兩種實現方法, 繼承Thread 或 實現Runnable介面:

繼承Thread

class PrimeThread extends Thread {
    long minPrime;
    PrimeThread(long minPrime) {
        this.minPrime = minPrime;
    }

    public void run() {
        // compute primes larger than minPrime
         . . .
    }
}

PrimeThread p = new PrimeThread(143);
p.start();複製程式碼

實現Runnable介面

class PrimeRun implements Runnable {
    long minPrime;
    PrimeRun(long minPrime) {
        this.minPrime = minPrime;
    }

    public void run() {
        // compute primes larger than minPrime
         . . .
    }
}

PrimeRun p = new PrimeRun(143);
new Thread(p).start();複製程式碼

3.2.2 使用AsyncTask

這個是Android特有的方式, AsyncTask顧名思義, 就是非同步任務的意思.

private class DownloadFilesTask extends AsyncTask {
    // Do the long-running work in here
    // 執行在子執行緒
    protected Long doInBackground(URL... urls) {
        int count = urls.length;
        long totalSize = 0;
        for (int i = 0; i < count; i++) {
            totalSize += Downloader.downloadFile(urls[i]);
            publishProgress((int) ((i / (float) count) * 100));
            // Escape early if cancel() is called
            if (isCancelled()) break;
        }
        return totalSize;
    }

    // This is called each time you call publishProgress()
    // 執行在主執行緒
    protected void onProgressUpdate(Integer... progress) {
        setProgressPercent(progress[0]);
    }

    // This is called when doInBackground() is finished
    // 執行在主執行緒
    protected void onPostExecute(Long result) {
        showNotification("Downloaded " + result + " bytes");
    }
}

// 啟動方式
new DownloadFilesTask().execute(url1, url2, url3);複製程式碼

3.2.3 HandlerThread

Android中結合Handler和Thread的一種方式. 前面有云, 預設情況下Handler的handleMessage是執行在主執行緒的, 但是如果我給這個Handler傳入了子執行緒的looper, handleMessage就會執行在這個子執行緒中的. HandlerThread正是這樣的一個結合體:

// 啟動一個名為new_thread的子執行緒
HandlerThread thread = new HandlerThread("new_thread");
thread.start();

// 取new_thread賦值給ServiceHandler
private ServiceHandler mServiceHandler;
mServiceLooper = thread.getLooper();
mServiceHandler = new ServiceHandler(mServiceLooper);

private final class ServiceHandler extends Handler {
    public ServiceHandler(Looper looper) {
      super(looper);
    }

    @Override
    public void handleMessage(Message msg) {
      // 此時handleMessage是執行在new_thread這個子執行緒中了.
    }
}複製程式碼

3.2.4 IntentService

Service是執行在主執行緒的, 然而IntentService是執行在子執行緒的.
實際上IntentService就是實現了一個HandlerThread + ServiceHandler的模式.

以上HandlerThread的使用程式碼示例也就來自於IntentService原始碼.

3.2.5 Loader

Android 3.0引入的資料載入器, 可以在Activity/Fragment中使用. 支援非同步載入資料, 並可監控資料來源在資料發生變化時傳遞新結果. 常用的有CursorLoader, 用來載入資料庫資料.

// Prepare the loader.  Either re-connect with an existing one,
// or start a new one.
// 使用LoaderManager來初始化Loader
getLoaderManager().initLoader(0, null, this);

//如果 ID 指定的載入器已存在,則將重複使用上次建立的載入器。
//如果 ID 指定的載入器不存在,則 initLoader() 將觸發 LoaderManager.LoaderCallbacks 方法 //onCreateLoader()。在此方法中,您可以實現程式碼以例項化並返回新載入器

// 建立一個Loader
public Loader onCreateLoader(int id, Bundle args) {
    // This is called when a new Loader needs to be created.  This
    // sample only has one Loader, so we don't care about the ID.
    // First, pick the base URI to use depending on whether we are
    // currently filtering.
    Uri baseUri;
    if (mCurFilter != null) {
        baseUri = Uri.withAppendedPath(Contacts.CONTENT_FILTER_URI,
                  Uri.encode(mCurFilter));
    } else {
        baseUri = Contacts.CONTENT_URI;
    }

    // Now create and return a CursorLoader that will take care of
    // creating a Cursor for the data being displayed.
    String select = "((" + Contacts.DISPLAY_NAME + " NOTNULL) AND ("
            + Contacts.HAS_PHONE_NUMBER + "=1) AND ("
            + Contacts.DISPLAY_NAME + " != '' ))";
    return new CursorLoader(getActivity(), baseUri,
            CONTACTS_SUMMARY_PROJECTION, select, null,
            Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC");
}

// 載入完成
public void onLoadFinished(Loader loader, Cursor data) {
    // Swap the new cursor in.  (The framework will take care of closing the
    // old cursor once we return.)
    mAdapter.swapCursor(data);
}複製程式碼

具體請參看官網Loader介紹.

3.2.6 特別注意

使用Thread和HandlerThread時, 為了使效果更好, 建議設定Thread的優先順序偏低一點:

Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND);複製程式碼

因為如果沒有做任何優先順序設定的話, 你建立的Thread預設和UI Thread是具有同樣的優先順序的, 你懂的. 同樣的優先順序的Thread, CPU排程上還是可能會阻塞掉你的UI Thread, 導致ANR的.

結語

對於ANR問題, 個人認為還是預防為主, 認清程式碼中的阻塞點, 善用執行緒. 同時形成良好的程式設計習慣, 要有MainThread和Worker Thread的概念的...(實際上人的工作狀態也是這樣的~~哈哈)

相關文章