写点什么

知乎安卓客户端启动优化:Retrofit 代理

  • 2020-03-26
  • 本文字数:5139 字

    阅读完需:约 17 分钟

知乎安卓客户端启动优化:Retrofit 代理

背景

知乎 Android 客户端作为一个比较大型的应用,由于功能不断地迭(zeng)代(jia),启动速度也会受到影响,为了提升用户体验,知乎移动平台团队把提高 App 启动速度定为了的一个长期而且重要的 OKR,于是我们在今年的第二季度,重点对客户端的启动做了一系列的优化。


虽然在性能优化相关领域我们还处于刚开始的阶段,但是在优化过程中我们还是总结出了一些经验可以拿出来分享。所以,今天我们来分享其中一次和 Retrofit 相关的优化经历。

开始之前

我们在做性能优化时,很多人可能苦恼于怎么去检验或者说量化最终优化的效果。这里面其实是有学问的,通常我们会选用系统输出的信息来作为指标,例如众所周知的用 GPU 渲染柱状图来作为 UI 是否卡顿的指标。为什么要选系统输出的信息?一来采集更方便,不需要自己写代码去测量,二来是我们还能采集到其他 App 的信息,方便与其他竞品做横向对比。


而启动速度的话,我们会选用系统打印的 Activity 启动 Log 作为指标:


107-11 15:09:32.519 1440-1502/? I/ActivityManager: Displayed com.zhihu.android/.app.ui.activity.MainActivity: +1s412ms (total +1s978ms)
复制代码


Log 数据的含义可以看 Stack Overflow 上的这个 回答,我们主要取 App 冷启动到第一个 Activity 显示出来的时间。


言归正传,在这次优化开始之前,为了能和最终的数据做对比,我们需要先测出 App 优化前的启动时间。通过对 App 进行多次冷启动并记录 Log 里的 Total duration,得出了 App 在优化前的平均启动时间为 1.905s(数据来自 OnePlus 3T):



Log 输出

发现 & 分析问题

我们想要找究竟是哪些代码导致启动变慢,光靠 Review 代码是做不到的,因为一段代码的执行效率除了受各种内因(CPU / IO 操作密集,锁…)的影响,还有可能受其他外因(系统资源争夺、GC…)的影响。所以想要快速准确地找出问题,最好的方法是让代码真正执行起来,然后去 Profile(监测)代码运行时的情况。


而 Profiling 其实是一门很大的学问,需要用到大量的工具,包括 Android 系统、SDK、甚至 IDE 提供的一些接口或工具,熟练运用这些工具去分析和发现性能问题是做性能优化的必备技能。而针对今天知乎 App 启动的 Profiling,我会简单介绍其中一部分的工具:


Method Tracing


Method Tracing,就是跟踪 App 某段时间内所有调用过的方法,这是测量代码执行性能常用的方式之一,我们可以通过它来查出 App 启动时具体都调用了方法,都花了多长时间。


这个功能是 Android 系统提供的,我们可以通过在代码里手动调用 android.os.Debug.startMethodTracing() 和 stopMethodTracing() 方法来开始和结束 Tracing,然后系统会把 Tracing 的结果保存到手机的 .trace 文件里。详情可以看 官方文档


此外,除了通过写代码来 Trace,我们也有更方便的方式。例如也可以通过 Android Studio Profiler 里的 Method Tracer 来进行。但是,针对 App 的冷启动,我们则通常会用 Android 系统自带的 Am 命令来进行,因为它能准确的在 App 启动的时候就开始 Trace:


# 启动指定 Activity,并同时进行采样跟踪adb shell am start -n com.zhihu.android/com.zhihu.android.app.ui.activity.MainActivity --start-profiler /data/local/tmp/zhihu-startup.trace --sampling 1000
复制代码


当 App 冷启动完毕后(首个 Activity 已经绘制到屏幕上),使用以下命令手动终止跟踪,并拉取 .trace 文件到本机的当前目录下:


# 终止跟踪adb shell am profile stop # 拉取 .trace 文件到本机当前目录adb pull /data/local/tmp/zhihu-startup.trace .
复制代码


