Android 卡頓研究
[TOC]
穩(wěn)定化实檀,不是說說而已
基礎(chǔ)概念
這里主要是根據(jù)張紹文老師的文章做的筆記,根據(jù)張紹文老師的文筆去實(shí)踐具體卡頓監(jiān)控的內(nèi)容
散列知識點(diǎn)
- JVM中的線程切換大概花費(fèi)CPU 20000個(gè)時(shí)鐘周期
CPU
這里CPU需要單獨(dú)搞出來提一下抓督,卡頓優(yōu)化前需要搞清楚CPU是什么,能干什么束亏,正在干什么铃在,然后才是“什么”這個(gè)區(qū)間里面應(yīng)用程序此時(shí)的參數(shù)是否合理,優(yōu)化的空間又是多少
查看一個(gè)CPU的參數(shù)需要看CPU的頻率碍遍,核心等參數(shù),具體參考 Wiki
這里就僅僅點(diǎn)相對重要的一些參數(shù)含義
時(shí)鐘周期:CPU每秒可以完成幾個(gè)時(shí)鐘周期,如
可以完成這么多個(gè)時(shí)鐘周期機(jī)器周期:主存中讀取一個(gè)指令字的最短時(shí)間(由于CPU內(nèi)部的操作速度較快.而CPU訪問一次主存所花的時(shí)間較長钠惩,因此機(jī)器周期通常用主存中讀取一個(gè)指令字的最短時(shí)間來規(guī)定秕狰。),所以 機(jī)器周期 = 時(shí)鐘周期 * n(n >= 1)
指令周期:完成一個(gè)指令需要的時(shí)間东跪,一般由 幾個(gè)或者一個(gè)機(jī)器周期組成祝沸,相當(dāng)于 指令周期 = 機(jī)器周期 * n(n >= 1)
方法論
指標(biāo)
- CPU使用率
如果 CPU 使用率長期大于 60% ,表示系統(tǒng)處于繁忙狀態(tài),就需要進(jìn)一步分析用戶時(shí)間和系統(tǒng)時(shí)間的比例越庇。對于普通應(yīng)用程序罩锐,系統(tǒng)時(shí)間不會長期高于 30%,如果超過這個(gè)值卤唉,就得考慮是否I/O調(diào)用過多或者鎖調(diào)用的過于頻繁的問題涩惑。利用Android Studio的profile也能查看CPU的使用率
- CPU飽和度
CPU 飽和度反映的是線程排隊(duì)等待 CPU 的情況,也就是 CPU 的負(fù)載情況桑驱。
CPU 飽和度首先會跟應(yīng)用的線程數(shù)有關(guān)竭恬,如果啟動(dòng)的線程過多跛蛋,易導(dǎo)致系統(tǒng)不斷地切換執(zhí)行的線程,把大量的時(shí)間浪費(fèi)在上下文切換,要知道每一次 CPU 上下文切換都需要刷新寄存器和計(jì)數(shù)器,至少需要幾十納秒的時(shí)間痊硕。
可以通過vmstat命令查看CPU上下文切換次數(shù)
proc/self/sched:
nr_voluntary_switches:主動(dòng)上下文切換次數(shù)赊级,因?yàn)榫€程無法獲取資源導(dǎo)致上下文切換,最普遍的就是IO
nr_involuntary_switches:被動(dòng)上下文切換次數(shù)岔绸,線程被系統(tǒng)強(qiáng)制調(diào)度導(dǎo)致上下文切換理逊,例如大量線程在搶占CPU
se.statistics.iowait_count:IO 等待次數(shù)
se.statistics.iowait_sum:IO 等待時(shí)間
此外也可以通過 uptime 命令可以檢查 CPU 在 1 分鐘、5 分鐘和 15 分鐘內(nèi)的平均負(fù)載盒揉。比如一個(gè) 4 核的 CPU晋被,如果當(dāng)前平均負(fù)載是 8,這意味著每個(gè) CPU 上有一個(gè)線程在運(yùn)行刚盈,還有一個(gè)線程在等待羡洛。一般平均負(fù)載建議控制在“0.7 × 核數(shù)”以內(nèi)。
00:02:39 up 7 days, 46 min, 0 users,
load average: 13.91, 14.70, 14.32
另外一個(gè)會影響 CPU 飽和度的是線程優(yōu)先級藕漱,線程優(yōu)先級會影響 Android 系統(tǒng)的調(diào)度策略欲侮,它主要由 nice 和 cgroup 類型共同決定。nice 值越低肋联,搶占 CPU 時(shí)間片的能力越強(qiáng)威蕉。當(dāng) CPU 空閑時(shí),線程的優(yōu)先級對執(zhí)行效率的影響并不會特別明顯牺蹄,但在 CPU 繁忙的時(shí)候忘伞,線程調(diào)度會對執(zhí)行效率有非常大的影響。
關(guān)于線程優(yōu)先級沙兰,你需要注意是否存在高優(yōu)先級的線程空等低優(yōu)先級線程氓奈,例如主線程等待某個(gè)后臺線程的鎖。從應(yīng)用程序的角度來看鼎天,無論是用戶時(shí)間舀奶、系統(tǒng)時(shí)間,還是等待 CPU 的調(diào)度斋射,都是程序運(yùn)行花費(fèi)的時(shí)間育勺。
市場調(diào)研
Traceview 和 systrace 都是我們比較熟悉的排查卡頓的工具,從實(shí)現(xiàn)上這些工具分為兩個(gè)流派罗岖。
第一個(gè)流派是 instrument涧至。獲取一段時(shí)間內(nèi)所有函數(shù)的調(diào)用過程,可以通過分析這段時(shí)間內(nèi)的函數(shù)調(diào)用流程桑包,再進(jìn)一步分析待優(yōu)化的點(diǎn)南蓬。
第二個(gè)流派是 sample。有選擇性或者采用抽樣的方式觀察某些函數(shù)調(diào)用過程,可以通過這些有限的信息推測出流程中的可疑點(diǎn)赘方,然后再繼續(xù)細(xì)化分析烧颖。
根據(jù)流派,對目前市場上的性能監(jiān)控工具做一些調(diào)研和使用窄陡,包括但不限于官方提供的性能監(jiān)控工具炕淮,如systrace,Matrix等跳夭,關(guān)于Android上Systrace的使用可以參考我之前寫過的一個(gè)blog里面有提到過如何使用Android 性能優(yōu)化
選擇哪種工具涂圆,需要看具體的場景。如果需要分析 Native 代碼的耗時(shí)优妙,可以選擇 Simpleperf乘综;如果想分析系統(tǒng)調(diào)用憎账,可以選擇 systrace套硼;如果想分析整個(gè)程序執(zhí)行流程的耗時(shí),可以選擇 Traceview 或者插樁版本的 systrace胞皱。
對目前市場上的一些性能監(jiān)控框架做基本調(diào)研邪意,如DoraemonKit,Matrix反砌,BlockCanary
BlockCanary & DoraemonKit
這里之所以把兩個(gè)都放到一起雾鬼,是因?yàn)榈蔚蔚亩呃睞夢的卡頓檢測其實(shí)就是blockCanary,實(shí)現(xiàn)很簡單宴树,但是思路很巧妙~
想要檢測卡頓策菜,其實(shí)就是檢測主線程的運(yùn)行情況,為什么這么說呢酒贬,因?yàn)槊恳粠秩緮?shù)據(jù)的創(chuàng)建又憨,就依托于主線程來創(chuàng)建,而想要保證每一幀CPU都能在16.7ms內(nèi)(這里僅限于60幀這種情況锭吨,如果是90或者120蠢莺,可以反推的哈~)完成工作,這樣就不會出現(xiàn)丟幀的現(xiàn)象零如,也就不會造成卡頓躏将,而我們就監(jiān)測每個(gè)而如何監(jiān)測主線程的運(yùn)行情況呢?這里需要知道安卓中的handler機(jī)制考蕾,通過檢測每次處理主線程消息的耗時(shí)情況祸憋,就能夠知道是否產(chǎn)生了卡頓,而在發(fā)生卡頓的時(shí)候肖卧,同時(shí)抓取此時(shí)主線程的堆棧蚯窥,那么就更能方便的定位到需要優(yōu)化的代碼。
BlockCanary核心的地方,主要分為兩個(gè)部分:
- 檢測handleMessage
- 主線程抓取堆棧的部分
handleMessage
在主線程Looper每次處理消息的過程中沟沙,通過hook主線程Looper每次處理消息的過程河劝,在處理消息之前記錄一個(gè)時(shí)間戳,處理完消息之后記錄一個(gè)時(shí)間戳矛紫,那么兩個(gè)時(shí)間的差值赎瞎,就是處理一條消息所花費(fèi)的時(shí)間。通過給這個(gè)時(shí)間設(shè)置閾值颊咬,如:處理時(shí)間 > 閾值時(shí)間(430ms > 200ms)那么就認(rèn)為是發(fā)生了卡頓
這里的hook其實(shí)非常簡單务甥,因?yàn)閒ramework給咱們預(yù)留了這樣的口子,可以看下在handlemessage這里的源碼:
public void loop(){
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);
}
......
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
try {
msg.target.dispatchMessage(msg);
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
msg.recycleUnchecked();
}
}
......
/**
* Control logging of messages as they are processed by this Looper. If
* enabled, a log message will be written to <var>printer</var>
* at the beginning and ending of each message dispatch, identifying the
* target Handler and message contents.
*
* @param printer A Printer object that will receive log messages, or
* null to disable message logging.
*/
public void setMessageLogging(@Nullable Printer printer) {
mLogging = printer;
}
可見們只需要手動(dòng)調(diào)用Looper.setMessageLogging方法就能給線程looper對象設(shè)置printer對象喳篇,在每次處理消息的時(shí)候敞临,通過監(jiān)聽printer的println回調(diào),解析出內(nèi)容麸澜,就能知道知道分發(fā)消息的開始和結(jié)束了
//Printer 接口
public interface Printer {
/**
* Write a line of text to the output. There is no need to terminate
* the given string with a newline.
*/
void println(String x);
}
通過過濾printer.println打印的內(nèi)容挺尿,判斷是否是在消息分發(fā)處理相關(guān)的內(nèi)容,然后進(jìn)行時(shí)間差的計(jì)算炊邦,來判斷卡頓是否發(fā)生
//LooperPrinter
class LooperPrinter implements Printer {
public Printer origin;
boolean isHasChecked = false;
boolean isValid = false;
LooperPrinter(Printer printer) {
this.origin = printer;
}
@Override
public void println(String x) {
if (null != origin) {
origin.println(x);
if (origin == this) {
throw new RuntimeException(MonitorConstants.LOG_TAG + " origin == this");
}
}
if (!isHasChecked) {
isValid = x.charAt(0) == '>' || x.charAt(0) == '<';
isHasChecked = true;
if (!isValid) {
InsectLogger.ne("[println] Printer is inValid! x:%s", x);
}
}
if (isValid) {
dispatch(x.charAt(0) == '>', x);
}
}
}
那么依據(jù)主線程卡頓的監(jiān)控就已經(jīng)完成了编矾,接下來是對于卡頓問題的定位,也就是對主線程堆棧的抓取
dumpStack
這里不完全參照blockCanary的實(shí)現(xiàn)馁害,但是大家都是為了解決能夠抓取到問題發(fā)生的堆棧窄俏,這里先說一下對于主線程堆棧dump需要關(guān)注的問題。不同的抓取策略也是為了解決這個(gè)問題碘菜,此處先不考慮對性能帶來的影響
假設(shè)此時(shí)發(fā)生了卡頓凹蜈,那么在調(diào)用getStackTrace的時(shí)候,這時(shí)候虛擬機(jī)中所跟蹤的堆棧中會把當(dāng)前記錄的一些堆棧返回忍啸。通過在發(fā)生卡頓的時(shí)候仰坦,dump出當(dāng)前的堆棧,記錄下來吊骤,再追溯問題的時(shí)候直接看存儲下來的堆棧信息缎岗,那么定位問題就會方便很多,而實(shí)際情況下并不能如此理想白粉,因?yàn)閺腣M中取出的堆棧dalvik.system.VMStack#getThreadStackTrace
返回的數(shù)據(jù)是未知的传泊,不能保證里面到底有多少內(nèi)容,可能只有一部分鸭巴,這樣就可能會遺漏真正的問題所在眷细,可以參考下圖~
可以看到真正有問題的函數(shù)其實(shí)是FunctionA-1,而如果撈出來的堆棧只有FunctionA-2或者A-3的話鹃祖,當(dāng)然可以優(yōu)化A-3溪椎,但是會漏掉真正發(fā)生問題的函數(shù)。所以對于堆棧的抓取,基于VMStack抓取堆棧的方式下校读,筆者思考了兩種方案來解決這樣的問題沼侣,這兩種應(yīng)該也是市面上基于VMStack方式的大概方案,再深入往VM中去研究感覺可以有歉秫,但是不推薦蛾洛,因?yàn)槌杀靖撸一貓?bào)的話不太會有預(yù)期中的高雁芙。
周期性Dump
通過每個(gè)一段時(shí)間從VM中獲取主線程的堆棧轧膘,在發(fā)生卡頓的時(shí)候,過濾出時(shí)間兔甘,然后直接取出這段時(shí)間內(nèi)的堆棧來進(jìn)行問題排查谎碍。
在實(shí)現(xiàn)的時(shí)候需要注意的一些小細(xì)節(jié):
- 循環(huán)隊(duì)列
- 堆棧去重
- 時(shí)間區(qū)間篩選
起止Dump
這里可以“忽略”多線程的特性,因?yàn)槲覀冴P(guān)注的僅僅是主線程洞焙,那么只需要在消息分發(fā)之初dump一次堆棧蟆淀,然后再消息處理之后再dump一次堆棧,這樣既能在dump出來的堆棧中發(fā)現(xiàn)可能存在的問題闽晦,同時(shí)又能自行推斷這中間的執(zhí)行過程來觀測代碼中出現(xiàn)的問題扳碍。當(dāng)然不可缺少一個(gè)代碼耗時(shí)檢測的小工具~
Matrix
關(guān)于matrix解剖提岔,需要先了解定義仙蛉,再根據(jù)具體代碼進(jìn)行分析,最后根據(jù)代碼梳理出實(shí)現(xiàn)的思路
卡頓定義
微信開發(fā)者對于卡頓的定義碱蒙,很簡單荠瘪,很清晰,很明了赛惩,這里就cv過來了哀墓,一定要仔細(xì)讀對卡頓的定義
什么是卡頓,很多人能馬上聯(lián)系到的是幀率 FPS (每秒顯示幀數(shù))喷兼。那么多低的 FPS 才是卡頓呢篮绰?又或者低 FPS 真的就是卡頓嗎?(以下 FPS 默認(rèn)指平均幀率)
其實(shí)并非如此季惯,舉個(gè)例子吠各,游戲玩家通常追求更流暢的游戲畫面體驗(yàn)一般要達(dá)到 60FPS 以上,但我們平時(shí)看到的大部分電影或視頻 FPS 其實(shí)不高勉抓,一般只有 25FPS ~ 30FPS贾漏,而實(shí)際上我們也沒有覺得卡頓。 在人眼結(jié)構(gòu)上看藕筋,當(dāng)一組動(dòng)作在 1 秒內(nèi)有 12 次變化(即 12FPS)纵散,我們會認(rèn)為這組動(dòng)作是連貫的;而當(dāng)大于 60FPS 時(shí),人眼很難區(qū)分出來明顯的變化伍掀,所以 60FPS 也一直作為業(yè)界衡量一個(gè)界面流暢程度的重要指標(biāo)掰茶。一個(gè)穩(wěn)定在 30FPS 的動(dòng)畫,我們不會認(rèn)為是卡頓的蜜笤,但一旦 FPS 很不穩(wěn)定符匾,人眼往往容易感知到。
FPS 低并不意味著卡頓發(fā)生瘩例,而卡頓發(fā)生 FPS 一定不高啊胶。 FPS 可以衡量一個(gè)界面的流程性,但往往不能很直觀的衡量卡頓的發(fā)生垛贤,這里有另一個(gè)指標(biāo)(掉幀程度)可以更直觀地衡量卡頓焰坪。
什么是掉幀(跳幀)? 按照理想幀率 60FPS 這個(gè)指標(biāo)聘惦,計(jì)算出平均每一幀的準(zhǔn)備時(shí)間有 1000ms/60 = 16.6667ms某饰,如果一幀的準(zhǔn)備時(shí)間超出這個(gè)值,則認(rèn)為發(fā)生掉幀善绎,超出的時(shí)間越長黔漂,掉幀程度越嚴(yán)重。假設(shè)每幀準(zhǔn)備時(shí)間約 32ms禀酱,每次只掉一幀炬守,那么 1 秒內(nèi)實(shí)際只刷新 30 幀,即平均幀率只有 30FPS剂跟,但這時(shí)往往不會覺得是卡頓减途。反而如果出現(xiàn)某次嚴(yán)重掉幀(>300ms),那么這一次的變化曹洽,通常很容易感知到鳍置。所以界面的掉幀程度,往往可以更直觀的反映出卡頓送淆。
流暢性
綜上所述税产,其實(shí)可以明白對于卡頓的定義,衡量流暢性的指標(biāo)可以簡單理解為:
- 在用戶有操作的前提下
- 平均掉幀率偷崩,只有在某一時(shí)刻發(fā)生的掉幀情況遠(yuǎn)遠(yuǎn)大于其他時(shí)刻辟拷,那么才界定為卡頓,這也是上面說到的界面的掉幀程度环凿,才更直觀的反映出卡頓
Best | Normal | Middle | High | Frozen |
---|---|---|---|---|
[0:3) | [3:9) | [9:24) | [24:42) | [42:∞) |
Code實(shí)現(xiàn)
關(guān)于反射 Choreographer 來做到如何監(jiān)測用戶觸發(fā)后開始計(jì)算平均幀率
關(guān)于Choreographer的知識相關(guān)梧兼,這里不做贅述,只根據(jù)實(shí)現(xiàn)原理來對使用的地方做說明
- 用戶觸發(fā)刷新
了解下源碼中callbackType的含義
/**
* Callback type: Input callback. Runs first.
* @hide
*/
public static final int CALLBACK_INPUT = 0;
/**
* Callback type: Animation callback. Runs before traversals.
* @hide
*/
@TestApi
public static final int CALLBACK_ANIMATION = 1;
/**
* Callback type: Traversal callback. Handles layout and draw. Runs
* after all other asynchronous messages have been handled.
* @hide
*/
public static final int CALLBACK_TRAVERSAL = 2;
顯而易見智听,CALLBACK_INPUT都已經(jīng)注釋好了羽杰,首先run的是這個(gè)類型的回調(diào)渡紫,然后我們平時(shí)注冊的又是什么樣子呢?
/**
* Posts a frame callback to run on the next frame.
* <p>
* The callback runs once then is automatically removed.
* </p>
*
* @param callback The frame callback to run during the next frame.
*
* @see #postFrameCallbackDelayed
* @see #removeFrameCallback
*/
public void postFrameCallback(FrameCallback callback) {
postFrameCallbackDelayed(callback, 0);
}
/**
* Posts a frame callback to run on the next frame after the specified delay.
* <p>
* The callback runs once then is automatically removed.
* </p>
*
* @param callback The frame callback to run during the next frame.
* @param delayMillis The delay time in milliseconds.
*
* @see #postFrameCallback
* @see #removeFrameCallback
*/
public void postFrameCallbackDelayed(FrameCallback callback, long delayMillis) {
if (callback == null) {
throw new IllegalArgumentException("callback must not be null");
}
postCallbackDelayedInternal(CALLBACK_ANIMATION,
callback, FRAME_CALLBACK_TOKEN, delayMillis);
}
注冊類型的callbackType為ANIMATION的考赛,而ANIMATION的type又是什么時(shí)候回調(diào)呢惕澎?
void doFrame(long frameTimeNanos, int frame) {
final long startNanos;
synchronized (mLock) {
......
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");
AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);
//優(yōu)先執(zhí)行CALLBACK_INPUT類型鏈表里面的回調(diào)
mFrameInfo.markInputHandlingStart();
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
mFrameInfo.markAnimationsStart();
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
mFrameInfo.markPerformTraversalsStart();
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
} finally {
AnimationUtils.unlockAnimationClock();
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
......
}
可見是在執(zhí)行完優(yōu)先級最高的輸入類型的回調(diào)才會回調(diào)ANIMATION的(注意這里提供的兩個(gè)參數(shù),第一個(gè)是執(zhí)行該frame的時(shí)間戳颜骤,第二個(gè)是當(dāng)前幀號唧喉,是native調(diào)用,在DisplayEventReceiver事件中收到后維護(hù)的一個(gè)成員變量忍抽,具體實(shí)現(xiàn)類也在Choreagrapher中)八孝,而顯然不能夠符合我們的要求,我們是期望在用戶有操作的情況下是否發(fā)生丟幀情況
而如何計(jì)算input時(shí)機(jī)的幀率呢鸠项? 勢必需要在input類型中添加自己實(shí)現(xiàn)的callback干跛,在animation開始的執(zhí)行的時(shí)候,標(biāo)識為input執(zhí)行結(jié)束
好了祟绊,原理分析完畢楼入,接下來看一下在Matrix中帶佬如何實(shí)現(xiàn)的,核心類主要是com.tencent.matrix.trace.core.UIThreadMonitor
初始化中先拿到需要hook的方法牧抽,然后模擬順序進(jìn)行執(zhí)行
public void init(TraceConfig config) {
......
//反射同步鎖對象和對應(yīng)doFrame的所有回調(diào)數(shù)組鏈表對象
choreographer = Choreographer.getInstance();
callbackQueueLock = reflectObject(choreographer, "mLock");
callbackQueues = reflectObject(choreographer, "mCallbackQueues");
//先拿到添加對應(yīng)回調(diào)的可執(zhí)行反射方法
addInputQueue = reflectChoreographerMethod(callbackQueues[CALLBACK_INPUT], ADD_CALLBACK, long.class, Object.class, Object.class);
addAnimationQueue = reflectChoreographerMethod(callbackQueues[CALLBACK_ANIMATION], ADD_CALLBACK, long.class, Object.class, Object.class);
addTraversalQueue = reflectChoreographerMethod(callbackQueues[CALLBACK_TRAVERSAL], ADD_CALLBACK, long.class, Object.class, Object.class);
frameIntervalNanos = reflectObject(choreographer, "mFrameIntervalNanos");
......
this.isInit = true;
......
}
通過上面分析可得嘉熊,doframe的回調(diào)執(zhí)行是順序執(zhí)行下來,也就是說一個(gè)類型的callback執(zhí)行結(jié)束時(shí)間扬舒,就是下一個(gè)類型的開始時(shí)間阐肤,那么在addCallback的時(shí)機(jī)也是如此,最開始要添加的則是input類型回調(diào)
public void init(TraceConfig config) {
......
choreographer = Choreographer.getInstance();
callbackQueueLock = ReflectUtils.reflectObject(choreographer, "mLock", new Object());
//反射獲取回調(diào)的數(shù)組鏈表對象呼巴,可以理解為單object的hashMap 數(shù)組+鏈表實(shí)現(xiàn)
callbackQueues = ReflectUtils.reflectObject(choreographer, "mCallbackQueues", null);
if (null != callbackQueues) {
addInputQueue = ReflectUtils.reflectMethod(callbackQueues[CALLBACK_INPUT], ADD_CALLBACK, long.class, Object.class, Object.class);
addAnimationQueue = ReflectUtils.reflectMethod(callbackQueues[CALLBACK_ANIMATION], ADD_CALLBACK, long.class, Object.class, Object.class);
addTraversalQueue = ReflectUtils.reflectMethod(callbackQueues[CALLBACK_TRAVERSAL], ADD_CALLBACK, long.class, Object.class, Object.class);
}
//主要是拿vsync回調(diào)上來的信號開始繪制的時(shí)間戳泽腮,可以分析出來丟幀數(shù),源碼也是這么干的
vsyncReceiver = ReflectUtils.reflectObject(choreographer, "mDisplayEventReceiver", null);
//產(chǎn)生vsync信號的時(shí)間戳
frameIntervalNanos = ReflectUtils.reflectObject(choreographer, "mFrameIntervalNanos", Constants.DEFAULT_FRAME_DURATION);
LooperMonitor.register(new LooperMonitor.LooperDispatchListener() {
@Override
public boolean isValid() {
return isAlive;
}
@Override
public void dispatchStart() {
super.dispatchStart();
UIThreadMonitor.this.dispatchBegin();
}
@Override
public void dispatchEnd() {
super.dispatchEnd();
UIThreadMonitor.this.dispatchEnd();
}
});
......
}
public synchronized void onStart() {
......
if (!isAlive) {
this.isAlive = true;
synchronized (this) {
MatrixLog.i(TAG, "[onStart] callbackExist:%s %s", Arrays.toString(callbackExist), Utils.getStack());
callbackExist = new boolean[CALLBACK_LAST + 1];
}
//為三種callback增加狀態(tài)維護(hù)數(shù)組
queueStatus = new int[CALLBACK_LAST + 1];
//為三種callback增加耗時(shí)數(shù)組
queueCost = new long[CALLBACK_LAST + 1];
//首次添加input類型callback
addFrameCallback(CALLBACK_INPUT, this, true);
}
}
可以看到衣赶,在添加input類型的回調(diào)時(shí),傳的是自己厚满,那么來分析一下接下來的run實(shí)現(xiàn)
@Override
public void run() {
//來自vsync信號開始
doFrameBegin(token);
//維護(hù)input類型數(shù)組們的狀態(tài)
doQueueBegin(CALLBACK_INPUT);
//animation回調(diào)注冊回調(diào)
addFrameCallback(CALLBACK_ANIMATION, new Runnable() {
@Override
public void run() {
//animation回調(diào)府瞄,input結(jié)束
doQueueEnd(CALLBACK_INPUT);
doQueueBegin(CALLBACK_ANIMATION);
}
}, true);
addFrameCallback(CALLBACK_TRAVERSAL, new Runnable() {
@Override
public void run() {
//traversal類型回調(diào),animation結(jié)束
doQueueEnd(CALLBACK_ANIMATION);
doQueueBegin(CALLBACK_TRAVERSAL);
}
}, true);
}
可以看到碘箍,如此就能得到一個(gè)Vsync信號過來的輪回遵馆,但是走到這里只能完成一次,matrix如何把每一次串起來的咧丰榴?
還記得上面初始化的時(shí)候注冊looper監(jiān)聽货邓,每次消息的處理開始和結(jié)束都會激活一次dispatchStart和dispatchEnd,start這里就不分析了四濒,其實(shí)就是往外回調(diào)换况,主要是end
private void dispatchEnd() {
......
//在第一次Vsync開始的時(shí)候賦值為true职辨,直接進(jìn)來
if (isVsyncFrame) {
doFrameEnd(token);
intendedFrameTimeNs = getIntendedFrameTimeNs(startNs);
}
......
//來自一次vsync信號結(jié)束
this.isVsyncFrame = false;
}
private void doFrameEnd(long token) {
//下一次input開始,上一次的traversal結(jié)束
doQueueEnd(CALLBACK_TRAVERSAL);
......
//開啟下一次input輪回
addFrameCallback(CALLBACK_INPUT, this, true);
}
可以看到戈二,這里才是真正的結(jié)束舒裤,一個(gè)完整的Choreographer循環(huán)~
卡頓策略
Matrix的文檔里面已經(jīng)非常清楚的用文字描述一個(gè)卡頓是如何產(chǎn)生的,以及卡頓的定義
FPS 低并不意味著卡頓發(fā)生觉吭,而卡頓發(fā)生 FPS 一定不高腾供。 FPS 可以衡量一個(gè)界面的流程性,但往往不能很直觀的衡量卡頓的發(fā)生鲜滩,這里有另一個(gè)指標(biāo)(掉幀程度)可以更直觀地衡量卡頓伴鳖。
什么是掉幀(跳幀)? 按照理想幀率 60FPS 這個(gè)指標(biāo)徙硅,計(jì)算出平均每一幀的準(zhǔn)備時(shí)間有 1000ms/60 = 16.6667ms黎侈,如果一幀的準(zhǔn)備時(shí)間超出這個(gè)值,則認(rèn)為發(fā)生掉幀闷游,超出的時(shí)間越長峻汉,掉幀程度越嚴(yán)重。假設(shè)每幀準(zhǔn)備時(shí)間約 32ms脐往,每次只掉一幀休吠,那么 1 秒內(nèi)實(shí)際只刷新 30 幀,即平均幀率只有 30FPS业簿,但這時(shí)往往不會覺得是卡頓瘤礁。反而如果出現(xiàn)某次嚴(yán)重掉幀(>300ms),那么這一次的變化梅尤,通常很容易感知到柜思。所以界面的掉幀程度,往往可以更直觀的反映出卡頓巷燥。
然后分析下關(guān)于瞬時(shí)平均幀率的代碼需要重點(diǎn)關(guān)注的就是com.tencent.matrix.trace.tracer.FrameTracer這個(gè)類
在每一次doFrame的回調(diào)中去分析這個(gè)參數(shù)
@Override
public void doFrame(String focusedActivity, long startNs, long endNs, boolean isVsyncFrame, long intendedFrameTimeNs, long inputCostNs, long animationCostNs, long traversalCostNs) {
if (isForeground()) {
notifyListener(focusedActivity, startNs, endNs, isVsyncFrame, intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);
}
}
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) {
try {
//計(jì)算丟幀赡盘,跟源碼的計(jì)算方式一致,上一個(gè)版本局部變量的聲明并沒有如此直觀缰揪,這個(gè)版本改了陨享,清爽許多
final long jiter = endNs - intendedFrameTimeNs;
final int dropFrame = (int) (jiter / frameIntervalNs);
synchronized (listeners) {
//listeners目前注冊進(jìn)來的就倆,一個(gè)內(nèi)部類FPSCollect钝腺,一個(gè)用于UI展示的FrameDecorator
for (final IDoFrameListener listener : listeners) {
if (config.isDevEnv()) {
listener.time = SystemClock.uptimeMillis();
}
if (null != listener.getExecutor()) {
if (listener.getIntervalFrameReplay() > 0) {
//數(shù)據(jù)收集部分
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);
}
}
}
} finally {
}
}
為什么說丟幀計(jì)算和源碼一致呢抛姑? 這里我們可以和源碼對比一下:
final long jiter = endNs - intendedFrameTimeNs;
final int dropFrame = (int) (jiter / frameIntervalNs);
//源碼 android.view.Choreographer#doFrame
final long jitterNanos = startNanos - frameTimeNanos;
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
這么看上去,瞬間就友好了很多~
從注釋里面也能看到艳狐,注冊的倆監(jiān)聽定硝,一個(gè)用于記錄,一個(gè)用于展示毫目,記錄其實(shí)就是填充此時(shí)此刻的關(guān)于FPS的快照蔬啡,沒什么可看的诲侮,學(xué)習(xí)而言,展示的要好一些星爪,因?yàn)樗枰治鰯?shù)據(jù)浆西,然后展示到UI上,那么接下來就直接看下Matix中的com.tencent.matrix.trace.view.FrameDecorator#doFrameAsync顽腾,源碼過長近零,這里就一步一步分析代碼是如何體現(xiàn)上面的表和描述
計(jì)算出平均每一幀的準(zhǔn)備時(shí)間有 1000ms/60 = 16.6667ms,如果一幀的準(zhǔn)備時(shí)間超出這個(gè)值抄肖,則認(rèn)為發(fā)生掉幀久信,超出的時(shí)間越長,掉幀程度越嚴(yán)重漓摩。假設(shè)每幀準(zhǔn)備時(shí)間約 32ms裙士,每次只掉一幀,那么 1 秒內(nèi)實(shí)際只刷新 30 幀管毙,即平均幀率只有 30FPS腿椎,但這時(shí)往往不會覺得是卡頓。反而如果出現(xiàn)某次嚴(yán)重掉幀(>300ms)夭咬,那么這一次的變化啃炸,通常很容易感知到
Best Normal Middle High Frozen [0:3) [3:9) [9:24) [24:42) [42:∞)
/**
* 流暢指標(biāo),佳0卓舵,正常1南用,中等2,嚴(yán)重3掏湾,凍幀4
*/
public enum DropStatus {
DROPPED_FROZEN(4), DROPPED_HIGH(3), DROPPED_MIDDLE(2), DROPPED_NORMAL(1), DROPPED_BEST(0);
public int index;
DropStatus(int index) {
this.index = index;
}
}
在回調(diào)回來的函數(shù)中裹虫,分析流暢指標(biāo)
@Override
public void doFrameAsync(String focusedActivity, long startNs, long endNs, int dropFrame, boolean isVsyncFrame, long intendedFrameTimeNs, long inputCostNs, long animationCostNs, long traversalCostNs) {
super.doFrameAsync(focusedActivity, startNs, endNs, dropFrame, isVsyncFrame, intendedFrameTimeNs, inputCostNs, animationCostNs, traversalCostNs);
......
if (dropFrame >= Constants.DEFAULT_DROPPED_FROZEN) { //凍幀
dropLevel[FrameTracer.DropStatus.DROPPED_FROZEN.index]++;
sumDropLevel[FrameTracer.DropStatus.DROPPED_FROZEN.index]++;
} else if (dropFrame >= Constants.DEFAULT_DROPPED_HIGH) { //嚴(yán)重
dropLevel[FrameTracer.DropStatus.DROPPED_HIGH.index]++;
sumDropLevel[FrameTracer.DropStatus.DROPPED_HIGH.index]++;
} else if (dropFrame >= Constants.DEFAULT_DROPPED_MIDDLE) { //中等
dropLevel[FrameTracer.DropStatus.DROPPED_MIDDLE.index]++;
sumDropLevel[FrameTracer.DropStatus.DROPPED_MIDDLE.index]++;
} else if (dropFrame >= Constants.DEFAULT_DROPPED_NORMAL) { //正常
dropLevel[FrameTracer.DropStatus.DROPPED_NORMAL.index]++;
sumDropLevel[FrameTracer.DropStatus.DROPPED_NORMAL.index]++;
} else {
dropLevel[FrameTracer.DropStatus.DROPPED_BEST.index]++;
sumDropLevel[FrameTracer.DropStatus.DROPPED_BEST.index]++;
}
......
}
這里的代碼非常簡單,接下來是分析造成嚴(yán)重卡頓的情況融击,也就是嚴(yán)重丟幀的時(shí)候筑公,也是文章中所分析的內(nèi)容:
如果出現(xiàn)某次嚴(yán)重掉幀(>300ms),那么這一次的變化砚嘴,通常很容易感知到
sumFrameCost += (dropFrame + 1) * frameIntervalMs;
sumFrames += 1;
float duration = sumFrameCost - lastCost[0];
long collectFrame = sumFrames - lastFrames[0];
if (duration >= 200) {
//更新視圖
}
綜上十酣,就是Matrix中對頁面流暢性分析的核心代碼,而對于Matrix中精準(zhǔn)命中堆棧則可以取自凍幀或者所謂的duration >= 200這個(gè)條件下dump一次主線程的堆棧來獲取
慢函數(shù)
其實(shí)關(guān)于上述嚴(yán)重掉幀情況下的抓取堆棧的數(shù)量不多际长,同樣避不開上面提到的漏掉其他耗時(shí)代碼的情況,不過筆者認(rèn)為這樣的情況不會特別多兴泥,因?yàn)榭D發(fā)生的時(shí)候工育,大概率避免不了正在執(zhí)行一個(gè)耗時(shí)操作,那么這個(gè)耗時(shí)操作的堆棧出現(xiàn)在此刻dump出來的堆棧里面的可能性很大搓彻,所以Matrix干脆利落的出了一個(gè)慢函數(shù)檢測如绸,這樣感覺就無孔不入了嘱朽,對所有在主線程上運(yùn)行的函數(shù)耗時(shí)進(jìn)行收集,和BlockCanary檢測卡頓的策略一樣怔接,但是堆棧的抓取就要復(fù)雜一些搪泳,這也是為什么Matrix性能更好的原因,能在灰度下上線的能力扼脐。同時(shí)也能有對應(yīng)的聚合策略岸军,這樣結(jié)合后端的能力方便我們分析代碼運(yùn)行情況,然后做 “狹義” 上的優(yōu)化
瓦侮,這里推薦一個(gè)在解決卡頓時(shí)候您可以用到的方法耗時(shí)小插件
Matrix中的慢函數(shù)和BlockCanary的卡頓堆棧獲取時(shí)機(jī)和檢測卡頓或者慢
的策略一致艰赞,下面可以簡單看下茎截,關(guān)于Matrix中如何去撈堆棧的~
@Override
public void dispatchEnd(long beginNs, long cpuBeginMs, long endNs, long cpuEndMs, long token, boolean isVsyncFrame) {
super.dispatchEnd(beginNs, cpuBeginMs, endNs, cpuEndMs, token, isVsyncFrame);
long start = config.isDevEnv() ? System.currentTimeMillis() : 0;
long dispatchCost = (endNs - beginNs) / Constants.TIME_MILLIS_TO_NANO;
try {
//查過閾值卵凑,和BlockCanary一樣com.tencent.matrix.trace.constants.Constants.DEFAULT_EVIL_METHOD_THRESHOLD_MS = 700
if (dispatchCost >= evilThresholdMs) {
long[] data = AppMethodBeat.getInstance().copyData(indexRecord);
long[] queueCosts = new long[3];
System.arraycopy(queueTypeCosts, 0, queueCosts, 0, 3);
//scene拿的是當(dāng)前的activity
String scene = AppMethodBeat.getVisibleScene();
MatrixHandlerThread.getDefaultHandler().post(new AnalyseTask(isForeground(), scene, data, queueCosts, cpuEndMs - cpuBeginMs, dispatchCost, endNs / Constants.TIME_MILLIS_TO_NANO));
}
} finally {
indexRecord.release();
}
}
可以看到赚楚,慢函數(shù)的閾值是700ms洽损,如果超出閾值萍鲸,則會從AppMethod中拿取相關(guān)的堆棧數(shù)據(jù)鲜棠,同時(shí)記錄下當(dāng)前的頁面然后上傳一波谅海,那么關(guān)鍵的地方就在于這個(gè)
AppMethodBeat.getInstance().copyData(indexRecord)
關(guān)于Matrix的堆棧實(shí)現(xiàn)主要分為兩塊
- ASM插樁記錄方法
- Java側(cè)關(guān)于記錄方法耗時(shí)以及方法記錄的實(shí)現(xiàn)
編譯期:
通過代理編譯期間的任務(wù) transformClassesWithDexTask倚喂,將全局 class 文件作為輸入斋泄,利用 ASM 工具杯瞻,高效地對所有 class 文件進(jìn)行掃描及插樁。
插樁過程有幾個(gè)關(guān)鍵點(diǎn):
1是己、選擇在該編譯任務(wù)執(zhí)行時(shí)插樁又兵,是因?yàn)?proguard 操作是在該任務(wù)之前就完成的,意味著插樁時(shí)的 class 文件已經(jīng)被混淆過的卒废。而選擇 proguard 之后去插樁沛厨,是因?yàn)槿绻崆安鍢稌斐刹糠址椒ú环蟽?nèi)聯(lián)規(guī)則,沒法在 proguard 時(shí)進(jìn)行優(yōu)化摔认,最終導(dǎo)致程序方法數(shù)無法減少逆皮,從而引發(fā)方法數(shù)過大問題。
2参袱、為了減少插樁量及性能損耗电谣,通過遍歷 class 方法指令集,判斷掃描的函數(shù)是否只含有 PUT/READ FIELD 等簡單的指令抹蚀,來過濾一些默認(rèn)或匿名構(gòu)造函數(shù)剿牺,以及 get/set 等簡單不耗時(shí)函數(shù)。
3环壤、針對界面啟動(dòng)耗時(shí)晒来,因?yàn)橐y(tǒng)計(jì)從 Activity#onCreate 到 Activity#onWindowFocusChange 間的耗時(shí),所以在插樁過程中需要收集應(yīng)用內(nèi)所有 Activity 的實(shí)現(xiàn)類郑现,并覆蓋 onWindowFocusChange 函數(shù)進(jìn)行打點(diǎn)湃崩。
4荧降、為了方便及高效記錄函數(shù)執(zhí)行過程,我們?yōu)槊總€(gè)插樁的函數(shù)分配一個(gè)獨(dú)立 ID攒读,在插樁過程中朵诫,記錄插樁的函數(shù)簽名及分配的 ID,在插樁完成后輸出一份 mapping薄扁,作為數(shù)據(jù)上報(bào)后的解析支持剪返。
下面重點(diǎn)介紹一下關(guān)于Matrix中對性能考量以及具體的業(yè)務(wù)插樁代碼
- 編譯優(yōu)化,內(nèi)聯(lián)規(guī)則
選擇在該編譯任務(wù)執(zhí)行時(shí)插樁泌辫,是因?yàn)?proguard 操作是在該任務(wù)之前就完成的随夸,意味著插樁時(shí)的 class 文件已經(jīng)被混淆過的。而選擇 proguard 之后去插樁震放,是因?yàn)槿绻崆安鍢稌斐刹糠址椒ú环蟽?nèi)聯(lián)規(guī)則宾毒,沒法在 proguard 時(shí)進(jìn)行優(yōu)化,最終導(dǎo)致程序方法數(shù)無法減少殿遂,從而引發(fā)方法數(shù)過大問題诈铛。
解釋一下什么叫做方法內(nèi)聯(lián),說白了其實(shí)就是你寫了兩個(gè)方法墨礁,在編譯時(shí)會將其中一個(gè)方法的實(shí)現(xiàn)直接放到調(diào)用的地方幢竹,這樣就無需去走一遍調(diào)用其他方法,從而達(dá)到優(yōu)化的目的恩静,因?yàn)槊總€(gè)方法會生成一個(gè)棧幀焕毫,然后進(jìn)行壓棧出棧的操作,過程比較反復(fù)驶乾,這里用代碼解釋一下編譯優(yōu)化之一邑飒,方法內(nèi)聯(lián):
//編譯前,source.java
public int doubleNum(int num){
return num * 2;
}
public void method1(){
int a = 10;
int b = doubleNum(2);
System.out.println(String.format("a:%s, b:%s", a, b));
}
//編譯后级乐, source.class
public void method1(){
int a = 10;
int b = a * 2;//doubleNum(2);
System.out.println(String.format("a:%s, b:%s", a, b));
}
可以看到在編譯之后就沒有了doubleNum這個(gè)方法疙咸,在method1中調(diào)用的時(shí)候直接變成了 a * 2 , 這樣就無需在運(yùn)行的過程中去對doubleNum壓棧操作
這里有一篇介紹jvm中關(guān)于內(nèi)聯(lián)的blog
雖然能保證proguard時(shí)候優(yōu)化能正常進(jìn)行风科,不過優(yōu)化程度上來講撒轮,筆者也沒有統(tǒng)計(jì)過哈,也不曉得從哪里能夠看到贼穆,因?yàn)槟壳霸?.0之后题山,安裝的時(shí)候會進(jìn)行JIT和AOT混合的方式,這個(gè)結(jié)果應(yīng)該不是很好看故痊,在JIT選擇編譯熱代碼的時(shí)候臀蛛,優(yōu)化的那部分內(nèi)聯(lián)又該如何考慮呢?想想太復(fù)雜了崖蜜。浊仆。。大家感興趣的話可以統(tǒng)計(jì)下在打出來的release包中豫领,可以看日志抡柿,優(yōu)化的效果~
那接下里就是Matrix中如何做到在proguard的transform后進(jìn)行插樁呢,核心工程是matrix-gradle-plugin
入口:
//com.tencent.matrix.plugin.MatrixPlugin#apply
/**
* <p>Adds a closure to be called immediately after this project has been evaluated. The project is passed to the
* closure as a parameter. Such a listener gets notified when the build file belonging to this project has been
* executed. A parent project may for example add such a listener to its child project. Such a listener can further
* configure those child projects based on the state of the child projects after their build files have been
* run.</p>
*
* @param closure The closure to call.
*/
void afterEvaluate(Closure closure);
@Override
void apply(Project project) {
......
//完成所有transform之后執(zhí)行
project.afterEvaluate {
def android = project.extensions.android
def configuration = project.matrix
android.applicationVariants.all { variant ->
if (configuration.trace.enable) {
//代碼插樁入口
MatrixTraceTransform.inject(project, configuration.trace, variant.getVariantData().getScope())
}
......
}
}
}
在afterEvaluate后傳入閉包等恐,開始插樁代碼洲劣,為什么是這個(gè)時(shí)機(jī),可以參考上面的源碼注釋哈~
接下來就是注入代碼课蔬,因?yàn)椴灰蕾囉谧远x的transformTask囱稽,Matrix的實(shí)現(xiàn)是通過往transformTask里面去注入執(zhí)行事件,這里的寫法也是個(gè)新姿勢~
//com.tencent.matrix.trace.transform.MatrixTraceTransform#inject
public static void inject(Project project, MatrixTraceExtension extension, VariantScope variantScope) {
......
try {
String[] hardTask = getTransformTaskName(extension.getCustomDexTransformName(), variant.getName());
for (Task task : project.getTasks()) {
for (String str : hardTask) {
if (task.getName().equalsIgnoreCase(str) && task instanceof TransformTask) {
//如果確實(shí)是Transform的task后進(jìn)來執(zhí)行反射hook注入matrix的transform任務(wù)
TransformTask transformTask = (TransformTask) task;
Log.i(TAG, "successfully inject task:" + transformTask.getName());
Field field = TransformTask.class.getDeclaredField("transform");
field.setAccessible(true);
//這里就是注入自定義的任務(wù)二跋,在編譯執(zhí)行的時(shí)候
field.set(task, new MatrixTraceTransform(config, transformTask.getTransform()));
break;
}
}
}
} catch (Exception e) {
Log.e(TAG, e.toString());
}
}
//源碼中可以看到執(zhí)行transformTask的時(shí)候調(diào)用這個(gè)注入的transform執(zhí)行處战惊,com.android.build.gradle.internal.pipeline.TransformTask#transform
@TaskAction
void transform(final IncrementalTaskInputs incrementalTaskInputs)
throws IOException, TransformException, InterruptedException {
......
ThreadRecorder.get().record(ExecutionType.TASK_TRANSFORM,
new Recorder.Block<Void>() {
@Override
public Void call() throws Exception {
//這里就是調(diào)用transform.transform的時(shí)機(jī)
transform.transform(new TransformInvocationBuilder(TransformTask.this)
.addInputs(consumedInputs.getValue())
.addReferencedInputs(referencedInputs.getValue())
.addSecondaryInputs(changedSecondaryInputs.getValue())
.addOutputProvider(outputStream != null
? outputStream.asOutput()
: null)
.setIncrementalMode(isIncremental.getValue())
.build());
return null;
}
},
new Recorder.Property("project", getProject().getName()),
new Recorder.Property("transform", transform.getName()),
new Recorder.Property("incremental", Boolean.toString(transform.isIncremental())));
}
[手動(dòng)表情秒啊~],簡直了扎即。吞获。。 不愧是微信的帶佬谚鄙,如果不是對編譯任務(wù)的task有一定了解的話各拷,要做到這里感覺不太可能。闷营。烤黍。 不過還好能站在巨人的肩膀上~
這里注意下關(guān)于MatrixTraceTransform的構(gòu)造,這里也是一個(gè)優(yōu)秀的細(xì)節(jié)處理傻盟,雖然hook了系統(tǒng)transform的task速蕊,但是不會扔棄系統(tǒng)的transformTask,而是會傳遞進(jìn)來莫杈,接下來在代碼中分析這個(gè)偽代理的使用~
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
long start = System.currentTimeMillis();
try {
//在執(zhí)行系統(tǒng)的transform之前互例,執(zhí)行自己的transform
doTransform(transformInvocation); // hack
} catch (ExecutionException e) {
e.printStackTrace();
}
long cost = System.currentTimeMillis() - start;
long begin = System.currentTimeMillis();
//ok 接下來,執(zhí)行系統(tǒng)原來內(nèi)置的orignTransformTask筝闹, 優(yōu)秀的細(xì)節(jié)媳叨,不過貌似也只能這么干,可以考慮到卡頓hook looer中printer的時(shí)候也可以這么干~
origTransform.transform(transformInvocation);
}
最后就是插樁的核心代碼了关顷,這里分為兩個(gè)部分來進(jìn)行介紹糊秆,一個(gè)是如何過濾簡單方法,一個(gè)是插樁細(xì)節(jié)
- 過濾簡單方法
- 插樁
總的來說议双,流程也比較簡單痘番,整個(gè)方法分為三個(gè)過程
首先解析mapping文件,畢竟是對混淆過的代碼插樁,這里要能知道自己到底插的哪個(gè)方法
然后是收集要插樁的方法汞舱,就是過濾伍纫,過濾黑名單中不需要插樁的類或者方法
對收集后的方法開始插樁
精簡一下代碼:
//com.tencent.matrix.trace.transform.MatrixTraceTransform#doTransform
private void doTransform(TransformInvocation transformInvocation) throws ExecutionException, InterruptedException {
final boolean isIncremental = transformInvocation.isIncremental() && this.isIncremental();
/**
* step 1 把編譯后的mapping文件對應(yīng)的混淆方法關(guān)系記錄下來,如:a() -> onCreate()
*/
List<Future> futures = new LinkedList<>();
//...干掉亂八七糟的代碼干掉哈昂芜,主要就是生成方法id莹规,然后解析~
//拿到需要查找的類文件
for (TransformInput input : inputs) {
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
futures.add(executor.submit(new CollectDirectoryInputTask(dirInputOutMap, directoryInput, isIncremental)));
}
for (JarInput inputJar : input.getJarInputs()) {
futures.add(executor.submit(new CollectJarInputTask(inputJar, isIncremental, jarInputOutMap, dirInputOutMap)));
}
}
//這里調(diào)用get方法就是執(zhí)行
for (Future future : futures) {
future.get();
}
futures.clear();
/**
* step 2 這里就是收集下來需要進(jìn)行插樁的方法,過濾出來黑名單或者是簡單方法泌神,這些方法不需要插樁~ 這里這個(gè)判斷再一次[手動(dòng)秒啊~]
*/
MethodCollector methodCollector = new MethodCollector(executor, mappingCollector, methodId, config, collectedMethodMap);
methodCollector.collect(dirInputOutMap.keySet(), jarInputOutMap.keySet());
/**
* step 3 對收集的方法進(jìn)行插樁~
*/
MethodTracer methodTracer = new MethodTracer(executor, mappingCollector, config, methodCollector.getCollectedMethodMap(), methodCollector.getCollectedClassExtendMap());
methodTracer.trace(dirInputOutMap, jarInputOutMap);
}
mapping的解析就是工作量問題良漱,我們可以看下輸入結(jié)果:
1,1,sample.tencent.matrix.listener.TestPluginListener <init> (Landroid.content.Context;)V
2,0,sample.tencent.matrix.trace.TestFpsActivity$4 <init> (Lsample.tencent.matrix.trace.TestFpsActivity;Landroid.content.Context;I[Ljava.lang.Object;)V
3,1,sample.tencent.matrix.trace.TestTraceFragmentActivity <init> ()V
4,1,sample.tencent.matrix.trace.TestTraceFragmentActivity$2 onClick (Landroid.view.View;)V
5,1,sample.tencent.matrix.listener.TestPluginListener onReportIssue (Lcom.tencent.matrix.report.Issue;)V
然后分析函數(shù)是否簡單的地方,先看一下文檔中的定義
為了減少插樁量及性能損耗欢际,通過遍歷 class 方法指令集母市,判斷掃描的函數(shù)是否只含有 PUT/READ FIELD 等簡單的指令,來過濾一些默認(rèn)或匿名構(gòu)造函數(shù)损趋,以及 get/set 等簡單不耗時(shí)函數(shù)患久。
簡單方法過濾
內(nèi)部獲取方法同樣利用ASM那一套邏輯,上面介紹耗時(shí)插件已經(jīng)介紹過了哈~
可以重點(diǎn)關(guān)注核心代碼:
//com.tencent.matrix.trace.MethodCollector.TraceClassAdapter#visitMethod
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
//抽象類不做處理
if (isABSClass) {
return super.visitMethod(access, name, desc, signature, exceptions);
} else {
//判斷該方法是不是onWindowFocus
if (!hasWindowFocusMethod) {
hasWindowFocusMethod = isWindowFocusChangeMethod(name, desc);
}
//真正核心處理過濾邏輯
return new CollectMethodNode(className, access, name, desc, signature, exceptions);
}
}
//com.tencent.matrix.trace.MethodCollector.CollectMethodNode#visitEnd
@Override
public void visitEnd() {
super.visitEnd();
TraceMethod traceMethod = TraceMethod.create(0, access, className, name, desc);
if ("<init>".equals(name)) {
isConstructor = true;
}
//判斷是否是黑名單里面的舶沿,這里和黑名單中進(jìn)行了互斥墙杯,在插樁中也會判斷
//所以可以不用在意這個(gè)細(xì)節(jié),說白了就是判斷這個(gè)方法不在黑名單中而已
boolean isNeedTrace = isNeedTrace(configuration, traceMethod.className, mappingCollector);
// filter simple methods 這里就是過濾簡單方法括荡,我們重點(diǎn)關(guān)注這里
if ((isEmptyMethod() || isGetSetMethod() || isSingleMethod())
&& isNeedTrace) {
ignoreCount.incrementAndGet();
collectedIgnoreMethodMap.put(traceMethod.getMethodName(), traceMethod);
return;
}
......
}
上面截取出來的額低嗎可見端倪高镐,一個(gè)是判斷構(gòu)造方法,然后就是isEmptyMethod
畸冲,isGetSetMethod
嫉髓,isSingleMethod
,接下來就分析下這個(gè)所謂的過濾簡單方法到底????與否
//com.tencent.matrix.trace.MethodCollector.CollectMethodNode#isEmptyMethod
/**
* 檢測空方法邑闲,不知道這里為什么這么寫算行。。苫耸。反正我驗(yàn)證這個(gè)方法基本沒有用
* -1 是F_NEW指令州邢,不應(yīng)該是判斷return指令么?
*
* @return
*/
private boolean isEmptyMethod() {
ListIterator<AbstractInsnNode> iterator = instructions.iterator();
while (iterator.hasNext()) {
//邏輯就是過濾掉是new指令褪子? 說白了就是如果指令集不為空量淌,就不是空方法
AbstractInsnNode insnNode = iterator.next();
int opcode = insnNode.getOpcode();
//-1對應(yīng)的opcode是NEW這個(gè)指令
if (-1 == opcode) {
continue;
} else {
return false;
}
}
return true;
}
/*
這里是空方法反編譯后的字節(jié)碼
public void logClueMethod();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 22: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this Lcom/done/testlibrary/Utils;
*/
首先空方法的判斷不夠牛批哈~
接下來看第二個(gè)isGetSetMethod
,
private boolean isGetSetMethod() {
int ignoreCount = 0;
ListIterator<AbstractInsnNode> iterator = instructions.iterator();
while (iterator.hasNext()) {
AbstractInsnNode insnNode = iterator.next();
int opcode = insnNode.getOpcode();
if (-1 == opcode) {
continue;
}
if (opcode != Opcodes.GETFIELD
&& opcode != Opcodes.GETSTATIC
&& opcode != Opcodes.H_GETFIELD
&& opcode != Opcodes.H_GETSTATIC
&& opcode != Opcodes.RETURN
&& opcode != Opcodes.ARETURN
&& opcode != Opcodes.DRETURN
&& opcode != Opcodes.FRETURN
&& opcode != Opcodes.LRETURN
&& opcode != Opcodes.IRETURN
&& opcode != Opcodes.PUTFIELD
&& opcode != Opcodes.PUTSTATIC
&& opcode != Opcodes.H_PUTFIELD
&& opcode != Opcodes.H_PUTSTATIC
&& opcode > Opcodes.SALOAD) {
if (isConstructor && opcode == Opcodes.INVOKESPECIAL) {
ignoreCount++;
if (ignoreCount > 1) {
return false;
}
continue;
}
return false;
}
}
return true;
}
最后是判斷是不是簡單方法
private boolean isSingleMethod() {
ListIterator<AbstractInsnNode> iterator = instructions.iterator();
while (iterator.hasNext()) {
AbstractInsnNode insnNode = iterator.next();
int opcode = insnNode.getOpcode();
if (-1 == opcode) {
continue;
//出現(xiàn)這個(gè)指令區(qū)間內(nèi)嫌褪,都標(biāo)識調(diào)用了其他方法呀枢,會出現(xiàn)壓棧的情況
// 調(diào)用了別的方法,自然就不是簡單的方法笼痛,不過這里沒有判斷指令的數(shù)量裙秋,感覺也不是一定可靠
} else if (Opcodes.INVOKEVIRTUAL <= opcode && opcode <= Opcodes.INVOKEDYNAMIC) {
return false;
}
}
return true;
}
三個(gè)過濾的函數(shù)都看完了琅拌,感覺不一定可取,可以借鑒和參考摘刑,但不一定準(zhǔn)进宝,可能是在下沒有嚴(yán)謹(jǐn)?shù)木幾g看吧。泣侮。即彪。不過有這種思路也還好,可以自己定義所謂的簡單方法吧~
插樁代碼
這里只簡單看一下插樁的代碼活尊,具體計(jì)算耗時(shí)的功能邏輯實(shí)現(xiàn)后面會繼續(xù)介紹,那部分也不在plugin的工程中~
3漏益、針對界面啟動(dòng)耗時(shí)蛹锰,因?yàn)橐y(tǒng)計(jì)從 Activity#onCreate 到 Activity#onWindowFocusChange 間的耗時(shí),所以在插樁過程中需要收集應(yīng)用內(nèi)所有 Activity 的實(shí)現(xiàn)類绰疤,并覆蓋 onWindowFocusChange 函數(shù)進(jìn)行打點(diǎn)铜犬。
4、為了方便及高效記錄函數(shù)執(zhí)行過程轻庆,我們?yōu)槊總€(gè)插樁的函數(shù)分配一個(gè)獨(dú)立 ID癣猾,在插樁過程中,記錄插樁的函數(shù)簽名及分配的 ID余爆,在插樁完成后輸出一份 mapping纷宇,作為數(shù)據(jù)上報(bào)后的解析支持。
插樁對外的類是com.tencent.matrix.trace.MethodTracer蛾方,這里就簡單看一個(gè)插樁source代碼的像捶,因?yàn)榈阶詈蟛还苁莏ar還是source,可都是對class文件進(jìn)行處理~
//com.tencent.matrix.trace.MethodTracer#innerTraceMethodFromSrc
private void innerTraceMethodFromSrc(File input, File output) {
ArrayList<File> classFileList = new ArrayList<>();
if (input.isDirectory()) {
listClassFiles(classFileList, input);
} else {
classFileList.add(input);
}
for (File classFile : classFileList) {
InputStream is = null;
FileOutputStream os = null;
try {
......
if (MethodCollector.isNeedTraceFile(classFile.getName())) {
is = new FileInputStream(classFile);
ClassReader classReader = new ClassReader(is);
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
//關(guān)注這里插樁訪問者訪問類桩砰,然后里面插樁
ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
//調(diào)用這里后開始插樁
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
......
} else {
FileUtil.copyFileUsingStream(classFile, changedFileOutput);
}
} catch (Exception e) {
......
} finally {
.......
}
}
}
代碼里面關(guān)鍵的實(shí)現(xiàn)還是asm的classVisitor拓春,我們只需要關(guān)注這里面的實(shí)現(xiàn)即可~
這里由于封裝的路徑還是有個(gè)兩三層,我就簡單說下調(diào)用關(guān)系亚隅,這里代碼咱們還是看核心實(shí)現(xiàn)哈~
//com.tencent.matrix.trace.MethodTracer.TraceMethodAdapter
//1. 調(diào)用com.tencent.matrix.trace.MethodTracer.TraceClassAdapter#visitMethod
//2. 調(diào)用com.tencent.matrix.trace.MethodTracer.TraceMethodAdapter#TraceMethodAdapter
//3. 利用AdviceAdapter的方法進(jìn)入和退出回調(diào)插樁
public final static String MATRIX_TRACE_CLASS = "com/tencent/matrix/trace/core/AppMethodBeat";
@Override
protected void onMethodEnter() {
//這里的插樁結(jié)果就是在方法進(jìn)入的時(shí)候插入 AppMethodBeat.i(methodid);
TraceMethod traceMethod = collectedMethodMap.get(methodName);
if (traceMethod != null) {
traceMethodCount.incrementAndGet();
mv.visitLdcInsn(traceMethod.id);
mv.visitMethodInsn(INVOKESTATIC, TraceBuildConstants.MATRIX_TRACE_CLASS, "i", "(I)V", false);
}
}
@Override
protected void onMethodExit(int opcode) {
//方法退出的插樁有判斷邏輯
TraceMethod traceMethod = collectedMethodMap.get(methodName);
if (traceMethod != null) {
if (hasWindowFocusMethod && isActivityOrSubClass && isNeedTrace) {
TraceMethod windowFocusChangeMethod = TraceMethod.create(-1, Opcodes.ACC_PUBLIC, className,
TraceBuildConstants.MATRIX_TRACE_ON_WINDOW_FOCUS_METHOD, TraceBuildConstants.MATRIX_TRACE_ON_WINDOW_FOCUS_METHOD_ARGS);
if (windowFocusChangeMethod.equals(traceMethod)) {
//如果是onWindowFocusChanged硼莽,那么還會插入AppMethodBeat.at(activity, isFocus)
traceWindowFocusChangeMethod(mv, className);
}
}
//無論是不是 onWindowFocusChanged,都會插入AppMethodBeat.o(methodid);
traceMethodCount.incrementAndGet();
mv.visitLdcInsn(traceMethod.id);
mv.visitMethodInsn(INVOKESTATIC, TraceBuildConstants.MATRIX_TRACE_CLASS, "o", "(I)V", false);
}
}
插裝部分就是純工作量的事情了煮纵,有了前面的判斷和過濾邏輯懂鸵,這里就非常簡單,主要插入 i 方法和 o 方法就行具體可以看上面的代碼注釋 多余的這里就不展開去看代碼了
?
JavaLib AppMethodBeat 實(shí)現(xiàn)
? 上面就是trace插件在編譯時(shí)所做的工作醉途,可以看到插樁時(shí)期絲毫沒有進(jìn)行任何系統(tǒng)方法的調(diào)用矾瑰,如:SystemClock.time或者System.nanoTime這些獲取時(shí)間戳的native方法,這樣可以理解為一個(gè)小優(yōu)化的點(diǎn)~不通過系統(tǒng)方法獲取時(shí)間隘擎,matrix利用很巧妙的方式來獲取時(shí)間
考慮到每個(gè)方法執(zhí)行前后都獲取系統(tǒng)時(shí)間(System.nanoTime)會對性能影響比較大殴穴,而實(shí)際上,單個(gè)函數(shù)執(zhí)行耗時(shí)小于 5ms 的情況,對卡頓來說不是主要原因采幌,可以忽略不計(jì)劲够,如果是多次調(diào)用的情況,則在它的父級方法中可以反映出來休傍,所以為了減少對性能的影響征绎,通過另一條更新時(shí)間的線程每 5ms 去更新一個(gè)時(shí)間變量,而每個(gè)方法執(zhí)行前后只讀取該變量來減少性能損耗磨取。
具體就在java lib中去實(shí)現(xiàn)人柿,下面我們就分析下java中的實(shí)現(xiàn),其實(shí)上面插樁的時(shí)候就已經(jīng)知道具體的實(shí)現(xiàn)的核心類是哪個(gè)了 => com.tencent.matrix.trace.core.AppMethodBeat
這個(gè)類的邏輯還是有一小丟丟繞忙厌,因?yàn)槠渲胁粌H僅包含了計(jì)算方法耗時(shí)凫岖,還兼顧了查看生命周期相關(guān)的,包括activity逢净、service的生命周期哥放,目測了下,contentProvider的還沒完成爹土,感興趣的同學(xué)可以具體查看下內(nèi)部關(guān)于hook mH相關(guān)的代碼甥雕,這里就貼一下關(guān)鍵性的代碼哈~
//com.tencent.matrix.trace.hacker.ActivityThreadHacker#hackSysHandlerCallback
public static void hackSysHandlerCallback() {
try {
Class<?> forName = Class.forName("android.app.ActivityThread");
Field field = forName.getDeclaredField("sCurrentActivityThread");
field.setAccessible(true);
Object activityThreadValue = field.get(forName);
//hook mH這個(gè)handler
Field mH = forName.getDeclaredField("mH");
mH.setAccessible(true);
Object handler = mH.get(activityThreadValue);
Class<?> handlerClass = handler.getClass().getSuperclass();
if (null != handlerClass) {
//接著hook系統(tǒng)的callback,方便內(nèi)部調(diào)用從而不影響系統(tǒng)調(diào)動(dòng)過程
Field callbackField = handlerClass.getDeclaredField("mCallback");
callbackField.setAccessible(true);
Handler.Callback originalCallback = (Handler.Callback) callbackField.get(handler);
HackCallback callback = new HackCallback(originalCallback);
callbackField.set(handler, callback);
}
} catch (Exception e) {
}
}
進(jìn)入正題胀茵,我們先從插樁中的方法入口和方法出口來分析
編譯期已經(jīng)對全局的函數(shù)進(jìn)行插樁社露,在運(yùn)行期間每個(gè)函數(shù)的執(zhí)行前后都會調(diào)用 MethodBeat.i/o 的方法,如果是在主線程中執(zhí)行宰掉,則在函數(shù)的執(zhí)行前后獲取當(dāng)前距離 MethodBeat 模塊初始化的時(shí)間 offset(為了壓縮數(shù)據(jù)呵哨,存進(jìn)一個(gè)long類型變量中),并將當(dāng)前執(zhí)行的是 MethodBeat i或者o轨奄、mehtod id 及時(shí)間 offset孟害,存放到一個(gè) long 類型變量中,記錄到一個(gè)預(yù)先初始化好的數(shù)組 long[] 中 index 的位置(預(yù)先分配記錄數(shù)據(jù)的 buffer 長度為 100w挪拟,內(nèi)存占用約 7.6M)挨务。數(shù)據(jù)存儲如下圖:
![方法數(shù)據(jù)邏輯.png](https://upload-images.jianshu.io/upload_images/2822814-fecb3594fa74d08c.png?imageMogr2/aut
o-orient/strip%7CimageView2/2/w/820)
在搞清楚內(nèi)部的邏輯之前,筆者認(rèn)為直接貼代碼不是很好理解玉组,所以我們先來配個(gè)圖谎柄,然后結(jié)合圖來理解這個(gè)過程:
private static final int STATUS_DEFAULT = Integer.MAX_VALUE;
private static final int STATUS_STARTED = 2;
private static final int STATUS_READY = 1;
private static final int STATUS_STOPPED = -1;
private static final int STATUS_EXPIRED_START = -2;
private static final int STATUS_OUT_RELEASE = -3;
public static void i(int methodId) {
......
//正式開始 step 1
if (status == STATUS_DEFAULT) {
synchronized (statusLock) {
if (status == STATUS_DEFAULT) {
realExecute();
status = STATUS_READY;
}
}
}
long threadId = Thread.currentThread().getId();
if (threadId == sMainThreadId) {
//合并方法堆棧 step 2
if (sIndex < Constants.BUFFER_SIZE) {
mergeData(methodId, sIndex, true);
} else {
sIndex = 0;
mergeData(methodId, sIndex, true);
}
++sIndex;
}
}
private static void realExecute() {
//記錄開始執(zhí)行的時(shí)間戳
sCurrentDiffTime = SystemClock.uptimeMillis() - sDiffTime;
sHandler.removeCallbacksAndMessages(null);
//啟動(dòng)計(jì)時(shí)器
sHandler.postDelayed(sUpdateDiffTimeRunnable, Constants.TIME_UPDATE_CYCLE_MS);
//狀態(tài)維護(hù),延遲15s后執(zhí)行惯雳,由上可知有i首次進(jìn)來以后狀態(tài)是STATUS_READY
//這里得結(jié)合looper的監(jiān)聽來說朝巫,后面分析捕捉細(xì)節(jié)再詳說,這里記得這個(gè)狀態(tài)維護(hù)
sHandler.postDelayed(checkStartExpiredRunnable = new Runnable() {
@Override
public void run() {
synchronized (statusLock) {
MatrixLog.i(TAG, "[startExpired] timestamp:%s status:%s", System.currentTimeMillis(), status);
if (status == STATUS_DEFAULT || status == STATUS_READY) {
status = STATUS_EXPIRED_START;
}
}
}
}, Constants.DEFAULT_RELEASE_BUFFER_DELAY);
//注冊監(jiān)聽
LooperMonitor.register(looperMonitorListener);
}
然后可以看到時(shí)間更新的方法為石景,主要工作其實(shí)就是更新時(shí)間戳劈猿,每次更新后睡5s拙吉,然后掛起自己,等待被主線程分發(fā)消息時(shí)候再喚醒:
/**
* 計(jì)時(shí)器
* update time runnable
*/
private static Runnable sUpdateDiffTimeRunnable = new Runnable() {
@Override
public void run() {
try {
while (true) {
while (!isPauseUpdateTime && status > STATUS_STOPPED) {
sCurrentDiffTime = SystemClock.uptimeMillis() - sDiffTime;
SystemClock.sleep(Constants.TIME_UPDATE_CYCLE_MS);
}
synchronized (updateTimeLock) {
updateTimeLock.wait();
}
}
} catch (Exception e) {
MatrixLog.e(TAG, "" + e.toString());
}
}
};
private static void dispatchBegin() {
sCurrentDiffTime = SystemClock.uptimeMillis() - sDiffTime;
isPauseUpdateTime = false;
synchronized (updateTimeLock) {
updateTimeLock.notify();
}
}
最后在合并數(shù)據(jù)的時(shí)候把方法進(jìn)入退出和方法id以及時(shí)間戳帶上揪荣,組成一個(gè)long變量:
/**
* merge trace info as a long data
*
* @param methodId
* @param index
* @param isIn
*/
private static void mergeData(int methodId, int index, boolean isIn) {
//如果是分發(fā)的函數(shù)過來的筷黔,更新一下時(shí)刻,以示尊重~ 就是更新時(shí)戳仗颈,這個(gè)時(shí)間可不能是簡單的5的倍數(shù)[手動(dòng)摳鼻]
if (methodId == AppMethodBeat.METHOD_ID_DISPATCH) {
sCurrentDiffTime = SystemClock.uptimeMillis() - sDiffTime;
}
//可見輸入為1輸出為0
long trueId = 0L;
if (isIn) {
trueId |= 1L << 63;
}
trueId |= (long) methodId << 43;
//將方法id和時(shí)間戳合并成一個(gè)long值佛舱,以達(dá)到8字節(jié)存儲的目的
trueId |= sCurrentDiffTime & 0x7FFFFFFFFFFL;
sBuffer[index] = trueId;
checkPileup(index);
sLastIndex = index;
}
最后就把數(shù)據(jù)合并完成了。matrix的慢函數(shù)監(jiān)控至此結(jié)束~