知乎 Android 客户端启动优化 - Retrofit 代理

更多移动技术文章请关注本文集:知乎移动平台专栏

背景

知乎 Android 客户端作为一个比较大型的应用,由于功能不断地迭(zeng)代(jia),启动速度也会受到影响,为了提升用户体验,知乎移动平台团队把提高 App 启动速度定为了的一个长期而且重要的 OKR,于是我们在今年的第二季度,重点对客户端的启动做了一系列的优化。
虽然在性能优化相关领域我们还处于刚开始的阶段,但是在优化过程中我们还是总结出了一些经验可以拿出来分享。所以,今天我们来分享其中一次和 Retrofit 相关的优化经历。

开始之前

我们在做性能优化时,很多人可能苦恼于怎么去检验或者说量化优化的最终效果。这里面其实是有一定的学问的,通常我们会选用系统输出的一些信息来作为指标,例如众所周知的用 GPU 渲染柱状图来作为 UI 是否卡顿的指标。

而启动速度的话,我们会用系统 ActivityManager 打印的 Activity 启动的 Log 里面的时间作为指标:

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

Log 更具体的含义可以看 Stack Overflow 上的这个 回答。为什么要用系统输出的信息作为指标呢?一来是系统输出的信息采集起来更方便,不需要自己写代码去测,二来是我们同时可以拿到其他 App 对应的系统输出信息,方便与其他竞品做横向对比。
言归正传,在这次优化开始之前,我们需要先测出 App 优化前的启动数据。通过对 App 进行多次冷启动并记录 Log 里的 Total duration,得出了 App 在优化前的平均启动时间为 1.905s(数据来自 OnePlus 3T):

image.png

发现 & 分析问题

大多数情况下,我们是从 App 的 UI 交互反馈上得知性能有问题的,例如页面卡顿、启动时间太长。当我们知道有问题之后,光靠 Review 代码是很难找出具体是哪里出的问题,因为一段代码的执行效率除了受各种内因(CPU / IO 操作密集,锁…)的影响,还有可能受其他外因(系统资源争夺、GC…)的影响。所以想要精确找出问题,最好的方法是让代码真正执行起来,然后去 Profile(监测)代码运行时的情况。这其实是一门很大的学问,需要用到大量用于 Profiling 的工具,包括 Android 系统、SDK、甚至 IDE 提供的一些接口或工具,熟练运用这些工具去分析和发现性能问题是新手玩家进阶的必学技能。针对今天 App 启动的 Profiling,我们可以借助以下的一些方式和工具来看看知乎 App 优化前的启动有哪些问题:

Method Tracing

Method Tracing,就是跟踪 App 某段时间内所有调用过的方法,这是测量应用执行性能常用的方式之一,通过它我们可以查出 App 启动时具体都调用了方法,都花了多长时间。这个功能是 Android 系统提供的,我们可以通过在代码里手动调用 android.os.Debug.startMethodTracing() 和 stopMethodTracing() 方法来开始和结束 Tracing,然后系统会把 Tracing 的结果保存到手机的 .trace 文件里。详情可以看 官方文档

此外,除了通过写代码来 Trace,我们也有更方便的方式。例如也可以通过 Android Studio Profiler 里的 Method Tracer 来 Trace。但是,针对 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 已经绘制到屏幕上),使用以下命令手动终止跟踪,并拉取跟踪结果到本机的当前目录下:

# 终止跟踪
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 打开之后的截图:

image.png

所有跟踪到的方法默认按照实际总耗时从大到小向下排序,点击某个方法可以看到它的所有父方法和子方法。通过从上往下一条条排查,可以看到 UserInfoInitialization 类的 initUserInfo() 方法竟然耗时超过 600ms:
image.png

image.png

我们来看看这个方法对应的代码:

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

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

继续往下跟踪:


image.png

image.png

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

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

问题的原因我们已经找到了,接下来我们可以用 Systrace 来看看问题发生时,系统和 App 的真实情况。

Systrace

Method Tracing 虽然是找出耗时方法的利器,但是执行 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;
}

3.添加 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 可见之后点击回车结束跟踪。跟踪结束后会把结果保存到当前目录下的 trace.html 文件,该文件需要用 Chrome 打开:


image.png

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


image.png

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

从上图可以看出,在「Retrofit:getSelft」这个 Section 里,UI Thread 基本都是 Running 状态,说明在执行 getSelft() 方法时几乎做的都是 CPU 密集的操作,很少进入等待状态。这也说明了 getSelft() 方法的耗时是真实的,不是由于其他原因(例如锁等待)造成。而且有一点需要注意,这里测出来的实际耗时是 250 多毫秒,和 Method Tracing 测量出来的 650 毫秒对比可以看出 Method Tracing 测量结果的误差还是很大的。
接着我们通过搜索「Retrofit:」关键字可以看到,在启动期间会多次调用到 Retrofit 的 loadServiceMethod() 方法:


而且有很多还是在主线程上调用的,所以我们需要想办法把所有 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。可以看出,这次优化是成功的。

结语

可以看到我们整篇文章里的「发现 & 分析问题」这一章占的篇幅是最大的,这其实也反映了我们在做性能优化时的常态,大多数时间都会花在 Profile 上,当遇到优化瓶颈时,更可能需要同时借助不同的工具花大量时间去抽丝剥茧地分析问题。其实上面也讲到了,Profiling 是一门大学问,本次分享只是挑了些比较简单但是关键的方式和工具来讲,实际在进行 Profile 的时候会用到更多方式和工具,甚至自己写工具去辅助定位问题。希望本篇文章能起到抛砖引玉的作用,让大家能了解到做启动优化时的一些常用思路。此外,该次只是我们优化知乎 Android 客户端启动速度的其中一次经历,知乎移动平台团队还在不断地为优化启动速度做努力,也希望未来还能分享更多经历给大家。
由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。
另外,知乎移动平台团队也在招人中,欢迎各位小伙伴的加入,和我们一起做一些酷事情!具体招聘信息在这里 Android 基础架构工程师

关于作者

nekocode,知乎 Android 基础架构工程师,Kotlin 早期使用和推广者。2017 年加入知乎,目前在移动平台团队负责知乎 Android App 性能优化相关工作。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容