移動端性能監控方案Hertz

移動端性能監控方案Hertz

吳凱 瑞利 富強 徐宏 ·2016-12-19 16:10javascript

性能問題是形成App用戶流失的罪魁禍首之一。App的性能問題包括崩潰、網絡請求錯誤或超時、響應速度慢、列表滾動卡頓、流量大、耗電等等。而致使App性能低下的緣由有不少,除去設備硬件和軟件的外部因素,其中大部分是開發者錯誤地使用線程、鎖、系統函數、編程範式、數據結構等致使的。即使是最有經驗的程序員,也很難在開發時就能避免全部致使性能低下的「坑」,所以解決性能問題的關鍵是在於能不能儘早地發現和定位這些「坑」。css

美團外賣在實踐中經過總結常見性能問題,並在學習了業內微信、360等性能監控技術原理後,開發了一套移動端性能監控解決方案——Hertz(赫茲)。Hertz的目標是實現這三個功能:html

  • 開發時期,檢查性能異常點並通知給開發者;
  • 測試時期,和現有測試工具結合產生性能測試報告;
  • 上線時期,經過監控平臺上報性能數據,實現線上問題定位和追查。

要實現這三個功能,首先要採集到可衡量、有價值的性能數據,所以性能數據的採集是咱們關注的最核心的問題之一。前端

數據採集

雖然用戶能夠感知到的性能問題多種多樣,咱們仍然能夠將其抽象成具體的監控指標。在Hertz中這些監控指標包括:FPS、CPU使用率、內存佔用、卡頓、頁面加載時間、網絡請求流量 等。這其中有的性能指標比較容易獲取,例如FPS、CPU使用率、內存佔用等,有的性能指標不易獲取,例如卡頓、頁面加載時間、網絡請求流量等。java

例如在iOS中咱們能夠這樣獲取FPS:android

- (void)tick:(CADisplayLink *)link
{
    NSTimeInterval deltaTime = link.timestamp - self.lastTime;
    self.currentFPS = 1 / deltaTime;
    self.lastTime = link.timestamp;
}

在Android中咱們能夠這樣獲取內存佔用:git

public long useSize() {
    Runtime runtime = Runtime.getRuntime();
    long totalSize = runtime.maxMemory() >> 10;
    this.memoryUsage = (runtime.totalMemory() - runtime.freeMemory()) >> 10;
    this.memoryUsageRate = this.memoryUsage * 100 / totalSize;
}

上面的例子只是爲了說明獲取FPS、內存、CPU這些指標很是簡單,可是這些指標必須與其它數據結合才具備意義,這些數據包括當前頁面的信息、當前App運行時間,或者卡頓發生時程序執行的堆棧和運行日誌等等。例如:CPU和當前頁面信息結合,能夠評測每一個頁面的運算複雜度;內存和App運行時間結合,能夠觀察內存和使用時長的關係進而分析是否發生內存泄漏;FPS和卡頓信息結合,能夠評估此次卡頓發生時App的性能究竟降低到什麼程度。程序員

流量消耗

移動端用戶對於流量很是敏感,美團外賣偶爾會收到用戶投訴說短期內消耗了巨大流量的問題,所以咱們思考能不能在App本地統計用戶的流量消耗,而且上報給後臺。這個統計沒必要精確到每一個API,可以粗略地歸類計算出總的流量消耗便可。咱們對於流量統計的維度是:天然日+請求來源+網絡類型。爲何有了服務端流量監控(例如CAT),還須要在客戶端本地監控流量呢?本地流量可以統計由用戶端發出的所有網絡請求,而這點服務端監控是很難作到的。一個例子是並不是全部的網絡請求都會上報服務端監控;另外一個例子是因爲網絡緣由可能形成用戶僅僅消耗了上行流量,但這些請求並無到服務端。github

