移动端性能监控方案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),在后台给咱们留言。咱们每周会挑选出一位热心小伙伴,送上一份精美的小礼品。快来扫码关注咱们吧!

相关文章
相关标签/搜索