9

页面切换提速30%!京东商城APP首屏耗时监控及优化实践

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzUyMDAxMjQ3Ng%3D%3D&%3Bmid=2247495258&%3Bidx=1&%3Bsn=4f9c28544f8a79ac48ae39bc8510737b
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

随着移动互联网及信息技术的不断发展,用户对移动设备的依赖性逐步增强,各类App如雨后春笋般出现,同时用户也越来越关注App在性能上的体验。其中关注较多,且直观感受最为强烈的就是页面打开速度。

在过去一年里,从京东商城App的用户反馈看,性能相关反馈占系统问题反馈的32%,其中反馈卡顿、打开速度慢的问题占性能问题的68%,对于用户的反馈情况,仅能从现象上发现有问题,如何更准确的定位并优化这些情况呢?京东App团队通过分析页面启动流程制定了一套App线上首屏耗时监控系统。本文主要以Android为例分享京东App团队如何建立首屏耗时监控系统(也称为飓风系统),并通过监控收集的数据解决App性能问题。

定义: 对于首屏耗时的定义,每个公司可能会有所不同,在本文中,首屏耗时指的是:从用户点击呼起或者切换页面开始到第一屏页面渲染完成的时间。

监控系统

对于首屏耗时监控很多人会有疑惑:业内有许多不错的App性能监控系统,为什么不直接复用呢?确实,许多大厂有现成的SDK可以使用,然而,这些系统的监控指标更偏于技术指标,存在几个缺陷: a)无法监控用户的真实感受;b)无法监控多tab页面切换的情况;c) 无法覆盖业务指标,如商品图加载耗时监控。飓风系统就是为解决这些问题而产生的,整体结构如下:

ZfqQZzj.png!mobile

整个系统主要划分为策略配置模块、数据采集模块和聚合分析平台。

  • 策略配置模块是数据采集的总控制台,能够配置下发需要采集的页面信息、用户黑白名单及数据采集需要的其他配置信息,同时具备灰度能力。

  • 数据采集模块通过监控页面生命周期、网络请求情况及页面渲染数据后,以页面为维度组合数据,然后通过接口上报到聚合分析平台。

  • 聚合分析平台将客户端采集上报的数据进行聚合计算,以avg、tp99等多维度展示页面首屏各阶段耗时情况。

数据采集

vA3uYnr.png!mobile

首屏耗时主要包含启动新页面和切换tab页两种情况,首屏渲染的流程都是大致相似的。其中页面初始化到UI组件初始化的过程可以通过采集Activity、Fragment的生命周期进行监控。

飓风数据采集以页面为维度(Activity或Fragment),所有数据均以(key,value)形式进行采集,key为Activity或Fragment对象地址的hash值,为了让数据统计更灵活、更容易扩展,所有value值均以时间戳的形式采集上报。

01

Activity/Fragment生命周期

Activity和Fragment都可以通过AOP的方式采集各生命周期的节点。以Activity为例,通过hook FragmentActivity的生命周期方法,根据App的框架情况也可以选择Activity基类,如BaseActivity。

private static final String POINTCUT_PERF_INIT = "execution(* androidx.appcompat.app.AppCompatActivity+.onCreate(..))";  
@Pointcut(POINTCUT_PERF_INIT)
public void onActivityOnCreate() {

}
@Before("onActivityOnCreate()")
public void beforeActivityOnCreate(JoinPoint point){
//记录点1
}
@After("onActivityOnCreate()")
public void afterActivityOnCreate(JoinPoint point){
//记录点2,记录点1〜记录点2即为onCreate方法执行耗时。
}

Activity页面启动起始点: Activity页面启动会先执行前一个Activity的onPause方法,通过hook方式记录每次onPause执行的Activity对象和时间戳,仅保留最近一次onPause执行数据。 当Activity初始化执行onCreate方法时,将最近一次onPause执行时间作为新Activity启动的起始点。

mmaMjav.png!mobile

需要注意的是如果当前App处于后台,此时打开新的页面并不会执行上一个页面的onPause方法,需要监听App前后台切换动作,将此情况特殊处理。

 ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleObserver() {  
// app进入后台时
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
public void onAppGoBackground() {
// 上报缓冲区的所有数据
Monitor.goBackground();
}
});

