移动端性能监控方案Hertz

美团外卖在实践中通过总结常见性能问题,并在学习了业内微信、360等性能监控技术原理后,开发了一套移动端性能监控解决方案——Hertz(赫兹)。Hertz的目标是实现这三个功能:

  • 开发时期,检查性能异常点并通知给开发者;
  • 测试时期,和现有测试工具结合产生性能测试报告;
  • 上线时期,通过监控平台上报性能数据,实现线上问题定位和追查。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

要实现这三个功能,首先要采集到可衡量、有价值的性能数据,因此性能数据的采集是我们关注的最核心的问题之一。

数据采集

虽然用户可以感知到的性能问题多种多样,我们仍然可以将其抽象成具体的监控指标。在Hertz中这些监控指标包括:FPS、CPU使用率、内存占用、卡顿、页面加载时间、网络请求流量 等。这其中有的性能指标比较容易获取,例如FPS、CPU使用率、内存占用等,有的性能指标不易获取,例如卡顿、页面加载时间、网络请求流量等。

例如在iOS中我们可以这样获取FPS:

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

在Android中我们可以这样获取内存占用:

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),还需要在客户端本地监控流量呢?本地流量能够统计由用户端发出的全部网络请求,而这点服务端监控是很难做到的。一个例子是并非所有的网络请求都会上报服务端监控;另一个例子是由于网络原因可能造成用户仅仅消耗了上行流量,但这些请求并没有到服务端。

在iOS中我们通过注册NSURLProtocol实现流量统计:

  • (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极少量的代码来添加一个适配器。同样的,性能采集模块和页面测速模块的设计也遵循这种思路。

最后

其实要轻松掌握很简单,要点就两个:

  1. 找到一套好的视频资料,紧跟大牛梳理好的知识框架进行学习。
  2. 多练。 (视频优势是互动感强,容易集中注意力)

你不需要是天才,也不需要具备强悍的天赋,只要做到这两点,短期内成功的概率是非常高的。

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。

阿里P7Android高级教程

下面资料部分截图,诚意满满:特别适合有3-5年开发经验的Android程序员们学习。

附送高清脑图,高清知识点讲解教程,以及一些面试真题及答案解析。送给需要的提升技术、近期面试跳槽、自身职业规划迷茫的朋友们。

Android核心高级技术PDF资料,BAT大厂面试真题解析;

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
C5mw7Mk-1715347337682)]

附送高清脑图,高清知识点讲解教程,以及一些面试真题及答案解析。送给需要的提升技术、近期面试跳槽、自身职业规划迷茫的朋友们。

Android核心高级技术PDF资料,BAT大厂面试真题解析;
[外链图片转存中…(img-5wEOd4AD-1715347337683)]
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

  • 27
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值