背景
各位大佬好久不見了,憋了一陣子發育了一下(主要是我在拼神龍鬥士),基本上完成了簡單的性能採集的Demo,分享一下相關的經驗給各位吧。
APM(Application perfmance monitor)就是應用性能監控。在移動互聯網對人們生活影響越來越大的今天,App的功能越來越全面,隨之而來的就是App性能的要求越來越高,不能被動的等待用戶異常的發生然後根據線上日誌去修復bug,再發補丁版本。主動監控App性能變得越來越重要,分析App的耗電,UI卡頓,網絡性能成爲了當物之急。
當前項目的Apm開發參考了騰訊的Matrix,360的ArgusAPM,滴滴的Dokit,還有一些細小的項目等等。根據項目進行定製,之後完成自己的Apm採集系統。
後續文章會根據我當前的開發進度緩慢更新,大家可以跟着我這個小菜雞緩慢前行,當前完成了這三個性能指標的採集工作,後續可能還會添加線程FD信息,所以本文就會着重分析這三個點。
先拋出問題
一個性能數據採集系統,你不能成爲一個app的負擔,不能在採集的時候耽誤主線程的渲染,接入了Apm之後反倒讓App變得更加卡頓。
由於Fps,內存,Cpu等都是需要頻繁採樣的,比如Fps,一秒鐘刷新60幀,如果全量數據上報,那麼後端大佬可能就把我給打死了。
在業務最少介入的情況下完成關鍵頁面數據的收集,以及將頁面數據和性能數據進行綁定。
Fps採集
首先我們還是要先介紹下什麼是Fps.
流暢度,是頁面在滑動、渲染等過程中的體驗。Android系統要求每一幀都要在 16ms 內繪製完成,平滑的完成一幀意味着任何特殊的幀需要執行所有的渲染代碼(包括 framework 發送給 GPU 和 CPU 繪製到緩衝區的命令)都要在 16ms 內完成,保持流暢的體驗。如果沒有在期間完成渲染秒就會發生掉幀。掉幀是用戶體驗中一個非常核心的問題。丟棄了當前幀,並且之後不能夠延續之前的幀率,這種不連續的間隔會容易會引起用戶的注意,也就是我們常說的卡頓、不流暢。
那麼是不是1s只要繪製了60幀是不是就是流暢的呢?也不一定,如果發生抖動的情況,那麼肯定會有其中幾幀是有問題的。其中肯定會有最大繪製幀,和最小繪製幀的情況,所以平均值,最大值最小值都是我們需要知道的。
在討論採集之前,我們要先簡單的說下兩個東西Choreographer
和LooperPrinter
。
Choreographer(編舞者)
Choreographer中文翻譯過來是"舞蹈指揮",字面上的意思就是優雅地指揮以上三個UI操作一起跳一支舞。這個詞可以概括這個類的工作,如果android系統是一場芭蕾舞,他就是Android UI顯示這出精彩舞劇的編舞,指揮台上的演員們相互合作,精彩演出。Google的工程師看來挺喜歡舞蹈的!
其中關於Choreographer的介紹相關的可以參考這篇文章,面試中也經常會問到這個問題,ViewRootImp和Vsync等等,但是我們還是比較專注於數據的採集和上報,所以關注的重點還是有點不同。
一般常規的Fps採集可以通過Choreographer
既UI線程繪製的編舞者,Choreographer是一個ThreadLocal的單例,接收vsync信號進行界面的渲染,我們只要對其添加一個CallBack,就可以巧妙的計算出這一幀的繪製時長。
Matrix對於核心Choreographer
是對CallbackQueue
的hook,通過hook addCallbackLocked
分別在不同類型的回調隊列的頭部添加自定義的FrameCallback
。這樣 系統CALLBACK_INPUT = 自定義CALLBACK_ANIMATION的開始時間-自定義CALLBACK_INPUT的完成時間 系統CALLBACK_ANIMATION = 自定義CALLBACK_TRAVERSAL開始時間-自定義CALLBACK_ANIMATION開始時間 系統CALLBACK_TRAVERSAL = msg dipatch結束時間- 自定義CALLBACK_TRAVERSAL開始時間
LooperPrinter
首先我們先要有個概念,所有的View相關的和生命週期相關的都被執行在主線程上。那麼我們有沒有方法可以監控到主線程耗時呢?
我們盤一盤Handler是如何執行的,首先Looper
從MessageQueue
中獲取到Message
,之後判斷Message內部的Handler
或者Runnable
來決定執行後續的操作。
從ActivityThread
分析,所有的主線程操作的被執行在主線程的Looper
上,那麼我們是不是隻要在主線程的Looper
的loop
方法,獲取到Message
的前後執行加上寫代碼就能監控到一個Message
被執行的時長了呢。
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
final MessageQueue queue = me.mQueue;
// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();
// Allow overriding a threshold with a system prop. e.g.
// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
final int thresholdOverride =
SystemProperties.getInt("log.looper."
+ Process.myUid() + "."
+ Thread.currentThread().getName()
+ ".slow", 0);
boolean slowDeliveryDetected = false;
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
...
}
}
從源碼上我們可以看到Looper
一開始就預留了一個Printer
的類,其中在Message執行開始和Message執行結束之後都會執行Printer
方法。我們可以通過 looper.setMessageLogging(new LooperPrinter());
的方法來設置這個監控。
IdleHandler
當Looper
中的MessageQueue
爲空的情況下,會觸發IdleHandler
,所以主線程卡頓,一般都會配合這個一起來重置耗時時間,這樣就能保證主線程空置的情況下,方法耗時不會計算出錯。
UIThreadMonitor(主線程監控)
簡單的介紹了下上面幾個東西之後,我們的Fps採集的這部分實際的採樣代碼我參考了下Matrix
的UIThreadMonitor
,而UIThreadMonitor
則是通過上述幾個組合的方式來完成的。
private void dispatchEnd() {
long traceBegin = 0;
if (config.isDevEnv()) {
traceBegin = System.nanoTime();
}
long startNs = token;
long intendedFrameTimeNs = startNs;
if (isVsyncFrame) {
doFrameEnd(token);
intendedFrameTimeNs = getIntendedFrameTimeNs(startNs);
}
long endNs = System.nanoTime();
synchronized (observers) {
for (LooperObserver observer : observers) {
if (observer.isDispatchBegin()) {
observer.doFrame(AppMethodBeat.getVisibleScene(), startNs, endNs, isVsyncFrame, intendedFrameTimeNs, queueCost[CALLBACK_INPUT], queueCost[CALLBACK_ANIMATION], queueCost[CALLBACK_TRAVERSAL]);
}
}
}
dispatchTimeMs[3] = SystemClock.currentThreadTimeMillis();
dispatchTimeMs[1] = System.nanoTime();
AppMethodBeat.o(AppMethodBeat.METHOD_ID_DISPATCH);
synchronized (observers) {
for (LooperObserver observer : observers) {
if (observer.isDispatchBegin()) {
observer.dispatchEnd(dispatchTimeMs[0], dispatchTimeMs[2], dispatchTimeMs[1], dispatchTimeMs[3], token, isVsyncFrame);
}
}
}
this.isVsyncFrame = false;
if (config.isDevEnv()) {
MatrixLog.d(TAG, "[dispatchEnd#run] inner cost:%sns", System.nanoTime() - traceBegin);
}
}
UIThreadMonitor
則不太一樣,其中dispatchEnd
方法有其中LooperMonitor
所接受到的。
而LooperMonitor
他通過主線程的Looper
的setMessageLogging
方法設置一個LooperPrinter
。dispatchEnd
在主線程的方法執行結束之後,通過反射Choreographer
獲取當前的繪製的Vsync和渲染時長。最後當IdleHandler被觸發的時候,則重置LooperPrinter
時間的方式,從而避免主線程閒置狀況下方法耗時計算出問題。
爲什麼要繞一個大圈子來監控Fps呢?這麼寫的好處是什麼呢?我特地去翻查了下Matrix官方的wiki,Martix參考了BlockCanary
的代碼,通過結合了下Choreographer
和BlockCanary
,當出現卡頓幀的時候獲取當前的主線程卡頓的堆棧,然後通過LooperPrinter
把當前的卡頓的堆棧方法輸出,這樣可以更好的輔助開發去定位卡頓問題,而不是直接告訴業務方你的頁面卡頓了。
採樣分析
文章開始拋出過一個問題,如果採集的每一個數據都上報首先會對服務器產生巨大的無效數據壓力,其次也會有很多無效的數據上報,那麼應該怎麼做呢?
這一塊我們參考了Matrix的代碼,首先Fps數據不可能是實時上報的,其次最好能從一個時間段內的數據中篩選出有問題的數據,Matrix的Fps採集的有幾個小細節其實做的很好。
- 延遲200毫秒.先收集200幀的數據,然後對其數據內容進行分析,篩選遍歷出最大幀最小幀,以及平均幀,之後內存保存數據。
- 子線程處理數據,篩選遍歷的操作移動到子線程,這樣避免APM反倒造成App卡頓問題。
- 200毫秒的數據只是作爲其中一個數據片段,Matrix的上報節點是以一個更長的時間段作爲上報的,當時間超過1分鐘左右的情況下,纔會作爲一個Issue片段上報。
- 前後臺切換狀態並不需要採集數據。
private void notifyListener(final String focusedActivity, final long startNs, final long endNs, final boolean isVsyncFrame,
final long intendedFrameTimeNs, final long inputCostNs, final long animationCostNs, final long traversalCostNs) {
long traceBegin = System.currentTimeMillis();
try {
final long jiter = endNs - intendedFrameTimeNs;
final int dropFrame = (int) (jiter / frameIntervalNs);
droppedSum += dropFrame;
durationSum += Math.max(jiter, frameIntervalNs);
synchronized (listeners) {
for (final IDoFrameListener listener : listeners) {
if (config.isDevEnv()) {
listener.time = SystemClock.uptimeMillis();
}
if (null != listener.getExecutor()) {
if (listener.getIntervalFrameReplay() > 0) {
listener.collect(focusedActivity, startNs, endNs, dropFrame, isVsyncFrame,
intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);
} else {
listener.getExecutor().execute(new Runnable() {
@Override
public void run() {
listener.doFrameAsync(focusedActivity, startNs, endNs, dropFrame, isVsyncFrame,
intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);
}
});
}
} else {
listener.doFrameSync(focusedActivity, startNs, endNs, dropFrame, isVsyncFrame,
intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);
}
...
}
}
}
}
上面是Matirx的源代碼,其中我們可以看出listener.getIntervalFrameReplay() > 0
當這個條件觸發的情況下,listener會先做一次collection操作,當觸發到一定的數據量之後,纔會觸發後續的邏輯。其次我們可以看到判斷了null != listener.getExecutor()
,所以這部分收集的操作被執行在線程池中。
private class FPSCollector extends IDoFrameListener {
private Handler frameHandler = new Handler(MatrixHandlerThread.getDefaultHandlerThread().getLooper());
Executor executor = new Executor() {
@Override
public void execute(Runnable command) {
frameHandler.post(command);
}
};
private HashMap<String, FrameCollectItem> map = new HashMap<>();
@Override
public Executor getExecutor() {
return executor;
}
@Override
public int getIntervalFrameReplay() {
return 200;
}
@Override
public void doReplay(List<FrameReplay> list) {
super.doReplay(list);
for (FrameReplay replay : list) {
doReplayInner(replay.focusedActivity, replay.startNs, replay.endNs, replay.dropFrame, replay.isVsyncFrame,
replay.intendedFrameTimeNs, replay.inputCostNs, replay.animationCostNs, replay.traversalCostNs);
}
}
public void doReplayInner(String visibleScene, long startNs, long endNs, int droppedFrames,
boolean isVsyncFrame, long intendedFrameTimeNs, long inputCostNs,
long animationCostNs, long traversalCostNs) {
if (Utils.isEmpty(visibleScene)) return;
if (!isVsyncFrame) return;
FrameCollectItem item = map.get(visibleScene);
if (null == item) {
item = new FrameCollectItem(visibleScene);
map.put(visibleScene, item);
}
item.collect(droppedFrames);
if (item.sumFrameCost >= timeSliceMs) { // report
map.remove(visibleScene);
item.report();
}
}
}
private class FrameCollectItem {
String visibleScene;
long sumFrameCost;
int sumFrame = 0;
int sumDroppedFrames;
// record the level of frames dropped each time
int[] dropLevel = new int[DropStatus.values().length];
int[] dropSum = new int[DropStatus.values().length];
FrameCollectItem(String visibleScene) {
this.visibleScene = visibleScene;
}
void collect(int droppedFrames) {
float frameIntervalCost = 1f * UIThreadMonitor.getMonitor().getFrameIntervalNanos() / Constants.TIME_MILLIS_TO_NANO;
sumFrameCost += (droppedFrames + 1) * frameIntervalCost;
sumDroppedFrames += droppedFrames;
sumFrame++;
if (droppedFrames >= frozenThreshold) {
dropLevel[DropStatus.DROPPED_FROZEN.index]++;
dropSum[DropStatus.DROPPED_FROZEN.index] += droppedFrames;
} else if (droppedFrames >= highThreshold) {
dropLevel[DropStatus.DROPPED_HIGH.index]++;
dropSum[DropStatus.DROPPED_HIGH.index] += droppedFrames;
} else if (droppedFrames >= middleThreshold) {
dropLevel[DropStatus.DROPPED_MIDDLE.index]++;
dropSum[DropStatus.DROPPED_MIDDLE.index] += droppedFrames;
} else if (droppedFrames >= normalThreshold) {
dropLevel[DropStatus.DROPPED_NORMAL.index]++;
dropSum[DropStatus.DROPPED_NORMAL.index] += droppedFrames;
} else {
dropLevel[DropStatus.DROPPED_BEST.index]++;
dropSum[DropStatus.DROPPED_BEST.index] += Math.max(droppedFrames, 0);
}
}
}
這部分代碼則是Matrix對於一個幀片段進行數據處理的邏輯。可以看出,collect
方法內篩選出了最大最小等等多個緯度的數據,豐富了一個數據片段。這個地方數據越多越能幫助一個開發定位問題。
採集邏輯也參考了Matrix的這部分代碼,但是在實際測試階段發現了個小Bug,因爲上報的是一個比較大的時間片段,用戶切換了頁面之後,會把上個頁面的fps數據也當做下個頁面的數據上報。
所以我們增加了一個ActivityLifeCycle
,當頁面發生變化的情況下進行一次數據上報操作。其次我們把Matrix內的前後臺切換等邏輯也進行了一次調整,更換成更可靠的ProcessLifecycleOwner
。
Cpu和Memory
內存和Cpu的使用狀況可以更好的幫我們檢測線上用戶的真實情況,而不是等到用戶crash之後我們再去反推這個問題,可以根據頁面維度篩選出不同的頁面數據,方便開發分析對應的問題。
在已經獲取到Fps的經驗之後,我們在這個基礎上增加了Cpu和Memory的數據收集。相對來說我們可以借鑑大量的採集邏輯,然後只要在獲取關鍵性數據進行調整就好了。
- 數據在子線程中採集,避免採集數據卡頓主線程。
- 同時每秒採集一次數據,數據內容本地分析,計算峯值谷值均值
- 數據上報節點拆分,一定時間內,頁面切換,生成一個數據。
- 合併Cpu和內存數據,作爲同一個數據結構上報,優化數據流量問題。
Memory 數據採集
Memory的數據我們參考了下Dokit的代碼,高低版本也有差異,高版本可以直接通過Debug.MemoryInfo()
獲取到內存的數據,低版本則需要通過ams
獲取到ActivityManager
從中獲取數據。
以下是性能採集的工具類同時採集了cpu數據,各位可以直接使用。
object PerformanceUtils {
private var CPU_CMD_INDEX = -1
@JvmStatic
fun getMemory(): Float {
val mActivityManager: ActivityManager? = Hasaki.getApplication().getSystemService(Context.ACTIVITY_SERVICE)
as ActivityManager?
var mem = 0.0f
try {
var memInfo: Debug.MemoryInfo? = null
if (Build.VERSION.SDK_INT > 28) {
// 統計進程的內存信息 totalPss
memInfo = Debug.MemoryInfo()
Debug.getMemoryInfo(memInfo)
} else {
//As of Android Q, for regular apps this method will only return information about the memory info for the processes running as the caller's uid;
// no other process memory info is available and will be zero. Also of Android Q the sample rate allowed by this API is significantly limited, if called faster the limit you will receive the same data as the previous call.
val memInfos = mActivityManager?.getProcessMemoryInfo(intArrayOf(Process.myPid()))
memInfos?.firstOrNull()?.apply {
memInfo = this
}
}
memInfo?.apply {
val totalPss = totalPss
if (totalPss >= 0) {
mem = totalPss / 1024.0f
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return mem
}
/**
* 8.0以下獲取cpu的方式
*
* @return
*/
private fun getCPUData(): String {
val commandResult = ShellUtils.execCmd("top -n 1 | grep ${Process.myPid()}", false)
val msg = commandResult.successMsg
return try {
msg.split("\\s+".toRegex())[CPU_CMD_INDEX]
} catch (e: Exception) {
"0.5%"
}
}
@WorkerThread
fun getCpu(): String {
if (CPU_CMD_INDEX == -1) {
getCpuIndex()
}
if (CPU_CMD_INDEX == -1) {
return ""
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getCpuDataForO()
} else {
getCPUData()
}
}
/**
* 8.0以上獲取cpu的方式
*
* @return
*/
private fun getCpuDataForO(): String {
return try {
val commandResult = ShellUtils.execCmd("top -n 1 | grep ${Process.myPid()}", false)
var cpu = 0F
commandResult.successMsg.split("\n").forEach {
val cpuTemp = it.split("\\s+".toRegex())
val cpuRate = cpuTemp[CPU_CMD_INDEX].toFloatOrNull()?.div(Runtime.getRuntime()
.availableProcessors())?.div(100) ?: 0F
cpu += cpuRate
}
NumberFormat.getPercentInstance().format(cpu)
} catch (e: Exception) {
""
}
}
private fun getCpuIndex() {
try {
val process = Runtime.getRuntime().exec("top -n 1")
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String? = null
while (reader.readLine().also { line = it } != null) {
line?.let {
line = it.trim { it <= ' ' }
line?.apply {
val tempIndex = getCPUIndex(this)
if (tempIndex != -1) {
CPU_CMD_INDEX = tempIndex
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getCPUIndex(line: String): Int {
if (line.contains("CPU")) {
val titles = line.split("\\s+".toRegex()).toTypedArray()
for (i in titles.indices) {
if (titles[i].contains("CPU")) {
return i
}
}
}
return -1
}
}
Cpu採集
Cpu的數據採集代碼也在上面,這部分代碼相對來說也比較簡單,複雜的地方在於命令行以及版本適配的問題。獲取的時候也需要區分系統版本,高低版本均通過cmd命令獲取,修復了低版本的CpuId獲取失敗的問題。然後優化了下DoKit的代碼邏輯。ShellUtils
則可以參考Blank
寫的工具類集合。
總結
Fps,cpu,內存這些數據只能算是Apm中最簡單的一個環節而已,Apm的實際目的是要更好的輔助開發人員去定位線上出現的問題狀況,通過預警機制避免線上問題的產生以及對性能的監控。而後續還需要收集更多的用戶行爲數據等來輔助開發來更準確的定位線上的問題。
因爲站在了巨人的肩膀上,所以其實這部分開發的難度相對來說少了很多,但是還是有一部分可以繼續優化的空間的,比如當前我們只監控了Activity的變化,有沒有辦法根據Fragment的不同進行數據上報呢,還有數據內容有沒有辦法提取更多的信息相關。
下一篇文章會給大家介紹下關於Apm中關於IO讀寫監控相關的內容,這一部分的代碼我們這邊魔改的量要更大一點,基本上我這邊已經做好了,但是內容可能我還是要重新整理下。
來源:掘金 究極逮蝦戶
原文地址:https://juejin.cn/post/6890754507639095303
本文在開源項目:https://github.com/xieyuliang/Note-Android(點擊此處藍色字體可看)中已收錄,裏面包含不同方向的自學編程路線、面試題集合/面經、及系列技術文章等,持續更新中...