Fragment页面切换起始点: Fragment页面切换的起点是onAttached方法,需要注意的是onAttached方法在页面多次切换时可能会多次执行,只有首次执行时采集的数据是有效的。 同时,Fragment页面还可能存在预加载的情况,如ViewPager+Fragment默认加载当前tab页和前后两个tab页面,预加载的过程对用户是不可见的,首屏耗时的统计是无意义的,可以通过Fragment#getUserVisibleHint()方法来判断是否预加载。

02

网络接口请求

网络请求同样可以使用AOP来hook网络请求的开始与结束,或直接在网络框架中手动插入开始与结束的记录节点,这里需要一个配置表来判断该接口是否为页面的首屏网络请求接口,只有配置表中的接口才会被记录为首屏的网络请求。配置信息放在服务端,接口的配置信息以json形式下发到客户端,当页面有接口增加或修改时,只需要更新服务端数据即可,无需客户端发版。配置信息如下:

 {  
"com.demo.MainActivity": {
"request": "home/config",
"request": "home/feeds",
"type": "activity"
},
"com.demo.CouponFragment": {
"request": "coupon/list",
"type": "fragment"
}
}

03

首屏渲染结束点

由于业务越来越复杂,页面缓存、骨架兜底等越来越多的页面形式出现,无法使用统一的节点记录首屏渲染完成,为了监控更符合用户感受,需要各页面进行代码调用,比如RecyclerView列表页可以在列表刷新时调用渲染完成同步信息给监控SDK。

 adapter.notifyDataSetChanged();  
Monitor.onRender(context);

04

自定义指标

增加自定义指标的目的在于让各模块可以更精准定位耗时问题,针对不同业务场景增加不同维度的监控指标。自定义指标数据以(page,key,value)的形式进行采集上报,page为Activity或Fragment对象的地址hash值,用于判断该耗时产生的页面来源;key为预定义值(key1〜key50),预定义原因是为了数据平台可以根据固定key进行聚合展示,业务根据各自的埋点进行数据映射;value值为耗时时长。

05

灰度控制

首屏数据采集前会通过后台下发策略配置,配置分为总开关、黑白名单、页面开关三个层级。总开关与黑白名单用于判断当前App是否开启采集线程,页面开关包含需要采集首屏数据的页面信息,用于判断是否采集当前页面的首屏信息。数据采集并不需要包含所有用户的情况,有足够的上报量就可以反馈线上的真实情况,三层开关均有灰度能力。

数据分析

可以从页面生命周期、网络接口请求、渲染耗时、自定义指标四个维度进行数据分析。

01

页面生命周期

RjyEZzN.png!mobile

页面生命周期主要包含onCreateTime、onStartTime和onResumeTime,其中onCreateTime指上一个页面的关闭到新页面创建(onPause〜onCreate)的时长,该耗时反应页面初始化耗时。onStartTime指页面创建到页面开始的时长(onCreate〜onStart),该耗时反应页面组件初始化耗时。onResumeTime指页面开始到组件开始渲染的时长(onStart〜onResume),同样反应组件初始化耗时。

02

网络接口请求

e6zYzu7.png!mobile

网络接口请求可以从接口结构合理性及多接口并行度两个维度进行分析。

接口合理性:接口耗时大可以减小网络请求数据大小,分析数据结构的合理性,是否存在冗余的数据,或者通过压缩的方式来减小网络数据大小(如gzip)。

接口并行度:接口并行度主要从接口总耗时和接口累加耗时两个维度分析,接口总耗时指从第一个首屏接口开始发出请求到所有首屏接口请求收到响应的时长,接口累加耗时指每个首屏接口耗时的累计,接口总耗时/接口累加耗时越小说明接口并行度越好。网络接口总耗时远大于网络接口累加耗时,说明接口与接口请求之间存在间隔。

接口总耗时T/接口累加耗时(t1+t2)>1,说明接口与接口之间有间隔(如下图)。接口总耗时T/接口累加耗时(t1+t2)<1则说明接口与接口之间并行度较好。

ziE7Nbb.png!mobile

03

渲染耗时

NvURFrq.png!mobile

渲染耗时,这里指的是首屏最后一个接口收到响应到首屏渲染结束的时长,部分接口访问前可能就存在组件渲染,如下图,其实就是木桶原理的长短板问题,与网络请求并行的渲染耗时不会直接影响首屏耗时,数据分析过程中只需要关注网络接口响应到渲染完成的时长。

rYvIjiJ.png!mobile

04

自定义指标

