记一次安卓APP启动耗时原因排查与优化过程

前言

不得不说,我这个前同事是喜欢整花活的,继前几篇文章中我提到过的滥用协程和错误的进程加锁导致各种问题之后,他又给我留下了一个大坑。

首先需要说明的一点是,由于我这个项目是属于一款智能硬件设备的配套APP,所以一般来说,APP 不会频繁的启动和关闭,基本都是打开之后就不会关闭的那种,所以对启动速度不是太敏感。

所以平时我也没有在意 APP 的启动速度,直至硬件同事总是和我吐槽这个 APP 启动速度也太慢了吧(他测试硬件需要频繁的上电和断电),我才意识到,似乎还真是?

于是,我抽空测试了一下 APP 的冷启动时间,好家伙,不试不知道,一试吓一跳,冷启动时间居然奔着 20 s 去了……

就算 APP 类型决定了对启动速度不敏感,但是这个 20 s 的启动速度是否有点过于夸张了?

于是,某天,我趁有空,好好的看了下它究竟是怎么做到的能够启动 20 s 的。

开始排查

常规问题优化

子线程和延迟初始化

首先,自然是按照常规思路,把启动时能够延迟初始化的延迟初始化,能放到子线程的初始化操作放到子线程。

嗯,前同事从来不会让我失望的,他往 BaseApplication 的 onCreate 中一股脑塞进了一堆初始化代码,压根不管是不是需要启动时立即初始化,最关键的是,他居然把这些初始化代码全放到了主线程去运行。

嗯,不愧是你,其实他估计也意识到了有些初始化是可以放到子线程去初始化的,所以他在某些组件初始化时还是加上了:

appViewModel.viewModelScope.launch {
	// ……
}

但是,看过我前两篇文章的人应该知道,这位前同事似乎对协程的使用不是太熟练。

咱就是说,有没有可能,你虽然启动了协程,但是你没有指定调度器啊,也就是说,你这个还是在主线程运行的啊……

所以优化第一步,我们先分析这堆初始化的东西,按照业务需求,将能延迟初始化的统统推迟到使用前再初始化或者等待首屏渲染完成后再初始化,同时将不需要在主线程进行初始化的逻辑放到子线程中去初始化。

ARouter 初始化

在优化了上述问题后,再次启动,发现速度还是非常慢,还是需要大约 10 s 才能完成冷启动,于是我就想着看看究竟是哪个组件耗费了这么多时间,所以我手动在初始化不同的组件之间打了一个日志:

1

可以看到,耗时最大的初始化在 3 和 4 之间,检查一下这里的代码: initArouter() 发现这里做的是初始化 ARouter 的操作。

检查初始化 ARouter 的操作,发现没有异常情况,并且初始化 ARouter 确实需要在启动时立即初始化,而且也不能放入线程中初始化。

那么,为何初始化 ARouter 会如此耗时呢?通过查阅 ARouter 的文档得知,原来 Arouter 会在调用初始化时扫描 APP 的 dex 文件,并找出对应的类生成相应的路由代码。

当 APP 的代码量较大时,初始化的耗时自然也会非常大。

好在,我们可以通过添加 ARouter 的 Gradle 插件,将初始化时的这步操作放到编译期间进行,而非在启动 APP 初始化时进行:

// 项目级 build.gradle 添加插件
dependencies {
	// ……
    classpath "com.alibaba:arouter-register:1.0.2"
}

// ==============

// 模块级 build.gradle 应用插件
plugins {
    id 'com.alibaba.arouter'
}

如此一来,ARouter 的启动速度就到了可接受范围内:

2

关键问题

在解决了 ARouter 初始化耗时问题后,能感觉到启动速度似乎快了一点,但是还是非常慢,只是此时我通过打日志的方式,并没有再发现有耗时的操作了:

3

可以看到,从 BaseApplication 启动,到渲染出第一帧画面 (即日志中的 “显示按钮”) 只用了 2 s 左右,但是实际体验下来,从点击启动到实际渲染出画面至少用了 10 s 。

