Android:獲取APP啟動時間的踩坑經(jīng)歷

1. 前言

首先說明一下應(yīng)用的幾種啟動方式

  • 冷啟動:系統(tǒng)不存在此 APP 的進程,此時需要重新創(chuàng)建進程、Application、Activity等,然后是 measure、layout、draw 過程
  • 溫啟動:用戶按 HOME 鍵后,如果 Activity 沒有被回收,啟動應(yīng)用也只是喚醒到前臺,不需要走初始化流程
  • 熱啟動:系統(tǒng)存在此 APP 的進程,比如用戶按 Back 鍵,或者按 Home鍵后 Activity 被回收了,此時由于進程存在,所以不會初始化 Application,只需要創(chuàng)建 Activity 并 measure、layout、draw。

最近有個需求需要統(tǒng)計App的啟動時間,在查閱了一些資料后總結(jié)有如下三種方案

  1. 通過 adb 的 am 命令獲取
  2. 通過 adb 的 logcat 命令獲取
  3. 通過在Application和業(yè)務(wù)的第一個Activity埋點進行統(tǒng)計

2. 通過 adb 的 am 命令獲取

網(wǎng)上大部分都是這種方案,可以通過 adb 的命令

adb shell am start -W com.gtr.sdkdemo/com.gtr.test.MainActivity

這個命令的輸出日志如下:

Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.gtr.sdkdemo/com.gtr.test.MainActivity }
Status: ok
Activity: com.gtr.sdkdemo/com.gtr.test.MainActivity
ThisTime: 716
TotalTime: 4680
Complete

幾個時間參數(shù)的講解:

  • WaitTime 返回從 startActivity 到應(yīng)用第一幀完全顯示這段時間. 就是總的耗時,包括前一個應(yīng)用 Activity pause 的時間和新應(yīng)用啟動的時間;
  • ThisTime 表示一連串啟動 Activity 的最后一個 Activity 的啟動耗時;
  • TotalTime 表示新應(yīng)用啟動的耗時,包括新進程的啟動和 Activity 的啟動,但不包括前一個應(yīng)用Activity pause的耗時。

所以只關(guān)心 TotalTime 參數(shù)就可以了。但是問題來了:

  • 首先這個是shell命令,能不能通過 Runtime 來進行調(diào)用這個命令我沒試過,極有可能是不行的
  • 這個命令需要新起一個Activity來統(tǒng)計,在已運行應(yīng)用中肯定不可能新起Activity來統(tǒng)計,因為新起肯定是熱啟動的,啟動時間不準

因此,這個方案被無情拋棄

3. 通過 adb 的 logcat 命令 獲取

在一次無意的瀏覽 StackOverFlow 過程中,看到有個大牛給了一個提示:應(yīng)用啟用時,會輸出一行日志

adb logcat -s ActivityManager:I | grep Displayed

我試了一下,果不其然:

I/ActivityManager(  949): Displayed com.gtr.sdkdemo/com.gtr.test.MainActivity: +303ms (total +1s546ms)

再啟動一次,發(fā)現(xiàn)還會出來一條

I/ActivityManager(  949): Displayed com.gtr.sdkdemo/com.gtr.test.MainActivity: +303ms (total +1s546ms)
I/ActivityManager(  949): Displayed com.gtr.sdkdemo/com.gtr.test.MainActivity: +590ms

也就是說,只需要讀取最后一條數(shù)據(jù)就OK了。激動的我馬上封裝了一個異步獲取類來試了一下,源代碼如下

public class BaseInfoManager {

    private static final int WHAT_START_GET_APP_LAUNCH_TIME = 1000;

    private static volatile BaseInfoManager instance;
    private HandlerThread mHandlerThread;
    private Handler mHandler;
    private DataInputStream mReader;

    // 數(shù)據(jù)相關(guān)
    private List<String> appLaunchTimeList = new ArrayList<>();

    private BaseInfoManager() {
    }

    public static BaseInfoManager getInstance() {
        if (instance == null) {
            synchronized (BaseInfoManager.class) {
                if (instance == null) {
                    instance = new BaseInfoManager();
                }
            }
        }
        return instance;
    }

    public interface Callback<T> {

        void callback(T t);

    }

    private void runThreadIfNeed() {
        if (mHandlerThread == null) {
            mHandlerThread = new HandlerThread(BaseInfoManager.class.getSimpleName());
            mHandlerThread.start();
            mHandler = new Handler(mHandlerThread.getLooper()) {
                @Override
                public void handleMessage(Message msg) {
                    switch (msg.what) {
                        case WHAT_START_GET_APP_LAUNCH_TIME:
                            runShellForAppLaunchTime();
                            break;
                    }
                }
            };
        }
    }

