写点什么

百度 App 启动性能优化实践篇

作者:百度Geek说
  • 2023-07-11
    上海
  • 本文字数:8258 字

    阅读完需:约 27 分钟

百度 App 启动性能优化实践篇

一、前言

启动性能是百度 App 最核心指标之一。用户希望应用能够及时响应并快速加载,启动时间过长的应用不能满足这个期望,并且可能会令用户失望,这种糟糕的体验可能会导致用户在应用商店针对您的应用给出很低的评分,甚至完全抛弃您的应用。启动性能的优化成为了体验优化中最关键的一环,百度 App 在此方向持续投入,不断优化,提升用户体验。


启动性能优化分为概述篇、工具篇、优化篇和防劣化篇,本篇文章主要阐述性能优化相关内容,前期已发表文章可以参阅:


百度App 低端机优化-启动性能优化(概述篇)


百度App Android启动性能优化-工具篇


百度App性能优化工具篇-Thor原理及实践

二、优化理论

对启动性能优化的认知,决定了启动性能优化的方向与思路,进而会决定优化的效果。较多开发者对启动过程的认知,来源于 Google 开发者文档中有段对启动过程的描述:



1、创建应用对象;


2、启动主线程;


3、创建主 activity;


4、扩充视图;


5、布局屏幕;


6、执行初始绘制。


一旦应用进程完成第一次绘制,系统进程就会换掉当前显示的后台窗口,替换为主 activity。此时,用户可以开始使用应用。


上面主要介绍了应用在启动过程中的各个阶段,但其实只是大致概括,其实启动方式会比较多,极有可能在不同的启动路径执行的逻辑有差异,因此全路径的认知在优化过程中起到了非常重要的作用,如下图所示:



在启动过程中,点击桌面图标是主流冷启动方式,而 Push 调起,浏览器调起等端外转化也是比较常见的调起方式,各种启动方式的启动过程基本可拆解为:进程创建、框架加载、首页渲染、预加载四个环节。而启动性能优化主要面对的不只是点击桌面图标这一种路径,更多的需要启动全路径的优化,达到体验的极致优化。


启动过程也需要结合系统层面来理解,进而挖掘优化点,探索优化的极限。启动过程是非常复杂的过程,需要较多系统级进程配合才能完成页面的展现,供用户正常使用,下图展示的点击 icon 的启动过程:



启动过程大概可概括为:


1、Launcher 通知 AMS 启动 APP 的主 Activity;


2、ActivityManagerService(以下简称 AMS)记录要启动的 Activity 信息,并且通知 Launcher 进入 pause 状态;


3、Launcher 进入 pause 状态后,通知 AMS 已经 paused 了,开始启动 App;


4、App 未开启过,AMS 启动新的进程,并且在新进程中创建 ActivityThread 对象,执行其中的 main 函数方法;


5、App 主线程启动完毕后通知 AMS,并传入 applicationThread 以便通讯;


6、AMS 通知 App 绑定 Application 并启动 MainActivity;


7、启动 MainActivitiy,并且创建和关联 Context,最后调用 onCreate 方法,最终完成页面绘制和上屏。


主要进程的功能主要是:


1、Launcher 进程:为手机桌面进程,负责接收用户的点击事件,并将事件通知到 AMS


2、SystemServer 进程:负责应用的启动流程调度、进程的创建和管理、窗口的创建和管理(StartingWindow 和 AppWindow) 等,比较核心的服务有 AMS 和 WMS(WindowManagerService);


3、Zygote 进程:通过 fork 创建应用程序进程,Zygote 进程在初始化时就会会创建虚拟机,同时把需要的系统类库和资源文件加载到内存中。而 Zygote 在 fork 出子进程后,这个子进程也会得到一个已经加载好基础资源的虚拟机,从而加速应用进程的启动;


4、SurfaceFlinger 进程:主要和应用的渲染相关,如 Vsync 信号处理、窗口的合成处理、帧缓冲区管理等。


有了全局的认知和视野后,我们就可以站在更高的角度,更加深入的思考与分析性能瓶颈,如手机负载合理性、系统资源使用等等,更加全面的考虑启动性能的优化方式,达到对启动性能的极致优化。

三、优化落地

百度 App 的启动性能的优化,大致分为三部分,常规优化、基础机制优化和底层技术优化三部分。

3.1 常规优化

如果是业务发展初期,业务的快速迭代较快,此时的优化会相对简单,极有可能会出现短时间内,启动速度提升秒级别的优化效果。启动性能的优化,也是基于对冷启动的理解以及启动任务的梳理,达到快速优化的目标。可通过性能工具,如前文提过的 Trace 工具、Thor Hook 工具,发现耗时较为突出问题,评估是否可通过延迟、异步、删除等方式优化,依据投入产出情况评估工作优先级,达到快速优化启动性能的目的。



随着启动场景承载业务逐步庞大,手百逐渐成长为承载业务最多,体量巨大的航母级移动端应用,庞大业务的预加载不可能完全去除或者通过异步来解决,此部分是启动性能优化中面临的较大难题,需要有机制批量解决业务预加载问题,因此基础机制中的调度机制逐渐衍生出来,处理启动过程不同业务的预加载需求。

3.2 基础机制优化

基础机制优化主要分为调度机制优化、基础组件性能优化。

3.2.1 任务调度优化

业务多,预加载任务的执行诉求各有不同,平衡启动性能和业务预加载,百度 App 需建设任务调度框架,业务方通过接入可快速优化性能问题。



任务调度整体建设情况如下,目前还处在快速迭代中:



智能调度可以根据任务输入和信息输入,做出不同的调度反应,如:


1、个性化调度策略:识别出业务预加载任务 ID 和用户行为习惯相匹配,则会将任务提前做初始化,任务优先级会做提升,与此同时,在用户进入业务对应页面时,非业务相关任务需做避让;


2、分级体验策略:识别出在指定的机型配置中有对应的调度策略,则会执行对应的调度能力,如立即调度、延迟调度、不调度等,主要用于体验降级;


3、精细化调度策略:在不同的场景精细化调度业务预加载任务,如在闪屏场景,会识别闪屏相关业务信息并做预加载,在端外调起场景,会识别落地页所属业务信息并做对应预加载;


4、分优先级延迟调度:有较大量的任务初始化会依赖于延迟调度,需保障有序控制业务初始化,因此在延迟调度基础上添加优先级概念,可以在延迟调度中也分优先级调度,让更高优先级任务可以更快的执行;


5、首页 UI 并行渲染调度:主要服务于冷启动阶段商业闪屏业务,商业闪屏是否需要展现、展现哪个物料均是冷启动阶段的实时网络请求决定的,需在冷启动阶段尽量提高商业网络请求的可用时间,进而提高网络请求成功率,百度 App 目前可以实现,首页可以先初始化,但不做上屏,待首页渲染业务提交的时候再检查商业闪屏是否展现,做到了提供给商业网络请求更多可用时间的同时不阻塞首页初始化,通过此项技术大幅提升商业网络请求的成功率,带来了商业收入的提升。


由于调度器框架中涉及细节非常多,在这里只简单介绍其中一种调度器的设计:分级体验调度器。



主要分为 3 个模块,机型评分、分级配置和分级调度机制,达到不同配置的手机上的最优体验。


  • 机型评分:

  • 通过设备信息计算评分信息,称为静态评分;

  • 通过性能指标计算评分信息,称为动态评分;

  • 依据模型训练评分信息,得出最终机型评分;

  • 分级配置:

  • 云端配置表:提供各业务级别按设备评分条件下的分级配置表,该表支持动态更新,增量更新,更新后端上及时生效。

  • 本地预置表:本地会预置一份配置表,供首次安装时使用;

  • 依据机型评分信息和分级配置信息得出控制策略;

  • 分级调度:

  • 业务方根据机型评分控制不同的业务逻辑,达到高端机全部功能最优体验,中端机部分功能良好体验,低端机核心功能流畅体验,如首页点赞动画在高端机上选择开启策略,中端机上选择延迟加载策略,低端机上选择关闭状态;

3.2.2 KV 存储优化

SharedPreferences 是 Android 平台轻量级的存储类,用来保存应用程序的配置信息,其本质是以“键-值”对的方式保存数据的 xml 文件,其文件保存在/data/data/pkg/shared_prefs 目录下,优点是以键值对的方式进行存储,使用方便,易于理解;但 SharedPreferences 的缺点很明显,读写性能慢,IO 读写使用 xml 数据格式,全量更新效率低;多进程支持差,存储数据易丢失;创建线程多,导致性能差。


读取性能差


每加载一个 SP 文件均会创建子线程,源码如下:


private final Object mLock = new Object();private boolean mLoaded = false;private void startLoadFromDisk() {    synchronized (mLock) {        mLoaded = false;    }    new Thread("SharedPreferencesImpl-load") {        public void run() {            loadFromDisk();        }    }.start();}
复制代码


但是在获取 key-value 时如果没有加载完成,则会 wait 等待 SP 文件加载完成:


public String getString(String key, @Nullable String defValue) {    synchronized (mLock) {        awaitLoadedLocked();        String v = (String)mMap.get(key);        return v != null ? v : defValue;    }}
复制代码


写入性能差


SP 采用 XML 格式,每次写入是全量更新,效率低,写入提供两种方式:


  • commit:阻塞当前线程方式,修改提交到内存后,等待 IO 完成,如果主线程使用 commit 方式,极有可能出现卡顿;

  • apply:不阻塞当前线程,但也有隐藏的坑,可能会导致主线程的卡顿问题,主要原因为 apply 方式将写入 Runnable 加入到 QueueWork 中,而在 Android 四大组件生命周期轮转时,会检查 QueueWork 是否完成,如果没有完成则会 wait,代码如:


  public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,        int configChanges, PendingTransactionActions pendingActions, String reason) {        ......        // 确保写任务都已经完成        QueuedWork.waitToFinish();        ......    }}
复制代码


因此,在 ANR/卡顿监控中能看到非常多的 SharedPreferences 堆栈,看堆栈是系统级堆栈,但其实是 SP apply 方式引入的问题,堆栈如:


java.lang.Object.wait(Native Method) java.lang.Thread.parkFor$(Thread.java: ) sun.misc.Unsafe.park(Unsafe.java: )java.util.concurrent.locks.LockSupport.park(LockSupport.java: ) java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java: ) java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java: )java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java: ) java.util.concurrent.CountDownLatch.await(CountDownLatch.java: ) android.app.SharedPreferencesImpl$EditorImpl$1.run(SharedPreferencesImpl.java: ) android.app.QueuedWork.waitToFinish(QueuedWork.java: ) android.app.ActivityThread.handleServiceArgs(ActivityThread.java: )android.app.ActivityThread. - wrap21(ActivityThread.java) android.app.ActivityThread$H.handleMessage(ActivityThread.java: ) android.os.Handler.dispatchMessage(Handler.java: ) ndroid.os.Looper.loop(Looper.java: ) ndroid.app.ActivityThread.main(ActivityThread.java: )java.lang.reflect.Method.invoke(Native Method) com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java: ) com.android.internal.os.ZygoteInit.main(ZygoteInit.java: )
复制代码


多进程支持差


当使用 MODE_MULTI_PROCESS 这个字段时,其实并不可靠,因为 Android 内部并没有合适的机制去防止多个进程所造成的冲突,应用不应该使用它,推荐使用 ContentProvider。上面这段介绍我们得知:多个进程访问{MODE_MULTI_PROCESS}标识的 SharedPreferences 时,会造成冲突,举个例子就是,在 A 进程,明明 set 了一个 key 进去,跳到 B 进程去取,却提示 null 的错误。


3.2.2.1 优化方案设计

目前各大厂商也对 SP 做了一定优化,有保守优化,在 SP 当前机制基础上做优化,主要是解决写入导致的 ANR 问题;也有颠覆性优化,比较有代表性的为 MMKV 和 Data Store,但经评估后,可能均有一定问题,因此在百度 App 的优化中,也是学习借鉴业界主流的处理方式,最终采用两种优化并存的方式:


  • 提供颠覆性优化组件:UniKV,彻底解决原生 SP 一系列问题,核心场景极致体验,业务方主动接入;

  • 在系统 SP 机制上做优化,解决写入时 ANR 等痛点问题,主要服务于未接入 UniKV 的 SP 文件;

3.2.2.1.1 UniKV 设计

层级设计



1: 业务使用时直接依赖 UniKV,UniKV 继承 SharedPreferences,对齐原生 SP 接口;


2: 工程中包含原生实现和 UniKV 实现,代码中直接依赖原生实现,编译打包时替换为 UniKV 实现,保证业务中台输出能力;


文件存储格式设计


分位文件头、数据块。文件头 40 个字节,主要存储版本号、回写次数、保留字段、容灾数据长度、容灾 CRC、实际数据长度、实际 CRC。



1:以 4KB 位单位分配空间,最小占用 4KB 空间,通过 mmap 映射文件,操作系统负责数据写入文件;


2:通过容灾数据长度和容灾 CRC 可做数据恢复;


3:通过保留字段可做功能拓展,比如是否从 SP 迁移成功标识;



数据块中存储主要数据体,以 append 形式写入,必要时再做数据整理


1:支持类型存储,对齐 SP 原生 getAll 接口;


2:支持类型有:BOOL、INT、FLOAT、DOUBLE、SHORT、LONG、STRING、STRING_ARRAY、BYTE_ARRAY9 种类型,相比于原生 SP 实现支持类型更多;


数据迁移


数据迁移过程需要先读取 SP 内容,再写入 KV 文件,耗时会较久,写入完成后 KV 文件才可用,这在线上会有隐患,需要解决。



UniKV 中数据迁移采用不影响业务方式,如果迁移完成,则会直接使用 KV 文件,如果未迁移完成,则继续使用 SP 文件,并将数据迁移 Runnable 提交至线程池。为避免数据迁移期间 SP 文件出现改动导致数据丢失,注册 SP 文件更改的数据监听。迁移完成标记位由保留字段来存储,往往数据迁移时需要标记位来保存是否迁移完成的 Flag,需要引入其他文件来保存,此处 UniKV 里的保留字段很好的解决了此问题。


多进程实现


采用 mmap 机制 + 自定义文件锁实现进程间数据同步,mmap 文件至每个进程的内存空间,自定义文件锁主要实现的递归锁和锁的升降级,多进程读时共享锁,多进程写时排他锁,原生文件锁不支持递归锁,升降级容易死锁或锁会被完全释放,因此自定义文件锁实现进程间数据同步。关于多进程这块实现,主要学习了 MMKV 的多进程实现逻辑,感兴趣的可以参阅:https://github.com/Tencent/MMKV/wiki/android_ipc


实现效果


彻底解决原生 SP 的性能问题,读写性能显著提高,支持多进程读写,减少线程创建,整体性能指标和业务指标均出现了明显优化。


3.2.2.1.2 系统 SP 机制优化

有些 SP 是在插件、第三方 SDK 中使用的,因此无法使用 UniKV 统一优化,需提供一种优化原生 SP 机制的方案。


优化方案:



目前百度 App 在 Android 12 上暂未优化,主要原因是 Android 12 实现方式有变化,代理方式相对复杂,且开销较大,而 SP 引起的 ANR 问题较少,因此暂未上线优化。


优化效果:


此方案对全局均有优化,除了 ANR 指标有显著下降外,DAU 和留存也出现正向。有同学会担心优化后数据写入是否会受影响,我们通过打点监控到 SP 写入及时性没有明显变化,而写入成功率出了正向,低端机提升明显,说明 SP 优化减少 ANR 的发生,更多任务被执行,写入成功率提升。

3.2.3 锁优化

多线程性能调优是性能优化中不可避免的话题,为了实现线程同步,加入了同步锁机制(Synchronized 同步锁、Lock 同步锁等),同步锁的诞生虽然保证了操作的原子性、线程的安全性,但是(相比不加锁的情况下)造成了程序性能下降。所以,我们这里要做的一件事就是“锁优化”,即既要保证实现锁的功能(即保证多线程下操作安全)又要提高程序性能(即不要让程序因为安全而损失太大效率)。


常见的锁优化方式:



下面以一个优化项,介绍百度 App 在锁优化中的实际优化落地。


在项目开展初期,通过 Trace 工具分析发现有较多的“monitor contentation XXX”,此部分信息是 Android ART 虚拟机输出的锁相关信息,其中会包括持有锁的线程、方法、等锁线程、等锁方法。具体如下图所示:



经分析,主要是基础组件的 AB 在初始化时由于 synchronized 关键字不正确使用导致,需对 AB 做性能优化,必要时做架构升级。而经过分析,AB 基础组件在多线程、文件 IO 性能均存在性能问题,因此对 AB 基础组件做了重构升级,彻底解决性能问题。



通过优化后,读写采用无锁实现,彻底解决业务使用 ABTest 组件锁同步问题;兼容新老 AB 数据,缓存实验开关和实验 sid 数据,并采用 JSON/PB 数据格式存储,首次读取性能 118ms,优化 95%(小米 5 机器)。

3.2.4 其他基础机制优化

在百度 App 的启动性能优化中,开展过较多的基础机制相关优化,如:线程优化、IO 优化、SO 优化、主线程优先级优化、ContentProvider 优化、类/图片预加载优化、图片预上传 GPU 优化等。


线程优化


通过 Hook 能力编写插件,发现线程使用不规范问题,制定线程使用规范,如:


1: 业务禁止私自设置线程优先级;


2: 提供统一的线程池,避免各业务各一个线程池;


3:优先选择线程池/任务调度器调度,业务禁止单独创建线程/线程池;


4: 线程池需避免线程频繁创建,参数标准化。


IO 优化


通过 Hook 能力编写插件,发现不合理 IO 问题,主要包括:


  1. 主线程读写时间超过 100ms,主线程读写时间过长会导致主线程长耗时问题,严重时可能会导致 ANR 问题;

  2. 读写 buffer 过小问题,如果 buffer 太小,会导致过多次系统调用和内存拷贝,read/wirte 次数过多,从而影响性能。


SO 优化


通过 Hook 能力编写插件,发现 So 加载问题,优化不必要的 SO 加载过程,对于必要的加载,尝试通过异步线程提前策略解决,达到优化性能的目的。


Binder 优化


通过 Hook 能力编写插件,发现 Binder 通信相关问题,优化不必要 Binder 通信,必要时可通过内存缓存、文件持久化等方式,达到优化性能的目的。


主线程优先级


主线程的优先级决定了系统为主线程分配的资源,如果线程优先级有问题,被改成了低优先级,极有可能出现得不到 CPU 时间片导致运行慢的问题。在主线程优先级的问题排查中,最有代表性的是业务在为相关子线程设置优先级时误设置了优先级,出问题方式:


Thread t = new Thread();t.start();t.setPriority(3);
复制代码


Android的离奇陷阱—设置线程优先级导致的微信卡顿惨案


https://mp.weixin.qq.com/s/oLz_F7zhUN6-b-KaI8CMRw


在百度 App 排查优先级设置时,原生库也有更改线程优先级逻辑,也需主动修正,如 facebook react 库的部分逻辑:



ContentProvider/FileProvider 优化


在 Application.attachbaseContext 和 Application.onCreate 之间,会执行 installContentProviders 方法,在此方法中会执行 AndroidManifest 中声明的 ContentProvider/FileProvider,一般耗时较大的为 FileProvider,主要原因是 FileProvider 初始化时有 IO 操作。主要优化为将 ContentProvder/FileProvider 移除,并通过 android:process 属性做控制,或者通过懒加载方式,必要进程中初始化。


图片 prepareToDraw 优化



在 Trace 工具有会看到 RenderThread 中执行 syncFrameState 时会 upload XXX Texture 相关耗时问题,首先检查在 trace 里面显示的图片的宽和高,确保图片的大小不比它显示出来的区域大太多。也可以通过 prepareToDraw 方法提前触发 Bitmap 上传 GPU 操作,这种方式可以使 Bitmap 在 RenderThread 空闲的时候提前完成。理想情况下,图片加载库会帮助你完成这些;如果你想要自己掌控图片加载,或者需要确保不在绘制的时候触发 Bitmap 上传,可以直接在代码里面调用 prepareToDraw。


可能有的同学比较疑惑,此优化没有优化主线程,会对启动性能有优化吗?答案是可以优化主线程,在启动的前几帧,每一帧耗时均会比较大,而每一帧的任务在 RenderThread 中以 DrawFrame Task 运行,如果上一帧的任务没有完成,则会阻塞当前帧的绘制,主线程中体现出来的就是 draw 过程变慢,如 nSyncAndDrawFrame 执行时长过长。

3.3 底层机制优化

主要通过探索底层的技术,来实现优化性能指标,进而撬动业务价值的目标,此方向风险性相对较高,成本也会较大,需依据具体人力情况及优化效果做最终决策。


百度 App 中目前已尝试过的有 VerifyClass 优化、CPU Booster 优化、GC 相关优化等,目前还在探索一些技术点,此部分优化基本为全局优化,会在后续的流畅度专题中为大家揭晓。

四、小结

启动性能优化是相对复杂的技术方向,不仅有较多的业务会和启动性能有千丝万缕的联系,在启动过程中也有非常多的系统行为值得关注与投入,目前百度 App 启动性能已逐渐步入瓶颈期,如何打破瓶颈并与业务紧密结合,是启动性能优化的挑战与机遇。启动性能的优化是不断学习、不断颠覆、不断进步的过程,中间可能会遇到非常多的挑战,也会出现非常多的机遇,因此,启动性能优化永无止境,任重而道远。


—— END——


参考资料:


1、抖音启动优化


https://heapdump.cn/article/3624814


2、快手 TTI 治理经验分享


https://zhuanlan.zhihu.com/p/422859543


3、浅析 Android 启动优化


https://juejin.cn/post/7183144743411384375


4、MMKV:


https://github.com/Tencent/MMKV/wiki/android_ipc


推荐阅读:


从php5.6到golang1.19-文库App性能跃迁之路


扫光动效在移动端应用实践


Android SDK安全加固问题与分析


搜索语义模型的大规模量化实践


如何设计一个高效的分布式日志服务平台


视频与图片检索中的多模态语义匹配模型:原理、启示、应用与展望

发布于: 刚刚阅读数: 3
用户头像

百度Geek说

关注

百度官方技术账号 2021-01-22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
百度 App 启动性能优化实践篇_百度_百度Geek说_InfoQ写作社区