Android 冷启动优化的3个小案例

背景

为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机上加起来也不超过50ms的收益,但为了冷启动场景的极致优化,给用户带来更好的体验,任何有收益的优化手段都是值得尝试的。

类预加载

一个类的完整加载流程至少包括 加载、链接、初始化,而类的加载在一个进程中只会触发一次,因此对于冷启动场景,我们可以异步加载原本在启动阶段会在主线程触发类加载过程的类,这样当原流程在主线程访问到该类时就不会触发类加载流程。

Hook ClassLoader 实现

在Android系统中,类的加载都是通过PathClassLoader 实现的,基于类加载的父类委托机制,我们可以通过Hook PathClassLoader 修改其默认的parent 来实现。

首先我们创建一个MonitorClassLoader 继承自PathClassLoader,并在其内部记录类加载耗时

class MonitorClassLoader(
    dexPath: String,
    parent: ClassLoader, private val onlyMainThread: Boolean = false,
) : PathClassLoader(dexPath, parent) {

    val TAG = "MonitorClassLoader"

    override fun loadClass(name: String?, resolve: Boolean): Class<*> {
    val begin = SystemClock.elapsedRealtimeNanos()
    if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){
        return super.loadClass(name, resolve)
    }
    val clazz = super.loadClass(name, resolve)
    val end = SystemClock.elapsedRealtimeNanos()
    val cost = end - begin
    if (cost > 1000_000){
        Log.e(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
    } else {
        Log.d(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
    }
    return  clazz;

}
}

之后,我们可以在Application attach阶段 反射替换 application实例的classLoader 对应的parent指向。

核心代码如下:

    companion object {
        @JvmStatic
        fun hook(application: Application, onlyMainThread: Boolean = false) {
            val pathClassLoader = application.classLoader
            try {
                val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread)
                val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList")
                pathListField.isAccessible = true
                val pathList = pathListField.get(pathClassLoader)
                pathListField.set(monitorClassLoader, pathList)

                val parentField = ClassLoader::class.java.getDeclaredField("parent")
                parentField.isAccessible = true
                parentField.set(pathClassLoader, monitorClassLoader)
            } catch (throwable: Throwable) {
                Log.e("hook", throwable.stackTraceToString())
            }
        }
    }

主要逻辑为

  • 反射获取原始 pathClassLoader 的 pathList
  • 创建MonitorClassLoader,并反射设置 正确的 pathList
  • 反射替换 原始pathClassLoader的 parent指向 MonitorClassLoader实例

这样,我们就获取启动阶段的加载类了

Android 冷启动优化的3个小案例_第1张图片

基于JVMTI 实现

除了通过 Hook ClassLoader的方案实现,我们也可以通过JVMTI 来实现类加载监控。关于JVMTI 可参考之前的文章 https://juejin.cn/post/6942782366993612813。

通过注册ClassPrepare Callback, 可以在每个类Prepare阶段触发回调。

Android 冷启动优化的3个小案例_第2张图片

Android 冷启动优化的3个小案例_第3张图片

当然这种方案,相比 Hook ClassLoader 还是要繁琐很多,不过基于JVMTI 还可以做很多其他更强大的事。

类预加载实现

目前应用通常都是多模块的,因此我们可以设计一个抽象接口,不同的业务模块可以继承该抽象接口,定义不同业务模块需要进行预加载的类。

/**
 * 资源预加载接口
 */
public interface PreloadDemander {
    /**
     * 配置所有需要预加载的类
     * @return
     */
    Class[] getPreloadClasses();
}

之后在启动阶段收集所有的 Demander实例,并触发预加载

/**
 * 类预加载执行器
 */
object ClassPreloadExecutor {


    private val demanders = mutableListOf()

    fun addDemander(classPreloadDemander: PreloadDemander) {
        demanders.add(classPreloadDemander)
    }

    /**
     * this method shouldn't run on main thread
     */
    @WorkerThread fun doPreload() {
        for (demander in localDemanders) {
            val classes = demander.preloadClasses
            classes.forEach {
                val classLoader = ClassPreloadExecutor::class.java.classLoader
                Class.forName(it.name, true, classLoader)
    			}
			}
    }
    
}

收益

第一个版本配置了大概90个类,在终端机型测试数据显示 这些类的加载需要消耗30ms左右的cpu时间,不同类加载的消耗时间差异主要来自于类的复杂度 比如继承体系、字段属性数量等, 以及类初始化阶段的耗时,比如静态成员变量的立即初始化、静态代码块的执行等。

方案优化思考