自定义指标主要用于辅助业务研发进一步分析首屏耗时,可以是一个View的初始化,也可以是一个方法的耗时。如果存在部分线上耗时问题无法确定原因,也可以通过新增自定义指标将首屏各阶段耗时进行二次分段监控。

京东商城APP优化实践

通过飓风系统线上数据分析,京东商城App进行了针对性的优化,App页面首屏耗时优化效果明显,APP整体首开耗时平均提速30%以上,大部分页面基本做到了秒开。下文将分享几个典型实践案例。

01

插件预加载

通过线上数据横向对比发现,插件页面onCreateTime平均值比非插件页面多150ms左右。插件的加载主要分为冷启动、暖启动、热启动,通过自定义耗时监控发现插件冷启动与插件热启动两种打开方式耗时相差500ms+(如下图)。

VRJZzyZ.png!mobile

当插件有更新,发版一周后插件冷启动和插件温启动数量仍然处于较多的情况,导致插件平均耗时仍然比较大。(如下图)

BJNrYfJ.png!mobile

插件预加载的方式,几乎可以将插件冷启动与温启动清零,通过本地测试一次性预加载30+个插件未出现CPU过载情况,内存也只增加10〜20Mb左右。京东商城App通过策略下发的方式进行插件加载管理,当CPU、内存、线程数符合策略要求后在后台根据优先级预加载插件,方案上线后插件页面整体平均提速150ms左右。

02

接口预加载&接口缓存

在Activity初始化的过程中采用后台预加载的方式提前执行网络数据请求(从上述Activity生命周期方法耗时的统计看,京东App的Activity初始化耗时在90ms左右)。

mqaaayN.png!mobile

除了接口预加载,也可以通过数据缓存的方式来减少请求次数从而减少首屏耗时,每次进入页面先加载有效期时间内的旧数据,网络实时数据获取到后diff更新,并将数据再次缓存。

NzYjq2N.png!mobile

03

网络接口并行化

提高接口执行的并行度并非简单粗暴的将所有接口都单独创建线程并行执行,而是要根据各个接口耗时情况,以及资源加载情况合理设计请求时序。将所有网络线程以及资源加载并行化可能造成线程数溢出或因大量线程同时执行导致内存暴涨等情况(如下图)。

aA73uyA.png!mobile

可根据耗时最长的接口来合理并行化,减少执行线程数的情况下同时减少网络接口总耗时(如下图)。

3QnUZ3.png!mobile

04

UI组件加载优化

安卓中最常用的是使用xml文件来布局页面,首先需要了解xml布局是如何解析成view树的,无论是Activity#setContentView,还是View#inflate方法最终都是通过LayoutInflater#inflate方法进行解析。通过源码可以了解到其实是通过XmlResourceParser这个类进行xml的解析,解析完后通过反射的方式创建View并按xml的结构添加到rootview中。

无论是解析xml的IO流还是反射创建实例都是耗时操作,其中反射的耗时一般是直接调用耗时的3倍,xml文件越大,耗时就越明显。如下布局通过LayoutInflater#inflater方法在谷歌模拟器上解析需要7ms:

 <?xml version="1.0" encoding="utf-8"?>  
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<View
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>

如果将这段布局改成通过动态添加的方式,在相同模拟器下只需要2ms,代码如下:

 LinearLayout linearLayout = new LinearLayout(this);  
linearLayout.addView(new TextView(this));
linearLayout.addView(new ImageView(this));
linearLayout.addView(new Button(this));
linearLayout.addView(new View(this));

谷歌提供了一个异步加载的类AsyncLayoutInflater,解析过程是在子线程进行,但是使用AsyncLayoutInflater过程中,由于创建View实例是在子线程中进行,View的实例化方法中不能存在创建Handler或者使用Looper.myLooper的情况,比如WebView组件在实例化时就会创建Handler并使用Looper.myLooper,如果WebView组件使用AsyncLayoutInflater解析,在主线程使用的过程中就会出现UI线程判断错误导致的异常。

AsyncLayoutInflater可能在很多情况下无法使用,那怎么办呢?答案是将xml的解析提前到代码编译阶段,在gradle打包过程中将xml翻译成java代码。网上也有不少现成的插件(如Android X2J),这种方式可以让布局加载速度提升200%+;

05

渲染优化

页面渲染的优化就要从View的绘制流程入手了,当一个View被添加到window上时,最终分别调用measure、layout、draw方法完成View组件测量、布局和绘制。绘制的时序图如下:

qEZ3aq7.png!mobile

图16