    private void destoryThreadIfNeed() {
        if (mReader != null) {
            try {
                mReader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (mHandler != null) {
            mHandler.removeCallbacksAndMessages(null);
            mHandler = null;
        }
        if (mHandlerThread != null) {
            mHandlerThread.quitSafely();
            mHandlerThread = null;
        }
    }

    public synchronized void getLaunchAppTimeASync(long delayTime, final Callback<String> callback) {
        runThreadIfNeed();
        appLaunchTimeList.clear();
        mHandler.sendEmptyMessage(WHAT_START_GET_APP_LAUNCH_TIME);
        new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
            @Override
            public void run() {
                destoryThreadIfNeed();
                // 獲取最近的一條數(shù)據(jù)
                if (appLaunchTimeList.isEmpty()) {
                    callback.callback("");
                } else {
                    callback.callback(appLaunchTimeList.get(appLaunchTimeList.size() - 1));
                }
            }
        }, delayTime);
    }

    private void runShellForAppLaunchTime() {
        Process logcatProcess = null;

        try {
            // adb logcat -s ActivityManager:I | grep Displayed
            String cmd = "logcat -s ActivityManager:I | grep Displayed";
            logcatProcess = Runtime.getRuntime().exec(cmd);

            mReader = new DataInputStream(logcatProcess.getInputStream());
            String line;
            while ((line = mReader.readUTF()) != null) {
                appLaunchTimeList.add(line);
            }
        } catch (IOException e){
            // nothing to do
        } catch (SecurityException |
                IllegalArgumentException |
                NullPointerException e) {
            e.printStackTrace();
        } finally {
            if (mReader != null) {
                try {
                    mReader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                mReader = null;
            }

            if (logcatProcess != null) {
                logcatProcess.destroy();
            }
        }
    }
}

這個類提供了延時異步獲取的方式,因為 logcat 命令是會阻塞 shell 進程的,如果在主線程直接讀取的話,會造成主線程阻塞。

在設(shè)計這個類的時候,也踩了一些坑,在這里稍微總結(jié)一下以防以后忘了

  1. Runtime 的 exec 方法只是執(zhí)行 shell 語句,并不是 shell 解釋器,因此一些管道符、重定向符是不生效的。也就是說 |grep 我無效的,只能手動在程序中判斷
  2. android 用 exec 執(zhí)行 logcat 中會自動過濾掉非本應(yīng)用的一些日志,可能是出于安全著想
  3. exec(String) 和 exec(String[]) 是等效的,最終都會將 String 參數(shù)轉(zhuǎn)換為 String[] 參數(shù)
  4. Progress 的 waitFor 方法會阻塞當前線程,直到 shell 子進程結(jié)束,如果 shell 子進程一直不結(jié)束,則會造成死鎖。
  5. BufferedReader 的 readLine 和 close 方法都會阻塞。起初我是用 BufferedReader 的 readLine 在子線程讀取數(shù)據(jù)的,但是在主線程調(diào)用 close 方法來解除 子線程的阻塞狀態(tài)時發(fā)現(xiàn)主線程也被阻塞了。查看代碼才發(fā)現(xiàn) BufferedReader 的 close 方法加了一把鎖。
public void close() throws IOException {
    synchronized (lock) {
        if (in == null)
            return;
        try {
            in.close();
        } finally {
            in = null;
            cb = null;
        }
    }
}

這樣做就非常危險了,也就是說 BufferedReader 除非讀到末尾的 '\n' 字符,否則是不能主動結(jié)束其阻塞狀態(tài)的。而且主線程想結(jié)束子線程的阻塞狀態(tài)調(diào)用 close 方法還可能把主線程給阻塞了。

后來我才用 DataInputStream 來替換的,因為它的 close 方法沒有加鎖,不會被阻塞,并且可以解除子線程的阻塞狀態(tài)

public void close() throws IOException {
    in.close();
}

總結(jié)的采坑就到這里差不多了,運行結(jié)果發(fā)現(xiàn)沒有任何啟動時間數(shù)據(jù)。原因正如上面說的第二點,android 用 Runtime 獲取的 logcat 日志信息,屏蔽了非本應(yīng)用的日志,而啟動時間的日志是屬于系統(tǒng)的,所以獲取不到。

4. 通過在Application和業(yè)務(wù)的第一個Activity埋點進行統(tǒng)計

上面的兩種方案都以失敗告終,沒辦法在跟老大溝通后只有犧牲數(shù)據(jù)準確性了。

  • 首先在 Application 的 attachBaseContext 方法記錄開始時間
  • 在業(yè)務(wù)的第一個 Activity 的 onWindowFocusChanged 方法記錄結(jié)束時間

這里解釋一下為什么是 onWindowFocusChanged 而不是 onResume 等其他生命周期。因為 onResume 只是 Activity 的一次步驟,此時控件只是被 measure 了,但是并沒有 draw, 因此此時并不能被用戶所見,而為了統(tǒng)計數(shù)據(jù)的準確性,以用戶所見作為結(jié)束時間更為恰當。

然后解釋一下這種方式的優(yōu)缺點

  • 優(yōu)點:可以自由選擇哪一個 Activity 作為業(yè)務(wù)上的“首頁”,比如把主頁作為首頁而不是啟動頁
  • 缺點:從用戶點擊 app icon 到 Application 被創(chuàng)建,中間還是有很多步驟的,比如冷啟動的進程創(chuàng)建過程,而這個時間用此版本是沒辦法統(tǒng)計了,必須得承受這點數(shù)據(jù)的不準確性。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容