抖音优化时怎样打开,抖音怎么优化版本

启动性能是APP体验的关键组成部分,较长的启动过程会降低用户使用你的APP的兴趣。抖音还通过启动性能下降实验验证了其对业务指标的显着影响。抖音拥有数亿日活跃用户,启动时间增加几百毫秒就会减少数千用户的留存,使得体验优化启动性能成为当务之急。最重要的事情。在之前的文章《抖音Android性能优化系列:开机优化理论与工具》中,我们从原理、方法和工具的角度介绍了抖音的开机性能优化。本文通过一个具体的例子,从实用的角度进行分析和介绍。抖音启动优化的计划和想法。

简介:Launch是指从点击图标到显示页面第一帧的整个过程。启动优化的目的就是减少这个过程的时间。

启动过程比较复杂。

工艺和螺纹尺寸

,这涉及到多个进程间通信以及多个线程之间的切换在耗时方面的原因。

这需要CPU、CPU 调度、IO、锁等待和其他类型的时间。启动过程比较复杂,但最终可以抽象成主线程上的线性过程。因此,优化启动性能就是缩短主线程的线性处理。

接下来继续

主线程直接优化、后台线程间接优化、全局优化

Logic 介绍了我们团队在启动优化实践中遇到的一些典型案例,并简要介绍了一些业界最佳的解决方案。

优化案例分析1.主线程直接优化我们按照主线程生命周期的顺序介绍主线程的优化。

1.1 优化MutilDex 首先我们看一下第一个阶段,Application的attachBaseContext阶段。此阶段通常不会有太多业务代码,并且由于应用程序上下文分配等问题预计不会花费太多时间。但在实际测试过程中,我们发现部分机型安装应用后首次启动速度非常慢,主要是耗时。

多Dex安装。

经过进一步分析,我们确定问题的核心是:

4.x

对于型号,这会影响后续更新后的首次安装和首次启动。

造成这个问题的原因是dex指令格式设计不完美。单个dex文件引用的Java方法总数不能超过65536个。如果方法数量超过65536,就会被分割成多个dexes。一般情况下,Dalvik虚拟机只能运行优化的odex文件。为了加快4.x 设备上的应用程序安装速度,在安装阶段仅优化应用程序的第一个dex。非初始dexes 在您第一次调用MultiDex.install 时进行优化,但此优化需要大量时间,导致4.x 设备上首次启动缓慢。

造成这个问题有几个必要条件。 dex被拆分成多个dex文件,安装过程中只优化第一个dex,启动阶段调用MultiDex.install,需要加载Dalvik虚拟机等。奥德克斯。

显然,前两个条件是不能违反的。 —— 对于抖音来说,仅针对单个dex很难进行优化,并且无法更改系统安装流程。在启动阶段调用

多Dex安装

—— 首先,随着业务的增长,在启动阶段运行代码会变得困难。其次,即使实现了,以后也会保持。

所以我们选择毁灭。”

Dalvik虚拟机需要加载odex

“这个限制绕过了Dalvik的限制,直接加载未优化的dex。该方案的核心是支持加载未优化的dex文件的原生函数Dalvik_dalvik_system_DexFile_openDexFile_bytearray,优化方案为:

首先,从APK中提取所有原始dex文件的字节码。

调用dalvik_dalvik_system_DexFile_openDexFile_bytearray,将之前从APK获取到的DEX字节码一一传入,完成DEX加载,获取有效的DexFile对象。

将所有DexFile添加到APP的PathClassLoader的DexPathList中。

推迟非初始dex 的异步odex 优化。

更多关于优化MutilDex的信息,请参阅我们之前的公众号文章。该解决方案现已开源。更多信息请参见项目的github地址:https://github.com/bytedance/BoostMultiDex。

1.2 优化ContentProvider 下面介绍一下。

内容提供商

在相关的优化中,Android四大主要组件之一的ContentProvider在其生命周期中是独一无二的:——这三个主要组件只有在ContentProvider被调用时才会被实例化并执行。它在启动阶段自动实例化,并执行其关联的生命周期而不被调用。在进程初始化阶段调用Application的attachBaseContext方法后,会运行installContentProviders方法来安装当前进程的所有ContentProvider。

该流程通过for循环一一实例化当前进程的所有ContentProvider,并调用attachInfo和onCreate生命周期方法,最终将这些ContentProvider关联的ContentProviderHolders一次性暴露给AMS进程。

由于ContentProvider在进程初始化阶段具有自动初始化的特性,因此它被用作进程间通信组件,也被一些模块用来进行自动初始化。最典型的就是官方的。

生命周期

组件使用名为ProcessLifecycleOwnerInitializer 的ContentProvider 进行初始化。

LifeCycle初始化只涉及注册Activity的LifecycleCallback,所以不需要在逻辑层面做太多的优化。请注意,如果有许多此类ContentProvider 用于初始化,则ContentProvider 本身的创建和生命周期可能非常耗时。为了解决这个问题:

使用JetPack 启动

将多个已初始化的ContentProvider 聚合为一个以进行优化。

除了这类耗时很少的ContentProvider之外,我们在实际优化过程中还发现了一些耗时较长的ContentProvider。简单介绍一下优化思路。

public class ProcessLifecycleOwnerInitializer extends ContentProvider { @Override public boolean onCreate() { LifecycleDispatcher.init(getContext()); ProcessLifecycleOwner.init(getContext()); 如果你自己的ContentProvider初始化时间较长,可以通过can。重构方式将自动初始化改为按需初始化。一些第三方或官方的ContentProvider无法通过重建直接优化。这里我们以官方的FileProvider为例介绍一下优化思路。

文件提供者使用

FileProvider是Android 7.0中引入的一个用于文件访问控制的组件。在介绍FileProvider之前,我是通过直接传递文件Uri来执行一些跨进程文件操作,比如拍照。引入FileProvider后的整个流程是:

首先,通过继承FileProvider实现一个自定义的FileProvider,在manifest中注册这个Provider,并将文件路径XML文件与其FILE_PROVIDER_PATHS属性关联起来。

要使用它,请使用FileProvider的getUriForFile方法将文件路径转换为Content Uri,然后调用ContentProvider的query和openFile等方法。

调用FileProvider 时,它首先验证文件路径以确定它是否在步骤1 中定义的XML 内。如果文件路径验证通过,则继续执行后续逻辑。

耗时分析

从上面的过程来看,除非您在启动阶段调用FileProvider,否则不会发生冗长的FileProvider 操作。但实际上,从启动轨迹来看,启动阶段相对于FileProvider来说是慢的。除了最著名的onCreate 方法调用之外,特别耗时的是FileProvider 生命周期方法。它还调用FileProvider 方法的AttachInfo 方法。

获取路径策略

getPathStrategy 方法是最耗时的方法。

从实现的角度来看,getPathStrategy方法主要用于解析FileProvider关联的XML文件,并将解析结果赋值给mStrategy变量。进一步分析发现,FileProvider的query、getType、openFile等接口中使用了mStrategy进行文件路径验证,但在启动阶段并没有调用query、getType、openFile等接口,因此FileProviderattachInfo方法内部的getPathStrategy并没有被调用。根本需要。调用query、getType、openFile等接口时,可以执行getPathStrategy逻辑。

优化

FileProvider 是androidx 代码。虽然不能直接更改,但可以通过在编译阶段更改字节码来更改实现。

在运行原始实现之前,检测ContentProvider 的AttachInfo 方法并将参数ProviderInfo 的GrantUriPermissions 设置为false。然后它调用原始实现来捕获异常。调用完成后,通过将ProviderInfo 中的GrantUriPermissions 设置回true 来检查GrantUriPermissions。绕过getPathStrategy 的执行。 (这里没有使用ProviderInfo的导出异常检测来绕过getPathStrategy调用的原因是因为ProviderInfo的导出属性缓存在attachInfo的super方法中。)

public voidattachInfo(@NonNull Context context, @NonNull ProviderInfo info) { super.attachInfo(context, info); //检查安全状况if (info.exported) { throw new SecurityException(\’Provider 不得导出。 2. 查询FileProvider 、 getType 、 openFile 和其他方法。在调用原方法之前先初始化getPathStrategy,初始化完成后再调用原实现。

单个FileProvider 不会花费太多时间,但一些大型应用程序可能由于模块分离而具有多个FileProvider,但即使在这种情况下您仍然可以从FileProvider 优化中受益仍然很大。与FileProvider类似,Google提供的WorkManager也有一个初始化的ContentProvider,可以用类似的方式进行优化。

1.3 重建启动任务并调度任务启动的第三阶段是应用程序的onCreate阶段。此阶段的优化是针对与业务高度相关的各种启动任务的优化。这里我们简单介绍一下优化的一般概念。

抖音启动任务优化的核心思想是:

最大化代码价值

资源利用率最大化。最大化代码价值主要是确定在启动阶段应该执行哪些任务。其核心目的是删除启动阶段不应该执行的任务。最大化资源利用率是指确定启动阶段需要执行的任务。在这些情况下,请使用尽可能多的系统资源来减少执行任务所需的时间。对于单个任务来说,应该减少自身的资源消耗,以优化其内部实现,为其他任务的执行提供更多的资源。对于多个任务,应该通过合理调度来最大化系统资源。

从实现的角度来看,我们主要关注两件事:重建启动任务和调度任务。

开始重建任务

抖音的启动阶段有300多个任务,因为业务非常复杂,早期启动任务控制松散。在这种情况下,您可以通过在启动阶段调度任务来显着提高启动速度。虽然有了一定程度的提升,但仍然比较慢,很难将启动速度提升到较高水平。因此,启动优化有一个非常重要的方向。

减少启动任务。

为了实现这个目标,我们将启动任务分为三类:配置任务、预加载任务和功能任务。配置任务主要用于在运行关联的SDK 之前使其正常工作。预加载任务主要是与以下函数相关的任务,以加快后续函数的执行速度:在进程启动生命周期期间执行。我们针对这三类任务采用了不同的转换方法。

配置任务

:配置任务的最终目标是将其从启动阶段移除。这有两个主要原因。首先,您可以通过从启动任务中删除一些配置任务来提高启动速度。其次,在配置任务执行之前,关联的SDK无法正常使用,影响优化过程中的功能可用性、稳定性和调度。为了实现消除配置任务的目标,配置任务需要主动调用并向SDK 注入各种参数,例如上下文和回调,配置任务已转换为原子性,现在可以通过SPI(服务发现)更改为按需。调用方法—— 对于抖音自己的代码,如果需要使用上下文、回调等参数,或者对于无法修改代码的第三方SDK,则必须请求应用上层获取。后续使用第三方SDK都是通过封装的中间层,调用中间层的相关接口时,中间层会执行SDK的配置任务。这样,您可以将配置任务从启动阶段删除,并在使用时按需运行。

预加载任务

:关于预加载任务,我们对预加载任务进行了标准化,以便降级时可以正常工作。同时,我们去除了过期预加载任务和预加载任务中的冗余逻辑,以增加预加载任务的价值。

功能性任务

:对于功能性启动任务,我们对其进行了分解和精简,删除了启动阶段不必要的逻辑。同时,我们增加了对特性任务调度和降级功能的支持,以便后续调度和降级。

调度任务

业界对任务调度的介绍有很多,这里我们不介绍任务依赖分析、任务投放等,主要介绍抖音实践中可以考虑的一些创新。

基于登陆页面的调度

:除了访问首页之外,抖音启动还有授权登录、推送推广等各种登陆页面。这些不同的登陆页面在完成任务的方式上有相对较大的差异。您可以反映应用程序主线程的消息队列。阶段。从页面中的消息获取要启动的目标页面,并根据登陆页面安排目标任务。

根据设备性能进行调度

:收集您设备的各种性能数据,在后台对您的设备进行评分和标准化,并将标准化结果发送到您的设备。终端根据性能水平调度任务。

基于功能活动的调度

:统计用户对各个功能的使用情况,计算出用户对各个功能的活跃程度数据并发送给终端,终端会根据该功能的活跃程度设置排班。

基于设备智能的调度

:通过设备智能预测用户后续在设备上的操作,预热后续功能。

开始功能降级

:如果某些设备或用户的性能较差,我们可能会在启动阶段降级任务和功能,延迟执行到启动后,甚至根本不运行它们,以确保良好的整体体验。

1.4 优化活动阶段前面的所有阶段都属于应用阶段。现在我们来看一下活动阶段的相关优化。本阶段介绍两个典型的例子:splash和main的合并和反序列化优化。

1.4.1 Splash和Main的组合首先我们看一下SplashActivity和MainActivity的组合。在之前的版本中,抖音的启动器Activity是SplashActivity,主要执行与打开屏幕相关的逻辑,例如广告和活动。通常,启动过程如下:

进入SplashActivity,检查SplashActivity当前是否有屏幕可以显示。

如果有屏幕开口要显示,请等待屏幕开口出现后再跳转到MainActivity。 如果没有屏幕打开,则直接跳转到MainActivity。

在这个过程中,启动必须经过两个Activity的启动。结合这两项活动会产生以下结果:

双方受益

减少了一次Activity的启动过程。

它利用读取屏幕启动信息的时间来执行一些与Activity 强相关的并发任务,例如预加载异步视图。

要合并splash和main,

需要解决的问题

主要有两个。

如何解决合并后由于Activity名称导致外部跳转的问题;

一旦解决了LaunchMode和多个实例的问题。

对于第一个问题,通过activity-alias+targetActivity将SplashActivity指向MainActivity比较容易。现在我们来看第二个问题。

启动模式问题

在Splash和Main集成之前,SplashActivity和MainActivity的LaunchMode分别是Standard和Single Task。在这种情况下,您可以看到只有一个MainActivity 实例,您可以通过离开应用程序并再次启动它来返回。转到上一页。

合并SplashActivity 和MainActivity 后,启动器Activity 成为MainActivity。如果继续使用单任务启动模式,离开二级页面首页,再次点击图标进入,将无法返回二级页面。但是,合并后,您将无法再在MainActivity 的启动模式下使用单个任务,因为将返回主页面。经过一番研究,我最终选择使用singletop作为launchMode。

多实例问题

1. 内部启动多个实例的问题

使用单顶解决了离开并重新进入主页后无法返回上一页的问题,但它引入了以下问题:

MainActivity 的多个实例

问题。抖音中有一些逻辑是和MainActivity生命周期强相关的。如果您有多个MainActivity 实例,这部分逻辑会受到影响。同时,多个MainActivity实现也会产生不必要的资源。我想解决这个问题,因为它会产生开销并且不符合预期。

我们解决这个问题的方法是增加应用程序中MainActivity 每次启动的意图。

FLAG_ACTIVITY_NEW_TASK 和FLAG_ACTIVITY_CLEAR_TOP

实现类似于单任务清除顶部功能的标志。

FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_CLEAR_TOP方案基本上解决了内部启动多个MainActivity实例的问题。但在实际测试过程中,我们发现部分系统即使实现了清顶功能,也会出现多实例启动的问题。该实例仍然存在。

经过分析,我们发现在这部分系统中,即使SplashActivity通过activity-alias+targetActivity指向MainActivity,如果后面启动MainActivity,SplashActivity也会被认为是在AMS端启动的。 MainActivity 再次启动,因为它之前不存在。

解决这个问题的方法是更改启动MainActivity Intent的组件信息,从MainActivity改为MainActivity。

飞溅活动

这样就彻底解决了内部启动MainActivity导致的多实例问题。

更改了对Context startActivity 的调用,以最大程度地减少业务侵入,并防止内部启动在后续迭代中导致MainActivity 出现问题。

仪表和仪表。对于启动MainActivity的调用,添加flags,用intent替换组件信息,然后调用原来的实现。选择这种插桩方式实现是因为抖音的代码结构比较复杂,有多个基类Activity,并且有些基类Activity无法在代码中直接修改。对于没有遇到此问题的公司,您可以通过重写Activity 和Application 基类的startActivity 方法来解决该问题。

2. 外部启动多个实例的问题

上述MainActivity多实例的解决方案是通过改变启动Activity之前启动的Activity的intent来实现的。该方法无法解决在应用程序外部启动MainActivity导致的MainActivity多个实例的问题。这就是为什么,

从外部启动MainActivity

对于由此引起的多实例问题还有其他解决方案吗?

首先我们回到解决MainActivity多实例问题的出发点。需要避免MainActivity 的多个实例的原因是为了防止多个MainActivity 对象同时出现并导致MainActivity 生命周期意外执行。因此,可以通过保证多个MainActivity对象不同时可见来解决MainActivity多个实例的问题。

防止多个MainActivity对象同时出现

首先,我们需要知道MainActivity对象当前是否存在。解决这个问题的思路比较简单:我们可以监控Activity的生命周期,并分别在MainActivity的onCreate和onDestroy中增加或减少MainActivity的实例数量。如果MainActivity实例的数量为0,则假设当前不存在MainActivity对象。

解决了统计MainActivity对象数量的问题后,我们需要创建MainActivity。

同时存在的对象数
永远保持在 1 个以下。要解决这个问题我们需要回顾一下 Activity 的启动流程,启动一个 Activity 首先会经过 AMS,AMS 会再调用到 Activity 所在的进程,在 Activity 所在的进程会经过主线程的 Handler post 到主线程,然后通过 Instrumentation 去创建 Activity 对象,以及执行后续的生命周期。对于外部启动 MainActivity ,我们能够控制的是从 AMS 回到进程之后的部分,这里可以选择以 Instrumentation 的 newActivity 作为入口。
具体来说我们的优化方案如下:
继承 Instrumentation 实现一个自定义的 Instrumentaion 类,以代理转发方式重写里面的所有方法;
反射获取 ActivityThread 中 Instrumentaion 对象,并以其为参数创建一个自定义的 Instrumentaion 对象,通过反射方式用自定义的 Instrumentaion 对象替换 ActivityThread 原有的 Instrumentaion;
在自定义 Instrumentaion 类的 newActivity 方法中,进行判断当前待创建的 Activity 是否为 MainActivity,如果不是 MainActivity 或者当前不存在 MainActivity 对象,则调用原有实现,否则替换其 className 参数将其指向一个空的 Activity,以创建一个空的 Activity;
在这个空的 Activity 的 onCreate 中 finish 掉自己,同时通过一个添加了 FLAG_ACTIVITY_NEW_TASK 和 FLAG_ACTIVITY_CLEAR_TOP flag 的 Intent 去启动一下 SplashActivity。
需要注意的是我们这里 hook Instrumentaion 的实现方案,在高版本的 Android 系统上我们也可以以 AppComponentFactory instantiateActivity 的方式替换。
1.4.2 反序列化优化抖音 Activity 阶段另一个典型的优化是
反序列化的优化
——在抖音使用过程中会在本地序列化一部分数据,在启动过程中需要对这部分数据进行反序列化,这个过程会对抖音的启动速度造成影响。在之前的优化过程中,我们从业务层面对 block 逻辑进行了异步化、快照化等 case by case 的优化,取得了不错的效果,但是这样的方式维护起来比较麻烦,迭代过程也经常出现劣化,因此我们尝试以正面优化反序列化的方式进行优化。
抖音启动阶段的反序列化问题具体来说就是
Gson 数据解析耗时问题
,Gson 是 Google 推出的一个 json 解析库,其具有接入成本低、使用便捷、功能扩展性良好等优点,但是其也有一个比较明显的弱点,那就是对于它在进行某个 Model 的首次解析时会比较耗时,并且随着 Model 复杂程度的增加,其耗时会不断膨胀。
Gson 的首次解析耗时与它的实现方案有关,在 Gson 的数据解析过程中有一个非常重要的角色,那就是
TypeAdapter
,对于每一个待解析的对象的 Class,Gson 会首先为其生成一个 TypeAdapter,然后利用这个 TypeAdapter 进行解析,Gson 默认的解析方案采用的是 ReflectiveTypeAdapterFactory 创建的 TypeAdapter 的,其创建与解析过程中涉及到大量的反射调用,具体流程为:
首先通过反射获取待解析对象的所有 Field,并逐个读取去读取它们的注解,生成一个从 serializeName 到 Filed 映射 map;
解析过程中,通过读取到的 serializeName,到生成的 map 中找到对应的 Filed 信息,然后根据 Filed 的数据类型采用特定类型的方式进行解析,然后通过反射方式进行赋值。
因此对于 Gson 解析耗时优化的核心就是
减少反射
,这里具体介绍一下抖音中使用到的一些优化方案。
自定义 TypeAdapter 优化
通过对 Gson 的源码分析,我们知道 Gson 的解析采用的是
责任链
的形式,如果在 ReflectiveTypeAdapterFactory 之前已经有 TypeAdapterFactory 能够处理某个 Class,那么它是不会执行到 ReflectiveTypeAdapterFactory 的,而 Gson 框架又是支持注入自定义的 TypeAdapterFactory 的,因此我们的一种优化方案就是注入一个自定义的 TypeAdapterFactory 去优化这个解析过程。
这个自定义 TypeAdapterFactory 会在编译期为每个待优化的 Class 生成一个自定义的 TypeAdapter,在这个 TypeAdapter 中会为 Class 的每个字段生成相关的解析代码,以达到避免反射的目的。
生成自定义 TypeAdapter 过程中的字节码处理,我们采用了抖音团队开源的字节码处理框架 Bytex(https://github.com/bytedance/ByteX/blob/master/README_zh.md),具体的实现过程如下:
配置待优化 Class
:在开发阶段,通过注解、配置文件的方式对我们需要优化的 Class 进行加白;
收集待优化 Class 信息
:开始编译后,我们从配置文件中读取通过配置文件配置 Class;在遍历工程中所有的 class 的 traverse 阶段,我们通过 ASM 提供的 ClassVisitor 去读取通过注解配置的 Class。对于所有需要优化的 Class,我们利用 ClassVisitor 的 visitField 方法收集当前 Class 的所有 Filed 信息;
生成自定义 TypeAdapter 和 TypeAdapterFactory
:在 trasform 阶段,我们利用收集到的 Class 和 Field 信息生成自定义的 TypeAdapter 类,同时生成创建这些 TypeAdapter 的自定义 TypeAdapterFactory;
public class GsonOptTypeAdapterFactory extends BaseAdapterFactory { protected BaseAdapter createTypeAdapter(String var1) { switch(var1.hashCode()) { case -1939156288: if (var1.equals(\”xxx/xxx/gsonopt/model/Model1\”)) { return new TypeAdapterForModel1(this.gson); } break; case -1914731121: if (var1.equals(\”xxx/xxx/gsonopt/model/Model2\”)) { return new TypeAdapterForModel2(this.gson); } break; return null; }}public abstract class TypeAdapterForModel1 extends BaseTypeAdapter { protected void setFieldValue(String var1, Object var2, JsonReader var3) { Object var4; switch(var1.hashCode()) { case 110371416: if (var1.equals(\”field1\”)) { var4 = this.gson.getAdapter(String.class).read(var3); ((Model1)var2).field1 = (String)var4; return true; } break; case 1223751172: if (var1.equals(\”filed2\”)) { var4 = this.gson.getAdapter(String.class).read(var3); ((Model1)var2).field2 = (String)var4; return true; } } return false;}}优化 ReflectiveTypeAdapterFactory 实现
上面这种自定义 TypeAdapter 的方式可以对 Gson 的首次解析耗时优化 70%左右,但是这个方案需要在编译期增加解析代码,会增加包体积,具有一定的局限性,为此我们也尝试了对 Gson 框架的实现进行了优化,为了降低接入成本我们通过修改字节码的方式去修改
ReflectiveTypeAdapterFactory
的实现。
原始的 ReflectiveTypeAdapterFactory 在进行实际数据解析之前,会首先去反射 Class 的所有字段信息,再进行解析,而在实际解析过程中并不是所有的字段都是会使用到的,以下面的 Person 类为例,在进行 Person 解析之前,会对 Person、Hometown、Job 这三个类都进行解析,但是实际输入可能只是简单的 name,这种情况下对于 Hometown、Job 的解析就是完全没有必要的,如果 Hometown、Job 类的实现比较复杂,这将导致较多不必要的时间开销。
class Person { @SerializedName(value = \”name\”,alternate = {\”nickname\”}) private String name; private Hometown hometown; private Job job;}class Hometown { private String name; private int code;}class Job { private String company; private int type;}//实际输入{ \”name\”:\”张三\”}针对这类情况我们的解决方案就是“
按需解析
”,以上面的 Person 为例我们在解析 Person 的 Class 结构时,对于基本数据类型的 name 字段会进行正常的解析,对于复杂类型的 hometown 和 job 字段,会去记录它们的 Class 类型,并且返回一个封装的 TypeAdapter;在实际进行数据解析时,如果确实包含 hometown 和 job 节点,我们再去进行 Hometown 与 Job 的 Class 结构解析。这种优化方案对于 Class 结构复杂但是实际数据节点缺失较多情况下效果尤为明显,在抖音的实践过程中某些场景优化幅度接近 80%。
其他优化方案
上面介绍了两种比较典型的优化方案,在抖音的实际优化过程中还尝试了其他的优化方案,在特定的场景也取得了不错的优化效果,大家可以参考:
统一 Gson 对象
:Gson 会对解析过的 Class 进行 TypeAdapter 的缓存,但是这个缓存是 Gson 对象级别的,不同 Gson 对象之间不会进行复用,通过统一 Gson 对象可以实现 TypeAdapter 的复用;
预创建 TypeAdapter
:对于有足够的并发空间场景,我们在异步线程提前创建相关 Class 的 TypeAdapter,后续则可以直接使用预创建的 TypeAdapter 进行数据解析;
使用其他协议
:对于本地数据的序列化与反序列化我们尝试使用了二进制顺序化的存储方式,将反序化耗时减少了 95%。在具体实现上我们采用的是 Android 原生提供的 Parcel 方案,对于跨版本数据不兼容的情况,我们通过版本控制的方式回滚为版本兼容的 Gson 解析方式。
1.5 UI 渲染优化介绍完 Activity 阶段的优化我们再来看一下 UI 渲染阶段的相关优化,这个阶段我们将介绍 View 加载的相关优化。
一般来说创建 View 有两种方式,第一种方式就是直接通过代码构建 View,第二种方式就是 LayoutInflate 去加载 xml 文件,这里将重点介绍
LayoutInflate 加载 xml 的优化
。LayoutInflate 进行 xml 加载包括三个步骤:
将 xml 文件解析到内存中 XmlResourceParser 的 IO 过程;
根据 XmlResourceParser 的 Tag name 获取 Class 的 Java 反射过程;
创建 View 实例,最终生成 View 树。
这 3 个步骤整体上是比较耗时的。在业务层面上,我们可以通过优化 xml 层级、使用 ViewStub 方式进行按需加载等方式进行优化,这些优化可以在一定程度上优化 xml 的加载时长。
这里我们介绍另一种比较通用优化方案——
异步预加载方案
,以下图中 fragment 的 rootview 为例,它是在 UI 渲染的 measure 阶段被 inflate 出来的,而从应用启动到 measure 是有一定的时间 gap 的,我们完全可以利用这段时间在后台线程提前将这些 view 加载到内存,在 measure 阶段再直接从内存中进行读取。
x2c 解决锁的问题
在 androidx 中已经有提供了 AsyncLayoutInflater 用于进行 xml 的异步加载,但是实际使用下来会发现直接使用 AsyncLayoutInflater 很容易出现锁的问题,甚至导致了更多的耗时。
通过分析我们发现,这是因为在 LayoutInflate 中存在着
对象锁
,并且即使通过构建不同的 LayoutInflate 对象绕过这个对象锁,在 AssetManager 层、Native 层仍然会有其他锁。我们的解决方案就是 xml2code
,在编译期为添加了注解的 xml 文件生成创建 View 的代码,然后异步进行 View 的预创建,通过 x2c 方案在解决了多线程锁的问题的同时,也提升了 View 的预创建效率。目前该方案正在打磨中,后续在打磨完毕后将会进行详细介绍。
LayoutParams 的问题
异步 Inflate 除了多线程锁的问题,另一个问题就是
LayoutParams
问题。
LayoutInflater 对 View LayoutParam 处理主要依赖于 root 参数,对于 root 不为 null 的情况,在 inflate 的时候将会为 View 构造一个 root 关联类型的 LayoutParams,并且为其设置 LayoutParams,但是我们在进行异步 Inflate 的时候是拿不到根布局的,如果传入的 root 为 null,那么被 Inflate 的 View 的 LayoutParams 将会为 null,在这个 View 被添加到父布局时会采用默认值,这会导致被 Inflate view 的属性丢失,解决这个问题的办法就是在进行预加载时候 new 一个相应类型的 root,以实现对待 inflate view 属性的正确解析。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) { // 省略其他逻辑 if (root != null) { // Create layout params that match root, if supplied params = root.generateLayoutParams(attrs); if (!attachToRoot) { // Set the layout params for temp if we are not // attaching. (If we are, we use addView, below) root.setLayoutParams(params); } }}public void addView(View child, int index) { LayoutParams params = child.getLayoutParams(); if (params == null) { params = generateDefaultLayoutParams(); if (params == null) { throw new IllegalArgumentException(\”generateDefaultLayoutParams() cannot return null\”); } } addView(child, index, params);}其他问题
除了上面提到的多线程锁的问题和 LayoutParams 的问题,在进行预加载过程中还遇到了一些其他的问题,这些问题具体如下:
inflate 线程优先级的问题
:一般情况下后台线程的优先级会比较低,在进行异步 inflate 时可能会因为 inflate 线程优先级过低导致来不及预加载甚至比不进行预加载更耗时的情况,在这种情况下建议适当提升异步 inflate 线程的优先级。
对 Handler 问题
:存在一些自定义 View 在创建的时候会去创建 handler,这种情况下我们需要去修改创建 Handler 的代码,为其指定主线程的 Looper。
对线程有要求
:典型的就是自定义 View 里使用了动画,动画在 start 时会校验是否是 UI 线程主线程,这种情况我们需要去修改业务代码,将相关逻辑移动到后续真正添加到 View tree 时。
需要使用 Activity context 的场景
:一种解决办法就是在 Activity 启动之后再进行异步预加载,这种方式无需专门处理 View 的 context 问题,但是预加载的并发空间可能会被压缩;另一种方式就是在 Application 阶段利用 Applicaiton 的 context 进行预加载,但是在 add 到 view tree 之前将预加载 View 的 context 替换为 Activity 的 context,以满足 Dialog 显示、LiveData 使用等场景对 Activity context 的需求。
1.6 主线程耗时消息优化以上我们基本介绍了主线程各大生命周期的相关优化,在抖音的实际优化过程中我们发现一些被 post 在这些生命周期之间的主线程耗时消息也会对启动速度造成影响。比如 Application 和 Activity 之间、Activity 和 UI 渲染之间。这些主线程消息会导致我们后续的生命周期被延后执行,影响启动速度,我们需要对它们进行优化。
1.6.1 主线程消息调度对于自己工程中的代码,我们可以比较方便的优化;但是有些是第三方 SDK 内部的逻辑,我们比较难以进行优化;即使是方便优化掉的消息后期的防止劣化成本也非常高。我们尝试从另外一个角度解决这个问题,在优化部分往主线程 post 消息的同时,对主线程消息队列进行调整,让启动相关的消息优先执行。
我们的核心原理是根据 App 启动流程确定核心启动路径,利用消息队列调整来保证冷启动场景涉及相关消息优先调度,进而提高启动速度,具体来说包括如下:
创建自定义的 Printer 通过 Looper 的 setMessageLogging 接口替换原有的 Printer,并对原始的 Printer 进行转发;
在 Application 的 onCreate、MainActivity 的 onResume 中更新下一个待调度的消息,Application 的 onCreate 之后预期的目标消息是 Launch Activity,MainActivity 的 onResume 之后的预期消息则是渲染相关的 doFrame 消息。为了缩小影响范围,在启动完成或者执行了非正常路径后则会对 disable 掉消息调度;
消息调度的具体执行则是在自定义 Printer 的 println 方法中进行的,在 println 方法中遍历主线程消息队列,根据 message.what 和 message.getTarget()判断在消息队列中是否存在目标消息,如果存在则将其移动到头部优先执行;
1.6.2 主线程耗时消息优化通过主线程消息调度,我们可以在一定程度上解决主线程消息对启动速度的影响,但是其也存在一定的局限性:
只能调整已经在消息队列中的消息
,比如在 MainActivity onResme 之后存在一个耗时的主线程消息,而此时 doFrame 的消息还没有进入主线程的消息队列,那我们则需要执行完我们的耗时消息才能执行 doFrame 消息,其仍然会对启动速度有所影响;
治标不治本
,虽然我们将主线程耗时消息从启动阶段移走,但是在启动后仍然会有卡顿存在。
基于这两个原因我们需要对启动阶段主线程的耗时消息进行优化。
一般来说主线程耗时消息大部分是业务强相关的,可以直接通过 trace 工具输出的主线程的堆栈发现问题逻辑并进行针对性的优化,这里主要介绍一个其他产品也可能会遇到的 case 的优化——
WebView 初始化造成的主线程耗时

在我们的优化过程中发现一个主线程较大的耗时,其调用堆栈第一层为
WebViewChromiumAwInit.startChromiumLocked
,是系统 Webview 中的代码,通过分析 WebView 代码发现其是在 WebViewChromiumAwInit 的 ensureChromiumStartedLocked 中 post 到主线程的,在每个进程周期首次使用 Webview 都会执行一次,无论是在主线程还是子线程调用最终都会被 post 到主线程造成耗时,因此我们无法通过修改调用线程解决主线程卡顿的问题;同时由于是系统代码我们也无法通过修改代码实现的方式去进行解决,因此我们只能从业务层从使用的角度尝试是否可以进行优化。
void ensureChromiumStartedLocked(boolean onMainThread) {//省略其他逻辑 // We must post to the UI thread to cover the case that the user has invoked Chromium // startup by using the (thread-safe) CookieManager rather than creating a WebView. PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() { @Override public void run() { synchronized (mLock) { startChromiumLocked(); } } }); while (!mStarted) { try { // Important: wait() releases |mLock| the UI thread can take it :-) mLock.wait(); } catch (InterruptedException e) { } } }问题定位
从业务角度优化我们首先需要找到业务的使用点,虽然我们通过分析代码定位到耗时消息是 Webview 相关的,但是我们仍然无法定位到最终的调用点。要定位最终的调用点,我们需要对
WebView 相关调用流程
有所了解。系统的 WebView 是一个独立的 App,其他应用对于 Webview 的使用都需要经过一个叫 WebViewFactory 的 framework 类,在这个类中首先会通过 Webview 的包名获取到 Webview 应用的 Context,然后通过获取到的 Context 获得 Webview 应用的 Classloader,最后通过 ClassLoader 去加载 Webview 的相关 so,反射加载 Webview 中的 WebViewFactoryProvider 的实现类并进行实例化,后续对于 WebiView 的相关调用都是通过 WebViewFactoryProvider 接口进行的。
通过后续分析发现对于 WebViewFactoryProvider 接口的 getStatics、 getGeolocationPermission、createWebView 等多个方法的首次调用都会触发 WebViewChromiumAwInit 的 ensureChromiumStartedLocked 往主线程 post 一个耗时消息,因此我们的问题就变成了对于
WebViewFactoryProvider 相关方法的调用定位

一种定位办法就是通过
插桩
的方式实现,由于 WebViewFactoryProvider 并不是应用能够直接访问到的类,因此我们对于 WebViewFactoryProvider 的调用必然是通过调用 framework 其他代码实现的,这种情况下我们需要去分析 framework 中所有对于 WebViewFactoryProvider 的调用点,然后把应用中所有对于这些调用点的调用都进行插桩,进行日志输出以进行定位。很显然这种方式成本是比较高的,比较容易出现漏掉的情况。
事实上对于 WebViewFactoryProvider 的情况我们可以采用一个更便捷的方式。在前面的分析中我们知道 WebViewFactoryProvider 是一个接口,我们是通过反射的方式获得其在 Webview 应用中实现的方式获得的,因此我们完全可以通过
动态代理方式
生成一个 WebViewFactoryProvider 对象,去替换 WebViewFactory 中的 WebViewFactoryProvider,在生成的 WebViewFactoryProvider 类的 invoke 方法中通过方法名过滤,对于我们的白名单方法输出其调用栈。通过这样的方式我们最终定位到触发主线程耗时逻辑的是我们的 WebView UA 的获取。
解决方案
确认到我们的耗时是由获取 WebView UA 引起的,我们可以采用
本地缓存
的方式解决:考虑到 WebView UA 记录的是 Webview 的版本等信息,其在绝大部分情况下是不会发生变化的,因此我们完全可以把 Webview UA 缓存在本地,后续直接从本地进行读取,并且在每次应用切到后台时,去获取一次 WebView UA 更新到本地缓存,以避免造成使用过程中的卡顿。
缓存的方案在 Webview 升级等造成 Webview UA 发生变化的情况下可能会出现更新不及时的情况,如果对 WebView 的实时性要求非常高,我们也可以通过调用子进程 ContentProvider 的方式在子进程去获取 WebView UA,这样虽然会影响到子进程的的主线程但是不会影响到我们的前台进程。当然这种方式由于需要启动一个子进程同时需要走完整的 Webview UA 读取,相对本地缓存的方式在读取速度方面是有明显的劣势的,对于一些对读取速度有要求的场景是不太适合的,我们可以根据实际需要采用相应的方案。
2. 后台任务优化前面的案例基本都是主线程相关耗时的优化,事实上除了主线程直接的耗时,
后台任务的耗时
也是会影响到我们的启动速度的,因为它们会抢占我们前台任务的 cpu、io 等资源,导致前台任务的执行时间变长,因此我们在优化前台耗时的同时也需要优化我们的后台任务。一般来说后台任务的优化与具体的业务有很强的关联性,不过我们也可以整理出来一些共性的优化原则

减少后台线程不必要的任务的执行,特别是一些重 CPU、IO 的任务;
对启动阶段线程数进行收敛,防止过多的并发任务抢占主线程资源,同时也可以避免频繁的线程间调度降低并发效率。
除了这些通用的原则,这里也介绍两个抖音中比较典型的后台任务优化的案例。
2.1 进程启动优化我们优化过程中除了需要关注当前进程后台线程的运行情况,也需要关注后台进程的运行情况。目前绝大部分应用都会有 push 功能,为了减少后台耗电、避免因为占用过多内存导致进程被杀,一般情况下会把 push 相关功能放在独立的进程。如果在启动阶段去启动 push 进程,其也会对我们的启动速度造成比较大的影响,我们尽量对 push 进程的启动去进行适当延迟,避免在启动阶段启动。
在线下情况下我们可以通过对 logcat 中“
Start proc
”等关键字进行过滤,去发现是否存在启动阶段启动子进程的情况,以及获得触发子进程启动的组件信息。对于一些复杂的工程或者是三方 sdk,我们即使知道了启动进程的组件,也比较难定位到具体的启动逻辑,我们可以通过对 startService、bindService 等启动Service、Recevier、ContentProvider
组件调用进行插桩,输入调用堆栈的方式,结合“Start proc”中组件的去精准定位我们的触发点。除了在 manifest 中生命的进程可能还存在一些 fork 出 native 进程的情况,这种进程我们可以通过adb shell ps
的方式去进行发现。
2.2 GC 抑制后台任务影响启动速度中还有还有另一个比较典型的 case 就是 GC,触发 GC 后可能会抢占我们的 cpu 资源甚至导致我们的线程被挂起,如果启动过程中存在大量的 GC,那么我们的启动速度将会受到比较大的影响。
解决这个问题的一个方法就是
减少我们启动阶段代码的执行
,减少内存资源的申请与占用,这个方案需要我们去改造我们的代码实现,是解决 gc 影响启动速度的最根本办法。同时我们也可以通过 GC 抑制的通用办法去减少 GC 对启动速度的影响,具体来说就是在启动阶段去抑制部分类型的 GC,以达到减少 GC 的目的。
近期公司的 Client Infrastructure-App Health 团队调研出了 ART 虚拟机上的 GC 抑制方案,在公司的部分产品上尝试对应用的启动速度有不错的优化效果,详细的技术细节在后续打磨完成后将会在“字节跳动终端技术”公众号分享出来。
3. 全局优化前面介绍的案例基本都是针对某个阶段一些比较耗时点的优化,实际上我们还存在一些单次耗时不那么明显,但是频率很高可能会影响到全局的点,比如我们业务中的高频函数、比如我们的类加载、方法执行效率等,这里我们将对抖音在这些方面的优化尝试做一些介绍。
3.1 类加载优化3.1.1 ClassLoader 优化首先我们来看一下抖音在类加载方面的一个优化案例。谈到类加载我们就离不开类加载的双亲委派机制,我们简单回顾一下这种机制下的类加载过程:
首先从已加载类中查找,如果能够找到则直接返回,找不到则调用 parent classloader 的 loadClass 进行查找;
如果 parent clasloader 能找到相关类则直接返回,否则调用 findClass 去进行类加载;
protected Class< > loadClass(String name, boolean resolve) throws ClassNotFoundException{ Class< > c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { } if (c == null) { c = findClass(name); } } return c;}Android 中的 ClassLoader
双亲委派机制中很重要的一个点就是 ClassLoader 的父子关系,我们再来看一下 Android 中 ClassLoader 情况。一般情况下 Android 中有两个 ClassLoader,分别是 BootClassLoader 和 PathClassLoader,BootClassLoaderart 负责加载 android sdk 的类,像我们的 Activity、TextView 等都由 BootClassLoader 加载。PathClassLoader 则负责加载 App 中的类,比如我们的自定义的 Activity、support 包中的 FragmentActivity 这些会被打进 app 中的类则由 PathClassLoader 进行加载。BootClassLoader 是 PathClassLoader 的 parent。
ART 虚拟机对类加载的优化
ART 虚拟机在类加载方面仍然遵循双亲委派的原则,不过在实现上做了一定的优化。一般情况下它的大致流程如下:
首先调用 PathClassLoader 的 findLoadedClass 方法去查找已加载的类中查找,这个方法将会通过 jni 调用到 ClassLinker 的 LookupClass 方法,如果能够找到则直接返回;
在已加载类中找不到的情况下,不会立刻返回到 java 层,其会在 native 层去调用 ClassLinker 的 FindClassInBaseDexClasLoader 进行类查找;
在 FindClassInBaseDexClasLoader 中,首先会去判断当前 ClassLoader 是否为 BootClassLoader,如果为 BootClasLoader 则尝试从当前 ClassLoader 的已加载类中查找,如果能够找到则直接返回,如果找不到则尝试使用当前 ClassLodaer 进行加载,无论能否加载到都返回;
如果当前 ClassLoader 不是 BootClassLoader,则会判断是否为 PathClasLoader,如果不是 PathClassLoader 则直接返回;
如果当前 ClassLoader 为 PathClassLoader,则会去判断当前 PathClassLoader 是否存在 parent,如果存在 parent 则将 parent 传入递归调用 FindClassInBaseDexClasLoader 方法,如果能够找到则直接返回;如果找不到或者当前 PathClassLoader 没有 parent 则直接在 native 层通过 DexFile 直接进行类加载。
可以看到当 PathClassLoader 到 BootClassLoader 的 ClassLoadeer 链路上只有 PathClassLoader 时,java 层的 findLoadedClass 方法调用后,并不止如其字面含义的去已加载的类中查找,其还会在 native 层直接通过 DexFile 去加载类,这种方式相对于回到 java 层调用 findClass 再调回 native 层通过 DexFile 加载可以减少一次不必要的 jni 调用,在运行效率上是更高的,这是 art 虚拟机对类加载效率的一个优化。
抖音中 ClassLoader 模型
在前面我们介绍了 Android 中的类加载相关机制,那么我们究竟在类加载方面做了哪些优化,要解答这个问题我们需要了解一下抖音中的
ClassLoader 模型
。在抖音中为了减少包体积,一些非核心功能我们通过插件化的方式进行了动态下发。在接入插件化框架后抖音中的 ClassLoader 模型如下:
除了原有的 BootClassLoader 和 PathClassLoader 另外引入了 DelegateClassLoader 和 PluginClasLoader;
DelegateClassloader 全局 1 个,它是 PathClassLoader 的 parent,它的 parent 为 BootClassLoader;
PluginClassLoader 每个插件一个,它的 parent 为 BootClassLoader;
DelegateClassLoader 会持有 PluginClassLoader 的引用,PluginClassLoader 则会持有 PathClasloader 的引用;
这种 ClassLoader 模型有一个非常明显的优点,那就是它能够非常方便的同时支持类的隔离、复用以及插件化与组件化的切换;
类的隔离
:如果在宿主和多个插件中存在同名类,在宿主中使用某个类则会首先从宿主 apk 加载,在插件中使用某个类,则会优先从当前插件的 apk 中加载,这种加载机制单 ClassLoader 模型的插件框架是无法支持的;
类的复用
:在宿主中使用某个插件中特有的类时,我们可以在 DelegateClassLoader 中检测到类加载失败,进而使用 PluginClassLoader 去插件中加载,实现宿主复用插件中的类;在插件中使用某个宿主特有的类时,可以在 PluginClassLoader 中检测到类加载失败,进而使用 PathClassLoader 去进行加载,实现插件复用宿主中的类,这种复用机制其他多 ClassLoader 模型的插件框是无法支持的;
插件化与组件化自由切换
:这种 ClassLoader 模型下,我们加载宿主/插件中的类时无需任何显示的 ClassLoader 的指定,我们可以很方便的在直接依赖的组件化方式以及 compileonly+插件化的方式之间切换;
ART 类加载优化机制被破坏
上面介绍了抖音的 ClassLoader 模型的优点,但是其也有一个比较隐蔽的不足,那就是它会破坏 ART 虚拟机对类加载的优化机制。
通过前面的介绍我们了解,当 PathClassLoader 到 BootClassLoader 的 ClassLoader 链路上只有 PathClassLoader 时,则可以在 native 层进行类的加载,以减少一次 jni 的调用。在抖音的 ClassLoader 模型中,PathClassLoader 与 BootClassLoader 之间存在一个 DelegateClassLoader,它的存在会导致“PathClassloader 到 BootClassLoader 的 ClassLoader 链路上只有 PathClassLoader”这一条件被破坏,这导致我们 app 中所有类的首次加载都需要多一次 jni 的调用。一般情况下多一次 jni 的调用不会带来多少消耗,但是对于启动阶段大量类加载的场景,这个影响也是比较大的,会对我们的启动速度造成一定的影响。
非侵入式优化方案:延迟注入
了解插件化对类加载造成负向的原因,优化思路也就比较清晰了——将 DelegateClassLoader 从 PathCLasLoader 和 BootClassLoader 之间移除掉。
通过前面的分析,我们知道引入 DelegateClassLoader 是为了在使用 PathClassLoader loadClass 失败时,可以使用 PluginClassloader 去插件中加载,因此对于不使用插件的场景,DelegateClassloader 是完全没有必要的,我们完全可以在需要用到插件功能时再进行 DelegateClassloader 的注入。
但在实际执行过程中,这种完全进行按需注入会比较困难,因为我们
无法精确掌握插件加载时机
,比如我们的可能通过是通过 compileonly 的方式隐式的依赖、加载插件的类,也可能在 xml 中使用某个插件的 view 的方式触发插件的加载,如果要进行适配会对业务开发带来比较大的侵入。
这里尝试
换一个思路进行优化
——我们虽然没法精确地知道插件加载时机,但却可以知道哪里没有插件加载。比如 Application 阶段是没有插件加载的,那么完全可以等 Applicaiton 阶段执行完成再进行 DelegateClassloader 的注入。事实上在启动过程中,类的加载主要集中在 Application 阶段,通过在 Applicaiton 执行完成再去进行 DelegateClassloader 进行注入,可以极大地减少插件化方案对启动速度的影响,同时也可以避免对业务的侵入。
侵入式优化方案:改造 ClassLoader 模型
上面的方案无需侵入业务改造成本很小,但是它只是优化了 Application 阶段的类加载,后续阶段 ART 对类加载的优化仍然无法享受到,从极致性能的角度我们做了进一步的优化。我们优化的核心思想就是把 DelegateClassloader 从 PathClassLoader 和 BootClassLoader 之间彻底去除掉,通过其他方式来解决宿主加载插件类的问题。通过分析我们可以知道宿主加载插件的类主要有几种方式:
通过 Class.forName 的方式去反射加载插件的类;
通过 compileOnly 隐式依赖插件的类,运行时直接加载插件的类;
启动插件的四大组件时加载插件的组件类;
在 xml 中使用插件的类;
因此我们的问题就变成了在不注入 ClassLoader 的情况下,如何实现宿主加载插件的这四大类。
首先是
Class.forName 的方式
,解决这种方式下插件类加载的问题最直接的解决办法就是调用 Class.forName 时显示的去指定 ClassLoader 为 DelegateClassloader,不过这样的方式对业务开发不够友好,且存在一些三方 sdk 中代码我们无法修改的问题。我们最终的解决办法就是对 Class.forName 调用进行字节码插桩,在类加载失败时再尝试使用 DelegateClassloader 去进行加载。
接下来是
compileOnly 的隐式依赖
,这种方式比较难进行通用处理,因为我们无法找到一个合适的时机去对类加载失败进行兜底。针对这个问题我们的解决办法就是进行业务的改造,将 compileOnly 的隐式依赖调用的方式改成通过 Class.forName 的方式,之所以进行这样的改造主要是基于几下几点考虑:
首先抖音中 compileOnly 隐式依赖调用的方式非常少,改造成本相对可控;
其次 compileOnly 的方式在插件的使用上虽然便捷,但是它在入口上不够收敛,在插件加载管控、问题排查、插件宿主版本间兼容上都存在一定的问题,通过 Class.forName + 接口化的方式可以较好的解决这些问题。
插件四大组件类的加载和 xml 中使用插件类的问题都可以通过同一个方案来解决——将 LoadedApk 中的 ClassLoader 替换为
DelegateClassLoader
,这样无论是四大组件 class 的加载还是 LayoutInflate 加载 xml 时的 class 加载都会使用 DelegateClassLoader 加载,关于这部分的原理大家可以参考 DroidPlugin、Replugin 等相关插件化原理解析,这里就不展开介绍了。
3.1.2 Class verify 优化对于 ClassLoader 的优化,优化的是类加载过程中的 load 阶段,对于类加载的其他阶段也可以进行一定的优化,比较典型的一个案例就是
classverify
的优化,classverify 过程主要是校验 class 是否符合 java 规范,如果不符合规范则会在 verify 阶段抛出 verify 相关的异常。
一般情况下 Android 中的 class 在应用安装或插件加载时就会进行 verify,但是存在一些特定 case,比如 Android10 之后的插件、插件编译采用 extract filter 类型、宿主与插件相互依赖导致静态 verify 失败等情况,则需要在运行时进行 verify。运行 verify 的过程除了会校验 class,还会触发它所依赖 class 的 load,从而造成耗时。
事实上 classverify 主要是针对网络下发的字节码进行校验,对于我们的插件代码其在编译的过程中就会去校验 class 的合法性,而且即使真的出现了非法的 class,最多也是将 verify 阶段抛出的异常转移到 class 使用的时候。
因此我们可以认为,运行时的 classverify 是没有必要的,可以通过
关闭 classverrify
来优化这些类的加载。关于关闭 classverify 目前业界已经有一些比较优秀的方案,比如运行时在内存中定位出 verify_所在内存地址,然后将其设置成跳过 verify 模式以实现跳过 classverify。
// If kNone, verification is disabled. kEnable by default. verifier::VerifyMode verify_; // If true, the runtime may use dex files directly with the interpreter if an oat file is not available/usable. bool allow_dex_file_fallback_; // List of supported cpu abis. std::vector cpu_abilist_; // Specifies target SDK version to allow workarounds for certain API levels. int32_t target_sdk_version_;当然关闭 classverify 的优化方案并不一定对所有的应用都有价值,在进行优化之前可以通过 oatdump 命令输出一下宿主、插件中在运行时进行 classverify 的类信息,对于存在大量类在运行时 verify 的情况可以采用上面介绍的方案进行优化。
oatdump –oat-file=xxx.odex > dump.txtcat dump.txt | grep -i \”verified at runtime\” |wc -l3.2 其他全局优化在全局优化方面,还有一些其他比较通用的优化方案,这里也进行一些简单的介绍,以供大家参考:
高频方法优化
:对服务发现(spi)、实验开关读取等高频调用方法进行优化,将原本在运行时的注解读取、反射等操作前置到编译阶段,通过编译阶段直接生成目标代码替换原有调用实现执行速度的提升;
IO 优化
:通过减少启动阶段不必要的 IO、对关键链路上的 IO 进行预读以及其他通用的 IO 优化方案提升 IO 效率;
binder 优化
:对启动阶段一些会多次调用的 binder 进行结果缓存以减少 IPC 的次数,比如我们应用自身的 packageinfo 的获取、网络状态获取等;
锁优化
:通过去除不必要的锁、降低锁粒度、减少持锁时间以及其他通用的方案减少锁问题对启动的影响
字节码执行优化
:通过方法调用内联的方式,减少一些不必要的字节码的执行,目前已经以插件的方式集成在抖音的字节码开源框架 Bytex 中(详见 Bytex 介绍);
预加载优化
:充分利用系统的并发能力,通过用户画像、端智能预测等方式在异步线程对各类资源进行精准精准预加载,以达到消除或者减少关键节点耗时的目的,可供预加载的内容包括 sp、resource、view、class 等;
线程调度优化
:通过任务的动态优先级调整以及在不同 CPU 核心上的负载均衡等手段,降低 Sleeping 状态和 Uninterrupible Sleeping 耗时,在不提高 CPU 频率的情况下,提高 CPU 时间片的利用率(由 Client Infrastructure-App Health 团队提供解决方案);
厂商合作
:与厂商合作通过 CPU 绑核、提频等方式获取到更多的系统资源,以达到提升启动速度的目的;
总结与展望至此,我们已经对抖音启动优化中比较典型、通用的案例进行了介绍,希望这些案列能够为大家的启动优化提供一些参考。回顾抖音以往的所有启动相关的优化,通用的优化只是占了其中一小部分,更多的是与业务相关的优化,这部分优化有着极强的业务关联性,其他业务无法直接进行迁移,针对这部分优化我们总结了一些优化的方法论,具体可以参见抖音 Android 性能优化系列:启动优化之理论和工具篇。最后从实践的角度对我们的启动优化做一些总结与展望, 希望能对大家有所帮助。
持续迭代启动优化是一个需要持续迭代与打磨的的过程,一般来说最开始的是“快、猛”的
快速优化阶段
,这个阶段优化空间会比较大,优化粒度会相对较粗,在投入不多的人力情况下就能取得不错的收益;第二个阶段难点攻坚阶段
,这个阶段需要的投入相对第一个阶段要大,最终的提升效果也取决于难点的攻坚情况;第三个阶段是防劣化与持续的精细化优化
过程,这个过程是最为持久的一个过程,对于快速迭代的产品,这个阶段也非常重要,是我们通向极致化启动性能的必经之路。
场景泛化启动优化也需要进行一定扩展与泛化的,一般情况下我们关注的是用户点击 icon 到首页首帧的时间,但是随着商业化开屏、push 点击等场景的增加,我们也需要扩展到这些场景。另外很多时候虽然页面的首帧出来了,但用户还是无法看到想看的内容,因为用户关注的可能不是
页面首帧
的时间,而是有效内容
加载出来的时间。以抖音为例,我们在关注启动速度的同时,也会去关注视频首帧的时间,从 AB 实验来看这个指标甚至比启动速度更重要,其他产品也可以结合自己的业务,去定义一些对应的指标,验证对用户体验的影响,决定是否需要进行优化。
全局意识一般来说,我们以启动速度来衡量启动性能。为了提升启动速度,我们可能会把一些原本在启动阶段执行的任务进行延后或者按需,这种方式能够有效优化启动速度,但同时也可能损害后续的使用体验。比如,如果将某个启动阶段的后台任务延后到后续使用时,如果首次使用是在主线程,则可能会造成使用卡顿。因此,我们在关注启动性能的同时,也需要关注其他可能影响的指标。
性能上我们需要有一个能
体现全局性能的宏观指标
,以防止局部最优效应。业务上我们需要建立启动性能与业务的关系,具体来说就是在优化过程中尽可能对一些较大的启动优化支持 AB 能力,这样做一方面可以实现对优化的定性分析,防止一些有局部性能收益但是对全局体验有损害的负优化被带到线上去;另一方面也可以利用实验的定性分析能力,量化各个优化对业务的效果,从而为后续的优化方向提供指导。同时也可以对一些可能造成稳定性或者功能异常的改动,提供回滚能力以及时止损。
目前,字节跳动旗下的企业级技术服务平台火山引擎已经对外开放了 AB 实验能力,感兴趣的同学可以到火山引擎官网进行了解。
全覆盖与精细化运营未来抖音的启动优化有两个大的目标,第一个目标是
启动优化的覆盖率做到最大化
:架构方面我们希望启动阶段的代码能够做到依赖简单、清晰,模块粒度尽可能的小,后续优化与迭代成本低;体验方面在做好性能优化的同时做好交互、内容质量等功能优化,提升功能的触达效率与品质;场景方面做到冷启动、温启动、热启动等各类启动方式、落地页的全面覆盖;优化方向上覆盖 CPU、IO、内存、锁、UI 渲染等各类优化方向。第二个目标是实现启动优化精细化运营
,做到千人千时千面,对于不同的用户、不同的设备性能与状况、不同的启动场景等采用不同的启动策略,实现体验优化的最大化。
加入我们抖音 Android 基础技术团队
是一个深度追求极致的团队,我们专注于性能、架构、包大小、稳定性、基础库、编译构建等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、杭州、深圳都有大量人才需要,欢迎有志之士与我们共同建设亿级用户的 APP!
对抖音工作机会感兴趣的同学,可以进入字节跳动招聘官网查询「抖音基础技术 Android」相关职位,也可以邮件联系:fengda.1@bytedance.com 咨询相关信息或者直接发送简历内推!

本文和图片来自网络,不代表火豚游戏立场,如若侵权请联系我们删除:https://www.huotun.com/game/665878.html

(0)
上一篇 2024年6月4日
下一篇 2024年6月4日

相关推荐

  • 和平精英开车不动? 和平精英镜头拖不动?

    和平精英开车不动? 开车会动的,除非你是卡在石头上或者哪个树枝上,要么就是没油了你自己没看到,可以在设置那里,设置你自己的个性开车按键模式,比如方向指针式的还是旋转式的,有三个模式可供选择。现在玩这个还可以是购买车子型号,上车后还有车型可以改动。 和平精英镜头拖不动? 如果不是操作设置问题,就是手机太卡了 和平精英怎么让攻击键不动? 可以在设置中更改操作面版…

    游戏快讯 46分钟前
  • 和平精英等级? 怎样解锁和平精英?

    和平精英等级? 和平精英段位等级顺序:从低至高:热血青铜→不屈白银→英勇黄金→坚韧铂金→不朽星钻→荣耀皇冠→超级王牌→无敌战神。 一、 热血青铜 1、热血青铜Ⅴ1000分 2、热血青铜Ⅳ1300分 3、热血青铜Ⅲ1400分 4、热血青铜Ⅱ1500分 5、热血青铜Ⅰ1600分 二、不屈白银 1、不屈白银Ⅴ1700分 2、不屈白银Ⅳ1800分 3、不屈白银Ⅲ19…

    游戏快讯 2小时前
  • 和平精英怎么反馈? 怎么反馈和平精英官方?

    和平精英怎么反馈? 在和平精英的游戏大厅中找到并点击右下角的设置功能,打开游戏设置界面后可在底部看到客服; 和平精英bug反馈在哪-bug反馈位置   2.点击联系客,在客服界面里会出现很多的常见问题,联系客服即可反馈bug。 怎么反馈和平精英官方? 答:反馈和平精英官方的方法步骤如下:1.官方客服投诉渠道 登录官网:登录和平精英官网,在官网首页可以找到客服…

    游戏快讯 3小时前
  • 和平精英黄金段位皮肤怎么领?

    和平精英黄金段位皮肤怎么领? 要领取和平精英黄金段位皮肤,你需要完成以下步骤: 首先,确保你已经达到了黄金段位。 然后,进入游戏的商城界面,找到黄金段位皮肤的选项。 点击进入后,你会看到领取的按钮。 点击领取后,系统会自动将黄金段位皮肤发送到你的游戏账号中。请注意,有时候需要等待一段时间才能收到皮肤。如果你遇到任何问题,可以联系游戏客服寻求帮助。祝你游戏愉快…

    游戏快讯 5小时前
  • 和平精英榴莲解说键位设置?

    和平精英榴莲解说键位设置? 我想我们打开和平精英,然后点击榴莲解说,然后点击键盘设置,找到相应的键位,就可以进行设置。 和平精英辛巴达解说谁是内鬼? 这是上线的内鬼模式内鬼可以扮演坏人,打死特种兵而特种兵要完成所有任务,获得胜利 和平精英夜间模式? 方法如下:首先我们进入游戏,点击左边的地图,接着在页面左边点击选择经典模式。 最后在这个地图下进入游戏就会机会…

    游戏快讯 6小时前