导致绘制流程耗时过大的原因很多,主要有以下几点:

  • 布局Layout过于复杂,导致measure、layout耗时都增加。

  • 布局中View过多,导致measure、layout、draw耗时都增加。

  • 同一时间动画执行的次数过多(特别是属性动画),导致layout耗时增加。

  • View的过渡绘制,导致部分区域像素点多次执行绘制,导致draw耗时增加。

  • 绘制过程大量创建变量或调用耗时操作。

布局合理性:分析具体引起耗时的原因,除了从代码检查外,可以借助google官方的一些工具来分析和解决耗时问题。Android Studio中Layout Inspector工具(如下图)可以查看页面层级以及每个层级的组成情况。通过Layout Inspector可以找出屏幕内外的所有布局情况,通过布局情况合理优化,主要的优化方案有:

N3aQzaN.png!mobile

图17

1. 减少布局层级,查看是否有无功能,无交互的层级,将对应层级去除。

2. 使用ViewStub,对于小概率显示的布局使用懒加载的方式在展示给用户时再进行真正的解析渲染。

3. 使用ConstraintLayout,ConstraintLayout出现的目的之一就是解决布局嵌套过多的问题。

过度绘制:过度绘制就是在同一帧情况下对同一块像素区域进行重复绘制,这样会加重GPU和CPU的渲染压力,导致渲染时间过长,如下图多个图层叠加在一起,系统会把从上到下所有卡片都进行绘制,但底部被覆盖部分是否绘制对用户而言是没有差别的,就会造成不必要的代码耗时。

ryaa2aa.png!mobile

Android手机可以通过手机系统设置的「调试GPU过度绘制」的选项或者adb命令查看当前Activity的过度绘制情况。

adb shell setprop debug.hwui.overdraw show

adb shell setprop debug.hwui.overdraw false

如下图,开启过度绘制后不同颜色显示过度绘制的程度,如果出现粉色及以上颜色,说明页面过度绘制较为严重。颜色与过度绘制程度映射如下:

  • 原色:没有过度绘制

  • 蓝色:1次过度绘制

  • 绿色:2次过度绘制

  • 粉色:3次过度绘制

  • 红色:4次或4次以上过度绘制

uaQb2iE.png!mobile

那么,当页面发生过度绘制可以从哪些方面进行优化?

1. 去除无用的背景色、前景色。如ImageView的background和imageDrawable,无论是默认图还是加载的图片都使用imageDrawable来实现, imageDrawable加载显示后会遮挡background,对于backgroud是无效的绘制。除了View的背景还有window的默认背景色,可以在Activity的onCreate方法中代码动态调用getWindow().setBackgroundDrawable(null)方法去除window背景。

2. 使用Canvas的clipRect和clipPath方法。一个window对应的是一个画布(Canvas),可以通过clipPath方法来将绘制限制在某个可见区域,对于不可见区域不重复绘制。

硬件加速:直观上说就是依赖GPU实现图形绘制加速,软硬件加速的主要区别是图形的绘制究竟是GPU来处理还是CPU,如果是GPU,就认为是硬件加速绘制,反之就是软件绘制。硬件加速会增加Render线程来完成绘制工作,而不占用UI线程资源,从而减少主线程耗时来缩短首开时间。以下两张图是通过Android的SystemTrace显示同一个页面开启硬件加速和关闭硬件加速的绘制过程(下图分别为未开启硬件加速和开启硬件加速的情况),两图对比很容易发现,开启硬件加速后除了将UI thread的大部分工作交给了RenderThread外,整体上每一帧的渲染耗时都有很大程度上减少。

e222eyR.png!mobile

06

优化成果

京东商城App通过一个季度的监控与优化,整体首屏渲染速度提升30%+,因为打开页面慢导致离开页面的用户减少了20%以上,下面是一张优化前后效果对比图,可以看到首屏渲染速度的显著提升。

32mQrm3.gif!mobile

写在最后

技术在更新,App也在更新,业务类型更是越来越复杂,本文提到的优化可能只是冰山一角,对本文提到的内容有疑惑,或者有更多更好的优化方案的读者可以留言一起讨论。前面我们也发布了卡顿的优化分享,后续也会有更多关于性能的优化分享给大家。

本文作者:朱跃棕、胡本奎、王晰源

参考文献

https://developer.android.com/topic/performance/rendering/inspect-gpu-rendering

https://tech.meituan.com/2018/07/12/autospeed.html

https://github.com/7hens/android-x2j


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK