背景
为了提高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实例
这样,我们就获取启动阶段的加载类了
基于JVMTI 实现
除了通过 Hook ClassLoader的方案实现,我们也可以通过JVMTI 来实现类加载监控。关于JVMTI 可参考之前的文章 juejin.cn/post/694278…。
通过注册ClassPrepare Callback, 可以在每个类Prepare阶段触发回调。
当然这种方案,相比 Hook ClassLoader 还是要繁琐很多,不过基于JVMTI 还可以做很多其他更强大的事。
类预加载实现
目前应用通常都是多模块的,因此我们可以设计一个抽象接口,不同的业务模块可以继承该抽象接口,定义不同业务模块需要进行预加载的类。
/**
* 资源预加载接口
*/
public interface PreloadDemander {
/**
* 配置所有需要预加载的类
* @return
*/
Class[] getPreloadClasses();
}
之后在启动阶段收集所有的 Demander实例,并触发预加载
/**
* 类预加载执行器
*/
object ClassPreloadExecutor {
private val demanders = mutableListOf<PreloadDemander>()
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) 函数时,会生成一个该抽象接口的动态代理实例。
接口的所有函数调用都会被转发到该动态代理对象的invoke函数,最终调用loadServiceMethod(method).invoke 调用。
在loadServiceMethod函数中,需要解析原函数上的各种元信息,包括函数注解、参数注解、参数类型、返回值类型等信息,并最终生成ServiceMethod 实例,对原接口函数的调用其实最终触发的是 这个生成的ServiceMethod invoke函数的调用。
从源码实现上可以看出,对ServiceMethod的实例做了缓存处理,每个Method 对应一个ServiceMethod。
耗时测试
这里我模拟了一个简单的 Service Method, 并调用archiveStat 观察首次调用及其后续调用的耗时,注意这里的调用还未触发网络请求,其返回的是一个Call对象。
从测试结果上看,首次调用需要触发需要消耗1.7ms,而后续的调用 只需要消耗50微妙左右。
优化方案
由于首次调用接口函数需要触发ServiceMethod实例的生成,这个过程比较耗时,因此优化思路也比较简单,收集启动阶段会调用的 函数,提前生成ServiceMethod实例并写入到缓存中。
serviceMethodCache 的类型本身是ConcurrentHashMap,所以它是并发安全的。
但是源码中 进行ServiceMethod缓存判断的时候 还是以 serviceMethodCache为Lock Object 进行了加锁,这导致多线程触发时,Method的解析只能是串行的。
这里首先需要理解为什么这里需要加锁,其目的也是因为parseAnnotations 是一个耗时操作,这里加锁的目的是为了实现类似putIfAbsent的完全原子性操作。 实际上这里加锁可以优化为对应的service 类作为锁对象,将全局的锁优化为相同的service才进行锁等待。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。 该优化已提交到retrofit,并被合入。github.com/square/retr…
当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 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<Method,ServiceMethod<Any>>?=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<String>) {
val field = serviceMethodCacheField ?: return
val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>>
for (declaredMethod in service.declaredMethods) {
if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
&& methodNames.contains(declaredMethod.name)) {
try {
val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any>
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 框架进行修改。
目前名单的配置是预先收集好,在配置中心进行配置,运行时根据配置中写的配置 进行预加载。 这里还可以提供其他的配置方案,比如 提供一个注解用于标注该Retrofit函数需要进行预解析,
之后,在编译期间收集所有需要预加载的Service及函数,生成对应的名单,不过这个方案需要一定开发成本,并且需要去修改业务模块的代码,目前的阶段还处于验证收益阶段,所以暂未实施。
收益
App收集了启动阶段20个左右的Method 进行预加载,预计提升10~20ms。
ARouter
背景
ARouter框架提供了路由注册跳转 及 SPI 能力。为了优化冷启动速度,对于某些服务实例可以在启动阶段进行预加载生成对应的实例对象。
ARouter的注册信息是在预编译阶段(基于APT) 生成的,在编译阶段又通过ASM 生成对应映射关系的注入代码。
而在运行时以获取Service实例为例,当调用navigation函数获取实例最终会调用到 completion函数。
当首次调用时,其对应的RouteMeta 实例尚未生成,会继续调用 addRouteGroupDynamic函数进行注册。
addRouteGroupDynamic 会创建对应预编译阶段生成的服务注册类并调用loadInto函数进行注册。而某些业务模块如何服务注册信息比较多,这里的loadInto就会比较耗时。
整体来看,对于获取Service实例的流程, completion的整个流程 涉及到 loadInto信息注册、Service实例反射生成、及init函数的调用。 而completion函数是synchronized的,因此无法利用多线程进行注册来缩短启动耗时。
优化方案
这里的优化其实和Retroift Service 的注册机制类似,不同的Service注册时,其对应的元信息类(IRouteGroup)其实是不同的,因此只需要对对应的IRouteGroup加锁即可。 另外由于这部分代码现在可能多线程同时在进行,部分逻辑需要进行二次判断,
在completion的后半部分流程中,针对Provider实例生产的流程也需要进行单独加锁,避免多次调用init函数。
收益
根据线下收集的数据 配置了20+预加载的Service Method, 预期收益 10~20ms (中端机) 。
其他
后续将继续结合自身业务现状以及其他一线大厂分享的样例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面继续尝试优化。
如果通过本文对你有所收获,可以来个点赞、收藏、关注三连,后续将分享更多性能监控与优化相关的文章。
也可以关注个人公众号:编程物语
本文相关测试代码已分享至github: github.com/Knight-ZXW/…
APM性能监控与优化专栏
性能优化专栏历史文章: