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

news2024/9/21 14:41:11

背景

为了提高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 可参考之前的文章 https://juejin.cn/post/6942782366993612813。

通过注册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的完全原子性操作。 但实际上这里加锁可以以 对应的Method类型为锁对象,因为本身不同Method 对应的ServiceMethod实例就是不同的。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。


当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 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等方面继续尝试优化。

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

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

image.png

本文相关测试代码已分享至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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/697956.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

docker创建mysql容器

步骤 引言执行创建命令设置远程访问使用Navicat连接 引言 只要有开发&#xff0c;就要用数据库&#xff0c;mysql是最简单&#xff0c;也是非常好用的数据库&#xff0c;也要学会用docker创建mysql数据库。 执行创建命令 docker run --name mysql\--restartalways\-p 13306:…

每天一点Python——day43

#第四十三天字典的视图操作&#xff1a; ①keys()获取字典中所有的键 ②values()获取字典中所有的值 ③items()获取字典中所有的键值对#如图&#xff1a; #例&#xff1a;获取所有的键 a{哥哥:18,妹妹:16,姐姐:17}#字典创立 ba.keys()#获取后我们存在变量b中&#xff0c;右边的…

【Mysql】索引数据结构深入研究(二)

前言 在这里需要明确的一点是&#xff0c;数据库的引擎InnoDB或者是MyISAM引擎它们是形容数据表的&#xff0c;不是形容数据库的。 另外&#xff1a;文章中提到的索引的数据结构暂且都默认使用BTree InnoDB引擎 InnoDB的索引数据文件有两个&#xff0c;tableName.frm和table…

Redis7【⑦ Redis哨兵(sentinel)】

Redis哨兵 Redis Sentinel&#xff08;哨兵&#xff09;是 Redis 的高可用性解决方案之一&#xff0c;它可以用于监控和管理 Redis 主从复制集群&#xff0c;并在主节点发生故障时自动将从节点升级为新的主节点&#xff0c;从而保证系统的高可用性和可靠性。 Redis Sentinel …

v8-tc39-ecma262:数组push执行了什么?

v8开发文档介绍 v8新特性 tc39-ecma262-push函数执行步骤 上图步骤&#xff0c;解释如下&#xff1a; 如果是对象&#xff0c;则当作对象调用设置该值如果是类数组&#xff0c;则执行类数组调用设置该值如果参数有多个参数&#xff0c;则&#xff1a;如果&#xff0c;参数长度…

golang,OpenGL,计算机图形学(二)

代码仓库 https://github.com/phprao/go-graphic 变换 矩阵操作与向量操作&#xff1a;https://learnopengl-cn.github.io/01%20Getting%20started/07%20Transformations/ 在OpenGL中&#xff0c;由于某些原因我们通常使用44的变换矩阵&#xff0c;而其中最重要的原因就是大…

uniapp的表单校验方式整理

uniapp的表单校验方式整理 这里我使用的模板为&#xff1a; 第一种&#xff1a; uniapp本身自带表单校验的js文件&#xff0c;代码写的很简洁&#xff0c;也是比较全面的 只要按照规则校验即可&#xff0c;下面是对应的校验代码&#xff1a; /** 数据验证&#xff08;表…

Jenkins 2.4 创建子节点

在 Dashboard > 系统管理 > 节点列表 页面&#xff0c;选择 New Node 按钮&#xff0c;新建节点 windows_10&#xff0c;节点类型选择 固定节点&#xff0c;点击 Create 创建&#xff1a; 将 远程工作目录 设置子节点电脑上的执行工作目录&#xff0c;例如在D盘创建一个 …

Django框架-3

使用admin后台管理数据 创建模型类 模型类&#xff1a;应用中的models.py文件中 from django.db import models# Create your models here. # 模型类必须要继承models.Model class doogs(models.Model):# 属性create_time models.DateTimeField(auto_now_addTrue, verbose_…

新闻丨INDEMIND荣获2023年北京市朝阳区创新型中小型企业认定