在iOS中咱們經過註冊NSURLProtocol實現流量統計:sql

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    [self.client URLProtocolDidFinishLoading:self];

    self.data = nil;
    if (connection.originalRequest) {
        WMNetworkUsageDataInfo *info = [[WMNetworkUsageDataInfo alloc] init];
        self.connectionEndTime = [[NSDate date] timeIntervalSince1970];
        info.responseSize = self.responseDataLength;
        info.requestSize = connection.originalRequest.HTTPBody.length;
        info.contentType = [WMNetworkUsageURLProtocol getContentTypeByURL:connection.originalRequest.URL andMIMEType:self.MIMEType];
    [[WMNetworkMeter sharedInstance] setLastDataInfo:info];
    [[WMNetworkUsageManager sharedManager] recordNetworkUsageDataInfo:info];
}

}

在Android中咱們經過基於Aspectj的AOP方式攔截網絡請求API實現流量統計:

@Pointcut("target(java.net.URLConnection) && " +
        "!within(retrofit.appengine.UrlFetchClient) " +
        "&& !within(okio.Okio) && !within(butterknife.internal.ButterKnifeProcessor) " +
        "&& !within(com.flurry.sdk.hb)" +
        "&& !within(rx.internal.util.unsafe.*) " +
        "&& !within(net.sf.cglib..*)" +
        "&& !within(com.huawei.android..*)" +
        "&& !within(com.sankuai.android.nettraffic..*)" +
        "&& !within(roboguice..*)" +
        "&& !within(com.alipay.sdk..*)")
protected void baseCondition() {

}

@Pointcut("call (org.apache.http.HttpResponse org.apache.http.client.HttpClient.execute(org.apache.http.client.methods.HttpUriRequest))"
        + "&& target(org.apache.http.client.HttpClient)"
        + "&& args(request)"
        + "&& !within(com.sankuai.android.nettraffic.factory..*)"
        + "&& baseClientCondition()"
)
void httpClientExecute(HttpUriRequest request) {

}

統計到總的流量消耗後,咱們還但願對流量進行粗略的歸類,方便定位問題。有兩個因素是咱們關心的:第一是請求來源,即流量消耗是來自API請求,H5仍是CDN的;第二是網絡類型,即Wifi、4G仍是3G流量。對於流量來源,咱們首先經過域名作下簡單的歸類。以iOS爲例,示例代碼以下:

- (NSString *) regApiHost {
    return _regApiHost ? _regApiHost :@"^(.*\\.)?(meituan\\.com|maoyan\\.com|dianping\\.com|kuxun\\.cn)$";
}

- (NSString *) regResHost {
    return _regResHost ? _regResHost : @"^(.*\\.)?(meituan\\.net|dpfile\\.com)$";
}

- (NSString *) regWebHost {
    return _regWebHost ? _regWebHost : @"^(.*\\.)?(meituan\\.com|maoyan\\.com|dianping\\.com|kuxun\\.cn|meituan\\.net|dpfile\\.com)$";
}

可是某些域名可能既部署了API服務,又部署了Web服務。對於這類域名,咱們還經過校驗返回包的MIMEType做進一步的區分。以iOS爲例,示例代碼以下:

+ (BOOL)isPermissiveWebURL:(NSURL *)URL andMIMEType:(NSString *)MIMEType
{
    NSRegularExpression *permissiveHost = [NSRegularExpression regularExpressionWithPattern:[[WMNetworkMeter sharedInstance] regWebHost]
                                                                                options:NSRegularExpressionCaseInsensitive
                                                                                  error:nil];
    NSString *host = URL.host;
    return ([MIMEType isEqualToString:@"text/css"] || [MIMEType isEqualToString:@"text/html"] || [MIMEType isEqualToString:@"application/x-javascript"] || [MIMEType isEqualToString:@"application/javascript"]) && (host && [permissiveHost numberOfMatchesInString:host options:0 range:NSMakeRange(0, [host length])]);
}

頁面加載時間

要測量頁面加載時間,咱們要解決兩個問題。第一,如何衡量一個頁面的加載時間;第二,如何儘可能不寫或少寫代碼來實現測速。先看第一個問題,以Android爲例,在Activity的建立加載過程當中,會執行不少操做,例如設置頁面主題,初始化頁面佈局,加載圖片,獲取網絡數據或讀寫數據庫等等。上述操做的任何一個環節出現性能問題均可能致使畫面不能及時顯示,影響用戶體驗。Hertz將這些可能發生的操做抽象爲下圖所示的測速模型:

其中T1指頁面初始化到第一個UI元素顯示的時間,這個UI元素通常是指數據加載時的等待動畫之類的。T2是指網絡請求時間,這個時間的開始點有可能早於T1的結束點。T3是加載到數據後,爲UI填充數據並從新渲染完成的時間。T是整個頁面從初始化到最終UI繪製完成的時間。

對於第二個問題,若是每一個時間點都須要人工寫代碼埋點的話,效率很是低而且容易出錯。所以,Hertz經過一個配置文件配置每一個頁面對應的API,在API請求的基類中統一埋點。這個方案固然還有優化空間,例如hook關鍵節點上的API調用注入埋點代碼。

[{
  "page": "MainActivity",
  "api": [
    "/poi/filter",
    "/home/head",
    "/home/rcmdboard"
  ]
},
{
  "page": "RestaurantActivity",
  "api": [
    "/poi/food"
  ]
}]

此外,還有一個問題是如何斷定UI渲染完成?在Android中,Hertz的作法是在Activity的rootView中插入一個FrameLayout,而且監聽這個FrameLayout是否調用了dispatchDraw方法實現的。固然,這個方案的缺點是因爲插入了一級View致使層級嵌套變深。

@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    if (!mIsComplete) {
        mIsComplete = mCallback.onDrawEnd(this, mKey);
    }
}

在iOS中咱們採起了不一樣的作法,Hertz在配置文件中指定最終渲染頁面的某個元素的tag,並在網絡請求成功後開啓CADisplayLink檢查該元素是否出如今根節點下面。

- (void)tick:(CADisplayLink *)link
{
    [_currentTrackRecordArray enumerateObjectsUsingBlock:^(WMHertzPageTrackRecord * _Nonnull record, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([self findTag:record.configItem.tag inViewHierarchy:record.rootView]) {
            [self endPageRenderEvent:record];
        }
    }];
}

卡頓

目前主流移動設備均採用雙緩存+垂直同步的顯示技術。大概原理是顯示系統有兩個緩衝區,GPU會預先渲染好一幀放入一個緩衝區內,讓視頻控制器讀取,當下一幀渲染好後,GPU會直接將視頻控制器的指針指向第二個容器。這裏,GPU會等待顯示器的VSync(即垂直同步)信號發出後,才進行新的一幀渲染和緩衝區更新。

大多數手機的屏幕刷新頻率是60HZ,若是在 1000/60=16.67ms 內沒有將這一幀的任務執行完畢,就會發生丟幀現象,這即是用戶感覺到卡頓的緣由。這一幀的繪製任務包括CPU的工做和GPU的工做兩部分,CPU負責計算顯示的內容,例如視圖建立、佈局計算、圖片解碼、文本繪製等等,隨後CPU將計算好的內容提交給GPU,由GPU進行變換、合成、渲染。

除了UI繪製外,系統事件、輸入事件、程序回調服務、以及咱們插入的其它代碼也都在主線程中執行,那麼一旦在主線程裏添加了操做複雜的代碼,這些代碼就有可能阻礙主線程去響應點擊、滑動事件,以及阻礙主線程的UI繪製操做,這就是形成卡頓的最多見緣由。

在瞭解了屏幕繪製原理和卡頓造成的緣由後,很容易想到經過檢測FPS就能夠知道App是否發生了卡頓,也可以經過一段連續的FPS幀數計算丟幀率來衡量當前頁面繪製的質量。然而實踐發現FPS的刷新頻率很是快,而且容易發生抖動,所以直接經過比較經過FPS來偵測卡頓是比較困難的。而檢測主線程消息循環執行的時間就要容易的多了,這也是業內經常使用的一種檢測卡頓的方法。所以,Hertz在實踐中採用的就是檢測主線程每次執行消息循環的時間,當這一時間大於閾值時,就記爲發生一次卡頓。

在實踐中咱們發現,有的卡頓連續性耗時較長,例如打開新頁面時的卡頓;而有的卡頓連續性耗時相對較短但頻次較快,例如列表滑動時的卡頓。所以,咱們採用了「N次卡頓超過閾值T」的斷定策略,即一個時間段內卡頓的次數累計大於N時才觸發採集和上報:例如卡頓閾值T=2000ms、卡頓次數N=1,能夠斷定爲單次耗時較長的卡頓;而卡頓閾值T=300ms、卡頓次數N=5,能夠斷定爲頻次較快的卡頓。

Runnable loopRunnable = new Runnable() {
    @Override
    public void run() {
        if (mStartedDetecting && !isCatched) {
            nowLaggyCount++;
            if (nowLaggyCount >= N) {
                blockHandler.onBlockEvent();
                isCatched = true;
                ...
            }
        }
    }
};

public void onMainLoopFinish(){
    if(isCatched){
        blockHandler.onBlockFinishEvent(loopStartTime,loopEndTime);
    }
    resetStatus();
    ...
}

當檢測到卡頓後,如何定位到形成卡頓的問題呢?若是能抓取到卡頓發生時程序的調用堆棧和運行日誌,是否是很酷?的確,經過抓取堆棧能夠很是有效地幫咱們定位到形成卡頓的「問題代碼」。

在實踐中咱們發現抓取堆棧有兩個須要注意的問題。

第一個問題是堆棧抓取的時機。抓取堆棧的時機必須是在卡頓發生當時,而不是以後,不然不能準確抓到形成卡頓的代碼,所以在子線程中當卡頓尚未結束時,咱們就會抓取堆棧。

第二個問題是堆棧如何歸類,卡頓堆棧的歸類和Crash堆棧不一樣,以最內層代碼歸類顯然是不合適的,由於外層不一樣的業務邏輯代碼在最內層的調用堆棧有多是相同的。以最外層代碼歸類也是不合適的,由於最外層代碼有多是業務邏輯代碼,也有多是系統調用。

目前Hertz的作法是按照最內層歸類的原則,並匹配一些簡單的規則,以命中規則的類名來歸類。

擴展性和易用性

Hertz很是重視SDK的可擴展性和易用性,在設計之初咱們就作了不少考量。SDK的框架以下圖所示,總體上分爲三層:最上層是接口層,提供極少許的對外暴露的方法,以及環境和配置參數等。第二層是業務層,包含了頁面測速、卡頓檢測和參數採集等全部的核心邏輯。第三層是數據適配層,將業務層產生的數據封裝爲統一的數據結構,並經過適配器適配到不一樣的輸出通道上。

設計上咱們第一個考量就是接口的易用性,Hertz內置了三種運行模式:開發模式、測試模式和線上模式。開發者只須要指定一種模式,Hertz就能夠開始工做了。各類模式預設了SDK運行所須要的參數,例如採樣頻率、卡頓閾值、上報通道開關等,而監控指標的採集、卡頓的偵測、頁面測速等邏輯都在內部自動執行。以Android爲例,示例代碼以下:

final HertzConfiguration configuration = new HertzConfiguration.Builder(this)
        .mode(HertzMode.HERTZ_MODE_DEBUG)
        .appId(APP_ID)
        .unionId(UNION_ID)
        .build();
Hertz.getInstance().init(configuration);

設計上咱們第二個考量是SDK的可擴展性。以數據適配層爲例,目前內置了五種適配通道,能夠將採集到的監控數據適配到不一樣的數據通道。根據選擇的工做模式不一樣,數據將被適配到服務端監控通道,生成測試報告,或者只在App本地輸出日誌和提示。這種設計帶來的一個好處是,若是須要新增一種數據輸出通道,既能夠在上層添加一個攔截器,也能夠只改動SDK極少許的代碼來添加一個適配器。一樣的,性能採集模塊和頁面測速模塊的設計也遵循這種思路。

實際應用

美團外賣在接入Hertz後,初步具有了發現、定位性能問題的能力,在開發期、測試期、線上期都對Hertz進行了實際驗證。

開發期應用

在開發期接入Hertz,至關於集成了一個離線的性能檢測工具,當檢測到異常時,Hertz將這些數據直接反饋給開發者,以下圖所示:

運行時採集的數據會輸出到日誌中,而App的頁面上也會插入一個浮層來展現當前的FPS、CPU、內存等基本信息。若是檢測到卡頓發生,會彈出提示頁面並列出當前的執行堆棧。目前從卡頓檢測結果來看,大部分堆棧日誌能夠比較明顯的定位到有問題的代碼,只要略微查看代碼和分析緣由,這些問題都能很容易的優化。

下面是初始化複雜UI形成卡頓的例子:

android.content.res.StringBlock.nativeGetString(Native Method)
android.content.res.StringBlock.get(StringBlock.java:82)
android.content.res.XmlBlock$Parser.getName(XmlBlock.java:175)
android.view.LayoutInflater.inflate(LayoutInflater.java:470)
android.view.LayoutInflater.inflate(LayoutInflater.java:420)
android.view.LayoutInflater.inflate(LayoutInflater.java:371)
com.sankuai.meituan.takeoutnew.controller.ui.PoiListAdapterController.getView(PoiListAdapterController.java:77)
com.sankuai.meituan.takeoutnew.adapter.PoiListAdapter.getView(PoiListAdapter.java:26)
android.widget.HeaderViewListAdapter.getView(HeaderViewListAdapter.java:220)

下面是使用Gson反向解析字符串時形成卡頓的例子:

com.google.gson.Gson.toJson(Gson.java:519)
com.meituan.android.common.locate.util.GoogleJsonWrapper    $MyGson.toJson(GoogleJsonWrapper.java:236)
com.sankuai.meituan.location.collector.CollectorJson    $MyGson.toJson(CollectorJson.java:216)
com.sankuai.meituan.location.collector.CollectorFilter.saveCurrentData(CollectorFilter.java:67)
com.sankuai.meituan.location.collector.CollectorFilter.init(CollectorFilter.java:33)
com.sankuai.meituan.location.collector.CollectorFilter.<init>(CollectorFilter.java:27)
com.sankuai.meituan.location.collector.CollectorMsgHandler.recordGps(CollectorMsgHandler.java:134)
com.sankuai.meituan.location.collector.CollectorMsgHandler.getNewLocation(CollectorMsgHandler.java:81)
com.meituan.android.common.locate.LocatorMsgHandler$1.handleMessage(LocatorMsgHandler.java:29)

下面是主線程讀寫數據庫形成卡頓的例子:

android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
android.database.sqlite.SQLiteConnection.executeForLastInsertedRowId(SQLiteConnection.java:782)
android.database.sqlite.SQLiteSession.executeForLastInsertedRowId(SQLiteSession.java:788)
android.database.sqlite.SQLiteStatement.executeInsert(SQLiteStatement.java:86)
de.greenrobot.dao.AbstractDao.executeInsert(AbstractDao.java:306)
de.greenrobot.dao.AbstractDao.insert(AbstractDao.java:276)
com.sankuai.meituan.takeoutnew.db.dao.BaseAbstractDao.insert(BaseAbstractDao.java:25)
com.sankuai.meituan.takeoutnew.log.LogDataUtil.insertIntoDb(LogDataUtil.java:243)
com.sankuai.meituan.takeoutnew.log.LogDataUtil.saveLogInfo(LogDataUtil.java:221)
com.sankuai.meituan.takeoutnew.log.LogDataUtil.saveLog(LogDataUtil.java:116)
com.sankuai.meituan.takeoutnew.log.LogDataUtil.saveLogInfo(LogDataUtil.java:112)
com.sankuai.meituan.takeoutnew.ui.page.main.order.OrderListFragment.onPageShown(OrderListFragment.java:306)
com.sankuai.meituan.takeoutnew.ui.page.main.order.OrderListFragment.init(OrderListFragment.java:151)
com.sankuai.meituan.takeoutnew.ui.page.main.order.OrderListFragment.onCreateView(OrderListFragment.java:81)

從上報的具體問題來看,大部分日誌能夠比較明顯的定位到有問題的代碼,只要略微查看代碼和分析緣由,這些問題都能很容易優化。