我们目前的方案 配置的具体类列表来源于手动配置,这种方案的弊端在于,类的列表需要开发维护,在版本快速迭代变更的情况下 维护成本较大, 并且对于一些大型App,存在着非常多的AB实验条件,这也可能导致不同的用户在类加载上是会有区别的。

在前面的小节中,我们介绍了使用自定义的 ClassLoader可以手动收集 启动阶段主线程的类列表,那么 我们是否可以在端上 每次启动时 自动收集加载的类,如果发现这个类不在现有 的名单中 则加入到名单,在下次启动时进行预加载。 当然 具体的策略还需要做详细设计,比如 控制预加载名单的列表大小, 被加入预加载名单的类最低耗时阈值, 淘汰策略等等。

Retrofit ServiceMethod 预解析注入

背景

Retrofit 是目前最常用的网络库框架,其基于注解配置的网络请求方式及Adapter的设计模式大大简化了网络请求的调用方式。 不过其并没有采用类似APT的方式在编译时生成请求代码,而是采用运行时解析的方式。

当我们调用Retrofit.create(final Class service) 函数时,会生成一个该抽象接口的动态代理实例。

Android 冷启动优化的3个小案例_第4张图片

接口的所有函数调用都会被转发到该动态代理对象的invoke函数,最终调用loadServiceMethod(method).invoke 调用。

Android 冷启动优化的3个小案例_第5张图片

在loadServiceMethod函数中,需要解析原函数上的各种元信息,包括函数注解、参数注解、参数类型、返回值类型等信息,并最终生成ServiceMethod 实例,对原接口函数的调用其实最终触发的是 这个生成的ServiceMethod invoke函数的调用。

从源码实现上可以看出,对ServiceMethod的实例做了缓存处理,每个Method 对应一个ServiceMethod。

耗时测试

这里我模拟了一个简单的 Service Method, 并调用archiveStat 观察首次调用及其后续调用的耗时,注意这里的调用还未触发网络请求,其返回的是一个Call对象。

Android 冷启动优化的3个小案例_第6张图片

Android 冷启动优化的3个小案例_第7张图片

从测试结果上看,首次调用需要触发需要消耗1.7ms,而后续的调用 只需要消耗50微妙左右。

Android 冷启动优化的3个小案例_第8张图片

优化方案

由于首次调用接口函数需要触发ServiceMethod实例的生成,这个过程比较耗时,因此优化思路也比较简单,收集启动阶段会调用的 函数,提前生成ServiceMethod实例并写入到缓存中。

serviceMethodCache 的类型本身是ConcurrentHashMap,所以它是并发安全的。

Android 冷启动优化的3个小案例_第9张图片

但是源码中 进行ServiceMethod缓存判断的时候 还是以 serviceMethodCache为Lock Object 进行了加锁,这导致 多线程触发同时首次触发不同Method的调用时,存在锁等待问题

Android 冷启动优化的3个小案例_第10张图片

这里首先需要理解为什么这里需要加锁,其目的也是因为parseAnnotations 是一个好事操作,这里是为了实现类似 putIfAbsent的完全原子性操作。 但实际上这里加锁可以以 对应的Method类型为锁对象,因为本身不同Method 对应的ServiceMethod实例就是不同的。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。

Android 冷启动优化的3个小案例_第11张图片
当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 ServiceMethod.parseAnnotations 是无锁的,毕竟它是一个纯函数。 因此我们可以在异步线程调用parseAnnotations 生成ServiceMethod 实例,之后通过反射 写入 Retrofit实例的 serviceMethodCache 中。这样存在的问题是 不同线程可能同时触发了一个Method的解析注入,但 由于serviceMethodCache 本身就是线程安全的,所以 它只是多做了一次解析,对最终结果并无影响。

ServiceMethod.parseAnnotations是包级私有的,我们可以在当前工程创建一个一样的包,这样就可以直接调用该函数了。 核心实现代码如下

package retrofit2

import android.os.Build
import timber.log.Timber
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier

object RetrofitPreloadUtil {
    private var loadServiceMethod: Method? = null
    var initSuccess: Boolean = false
    //    private var serviceMethodCacheField:Map>?=null
    private var serviceMethodCacheField: Field? = null