这说明在 BaseApplication 的 onCreate 生命周期之前还有其他的耗时操作在运行,但是根据当前项目的实际代码, BaseApplication 的 onCreate 已经是当前项目中重载的最早被调用的一个生命周期了,难道还有哪儿能比它更先被调用?从代码中来看,似乎并不存在啊。

无论如何,感觉总归是不准的,我们还是先通过严谨的方式真正的测试一下冷启动时间是多少,通过 ADB 执行以下命令可以得到 APP 的冷启动时间:

adb shell am start -S -W com.xxx.yyy/com.xxx.yyy.WelcomeActivity

以上命令中 com.xxx.yyy 为应用包名,com.xxx.yyy.WelcomeActivity 为启动的 Activity 。

执行结果:

4

冷启动时间还真是 10 s……

那么怎么才能找到启动时的耗时操作呢?

也很简单,我们可以捕获一下启动时的运行堆栈。

我们可以通过在代码中添加一行代码:

Debug.startMethodTracing("/data/data/com.xxx.yyy/files/TestApp")

用于启动记录堆栈,其中的参数为记录堆栈文件的保存位置。

然后通过:

Debug.stopMethodTracing()

停止记录并保存。

在这里,我们可以把启动记录堆栈的代码放到 BaseApplication 的 attachBaseContext 中,该方法的调用时间早于其他方法(包括 onCreate)因此将启动堆栈记录放到这里相当合适。

然后,在我们处理完所有的初始化逻辑的末尾加上停止记录堆栈即可。

另外,需要注意的是,因为我们的堆栈信息需要写入文件,而在我们这个场景中,显然调用时是没有办法申请到储存权限的,所以我们将堆栈文件写入到了 /data/data/com.xxx.yyy/files/ 文件夹,即 APP 的私有数据目录,读写这个目录不需要任何权限。

此外,这里其实还是会有一个问题,但是我们姑且按下不表,先看看记录到的堆栈信息。

在上述的文件夹中找到生成的堆栈文件,然后在 Android Studio 中的 Profiler -> Load from file 打开这个文件,然后选中 CPU ,排序后查看各个堆栈的耗时。

这里我们只需要查看主线程的耗时(即 “main”):

5

可以看到,其中耗时最长的是这个 Yolov5 的初始化方法,足足耗费了将近 8 s 的时间。

等会儿,这个初始化是从哪儿调用的??

追溯这个方法的调用栈可以发现,是从 installProvider 调用的……

嗯???汗流浃背了兄弟们,打开 AndroidManifest.xml 一看:

<provider
    android:name=".NcnnFileProvider"
    android:authorities="${applicationId}.ncnn.provider"
    android:exported="false"
    android:grantUriPermissions="true" >
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/util_code_provider_paths" />
</provider>

再打开 NcnnFileProvider 一看:

public class NcnnFileProvider extends FileProvider {

    @Override
    public boolean onCreate() {
        //noinspection ConstantConditions
        Yolo5Util.INSTANCE.init(getContext().getApplicationContext());
        return true;
    }
}

啊???

FileProvider 是给你这么用的???

显然,罪魁祸首就出在这里,我是怎么想都想不明白,为什么要在 FileProvider 中初始化 YoloV5 ,一开始我以为是因为 YoloV5 的初始化有特殊的要求,必须在这个地方初始化,但是在这个项目中,YoloV5 的使用场景是用于某个功能模块的对象检测,显然完全不需要启动初始化,完全可以延迟初始化或放入子线程中初始化。

所以我先试着去除 FileProvider 中的初始化 YoloV5 代码,然后把它放到 BaseApplication 中统一初始化的地方,并且延迟初始化,然后测试 APP 的启动和相关功能是否正常,发现运行一切正常,并未出现任何异常,并且此时再次测试冷启动时间:

6

可以看到,此时的冷启动时间在 2s 左右,已经达到了能够接受的水平。

那么,为什么要这么初始化 YoloV5 呢?

俗话说的好,每一行看似离谱的代码,或许都有它存在的理由,轻易不要去动它,否则可能导致项目崩溃。

由于这个代码实在太过于离谱,以至于我开始怀疑或许这么写是有深意的?

毕竟,FileProvider 只是用来作为跨应用文件共享时声明共享目录并提供文件访问适配的一个适配器而已,并不适用于初始化非相关代码逻辑。

而且,这个项目根本没有需要跨应用共享的需求,那么他还特意实现了一个 FileProvider 一定是有他的道理吧。

但是,为何要在 FileProvider 的 onCreate 中初始化 YoloV5 呢?

翻阅 onCreate 的文档就能看到,它明确表示了这个方法会在应用启动时在主线程上为注册了的内容提供器调用此方法,并且强烈不建议执行耗时操作,否则会严重影响应用启动速度:

Implement this to initialize your content provider on startup. This method is called for all registered content providers on the application main thread at application launch time. It must not perform lengthy operations, or application startup will be delayed. You should defer nontrivial initialization (such as opening, upgrading, and scanning databases) until the content provider is used (via query, insert, etc). Deferred initialization keeps application startup fast, avoids unnecessary work if the provider turns out not to be needed, and stops database errors (such as a full disk) from halting application launch.

那么,究竟他为什么要这么做呢?这个问题我可能永远也不会知道了,毕竟这位老哥已经跑路了。

就此结束了吗?非也非也,还记得上面我们提到过记录堆栈时还有一个问题吗?

在说这个问题之前,我们需要理解一个概念,那就是 Application 在一个进程中仅仅只会创建一次,换句话说,我们的 BaseApplication 中的各个生命周期方法,如 onCreate 在 APP 运行时也只会被调用一次。

一般来说,我们的 App 只会有一个进程,也就是说我们可以认为 BaseApplication 的 onCreate 只会被调用一次,所以可以在里面做一些全局的初始化操作。

但是,问题在于,有时候我们引入的一些第三方库,会创建一个新的进程来运行,例如在这个项目中,就会生成三个进程,一个是 APP 本身的进程,另外两个分别是友盟和极光推送的进程。

换句话说,在这个项目中,实际上初始化过程重复进行了三次……

那么这个问题怎么解决呢?其实也不难,首先,我们需要获取到当前运行的进程名称:

fun getCurProcessName(context: Context): String? {
    val pid = Process.myPid()
    val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
    for (appProcess in activityManager
        .runningAppProcesses) {
        if (appProcess.pid == pid) {
            return appProcess.processName
        }
    }
    return null
}

而我们的 APP 的进程名称一般来说就是 applicationId ,即我们的包名,所以我们只需要在初始化之前判断当前进程是不是 APP 自己的进程,只在 APP 的进程初始化即可:

// 避免重复初始化,以下初始化仅在当前主进程才初始化
val curProcessName = getCurProcessName(this@MyApplication)
if (!curProcessName.isNullOrBlank() && curProcessName == "com.xxxx.yyyy") {
    withContext(Dispatchers.IO) {
        init()
    }
}

这下也解答我的一个疑惑,为啥每次 APP 启动时日志都会抛出一堆初始化错误的警告,起先我以为只是因为这些 SDK 自身的问题,并且毕竟只是警告不是错误,而且 APP 一直都能稳定正常运行,秉承着能不动代码就不动代码的原则,我也一直没有去研究过原因。

现在看来,启动时抛出的初始化错误就是因为被重复初始化了导致的。

总结

自此 APP 冷启动优化过程就结束了。

其实这篇文章去年就已经写完了,但是因为觉得篇幅太小,所以一直没有发。

最近因为遇到一些事,突然想到这件事,所以想了想还是改了改发出来了,一则万一恰好能够解决一些人的问题那就是最好的,二则是希望能用这次经历来时刻警醒自己,写代码还是不能一味的简单 CV,而是应该去了解一下基本原理,否则真的很容易写出这种令人啼笑皆非的代码来。