APP啟動優化,監控與實踐一起來!

語言: CN / TW / HK

本文作者

作者: 付十一

連結:

https://juejin.cn/post/6997253505723432974

本文由作者授權釋出。

前言

一年多以前寫過一篇關於啟動優化的文章,見 「效能優化系列」APP啟動優化理論與實踐(上) 。每一年都有新的見解,本篇將在前篇的基礎上補充說明app的啟動優化方案,請結合檢視。

https://juejin.cn/post/6844904131816079367

本篇內容主要如下:

1、啟動耗時監測實戰:手動打點以及AspectJ方式對比;

2、啟動優化實戰:有向無環圖啟動器、IdleHandler啟動器以及其他黑科技方案;

3、優化工具介紹。

1

優化工具

1.1、Traceview(棄用)

TraceView是Android平臺一個很好的效能分析的工具,能夠以圖形的形式顯示跟蹤日誌,但是已棄用。另外TraceView的效能消耗太大,得到的結果不真實。

1.2、CPU Profiler

代替Traceview便是CPU Profiler。它可以檢查通過使用Debug類對應用進行插樁檢測而捕獲的 .trace 檔案、記錄新方法跟蹤資訊、儲存 .trace 檔案以及檢查應用程序的實時CPU使用情況。具體使用參考 使用CPU效能剖析器檢查 CPU 活動

https://developer.android.google.cn/studio/profile/cpu-profiler?hl=zh-cn

1.3、Systrace + 函式插樁

Systrace 允許你收集和檢查裝置上執行的所有程序的計時資訊。它包括Androidkernel的一些資料(例如CPU排程程式,IO和APP Thread),並且會生成HTML報告,方便使用者檢視分析trace內容。但是不支援應用程式程式碼的耗時分析,如果需要分析程式程式碼的執行時間,那就要結合函式插樁的方式,對細節進行分析。在下面第二節給出了實戰案例,請參考。

2

啟動耗時監測

對於啟動速度的計算方式有很多種,如手動打點、AOP打點、adb命令、Traceview、Systrace等,在 「效能優化系列」APP啟動優化理論與實踐(上) 這篇文章裡已經初步說明,這裡就不在贅述。下面將從實戰方向進行耗時監測處理。

https://juejin.cn/post/6844904131816079367

為了監測啟動耗時,我在Application的onCreate中初始化了一些第三方框架,比如初始化ARouter、Bugly、LoadSir等,模擬耗時操作。

2.1、如何監測每個方法的執行時間?

2.1.1、方式一:手動打點

在瞭解到手動打點可監測app啟動時間,那是不是可以應用到每個方法中,那就來試一下,我們在每個第三方框架初始化的方法前後都進行打點。

override fun onCreate() {
    super.onCreate()
    //Debug.startMethodTracing("App")
    //TraceCompat.beginSection("onCreate")
    TimeMonitorManager.instance?.startMonitor()
    initRouter()
    TimeMonitorManager.instance?.endMonitor("initRouter")

    TimeMonitorManager.instance?.startMonitor()
    initBugly()
    TimeMonitorManager.instance?.endMonitor("initBugly")

    TimeMonitorManager.instance?.startMonitor()
    initLoadSir()
    TimeMonitorManager.instance?.endMonitor("initLoadSir")

    //Debug.stopMethodTracing()
    //TraceCompat.endSection()
}

按照這個方式,毋庸置疑,每個方法的耗時時間是肯定能計算出來的,但是,每個方法都加上重複的程式碼,一個方法加兩行,那有一百,一千個方法呢?難道一個一個的手敲嗎?!!

這種方式太“笨”,並且對原始碼的侵入性極強,棄。

那是否有更優雅的方式計算每個方法的執行時間? 答案是當然有。

AOP(面向切面程式設計),可以通過預編譯方式和執行其動態代理實現在不修改原始碼的情況下給程式動態統一新增某種特定功能的一種技術。

而它的目的主要將日誌記錄,效能統計,安全控制,事務處理,異常處理等程式碼從業務邏輯程式碼中劃分出來,通過對這些行為的分離,我們希望可以將它們獨立到非指導業務邏輯的方法中,進而改變這些行為的時候不影響業務邏輯的程式碼。

上面手動打點的方式,與業務邏輯程式碼耦合性強,而AOP就很好的解決了這個問題。在Android中實現AOP的方式有多種,這裡將講述其中比較常用的實現-AspectJ。