    init {
        try {
            serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache")
            serviceMethodCacheField?.isAccessible = true
            if (serviceMethodCacheField == null) {
                for (declaredField in Retrofit::class.java.declaredFields) {
                    if (Map::class.java.isAssignableFrom(declaredField.type)) {
                        declaredField.isAccessible =true
                        serviceMethodCacheField = declaredField
                        break
                    }
                }
            }
            loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java)
            loadServiceMethod?.isAccessible = true
        } catch (e: Exception) {
            initSuccess = false
        }
    }

    /**
     * 预加载 目标service 的 相关函数,并注入到对应retrofit实例中
     */
    fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array) {
        val field = serviceMethodCacheField ?: return
        val map = field.get(retrofit) as MutableMap>

        for (declaredMethod in service.declaredMethods) {
            if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
                && methodNames.contains(declaredMethod.name)) {
                try {
                    val parsedMethod = ServiceMethod.parseAnnotations(retrofit, declaredMethod) as ServiceMethod
                    map[declaredMethod] =parsedMethod
                } catch (e: Exception) {
                    Timber.e(e, "load method $declaredMethod for class $service failed")
                }
            }
        }

    }

    private fun isDefaultMethod(method: Method): Boolean {
        return Build.VERSION.SDK_INT >= 24 && method.isDefault;
    }

}

预加载名单收集

有了优化方案后,还需要收集原本在启动阶段会在主线程进行Retrofit ServiceMethod调用的列表, 这里采取的是字节码插桩的方式,使用的LancetX 框架进行修改。

Android 冷启动优化的3个小案例_第12张图片

目前名单的配置是预先收集好,在配置中心进行配置,运行时根据配置中写的配置 进行预加载。 这里还可以提供其他的配置方案,比如 提供一个注解用于标注该Retrofit函数需要进行预解析,

Android 冷启动优化的3个小案例_第13张图片

之后,在编译期间收集所有需要预加载的Service及函数,生成对应的名单,不过这个方案需要一定开发成本,并且需要去修改业务模块的代码,目前的阶段还处于验证收益阶段,所以暂未实施。

收益

App收集了启动阶段20个左右的Method 进行预加载,预计提升10~20ms。

ARouter

背景

ARouter框架提供了路由注册跳转 及 SPI 能力。为了优化冷启动速度,对于某些服务实例可以在启动阶段进行预加载生成对应的实例对象。

ARouter的注册信息是在预编译阶段(基于APT) 生成的,在编译阶段又通过ASM 生成对应映射关系的注入代码。

Android 冷启动优化的3个小案例_第14张图片

而在运行时以获取Service实例为例,当调用navigation函数获取实例最终会调用到 completion函数。

Android 冷启动优化的3个小案例_第15张图片

当首次调用时,其对应的RouteMeta 实例尚未生成,会继续调用 addRouteGroupDynamic函数进行注册。

Android 冷启动优化的3个小案例_第16张图片

addRouteGroupDynamic 会创建对应预编译阶段生成的服务注册类并调用loadInto函数进行注册。而某些业务模块如何服务注册信息比较多,这里的loadInto就会比较耗时。

Android 冷启动优化的3个小案例_第17张图片

整体来看,对于获取Service实例的流程, completion的整个流程 涉及到 loadInto信息注册、Service实例反射生成、及init函数的调用。 而completion函数是synchronized的,因此无法利用多线程进行注册来缩短启动耗时。

优化方案

这里的优化其实和Retroift Service 的注册机制类似,不同的Service注册时,其对应的元信息类(IRouteGroup)其实是不同的,因此只需要对对应的IRouteGroup加锁即可。
Android 冷启动优化的3个小案例_第18张图片

在completion的后半部分流程中,针对Provider实例生产的流程也需要进行单独加锁,避免多次调用init函数。

Android 冷启动优化的3个小案例_第19张图片

收益

根据线下收集的数据 配置了20+预加载的Service Method, 预期收益 10~20ms (中端机) 。

其他

后续将继续结合自身业务现状以及其他一线大厂分享的样例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面继续尝试优化。

如果通过本文对你有所收获,可以来个点赞、收藏、关注三连,后续将分享更多性能监控与优化相关的文章。

也可以关注个人公众号:编程物语

Android 冷启动优化的3个小案例_第20张图片

本文相关测试代码已分享至github: https://github.com/Knight-ZXW/AppOptimizeFramework

APM性能监控与优化专栏

性能优化专栏历史文章:

文章 地址
Android平台下的cpu利用率优化实现 https://juejin.cn/post/7243240618788388922
抖音消息调度优化启动速度方案实践 https://juejin.cn/post/7217664665090080826
扒一扒抖音是如何做线程优化的 https://juejin.cn/post/7212446354920407096
监控Android Looper Message调度的另一种姿势 https://juejin.cn/post/7139741012456374279
Android 高版本采集系统CPU使用率的方式 https://juejin.cn/post/7135034198158475300
Android 平台下的 Method Trace 实现及应用 https://juejin.cn/post/7107137302043820039
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题 https://juejin.cn/post/7054766647026352158
基于JVMTI 实现性能监控 https://juejin.cn/post/6942782366993612813

你可能感兴趣的:(Android性能监控与优化,android)