測試期應用

傳統的性能測試大多依賴於第三方工具,產生的數據和開發實測的數據有較大出入,此外,這些測試每每只給出一些指標的數據,而不能幫助開發者定位到問題所在。咱們在測試階段使用Hertz採集性能數據,測試手段能夠是人工測試,也能夠是自動化測試或者monkey測試。獲得性能數據後,經過腳本處理後會發出一個簡單的測試報告。

固然這種形式的測試報告仍然須要手工來導出日誌和執行腳本,將來咱們會在此基礎上開發一套自動化的測試工具。

上線期應用

對於卡頓檢測,除了在開發期和測試期Hertz能當即將問題反饋給開發者外,在灰度或線上運行時Hertz也會將數據上傳到服務端,目前上報通道是公司內部的CAT(已經開源,詳情請參考深度剖析開源分佈式監控CAT一文)。能夠看到堆棧的歸類和展現和咱們熟悉的Crash監控很是相似,按照前面提到的歸類原則,卡頓堆棧按照發生的次數排列,而且能夠按照版本、操做系統、設備過濾,比較符合開發者的使用習慣。

對於流量的統計,咱們天天會上報到服務端全網用戶的流量消耗數據,並輸出一個報表,列出全網流量消耗Top100的用戶。若是發現異常,能夠進一步根據後端日誌和客戶端診斷日誌來排查具體是哪一個網絡請求致使的流量異常。

對於頁面測速數據和FPS、CPU、內存等基礎指標,Hertz也會將數據上報到CAT,評測App總體的性能情況。

總結

性能優化是每個成熟的App都必須認真對待的話題,而性能優化的痛點每每在於不能及時發現問題,或者發現了問題卻不能定位問題。美團外賣以監控數據指導性能優化的思路,在實踐中開發和完善了App性能監控方案Hertz,而且在性能數據的監控和應用方面作了一些探索和驗證。

目前Hertz的監控指標包括了FPS、CPU使用率、內存佔用、卡頓、頁面加載時間、網絡請求流量等,而耗電量、App冷啓動,以及Exception等監控後續會逐步加入到Hertz的監控目標中去。性能監控的指標在將來可能會複用多個現有工具,而且在此基礎上逐步完善

Hertz的卡頓偵測和堆棧抓取可以很是有效地幫助開發者定位性能問題,可是目前的卡頓偵測策略還有不少優化的空間。例如是否能夠根據設備不一樣設定不一樣的閾值,以及在App運行的不一樣時期設置不一樣的策略。而對於堆棧的歸類,目前的規則只是簡單地匹配類名前綴,如何更精準、更合理的分類也是咱們將來要更多考慮的問題。固然,這些優化還須要更多的數據樣本作支撐。

創建可視化的、友好的性能測試工具也一樣很是重要,例如一個可實時查看,也可翻閱歷史報告的Web頁面。同時,Hertz在設計上能夠很容易的和自動化測試手段相結合,或者在集成階段自動生成測試報告,然而在這方面咱們才僅僅作了一些初步的嘗試。當咱們具有了準確採集性能數據的能力以後,如何更好地應用到包括測試環節在內的整個開發流程中,仍然須要長期的探索和實踐

本文主要介紹美團外賣在Hertz的實踐過程當中總結的一些思路和實現手段,而圍繞App性能監控還有不少有趣的,和更深刻的主題並無涉及。例如如何平衡性能監控工具和工具自己所帶來的性能問題,性能優化的具體技巧和手段,以及對性能數據作進一步分析從而創建起異常設備的監控體系等等。將來咱們也將在這些問題上作進一步探索、實踐和分享。

參考文獻

  1. BlockCanary.
  2. Leakcanary.
  3. Watchdog.
  4. iOS-System-Services.
  5. guoling, 微信iOS卡頓監控系統.



發現文章有錯誤、對內容有疑問,均可以關注美團技術團隊微信公衆號(meituantech),在後臺給咱們留言。咱們每週會挑選出一位熱心小夥伴,送上一份精美的小禮品。快來掃碼關注咱們吧!

相關文章
相關標籤/搜索