近日&#xff0c;北京市朝阳区科学技术和信息化局公布了“朝阳区2023年度第五批创新型中小企业名单”&#xff0c;INDEMIND凭借专业研发和创新能力等优势&#xff0c;成功入选。 创新型中小企业名单依据工业和信息化部2022年6月发布《优质中小企业梯度培育管理暂行办法》&#…

Golang每日一练(leetDay0111) 摆动排序II\I Wiggle Sort

目录 324. 摆动排序 II Wiggle Sort ii &#x1f31f;&#x1f31f; 280. 摆动排序 I Wiggle Sort i &#x1f31f;&#x1f31f; &#x1f31f; 每日一练刷题专栏 &#x1f31f; Rust每日一练 专栏 Golang每日一练 专栏 Python每日一练 专栏 C/C每日一练 专栏 Java每…

威胁建模之绘制数据流图

0x00 前言 1、什么是威胁建模&#xff1a; 以结构化的方式思考、记录并讨论系统存在的安全威胁&#xff0c;并针对这些威胁制定相应的消减措施。 2、为什么要威胁建模&#xff1a; &#xff08;1&#xff09;在设计阶段开展威胁建模&#xff0c;一方面可以更全面的发现系统存…

数据结构--静态链表

数据结构–静态链表 单链表 VS 静态链表 单链表:各个结点在内存中星罗棋布、散落天涯。 静态链表:分配一整片连续的内存空间&#xff0c;各个结点集中安置。 代码定义 代码一&#xff1a; #define MaxSize 10 //静态链表的最大长度 typedef struct //静态链表结构类型的定…

无限容量分布式文件存储解决方案

常见分布式文件系统 常见分布式文件系统比较 常见的分布式文件系统有GFS、HDFS 、Ceph 、GridFS 、TFS、FastDFS等。各自适用于不同的领域。 类 Google FS 都支持文件冗余备份&#xff0c;例如 Google FS、TFS 的备份数是 3。一个文件存储到哪几个存储结点&#xff0c;通常采…

《计算机系统与网络安全》第一章 计算机系统与网络安全概述

&#x1f337;&#x1f341; 博主 libin9iOak带您 Go to New World.✨&#x1f341; &#x1f984; 个人主页——libin9iOak的博客&#x1f390; &#x1f433; 《面试题大全》 文章图文并茂&#x1f995;生动形象&#x1f996;简单易学&#xff01;欢迎大家来踩踩~&#x1f33…

java的注解方式和xml方式这两种方式对数据库进行操作详解

首先需要引入mybatisplus包 <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.1.1</version> </dependency>第一种注解方式&#xff1a;参数是通过#{}来接收的 p…

腾讯云服务-云点播:删除腾讯云点播录制的视频文件(保存在腾讯云是需要收费的)

文档地址&#xff1a;登录 - 腾讯云https://console.cloud.tencent.com/api/explorer?Productvod&Version2018-07-17&ActionDeleteMedia 使用python后端删除&#xff1a; import json from tencentcloud.common import credential from tencentcloud.common.profile…

kafka入门,数据有序、数据乱序(十)

数据有序 数据乱序 max.in.flight.requests.per.connection指定了生产者在接收到服务器相应之前可以发送多个消息。 kafka在1.x版本之前保证单分区有序&#xff0c;条件如下 max.in.flight.requests.per.connection1 2) kafka在1.x及以后版本保证数据单区间分区有序&#xff0…

vue 函数式(编程式) render (functional:true)

文章目录 一、文档二、区别三、使用h函数的参数解释&#xff1a;参数一&#xff1a;使用导入的组件名 参数二&#xff1a;绑定css绑定事件往组件里面传递参数动态绑定值props动态绑定值datafunctional:true到底是做什么的&#xff1f;动态绑定props 非functional:true版本 参数…

JSONUtil.toJsonStr 时间变成了时间戳

问题描述 我的接口是以Date来接收日期的&#xff0c;然后我在拿到这个对象参数后&#xff0c;通过hutool当中的JSONUtil.toJsonStr将其序列化成json字符串&#xff0c;然后存储到数据库。然后存储到数据库当中发现这个字段是时间戳。 DateTimeFormat和JsonFormat 前者是控制 请…