拿到 .trace 文件之后,下一步就是进行可视化了。可以直接拖动 .trace 文件到 Android Studio 里打开,但 Android Studio 目前版本对 .trace 文件的可视化和交互做得还不怎么样,所以不推荐。在这里推荐使用 Android Device Monitor 里的 Traceview 来打开,详情可以查阅 官方文档


用 Traceview 打开之后的截图:



Traceview 截图


所有跟踪到的方法会默认按照实际总耗时从多到少向下排序,点击某个方法可以看到它的所有父调用方法和子调用方法。通过从上往下一条条排查,可以看到在知乎 App 启动时 UserInfoInitialization 类的 initUserInfo() 方法竟然耗时超过 600ms:



initUserInfo() 方法



159 (0x68f8) 方法


我们来看看这个 initUserInfo() 方法的代码:


private void initUserInfo() {    NetworkUtils.createService(ProfileService.class)            .getSelf(AppInfo.getAppId())            // ...            .subscribe(response -> {                // ...            }, Debug::e);}
复制代码


NetworkUtils.createService() 是我们自己封装的一个方法,内部调用 Retrofit 来获取 ProfileService 的动态代理类,而 getSelf() 则是动态代理类提供的一个方法。从 Traceview 中可以看到,159 (0x68f8) 这个是运行时生成的代理方法,所以可以判断这里的耗时是因为调用 getSelf() 引起的。


继续往下跟踪:



responseBodyConverter() 方法



_newReader() 方法


可以看出来,最终主要的耗时在 Jackson 库的几个方法上,而 Jackson 库是我们是用来给 Retrofit 序列化和反序列化数据(例如 Response Body)用的。


接下来就是深入追查原因了。通过查看 Retrofit 的代码可以知道,在第一次调用 Retrofit 的某个动态代理方法时,Retrofit 会新建一个 ServiceMethod 实例来储存该代理方法相关的一些数据,包括一个用来转换 Response Body 的 Converter。而在新建这个 Converter 的时候,则会根据 Body 反序列化结果对应的 Java Model 类来生成一个 ObjectReader,如果对应的 Java Model 类特别复杂,那么新建 ObjectReader 的时间也会特别长(内部会进行一堆反射操作)。而恰恰我们 getSelf() 方法返回的 Java Model 是一个字段极其多的类,所以造成第一次调用该代理方法时特别耗时。


问题的原因我们已经通过 Method Tracing 找到了,接下来我们可以通过另外一个工具来看看问题发生时,App 和系统的真实情况。


Systrace


Method Tracing 虽然是找出耗时方法的利器,但是执行 Method Tracing 时会严重拖慢 App 的执行速度,即便使用采样跟踪,测量得到的结果和实际结果肯定还是有很大偏差,只能作为参考。而且 Method Tracing 对锁、GC、资源匮乏等其他因素的追查显得十分无力。所以,我们可以借助另一个 Google 官方极力推荐的工具 -「Systrace」来跟踪 App 实际运行时的情况。详细介绍可以查看 官方文档


Systrace 的原理是通过 Android 系统自带的 atrace 工具来捕获系统以及 App 的一些关键信息,然后通过 Chrome 浏览器来进行可视化。


接下来,我们将通过 Systrace 来跟踪知乎 App 在启动时执行 Retrofit 代理方法的整个过程。首先,我们需要做一些准备:


  1. 为了让结果更接近真实情况,我们需要为 Release 包开启 App Tracing 的功能,详情可以查看 这里

  2. 给 Retrofit 的 loadServiceMethod() 方法添加 Tracing Section(PS:可以借助 JarFilterPlugin 来修改 Retrofit 的内部代码):


ServiceMethod<?, ?> loadServiceMethod(Method method) {    Trace.beginSection("Retrofit:" + method.getName());    // ...    Trace.endSection();    return result;}
复制代码


  1. 添加 Proguard 规则,保证 method.getName() 获取到正确的方法名:


-keepclassmembernames class * {    @retrofit2.http.GET *;    @retrofit2.http.POST *;    @retrofit2.http.PUT *;    @retrofit2.http.DELETE *;}
复制代码


做完准备后用 Gradle 命令打出一个上线标准的 Release 包并安装上手机,然后就可以开始用 Systrace 来跟踪了:


systrace -a com.zhihu.android app view res am sched dalvik
复制代码


开始跟踪后,冷启动 App,等到首个 Activity 可见之后点击回车结束跟踪。跟踪结束后 Systrace 会把跟踪结果保存到当前目录下的 trace.html 文件,使用 Chrome 打开后:



Chrome 截图


通过 Chrome 可以很直观地看到 App 在启动时的各种关键的 Section。此外,我们还能看系统 CPU 每个时期的使用率以及每个核心上执行的线程:



CPU 使用率和每个核心上执行的线程


通过选定某个线程上面的线程状态条,我们可以查看某段时间或 Section 里线程的运行状态:



线程状态


从上图可以看出,在「Retrofit:getSelft」这个 Section 里,UI Thread 基本都是 Running 状态,说明 getSelft() 内部执行的都是 CPU 密集的操作,很少进入等待状态。这也说明了 getSelft() 方法的耗时是实打实的,不是由于其他原因(例如锁等待)造成。而且有一点需要注意,这里测出来的实际耗时是 250 多毫秒,而 Method Tracing 测量出来的耗时是 650 毫秒,由此可以看出 Method Tracing 的测量结果误差还是很大的。


接着我们通过搜索「Retrofit:」关键字可以看到,在启动期间会不止一次调用到 Retrofit 的 loadServiceMethod() 方法:



搜索结果


而且有很多还是在 UI 线程上调用的,但是其实这些操作没必要放在 UI 线程上,我们需要想办法把所有 loadServiceMethod() 的调用都扔到其他线程上。

解决问题

耗时的原因已经找到了,而优化的思路就是把 loadServiceMethod() 的调用扔到非 UI 线程上。例如,针对 getSelf() 这个方法,我们可以使用 Observable 的 defer() 操作符把它放到非 UI 线程上去异步执行:


private void initUserInfo() {    Observable.defer(() ->            NetworkUtils.createService(ProfileService.class).getSelf(AppInfo.getAppId())                    .subscribeOn(Schedulers.io())    )            .subscribeOn(Schedulers.single())            // ...            .subscribe(response -> {                // ...            }, Debug::e); }
复制代码


这是针对单处地方的改法,但要知道,启动的时候不单单只有这一处会调用到 Retrofit 的 loadServiceMethod(),要把所有地方都加上 defer() 操作符的化修改量有点大。所以有没有更好的办法呢?


答案就是二次动态代理:


public final class Net {    public static <T> createService(Class<T> service) {        // ...        return createWrapperService(mRetrofit, service);    }
private static <T> T createWrapperService(Retrofit retrofit, Class<T> service) { return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[]{service}, new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, @Nullable Object[] args) throws Throwable { if (method.getReturnType() == Observable.class) { // 如果方法返回值是 Observable 的话,则包一层再返回 return Observable.defer(() -> { final T service = getRetrofitService(); // 执行真正的 Retrofit 动态代理的方法 return ((Observable) getRetrofitMethod(service, method) .invoke(service, args)) .subscribeOn(Schedulers.io()); }) .subscribeOn(Schedulers.single()); } // 返回值不是 Observable 的话不处理 final T service = getRetrofitService(); return getRetrofitMethod(service, method).invoke(service, args); } // ... }); }}
复制代码


原理就是再创建一层动态代理,然后把底层 Retrofit 代理方法的调用包进新的 Observable 里再返回。这么改的好处是可以一劳永逸,让所有调用的地方无需做任何更改,减少了代码的修改量。


经过这么优化之后,我们重新测试了一遍启动时长,最终得出优化后的启动时间大概快了 400ms。可以看出,这次优化是成功的。

结语

我们这次优化经历到这里就结束啦,可以看到我们整篇文章里的「发现 & 分析问题」这一章占的篇幅是最大的,这其实也反映了我们在做性能优化时的常态,大多数时间都会花在 Profiling 上,当遇到优化瓶颈时,更可能花大量时间去通过各种工具抽丝剥茧地分析问题。


其实上面也讲到了,Profiling 是一门大学问,本次分享只是挑了些比较简单但是关键的方式和工具来讲,实际在进行 Profile 的时候会用到更多的知识,有时候甚至需要自己写工具去辅助定位问题。希望本篇文章能起到抛砖引玉的作用,让大家能了解到做启动优化时的一些常用思路。此外,该次只是我们优化知乎 Android 客户端启动速度的其中一次经历,知乎移动平台团队还在不断地为优化启动速度做努力~也希望未来还能分享更多的经历给大家。


由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。


2020-03-26 19:001101

评论

发布
暂无评论
发现更多内容

全国数据标准化技术委员会成立,企业该对数据”下狠手”了

用友BIP

为什么真全闪分布式存储离不开 RoCE/RDMA 流控技术?

XSKY星辰天合

#分布式存储 流控技术

探索微店API接口:如何高效获取商品详情数据

代码忍者

API 接口 pinduoduo API

鸿蒙开发实战:灵活定制编译选项,打造高效应用

王二蛋和他的张大花

鸿蒙

鸿蒙开发实战:深度解析网络管理技巧与实战应用

王二蛋和他的张大花

鸿蒙

Pytest 并行与分布式运行测试用例的实现与优化

测吧(北京)科技有限公司

测试

C# 单例模式的多种实现

不在线第一只蜗牛

JavaScript C#

61支队伍入围!用友第六届企业数智化应用开发大赛决赛名单公布

新消费日报

推理王者o1到底怎么落地?

脑极体

AI

机器学习与AI|如何利用数据科学优化库存周转率?

Altair RapidMiner

人工智能 数据分析 altair RapidMiner

和鲸社区地球科学轻科研交流局:在这个卷来卷去的时代,我们都想要找到一些答案

ModelWhale

数据科学 气象 地球科学 DDE 深时数字地球 大气

深入理解 fixture 的作用范围:优化测试环境的管理

测吧(北京)科技有限公司

测试

深入理解 Fixture 作为参数使用的技巧:提升测试代码的灵活性和复用性

测吧(北京)科技有限公司

测试

智源举办2024具身与世界模型专题峰会 产学研共促技术创新与产业应用

智源研究院

什么是公共数据资源授权运营?

郑州埃文科技

公共数据

鸿蒙开发实战:智能日志定位与高效调试技巧

王二蛋和他的张大花

鸿蒙

Pytest-ordering:自定义 Pytest 测试用例执行顺序的指南

测吧(北京)科技有限公司

测试

全面升级的“新清影”,给AI生成视频带来了哪些新玩法?

Alter

Robotaxi三国杀

脑洞汽车

AI

利用外部数据源 CSV 管理测试:轻量化数据驱动测试方案

测吧(北京)科技有限公司

测试

改变财务规划思维方式,迎接创新技术新时代

智达方通

技术创新 预算管理 财务规划

从消息中间件架构发展趋势,探讨物联网平台如何支持亿级设备推送?

华为云开发者联盟

IoT Apache Pulsar 消息中间件 华为云IoTDA

利用外部数据源 JSON 管理测试:灵活的数据驱动测试方法

测吧(北京)科技有限公司

测试

深入理解 yield 用法:从生成器到高级测试场景的应用

测吧(北京)科技有限公司

测试

七届“全勤生”高通已经确认参加明年第八届进博会

业界

全链路压力测试:确保系统在高负载下的稳定性与响应能力

测吧(北京)科技有限公司

测试

鸿蒙开发实战:轻松配置多环境目录,实现高效应用部署

王二蛋和他的张大花

鸿蒙

Pytest 内置插件 Hook 体系:深入了解与实践

测吧(北京)科技有限公司

测试

知乎安卓客户端启动优化:Retrofit 代理_文化 & 方法_nekocode_InfoQ精选文章