2.1.2、方式二、AOP-AspectJ

AspectJ是AOP的具體實現方式之一,它針對於橫切關注點進行處理。而作為AOP的具體實現之一的AspectJ,它向Java中加入了連線點(Join Point)這個概念。它向Java語言中加入少許新結構,比如:切點(pointcut)、通知(Advice)、型別間宣告(Inter-type declaration)和方面(Aspect)。切點和通知動態地影響程式流程,型別間宣告則是靜態的影響程式的類等級結構,而方面則是對所有這些新結構的封裝。

那麼就下來就使用AspectJ進行計算操作。

新增依賴

build.gradle

dependencies {
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
}

app#build.gradle

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'android-aspectjx'
}
...
dependencies {
    implementation 'org.aspectj:aspectjrt:1.8.+'
}

新建class類,加上 @Aspect 註解表示當前類是為一個切面供容器讀取。

@Aspect
class PerformanceAOP {
}

接下來就開始針對需求,編寫邏輯程式碼。我們的需求是計算每個方法的執行時間,則使用 @Around 以及JoinPoint對方法做統一處理。

@Around("call(* com.fuusy.fuperformance.App.**(..))")
fun getMethodTime(joinPoint: ProceedingJoinPoint) {
    val signature = joinPoint.signature
    val time: Long = System.currentTimeMillis()
    joinPoint.proceed()
    Log.d(TAG, "${signature.toShortString()} speed time = ${System.currentTimeMillis() - time}")
}

執行看看效果:

21:05:44.504 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initRouter() speed time = 2009
21:05:45.104 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 599
21:05:45.112 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initLoadSir() speed time = 8

3

啟動優化手段

對於app啟動速度的優化,應用層所能做的只有干預其Application和Activity裡的業務邏輯。比如在Application裡,經常在onCreate中初始化第三方框架,這無疑是耗時的。那具體的優化操作該怎麼做?

啟動優化主要有兩個方向,非同步執行、延遲執行。

3.1、非同步執行

3.1.1、開啟子執行緒

說到非同步處理邏輯,第一反應是不是開啟子執行緒?那麼就來實戰一下吧。還是在Application中模擬耗時操作,這次我會建立一個執行緒池,線上程池中執行三方框架的初始化。

override fun onCreate() {
        super.onCreate()

        TimeMonitorManager.instance?.startMonitor()
        //非同步方法一、建立執行緒池
        val newFixedThreadPool = Executors.newFixedThreadPool(CORE_POOL_SIZE)
        newFixedThreadPool.submit {
            initRouter()
        }
        newFixedThreadPool.submit {
            initBugly()
        }
        newFixedThreadPool.submit {
            initLoadSir()
        }

        TimeMonitorManager.instance?.endMonitor("APP onCreate")
    }

看看執行時間:

//總時間
com.fuusy.fuperformance D/TimeMonitorManager: APP onCreate: 45
//單個方法執行時間
com.fuusy.fuperformance D/PerformanceAOP: App.initLoadSir() speed time = 8
com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 678
com.fuusy.fuperformance D/PerformanceAOP: App.initRouter() speed time = 1768

單個方法 initLoadSir 執行時間為8毫秒, initBugly 為678毫秒, initRouter 為1768毫秒,而使用執行緒池後, onCreate 執行的總時間只有45毫秒,2400毫秒到45毫秒,速度提升了90%多。這效果無疑是顯著的。

但是, 實際專案中業務是複雜的,執行緒池的方案也cover不住所有的情況,比如一個第三方框架只能在主執行緒中初始化,比如一個框架只有先在 onCreate 中初始化完成,才能繼續下一步。那麼這些情況下又改如何處理?

如果方法只能在主執行緒中執行,那就只能放棄子執行緒的方式;

如果方法需要在特定階段就要完成,可以使用 CountDownLatch 這麼一個同步輔助工具。

CountDownLatch 是一種通用的同步工具,可用於多種目的。計數為 1 的 CountDownLatch 用作簡單的開/關鎖存器或門:呼叫 await 所有執行緒在門處等待,直到它被呼叫countDown的執行緒countDown 。初始化為N的 CountDownLatch 可用於使一個執行緒等待,直到N 個執行緒完成某個操作,或者某個操作已完成 N 次。 CountDownLatch 一個有用屬性是它不需要呼叫countDown執行緒在繼續之前等待計數達到零,它只是阻止任何執行緒通過 await 直到所有執行緒都可以通過。

說的通俗一點, CountDownLatch 是用來等待子執行緒完成,然後才讓程式繼續下一步操作的工具類。那麼來實戰看看。

建立一個 CountDownLatch 並計數為1,模擬 initBugly 方法需要等待。

class App : Application() {
    //建立CountDownLatch
    private val countDownLatch: CountDownLatch = CountDownLatch(1)

    override fun onCreate() {

          ...
         newFixedThreadPool.submit {
              initBugly()
              //執行countDown
              countDownLatch.countDown()
         }
         //await
         countDownLatch.await()
         TimeMonitorManager.instance?.endMonitor("APP onCreate")
     }
}

重新啟動APP:

com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 642
com.fuusy.fuperformance D/TimeMonitorManager: APP onCreate: 667

可以看到最後總時間是等待 initBugly 執行完成後才執行,啟動時間也就加長了。

從上面說明就可以知道開啟執行緒池的方式只能應對一般情況,遇到複雜的邏輯就出現弊端了。例如當兩個任務之間出現依賴關係,又該如何處理?同時發現,每針對一個方法,都需要提交一個Runnable任務以供執行,這無疑也是在消耗資源。

那既能夠非同步操作、又能解決任務之間的依賴關係,同時執行程式碼更加優雅的方式有沒有?當然有,接下來就提供一種更優雅的非同步手段-有向無環圖啟動器。

3.1.2、有向無環圖啟動器

在實際專案中,任務的執行是有先後順序的,比如說在進行微信支付SDK初始化時,需要從後臺先拿到對應的App金鑰,再依據這個金鑰進行支付的初始化操作。

對於任務執行順序的問題,有一種資料結構可以很好解決,那就是有向無環圖。先來看一下有向無環圖的具體說明。

3.1.2.1、有限無環圖(DAG)

有向無環圖:若一個有向圖中不存在環,則稱為有向無環圖圖,也稱為DAG圖。

上圖就是一個有向無環圖圖,兩個頂點之間不存在相互指向的邊。若該圖中B->A那麼就存在環了,便不是有向無環圖。

那麼啟動優化和這有什麼關係?

在上面說過,DAG圖所要解決的便是任務之間的依賴關係。而解決這個問題,其實還涉及到一個知識點AOV網(Activity On Vertex Network)。

3.1.2.2、AOV網(Activity On Vertex Network)

AOV網是用頂點表示活動的網,是DAG典型的應用之一。用DAG作為一個工程,頂點表示活動,有向邊<Vi,Vj>則表示活動Vi必須先於活動Vj進行。如上面有向無環圖,B必須在A後面執行,D必須優先於E執行,各頂點之間存在先後執行的關係。

這恰恰和啟動任務的依賴關係不謀而合,只要通過AOV網的執行方式去執行啟動任務,也就解決了啟動任務的依賴關係問題。

在AOV網中,找到任務執行的先後順序,就要用到拓撲排序。

3.1.2.3、拓撲排序

拓撲排序是對有向無環圖的頂點的一種排序,它使得若存在一條從頂點A到頂點B的路徑,則在排序中頂點B出現在頂點A的後面,每個AOV網都有一個或多個拓撲排序。而拓撲排序實現步驟也很簡單,如下:

拓撲排序的實現:

1、從AOV網中選擇一個沒有前驅(入度為0)的頂點並輸出;

2、從網中刪除該頂點和所有以它為起點的有向邊;

3、重複1和2的操作,直到當前AOV網為空或者當前網中不存在無前驅的頂點為止。

舉個生活中泡茶的案例。

如上圖,就是一個泡茶的有向無環圖,而對於拓撲排序的實現,我們就按照上述步驟執行:

1、找到入度為0的頂點,這裡入度為0的頂點只有“準備茶具”和“買茶葉”,隨便選擇其中一個“準備茶具”;

2、去掉“準備茶具”這個頂點且去除以它為起點的邊,也就變為下圖:

3、這個時候只有“買茶葉”頂點入度為0,則選擇該頂點,且重複1和2的操作。

如此反覆,最後頂點執行的順序如下:

當然,拓撲排序最後的結果有多種,例如這裡一開始可以選擇入度為0的“買茶葉”頂點作為初始任務,結果就變了,這裡就不做詳細討論。

上面有向無環圖、AOV網以及拓撲排序已經說明清楚,接下來就是與啟動任務相結合。其實就是按照拓撲排序的規則將任務按順序執行。

/**
 * 拓撲排序
 */
fun topologicalSort(): Vector<Int> {
    val indegree = IntArray(mVerticeCount)
    for (i in 0 until mVerticeCount) { //初始化所有點的入度數量
        val temp = mAdj[i] as ArrayList<Int>
        for (node in temp) {
            indegree[node]++
        }
    }
    val queue: Queue<Int> = LinkedList()
    for (i in 0 until mVerticeCount) { //找出所有入度為0的點
        if (indegree[i] == 0) {
            queue.add(i)
        }
    }
    var cnt = 0
    val topOrder = Vector<Int>()
    while (!queue.isEmpty()) {
        val u = queue.poll()
        topOrder.add(u)
        for (node in mAdj[u]) { //找到該點(入度為0)的所有鄰接點
            if (--indegree[node] == 0) { //把這個點的入度減一,如果入度變成了0,那麼新增到入度0的佇列裡
                queue.add(node)
            }
        }
        cnt++
    }
    check(cnt == mVerticeCount) {  //檢查是否有環,理論上拿出來的點的次數和點的數量應該一致,如果不一致,說明有環
        "Exists a cycle in the graph"
    }
    return topOrder
}

具體處理啟動任務的啟動器可直接去github中檢視FuPerformance。

實現啟動器後,在Application或者Activity中的基本使用如下:

將每個任務單獨拎出來,在子執行緒中執行繼承Task抽象類,如初始化ARouter;

class RouterTask() : Task() {
    override fun run() {
        if (BuildConfig.DEBUG) {
            ARouter.openLog()
            ARouter.openDebug()
        }
        ARouter.init(mContext as Application?)
    }
}

如果必須在主執行緒中執行則繼承MainTask,如果需要等待該任務執行完畢才能進行下一步,則需要實現 needWait 方法,返回true。

override fun needWait(): Boolean {
    return true
}

如果任務之間存在依賴關係,則需要實現 dependsOn 方法,例如微信支付需要依賴於AppId的獲取。

class WeChatPayTask :Task(){

    /**
     * 微信支付依賴AppId
     */
    override fun dependsOn(): List<Class<out Task?>?>? {
        val task = mutableListOf<Class<out Task?>>()
        //新增AppID的獲取Task
        task.add(LoadAppIdTask::class.java)
        return task
    }

    override fun run() {
        //初始化微信支付
    }
}

將任務分別處理後,最後在Application的 onCreate 中新增任務佇列。

//方式二、啟動器
TaskDispatcher.init(this)

TaskDispatcher.newInstance()
    .addTask(RouterTask())
    .addTask(LoadSirTask())
    .addTask(BuglyTask())
    .addTask(LoadAppIdTask())
    .addTask(WeChatPayTask())

這就是有向無環圖啟動器的實現與使用,可以發現它既使得程式碼變得優雅,又解決了一開始所提到的幾個痛點:

1、子執行緒中任務的依賴問題;

2、任務在子執行緒中執行時必須等待其執行完的問題;

3、設定在主執行緒中執行。

4、程式碼高耦合且資源浪費的問題。

3.2、延遲執行

第二部分的優化方式就是延遲執行,實現延時執行操作有多種方法:

執行緒休眠

object : Thread() {
    override fun run() {
        super.run()
        sleep(3000) //休眠3秒
        /**
         * 要執行的操作
         */
    }
}.start()

Handler#postDelayed

handler.postDelayed(
    Runnable {
        /**
         * 要執行的操作
         */
    }, 3000
)

TimerTask實現

val task: TimerTask = object : TimerTask() {
    override fun run() {
        /**
         * 要執行的操作
         */
    }
}
val timer = Timer()
timer.schedule(task, 3000) //3秒後執行TimeTask的run方法

這三種方式都可以實現延時操作,但應用到啟動任務中,它們都有一個共同的痛點-無法確定延時時長。

那如何解決這個痛點?

可以利用Handler中的IdleHandler機制。

3.2.1、IdleHandler

在啟動的過程中,其實存在一些任務不是App啟動後就必須馬上執行,這種情況下就需要我們找到合適的時機再去執行任務。那這個時間該如何查詢?Android其實給我們提供了一個很好的機制。在Handler機制中,提供了一種在訊息佇列空閒時,執行任務的時機-IdleHandler。

IdleHandler主要用在當前執行緒訊息佇列空閒時。可能你想問,如果訊息佇列一直不空閒,IdleHandler就一直得不到執行,那又該如何?因為IdleHandler的開始時間的不可控性,實際就需要結合專案業務來使用。

依據IdleHandler的特性,實現一個IdleHandler啟動器,如下:

class DelayDispatcher {
    private val mDelayTasks: Queue<Task> = LinkedList<Task>()

    private val mIdleHandler = IdleHandler {
        if (mDelayTasks.size > 0) {
            val task: Task = mDelayTasks.poll()
            DispatchRunnable(task).run()
        }
        !mDelayTasks.isEmpty()
    }

    /**
     * 新增延時任務
     */
    fun addTask(task: Task): DelayDispatcher? {
        mDelayTasks.add(task)
        return this
    }

    fun start() {
        Looper.myQueue().addIdleHandler(mIdleHandler)
    }
}

使用

DelayDispatcher().addTask(Task())?.start()

3.3、其他方案

提前載入SharedPreferences;

啟動階段不啟動子程序;

類載入優化

I/O 優化

張邵文在開發高手課中提到:

在負載過高的時候,I/O 效能下降得會比較快。特別是對於低端機,同樣的 I/O 操作耗時可能是高階機器的幾十倍。啟動過程不建議出現網路I/O,而磁碟 I/O優化就要清楚啟動過程讀了什麼檔案、多少個位元組、Buffer 是多大、使用了多長時間、在什麼執行緒等一系列資訊。

類重排

啟動過程類載入順序可以通過複寫 ClassLoader 得到:

class GetClassLoader extends PathClassLoader {
    public Class<?> findClass(String name) {
        // 將 name 記錄到檔案
        writeToFile(name,"coldstart_classes.txt");
        return super.findClass(name);
    }
}

然後利用Facebook開源的 Dex優化工具 整類在Dex中的排列順序。

https://github.com/facebook/redex

ReDex是一個Android位元組碼(dex)優化器,最初由Facebook開發。它提供了一個用於讀取、寫入和分析.dex檔案的框架,以及一組使用該框架改進位元組碼的優化傳遞。

資原始檔重排

關於資原始檔重排的原理以及落地方案可參考 支付寶App構建優化解析:通過安裝包重排布優化 Android 端啟動效能

https://mp.weixin.qq.com/s/79tAFx6zi3JRG-ewoapIVQ

3.4、黑科技

啟動階段抑制GC

支付寶使用了這種方式,可直接參考 支付寶客戶端架構解析:Android 客戶端啟動速度優化之「垃圾回收」

https://developer.aliyun.com/article/672750

CPU鎖頻

CPU的工作頻率越高,運算就越快,但是能耗就越高,為了啟動速度提升,拉伸CPU頻率,速度快了,但是手機能耗也更快了。

總結

上文介紹了一些與業務相關的優化手段以及一些與業務無關的黑科技,能夠有效的提升App的啟動速度。啟動優化的方案有很多,但還需要我們結合實際專案情況進行方案判斷並落地。

最後,效能優化是一個長期的過程,我將開一個性能優化理論與實踐系列,主要涉及啟動、記憶體、卡頓、瘦身、網路等優化,請持續關注。

啟動優化

專案地址: fuusy/FuPerformance

https://github.com/fuusy/FuPerformance

參考資料:

支付寶客戶端架構解析:Android 客戶端啟動速度優化之「垃圾回收」

https://develop er.aliyun.com/article/672750

支付寶App構建優化解析:通過安裝包重排布優化 Android 端啟動效能

https://mp.weixin.qq.com/s/79tAFx6zi3JRG-ewoapIVQ
國內Top團隊大牛帶你玩轉Android效能分析與優化
Android開發高手課
輕量級APP啟動資訊構建方案

https://juejin.cn/post/6992744674796503077

最後推薦一下我做的網站,玩Android:  wanandroid.com ,包含詳盡的知識體系、好用的工具,還有本公眾號文章合集,歡迎體驗和收藏!

推薦閱讀

Android帶你實現增量更新!

吹爆系列:Android 插樁之美,全面掌握!

記一次 Android 線上 OOM 的排查過程

點選  關注我的公眾號

如果你想要跟大家分享你的文章,歡迎投稿~

┏(^0^)┛明天見!