前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >启动优化

启动优化

原创
作者头像
ruochen
修改2021-11-22 09:26:34
3.5K0
修改2021-11-22 09:26:34
举报
文章被收录于专栏:若尘的技术专栏

启动分析

启动类型
  • Android Vitals可以对应用冷,热,温启动时间做监控。
  • 通过adb shell am start -W ... 命令执行启动并打印启动耗时信息,下面的启动监控中会详细讲解
1. 冷启动
  • 应用从头开始启动,系统进程在冷启动后才创建应用进程
  • 启动流程:Click Event -> IPC -> Process.start -> ActivityThread -> bindApplication -> LifeCycle -> ViewRootImpl
  • 冷启动阶段系统的三个任务:
    1. 加载并启动应用
    2. 显示应用的空白启动窗口
    3. 创建应用进程
  • 应用进程负责后续阶段:
    1. 创建应用对象(Application)
    2. 启动主线程
    3. 创建主Activity
    4. 扩充视图/加载布局
    5. 布局屏幕
    6. 执行初始绘制/首帧绘制
  • 应用进程完成第一次绘制,系统进程就会换掉当前显示的启动窗口,替换为主 Activity。此时,用户可以开始使用应用。
2. 热启动
  • 系统的所有工作就是将Activity带到前台,
  • 只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局膨胀和呈现;undefined但是,如果一些内存为响应内存整理事件(如 onTrimMemory())而被完全清除,则需要为了响应热启动事件而重新创建相应的对象;
  • 热启动显示的屏幕上行为和冷启动场景相同:在应用完成 Activity 呈现之前,系统进程将显示空白屏幕。
3. 温启动
  • 包含了在冷启动期间发生的部分操作;同时,它的开销要比热启动高
  • 场景1:用户在退出应用后又重新启动应用(进程可能存活,通过 onCreate() 从头开始重新创建Activity)
  • 场景2:系统将应用从内存中逐出,然后用户又重新启动(进程和Activity都需要重启,但传递到onCreate()的已保存的实例state bundle对于完成此任务有一定助益)
  • 下面说到的启动一般指冷启动

启动过程

  1. (桌面) 点击响应,应用解析
  2. (系统) 预览窗口显示(根据Theme属性创建,如果Theme中指定为透明,看到的仍然是桌面)
  3. (应用) Application创建, 闪屏页/启动页 Activity创建(一系列的inflateView、onMeasure、onLayout)
  4. (系统) 闪屏显示
  5. (应用) MainActivity创建界面准备
  6. (系统) 主页/首页 显示
  7. (应用) 其他工作(数据的加载,预加载,业务组件初始化)
  8. 窗口可操作

启动问题分析

  • 由启动过程可以推测出用户可能遇到的三个问题
1. 点击桌面图标无响应:
  • 原因:theme中禁用预览窗口或指定了透明背景
代码语言:txt
复制
//优点:避免启动app时白屏黑屏等现象
代码语言:txt
复制
//缺点:容易造成点击桌面图标无响应
代码语言:txt
复制
//(可以配合三方库懒加载,异步初始化等方案使用,减少初始化时长)
代码语言:txt
复制
//实现如下
代码语言:txt
复制
//0. appTheme
代码语言:txt
复制
 <!-- Base application theme. -->
代码语言:txt
复制
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
代码语言:txt
复制
    <!-- Customize your theme here. -->
代码语言:txt
复制
    <item name="colorPrimary">@color/c_ff000000</item>
代码语言:txt
复制
    <item name="colorPrimaryDark">@color/c_ff000000</item>
代码语言:txt
复制
    <item name="colorAccent">@color/c_ff000000</item>
代码语言:txt
复制
    <item name="android:windowActionBar">false</item>
代码语言:txt
复制
    <item name="android:windowNoTitle">true</item>
代码语言:txt
复制
</style>
代码语言:txt
复制
//1. styles.xml中设置
代码语言:txt
复制
//1.1 禁用预览窗口
代码语言:txt
复制
<style name="AppTheme.Launcher">
代码语言:txt
复制
    <item name="android:windowBackground">@null</item>
代码语言:txt
复制
    <item name="android:windowDisablePreview">true</item>
代码语言:txt
复制
</style>
代码语言:txt
复制
//1.2 指定透明背景
代码语言:txt
复制
<style name="AppTheme.Launcher">
代码语言:txt
复制
    <item name="android:windowBackground">@color/c_00ffffff</item>
代码语言:txt
复制
    <item name="android:windowIsTranslucent">true</item>
代码语言:txt
复制
</style>
代码语言:txt
复制
//2. 为启动页/闪屏页Activity设置theme
代码语言:txt
复制
<activity
代码语言:txt
复制
    android:name=".splash.SplashActivity"
代码语言:txt
复制
    android:screenOrientation="portrait"
代码语言:txt
复制
    android:theme="@style/AppTheme.Launcher">
代码语言:txt
复制
    <intent-filter>
代码语言:txt
复制
        <action android:name="android.intent.action.MAIN" />
代码语言:txt
复制
        <category android:name="android.intent.category.LAUNCHER" />
代码语言:txt
复制
    </intent-filter>
代码语言:txt
复制
</activity>
代码语言:txt
复制
//3. 在该Activity.onCreate()中设置AppTheme(设置布局id之前)
代码语言:txt
复制
//比如我是基类中单独抽取的获取布局id方法,那么在启动页中重写此方法时加入如下配置:
代码语言:txt
复制
 @Override
代码语言:txt
复制
protected int getContentViewId() {
代码语言:txt
复制
    setTheme(R.style.AppTheme_Launcher);
代码语言:txt
复制
    return R.layout.activity_splash;
代码语言:txt
复制
}
2. 首页显示慢
  • 原因:启动流程复杂,初始化的组件&三方库过多
3. 首页显示后无法操作
  • 原因:同上

启动优化

  • 方法和卡顿优化基本相同,只是启动太过重要,需要更加精打细算;

优化工具

  • Traceview 性能损耗太大,得出的结果并不真实;
  • Nanoscope 非常真实,不过暂时只支持 Nexus 6P 和 x86 模拟器,无法针对中低端机做测试;
  • Simpleperf 的火焰图并不适合做启动流程分析;
  • systrace 可以很方便地追踪关键系统调用的耗时情况,但是不支持应用程序代码的耗时分析;
  • 综合来看,在卡顿优化中提到“systrace + 函数插桩” 似乎是比较理想的方案(可以参考课后作业),拿到整个启动流程的全景图之后,我们可以清楚地看到这段时间内系统、应用各个进程和线程的运行情况;

优化方法

1. 闪屏优化:
  • 预览闪屏(今日头条),预览窗口实现成闪屏效果,高端机上体验非常好,不过低端机上会拉长总的闪屏时长(建议在Android6.0以上才启用此方案);
代码语言:txt
复制
//优点:避免点击桌面图标无响应  
代码语言:txt
复制
//缺点:拉长总的闪屏时长
代码语言:txt
复制
//(可以配合三方库懒加载,异步初始化等方案使用,减少初始化时长)
代码语言:txt
复制
//1. 就是给windowBackground设置一个背景图片
代码语言:txt
复制
<style name="AppTheme.Launcher">
代码语言:txt
复制
    <item name="android:windowBackground">@drawable/bg_splash</item>
代码语言:txt
复制
    <item name="android:windowFullscreen">true</item>
代码语言:txt
复制
</style>  
代码语言:txt
复制
//2. bg_splash文件如下(使用layer-list实现)  
代码语言:txt
复制
<?xml version="1.0" encoding="utf-8"?>
代码语言:txt
复制
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
代码语言:txt
复制
    <item android:drawable="@color/color_ToolbarLeftItem" />
代码语言:txt
复制
    <item>
代码语言:txt
复制
        <bitmap
代码语言:txt
复制
            android:antialias="true"
代码语言:txt
复制
            android:gravity="center"
代码语言:txt
复制
            android:src="@drawable/ic_splash" />
代码语言:txt
复制
    </item>
代码语言:txt
复制
</layer-list>
代码语言:txt
复制
//3. 为启动页/闪屏页Activity设置theme
代码语言:txt
复制
<activity
代码语言:txt
复制
    android:name=".splash.SplashActivity"
代码语言:txt
复制
    android:screenOrientation="portrait"
代码语言:txt
复制
    android:theme="@style/AppTheme.Launcher">
代码语言:txt
复制
    <intent-filter>
代码语言:txt
复制
        <action android:name="android.intent.action.MAIN" />
代码语言:txt
复制
        <category android:name="android.intent.category.LAUNCHER" />
代码语言:txt
复制
    </intent-filter>
代码语言:txt
复制
</activity>  
代码语言:txt
复制
//4. 在该Activity.onCreate()中设置AppTheme(设置布局id之前)
代码语言:txt
复制
//比如我是基类中单独抽取的获取布局id方法,那么在启动页中重写此方法时加入如下配置:
代码语言:txt
复制
 @Override
代码语言:txt
复制
protected int getContentViewId() {
代码语言:txt
复制
    setTheme(R.style.AppTheme_Launcher);
代码语言:txt
复制
    return R.layout.activity_splash;
代码语言:txt
复制
}  
  1. 合并闪屏和主页面的Activity(微信),不过违法单一职责原则,业务逻辑比较复杂;
2. 业务梳理
  • 理清启动过程中的模块,哪些需要,哪些可以砍掉,哪些可以懒加载(懒加载要防止集中化,避免首页可见但用户无法操作的情况);
  • 根据业务场景决定不同的启动模式;
  • 对低端机降级,做功能取舍;
  • 启动优化带来整体留存、转化的正向价值,是大于某个业务取消预加载带来的负面影响的;
3. 业务优化
  • 抓大放小,解决主要耗时问题,如优化解密算法;
  • 异步线程预加载,但过度使用会让代码逻辑更加复杂;
  • 偿还技术债,如有必要,择时对老代码进行重构;
4. 线程优化
  • 减少CPU调度带来的波动,让应用的启动时间更加稳定
  1. 控制线程的数量,避免线程太多互争CPU资源,用统一线程池,根据机器性能来控制数量;
  2. 检查线程间的锁,特别是防止主线程出现长时间的空转(主线程因为锁而干等子线程任务);
代码语言:txt
复制
  //通过sched查看线程切换数据
代码语言:txt
复制
  proc/[pid]/sched:
代码语言:txt
复制
  nr_voluntary_switches:     
代码语言:txt
复制
  主动上下文切换次数,因为线程无法获取所需资源导致上下文切换,最普遍的是IO。    
代码语言:txt
复制
  nr_involuntary_switches:   
代码语言:txt
复制
  被动上下文切换次数,线程被系统强制调度导致上下文切换,例如大量线程在抢占CPU。
  • 现在有很多启动框架,使用Pipeline机制,根据业务优先级规定业务初始化时机,如微信的mmkernel,阿里的alpha, 会为任务建立依赖关系,最终形成一个有向无环图;
  • 下面是自定义的一个可以区分多类型任务的线程池工具类,也可以用于异步初始化
代码语言:txt
复制
//- 注意区分任务类型:
代码语言:txt
复制
//    - IO密集型任务:不消耗CPU,核心池可以很大,如文件读写,网络请求等。
代码语言:txt
复制
//    - CPU密集型任务:核心池大小和CPU核心数相关,如复杂的计算,需要使用大量的CPU计算单元。
代码语言:txt
复制
//
代码语言:txt
复制
// 执行的任务是CPU密集型
代码语言:txt
复制
DispatcherExecutor.getCPUExecutor().execute(YourRunable());
代码语言:txt
复制
// 执行的任务是IO密集型
代码语言:txt
复制
DispatcherExecutor.getIOExecutor().execute(YourRunable());
代码语言:txt
复制
/**
代码语言:txt
复制
 * @Author: LiuJinYang
 * @CreateDate: 2020/12/16
 * 
 * 实现用于执行多类型任务的基础线程池
 */
public class DispatcherExecutor {
代码语言:txt
复制
    /**
代码语言:txt
复制
     * CPU 密集型任务的线程池
     */
    private static ThreadPoolExecutor sCPUThreadPoolExecutor;
代码语言:txt
复制
    /**
代码语言:txt
复制
     * IO 密集型任务的线程池
     */
    private static ExecutorService sIOThreadPoolExecutor;
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 当前设备可以使用的 CPU 核数
     */
    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 线程池核心线程数,其数量在2 ~ 5这个区域内
     */
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 5));
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 线程池线程数的最大值:这里指定为了核心线程数的大小
     */
    private static final int MAXIMUM_POOL_SIZE = CORE_POOL_SIZE;
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 线程池中空闲线程等待工作的超时时间,当线程池中
     * 线程数量大于corePoolSize(核心线程数量)或
     * 设置了allowCoreThreadTimeOut(是否允许空闲核心线程超时)时,
     * 线程会根据keepAliveTime的值进行活性检查,一旦超时便销毁线程。
     * 否则,线程会永远等待新的工作。
     */
    private static final int KEEP_ALIVE_SECONDS = 5;
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 创建一个基于链表节点的阻塞队列
     */
    private static final BlockingQueue<Runnable> S_POOL_WORK_QUEUE = new LinkedBlockingQueue<>();
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 用于创建线程的线程工厂
     */
    private static final DefaultThreadFactory S_THREAD_FACTORY = new DefaultThreadFactory();
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 线程池执行耗时任务时发生异常所需要做的拒绝执行处理
     * 注意:一般不会执行到这里
     */
    private static final RejectedExecutionHandler S_HANDLER = new RejectedExecutionHandler() {
        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            Executors.newCachedThreadPool().execute(r);
        }
    };
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 获取CPU线程池
     *
     * @return CPU线程池
     */
    public static ThreadPoolExecutor getCPUExecutor() {
        return sCPUThreadPoolExecutor;
    }
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 获取IO线程池
     *
     * @return IO线程池
     */
    public static ExecutorService getIOExecutor() {
        return sIOThreadPoolExecutor;
    }
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 实现一个默认的线程工厂
     */
    private static class DefaultThreadFactory implements ThreadFactory {
        private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
        private final ThreadGroup group;
        private final AtomicInteger threadNumber = new AtomicInteger(1);
        private final String namePrefix;
代码语言:txt
复制
        DefaultThreadFactory() {
代码语言:txt
复制
            SecurityManager s = System.getSecurityManager();
代码语言:txt
复制
            group = (s != null) ? s.getThreadGroup() :
代码语言:txt
复制
                    Thread.currentThread().getThreadGroup();
代码语言:txt
复制
            namePrefix = "TaskDispatcherPool-" +
代码语言:txt
复制
                    POOL_NUMBER.getAndIncrement() +
代码语言:txt
复制
                    "-Thread-";
代码语言:txt
复制
        }
代码语言:txt
复制
        @Override
代码语言:txt
复制
        public Thread newThread(Runnable r) {
代码语言:txt
复制
            // 每一个新创建的线程都会分配到线程组group当中
代码语言:txt
复制
            Thread t = new Thread(group, r,
代码语言:txt
复制
                    namePrefix + threadNumber.getAndIncrement(),
代码语言:txt
复制
                    0);
代码语言:txt
复制
            if (t.isDaemon()) {
代码语言:txt
复制
                // 非守护线程
代码语言:txt
复制
                t.setDaemon(false);
代码语言:txt
复制
            }
代码语言:txt
复制
            // 设置线程优先级
代码语言:txt
复制
            if (t.getPriority() != Thread.NORM_PRIORITY) {
代码语言:txt
复制
                t.setPriority(Thread.NORM_PRIORITY);
代码语言:txt
复制
            }
代码语言:txt
复制
            return t;
代码语言:txt
复制
        }
代码语言:txt
复制
    }
代码语言:txt
复制
    static {
代码语言:txt
复制
        sCPUThreadPoolExecutor = new ThreadPoolExecutor(
代码语言:txt
复制
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
代码语言:txt
复制
                S_POOL_WORK_QUEUE, S_THREAD_FACTORY, S_HANDLER);
代码语言:txt
复制
        // 设置是否允许空闲核心线程超时时,线程会根据keepAliveTime的值进行活性检查,一旦超时便销毁线程。否则,线程会永远等待新的工作。
代码语言:txt
复制
        sCPUThreadPoolExecutor.allowCoreThreadTimeOut(true);
代码语言:txt
复制
        // IO密集型任务线程池直接采用CachedThreadPool来实现,
代码语言:txt
复制
        // 它最多可以分配Integer.MAX_VALUE个非核心线程用来执行任务
代码语言:txt
复制
        sIOThreadPoolExecutor = Executors.newCachedThreadPool(S_THREAD_FACTORY);
代码语言:txt
复制
    }
代码语言:txt
复制
}
5. GC优化
  • 启动过程,要尽量减少GC次数,避免造成主线程长时间的卡顿
代码语言:txt
复制
  //1. 通过 systrace 单独查看整个启动过程 GC 的时间
代码语言:txt
复制
  python systrace.py dalvik -b 90960 -a com.sample.gc
代码语言:txt
复制
  //2. 通过Debug.startAllocCounting监控启动过程总GC的耗时情况
代码语言:txt
复制
  // GC使用的总耗时,单位是毫秒
代码语言:txt
复制
  Debug.getRuntimeStat("art.gc.gc-time");
代码语言:txt
复制
  // 阻塞式GC的总耗时
代码语言:txt
复制
  Debug.getRuntimeStat("art.gc.blocking-gc-time");
代码语言:txt
复制
  //如果发现主线程出现比较多的 GC 同步等待,就需要通过 Allocation 工具做进一步的分析  
  • 启动过程避免大量字符串操作,序列化和反序列化,减少对象创建(提高服用或移到Native实现);
  • java对象逃逸也很容易引起GC,应保证对象生命周期尽量短,在栈上就进行销毁;
6. 系统调用优化
  • 通过systrace的System Service类型,可以看到启动过程System Server的 CPU 工作情况
  • 启动过程尽量不要做系统调用,如PackageManagerService操作,Binder调用
  • 启动过程也不要过早的拉起应用的其他进程,System Server和新的进程都会竞争CPU资源,内存不足时可能触发系统的low memory killer 机制,导致系统杀死和拉起(保活)大量进程,进而影响前台进程

启动进阶方法

1. IO优化

  • 负载过高时,IO性能下降的会比较快,特别是对低端机;
  • 启动过程不建议出现网络IO
  • 磁盘IO要清楚启动过程读取了什么文件,多少字节,buffer大小,耗时多少,在什么线程等
  • 重度用户是启动优化一定要覆盖的群体,如本地缓存,数据库,SP文件非常多时的耗时
  • 数据结构的选择,如启动时可能只需要sp文件中的几个字段,SharedPreference就需要分开存储,避免解析全部sp数据耗时过长;
  • 启动过程适合使用随机读写的数据结构,可以将ArrayMap改造成支持随机读写、延时解析的数据存储方式。

2. 数据重排

  • Linux 文件 I/O 流程
代码语言:txt
复制
Linux 文件系统从磁盘读文件的时候,会以 block 为单位去磁盘读取,一般 block 
代码语言:txt
复制
大小是 4KB。也就是说一次磁盘读写大小至少是 4KB,然后会把 4KB 数据放到页缓存
代码语言:txt
复制
 Page Cache 中。如果下次读取文件数据已经在页缓存中,那就不会发生真实的磁盘 
代码语言:txt
复制
 I/O,而是直接从页缓存中读取,大大提升了读的速度。
代码语言:txt
复制
例如读取文件中1KB数据,因为Buffer不小心写成了 1 byte,总共要读取 1000 次。
代码语言:txt
复制
那系统是不是真的会读1000次磁盘呢?事实上1000次读操作只是我们发起的次数,
代码语言:txt
复制
并不是真正的磁盘 I/O 次数,我们虽然读了 1000 次,但事实上只会发生一次磁盘 
代码语言:txt
复制
I/O,其他的数据都会在页缓存中得到。

Dex文件用的到的类和安装包APK里面各种资源文件一般都比较小,但是读取非常频繁。

我们可以利用系统这个机制将它们按照读取顺序重新排列,减少真实的磁盘 I/O 次数;

类重排
代码语言:txt
复制
// 启动过程类加载顺序可以通过复写 ClassLoader 得到
代码语言:txt
复制
class MyClassLoader extends PathClassLoader {
代码语言:txt
复制
    public Class<?> findClass(String name) {
代码语言:txt
复制
        //将name记录到文件
代码语言:txt
复制
        writeToFile(name,"coldstart_classes.txt");
代码语言:txt
复制
        return super.findClass(name);
代码语言:txt
复制
    }
代码语言:txt
复制
}
代码语言:txt
复制
//然后通过ReDex的Interdex调整类在Dex中的排列顺序,最后可以利用 010 Editor 查看修改后的效果。
资源文件重排
  • Facebook 在比较早的时候就使用“资源热图”来实现资源文件的重排
  • 支付宝在《通过安装包重排布优化 Android 端启动性能》中详细讲述了资源重排的原理和落地方法;
  • 实现上都是通过修改 Kernel 源码,单独编译了一个特殊的 ROM,为了便于资源文件统计,重排后实现效果的度量,流程自动化
  • 如果仅仅为了统计,也可以用hook方式
代码语言:txt
复制
    //Hook,利用 Frida 实现获得 Android 资源加载顺序的方法
代码语言:txt
复制
resourceImpl.loadXmlResourceParser.implementation=function(a,b,c,d){
代码语言:txt
复制
   send('file:'+a)
代码语言:txt
复制
   return this.loadXmlResourceParser(a,b,c,d)
代码语言:txt
复制
}
代码语言:txt
复制
resourceImpl.loadDrawableForCookie.implementation=function(a,b,c,d,e){
代码语言:txt
复制
   send("file:"+a)
代码语言:txt
复制
   return this.loadDrawableForCookie(a,b,c,d,e)
代码语言:txt
复制
}
代码语言:txt
复制
//Frida相对小众,后面会替换其他更加成熟的 Hook 框架
代码语言:txt
复制
//调整安装包文件排列需要修改 7zip 源码实现支持传入文件列表顺序,同样最后可以利用 010 Editor 查看修改后的效果;
代码语言:txt
复制
* 所谓创新,不一定是创造前所未有的东西。我们将已有的方案移植到新的平台,并且很好地结合该平台的特性将其落地,就是一个很大的创新

3. 类的加载

  • 在加载类的过程有一个 verify class 的步骤,它需要校验方法的每一个指令,是一个比较耗时的操作,可以通过 Hook 来去掉 verify 这个步骤
  • 最大的优化场景在于首次和覆盖安装时
代码语言:txt
复制
//Dalvik 平台: 将 classVerifyMode 设为 VERIFY_MODE_NONE
代码语言:txt
复制
// Dalvik Globals.h
代码语言:txt
复制
gDvm.classVerifyMode = VERIFY_MODE_NONE;
代码语言:txt
复制
// Art runtime.cc
代码语言:txt
复制
verify_ = verifier::VerifyMode::kNone;
代码语言:txt
复制
//ART 平台要复杂很多,Hook 需要兼容几个版本
代码语言:txt
复制
//在安装时大部分 Dex 已经优化好了,去掉 ART 平台的 verify 只会对动态加载的 Dex 带来一些好处
代码语言:txt
复制
//Atlas 中的dalvik_hack-3.0.0.5.jar可以通过下面的方法去掉 verify
代码语言:txt
复制
AndroidRuntime runtime = AndroidRuntime.getInstance();
代码语言:txt
复制
runtime.init(context);
代码语言:txt
复制
runtime.setVerificationEnabled(false);
代码语言:txt
复制
//这个黑科技可以大大降低首次启动的速度,代价是对后续运行会产生轻微的影响。同时也要考虑兼容性问题,暂时不建议在 ART 平台使用

4. 黑科技

保活:
  • 保活可以减少Application创建跟初始化的时间,让冷启动变成温启动。不过在Target 26之后,保活的确变得越来越难;(大厂一般是厂商合作,例如微信的 Hardcoder 方案和 OPPO 推出的Hyper Boost方案,当应用体量足够大,就可以倒逼厂商去专门为它们做优化)
插件化和熱修復:
  • 事实上大部分的框架在设计上都存在大量的 Hook 和私有 API 调用,带来的缺点主要有两个:
  1. 稳定性/兼容性: 厂商的兼容性、安装失败、dex2oat 失败等,Android P推出的non-sdk-interface调用限制
  2. 性能:Android Runtime 每个版本都有很多的优化,黑科技会导致失效
应用加固:
  • 对启动速度来说简直是灾难,有时候我们需要做一些权衡和选择
GC 抑制:
  • 参考支付宝客户端架构解析-Android 客户端启动速度优化之「垃圾回收」;
  • 允许堆一直增长,直到手动或OOM停止GC抑制

5. MultiDex 优化

apk编译流程/Android Studio 按下编译按钮后发生了什么?
代码语言:txt
复制
1. 打包资源文件,生成R.java文件(使用工具AAPT)
2. 处理AIDL文件,生成java代码(没有AIDL则忽略)
3. 编译 java 文件,生成对应.class文件(java compiler)
4. .class 文件转换成dex文件(dex)
5. 打包成没有签名的apk(使用工具apkbuilder)
6. 使用签名工具给apk签名(使用工具Jarsigner)
7. 对签名后的.apk文件进行对齐处理,不进行对齐处理不能发布到Google Market(使用工具zipalign)

其中第4步,单个dex文件中的方法数不能超过65536,不然编译会报错:Unable to execute dex: method ID not in

0, 0xffff: 65536, 所以我们项目中一般都会用到multidex:

代码语言:txt
复制
1. gradle中配置
 defaultConfig {
    ...
    multiDexEnabled true
 }
 
代码语言:txt
复制
 implementation 'androidx.multidex:multidex:2.0.1'
代码语言:txt
复制
2. Application中初始化
@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    MultiDex.install(this);
}

然鹅,这个multidex过程是比较耗时的,那么能否针对这个问题进行优化呢?

MultiDex优化的两种方案
1. 子线程install(不推荐):
  • 闪屏页开一个子线程去执行MultiDex.install,然后加载完才跳转到主页,
代码语言:txt
复制
需要注意的是闪屏页的Activity,包括闪屏页中引用到的其它类必须在主dex中,
代码语言:txt
复制
不然在MultiDex.install之前加载这些不在主dex中的类会报错Class Not Found。
代码语言:txt
复制
这个可以通过gradle配置,如下:
代码语言:txt
复制
defaultConfig {
代码语言:txt
复制
    //分包,指定某个类在main dex
代码语言:txt
复制
    multiDexEnabled true
代码语言:txt
复制
    multiDexKeepProguard file('multiDexKeep.pro') // 打包到main dex的这些类的混淆规制,没特殊需求就给个空文件
代码语言:txt
复制
    multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到main dex
代码语言:txt
复制
}
2. 今日头条方案
  1. 在主进程Application 的 attachBaseContext 方法中判断如果需要使用MultiDex,则创建一个临时文件,然后开一个进程(LoadDexActivity),显示Loading,异步执行MultiDex.install 逻辑,执行完就删除临时文件并finish自己。
  2. 主进程Application 的 attachBaseContext 进入while代码块,定时轮循临时文件是否被删除,如果被删除,说明MultiDex已经执行完,则跳出循环,继续正常的应用启动流程。
  3. 注意LoadDexActivity 必须要配置在main dex中。
  4. 具体实现参考项目MultiDexTest

6. 预加载优化

1. 类预加载:
  • 在Application中提前异步加载初始化耗时较长的类
2. 页面数据预加载:
  • 在主页空闲时,将其它页面的数据加载好保存到内存或数据库
3. WebView预加载:
  1. WebView第一次创建比较耗时,可以预先创建WebView,提前将其内核初始化;
  2. 使用WebView缓存池,用到WebView的地方都从缓存池取,缓存池中没有缓存再创建,注意内存泄漏问题。
  3. 本地预置html和css,WebView创建的时候先预加载本地html,之后通过js脚本填充内容部分。
4. Activity预创建: (今日头条)
  • Activity对象是在子线程预先new出来,例如在闪屏页等待广告时调用下面代码
代码语言:txt
复制
DispatcherExecutor.getCPUExecutor().execute(new Runnable() {
代码语言:txt
复制
    @Override
代码语言:txt
复制
    public void run() {
代码语言:txt
复制
            long startTime = System.currentTimeMillis();
代码语言:txt
复制
            MainActivity mainActivity = new MainActivity();
代码语言:txt
复制
            LjyLogUtil.d( "preNewActivity 耗时: " + (System.currentTimeMillis() - startTime));
代码语言:txt
复制
    }
代码语言:txt
复制
});

对象第一次创建的时候,java虚拟机首先检查类对应的Class

对象是否已经加载。如果没有加载,jvm会根据类名查找.class文件,将其Class对象载入。同一个类第二次new的时候就不需要加载类对象,而是直接实例化,创建时间就缩短了。

7. 启动阶段不启动子进程

  • 子进程会共享CPU资源,导致主进程CPU紧张

8. CPU锁频

  • 当下移动设备cpu性能暴增,但一般利用率并不高,我们可以在启动时暴力拉伸CPU频率,来增加启动速度
  • 但是会导致耗电量增加
  • Android系统中,CPU相关的信息存储在/sys/devices/system/cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改。
代码语言:txt
复制
- CPU工作模式
performance:最高性能模式,即使系统负载非常低,cpu也在最高频率下运行。
powersave:省电模式,与performance模式相反,cpu始终在最低频率下运行。
ondemand:CPU频率跟随系统负载进行变化。
userspace:可以简单理解为自定义模式,在该模式下可以对频率进行设定。

启动监控/耗时检测

logcat

  • Android Studio的logcat中过滤关键字Displayed

adb shell

代码语言:txt
复制
adb shell am start -W com.ljy.publicdemo.lite/com.ljy.publicdemo.activity.MainActivity
代码语言:txt
复制
执行结果:
代码语言:txt
复制
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.ljy.publicdemo.lite/com.ljy.publicdemo.activity.MainActivity }
代码语言:txt
复制
Status: ok
代码语言:txt
复制
LaunchState: COLD 
代码语言:txt
复制
Activity: com.ljy.publicdemo.lite/com.ljy.publicdemo.activity.MainActivity
代码语言:txt
复制
TotalTime: 2065
代码语言:txt
复制
WaitTime: 2069
代码语言:txt
复制
Complete
代码语言:txt
复制
//LaunchState表示冷热温启动
代码语言:txt
复制
//TotalTime:表示所有Activity启动耗时。(主要数据,包括 创建进程 + Application初始化 + Activity初始化到界面显示 的过程)
代码语言:txt
复制
//WaitTime:表示AMS启动Activity的总耗时。

实验室监控

  • 通过定期自动录屏并分析,也适合做竞品的对比测试
  • 如何找到启动结束的点
    • 80%绘制
    • 图像识别
  • 门槛高,适合大厂

线上监控

启动耗时计算的细节:
  • 启动结束的统计时机:使用用户真正可以操作的时间
  • 启动时间的扣除逻辑:闪屏,广告,新手引导的时间都应扣除
  • 启动排除逻辑:Broadcast、Server 拉起,启动过程进入后台等都需排除掉
衡量启动速度快慢的标准
  • 平均启动时间(体验差的用户可能被平均)
  • 快开慢开比,如2秒快开比、5秒慢开比
  • 90%用户的启动时间
区分启动类型:
  • 首次安装启动、覆盖安装启动、冷启动,温启动,热启动
  • 热启动的占比也可以反映出我们程序的活跃或保活能力
代码语言:txt
复制
除了指标的监控,启动的线上堆栈监控更加困难。Facebook 会利用 Profilo 工具对启动的
代码语言:txt
复制
整个流程耗时做监控,并且在后台直接对不同的版本做自动化对比,监控新版本是否有新增耗时的函数。

对于启动优化要警惕 KPI 化,要解决的不是一个数字,而是用户真正的体验问题。

代码打点(函数插桩),缺点是代码有侵入性较强
代码语言:txt
复制
/**
代码语言:txt
复制
 * @Author: LiuJinYang
 * @CreateDate: 2020/12/14
 *
 * 在项目中需要统计时间的地方加入打点, 比如
 * 应用程序的生命周期节点。
 * 启动时需要初始化的重要方法,例如数据库初始化,读取本地的一些数据。
 * 其他耗时的一些算法。
 */
public class TimeMonitor {
    private int mMonitorId = -1;
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 保存一个耗时统计模块的各种耗时,tag对应某一个阶段的时间
     */
    private HashMap<String, Long> mTimeTag = new HashMap<>();
    private long mStartTime = 0;
代码语言:txt
复制
    public TimeMonitor(int mMonitorId) {
代码语言:txt
复制
        LjyLogUtil.d("init TimeMonitor id: " + mMonitorId);
代码语言:txt
复制
        this.mMonitorId = mMonitorId;
代码语言:txt
复制
    }
代码语言:txt
复制
    public int getMonitorId() {
代码语言:txt
复制
        return mMonitorId;
代码语言:txt
复制
    }
代码语言:txt
复制
    public void startMonitor() {
代码语言:txt
复制
        // 每次重新启动都把前面的数据清除,避免统计错误的数据
代码语言:txt
复制
        if (mTimeTag.size() > 0) {
代码语言:txt
复制
            mTimeTag.clear();
代码语言:txt
复制
        }
代码语言:txt
复制
        mStartTime = System.currentTimeMillis();
代码语言:txt
复制
    }
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 每打一次点,记录某个tag的耗时
     */
    public void recordingTimeTag(String tag) {
        // 若保存过相同的tag,先清除
        if (mTimeTag.get(tag) != null) {
            mTimeTag.remove(tag);
        }
        long time = System.currentTimeMillis() - mStartTime;
        LjyLogUtil.d(tag + ": " + time);
        mTimeTag.put(tag, time);
    }
代码语言:txt
复制
    public void end(String tag, boolean writeLog) {
代码语言:txt
复制
        recordingTimeTag(tag);
代码语言:txt
复制
        end(writeLog);
代码语言:txt
复制
    }
代码语言:txt
复制
    public void end(boolean writeLog) {
代码语言:txt
复制
        if (writeLog) {
代码语言:txt
复制
            //写入到本地文件
代码语言:txt
复制
        }
代码语言:txt
复制
    }
代码语言:txt
复制
    public HashMap<String, Long> getTimeTags() {
代码语言:txt
复制
        return mTimeTag;
代码语言:txt
复制
    }
代码语言:txt
复制
}
  • 耗时统计可能会在多个模块和类中需要打点,所以需要一个单例类来管理各个耗时统计的数据:
代码语言:txt
复制
/**
代码语言:txt
复制
 * @Author: LiuJinYang
 * @CreateDate: 2020/12/14
 */
public class TimeMonitorManager {
    private HashMap<Integer, TimeMonitor> mTimeMonitorMap;
代码语言:txt
复制
    private TimeMonitorManager() {
代码语言:txt
复制
        this.mTimeMonitorMap = new HashMap<>();
代码语言:txt
复制
    }
代码语言:txt
复制
    private static class TimeMonitorManagerHolder {
代码语言:txt
复制
        private static TimeMonitorManager mTimeMonitorManager = new TimeMonitorManager();
代码语言:txt
复制
    }
代码语言:txt
复制
    public static TimeMonitorManager getInstance() {
代码语言:txt
复制
        return TimeMonitorManagerHolder.mTimeMonitorManager;
代码语言:txt
复制
    }
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 初始化打点模块
     */
    public void resetTimeMonitor(int id) {
        if (mTimeMonitorMap.get(id) != null) {
            mTimeMonitorMap.remove(id);
        }
        getTimeMonitor(id).startMonitor();
    }
代码语言:txt
复制
    /**
代码语言:txt
复制
     * 获取打点器
     */
    public TimeMonitor getTimeMonitor(int id) {
        TimeMonitor monitor = mTimeMonitorMap.get(id);
        if (monitor == null) {
            monitor = new TimeMonitor(id);
            mTimeMonitorMap.put(id, monitor);
        }
        return monitor;
    }
}
AOP打点,例如统计Application中的所有方法耗
1. 通过AspectJ
代码语言:txt
复制
//1. 集成aspectj
代码语言:txt
复制
   //根目录build.gradle中
代码语言:txt
复制
  classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
代码语言:txt
复制
  //app module的build.gradle中
代码语言:txt
复制
  apply plugin: 'android-aspectjx'
代码语言:txt
复制
  //如果遇到报错Cause: zip file is empty,可添加如下配置
代码语言:txt
复制
  android{
代码语言:txt
复制
    aspectjx {
代码语言:txt
复制
        include 'com.ljy.publicdemo'
代码语言:txt
复制
    }
代码语言:txt
复制
  }
代码语言:txt
复制
//2. 创建注解类  
代码语言:txt
复制
@Target(ElementType.METHOD)
代码语言:txt
复制
@Retention(RetentionPolicy.RUNTIME)
代码语言:txt
复制
public @interface GetTime {
代码语言:txt
复制
    String tag() default "";
代码语言:txt
复制
}
代码语言:txt
复制
//3. 使用aspectj解析注解并实现耗时记录
代码语言:txt
复制
@Aspect
代码语言:txt
复制
public class AspectHelper {
代码语言:txt
复制
    @Around("execution(@GetTime * *(..))")
代码语言:txt
复制
    public void getTime(ProceedingJoinPoint joinPoint) throws Throwable {
代码语言:txt
复制
        MethodSignature joinPointObject = (MethodSignature) joinPoint.getSignature();
代码语言:txt
复制
        Method method = joinPointObject.getMethod();
代码语言:txt
复制
        boolean flag = method.isAnnotationPresent(GetTime.class);
代码语言:txt
复制
        LjyLogUtil.d("flag:"+flag);
代码语言:txt
复制
        String tag = null;
代码语言:txt
复制
        if (flag) {
代码语言:txt
复制
            GetTime getTime = method.getAnnotation(GetTime.class);
代码语言:txt
复制
            tag = getTime.tag();
代码语言:txt
复制
        }
代码语言:txt
复制
        if (TextUtils.isEmpty(tag)) {
代码语言:txt
复制
            Signature signature = joinPoint.getSignature();
代码语言:txt
复制
            tag = signature.toShortString();
代码语言:txt
复制
        }
代码语言:txt
复制
        long time = System.currentTimeMillis();
代码语言:txt
复制
        try {
代码语言:txt
复制
            joinPoint.proceed();
代码语言:txt
复制
        } catch (Throwable throwable) {
代码语言:txt
复制
            throwable.printStackTrace();
代码语言:txt
复制
        }
代码语言:txt
复制
        LjyLogUtil.d( tag+" get time: " + (System.currentTimeMillis() - time));
代码语言:txt
复制
    }
代码语言:txt
复制
}
2. 通过Epic三方库
代码语言:txt
复制
//目前 Epic 支持 Android 5.0 ~ 11 的 Thumb-2/ARM64 指令集,arm32/x86/x86_64/mips/mips64 不支持。
代码语言:txt
复制
//1. 添加epic依赖
代码语言:txt
复制
implementation 'me.weishu:epic:0.11.0'
代码语言:txt
复制
//2. 使用epic
代码语言:txt
复制
public static class ActivityMethodHook extends XC_MethodHook{
代码语言:txt
复制
    private long startTime;
代码语言:txt
复制
    @Override
代码语言:txt
复制
    protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
代码语言:txt
复制
                super.beforeHookedMethod(param);
代码语言:txt
复制
        startTime = System.currentTimeMillis();
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
代码语言:txt
复制
        super.afterHookedMethod(param);
代码语言:txt
复制
        Activity act = (Activity) param.thisObject;
代码语言:txt
复制
        String methodName=param.method.getName();
代码语言:txt
复制
        LjyLogUtil.d( act.getLocalClassName()+"."+methodName+" get time: " + (System.currentTimeMillis() - startTime));
代码语言:txt
复制
    }
代码语言:txt
复制
}
代码语言:txt
复制
private void initEpic() {
代码语言:txt
复制
    //对所有activity的onCreate执行耗时进行打印
代码语言:txt
复制
     DexposedBridge.hookAllMethods(Activity.class, "onCreate", new ActivityMethodHook());
代码语言:txt
复制
}
代码语言:txt
复制
//也可以用于锁定线程创建者
代码语言:txt
复制
DexposedBridge.hookAllConstructors(Thread.class, new XC_MethodHook() {
代码语言:txt
复制
    @Override
代码语言:txt
复制
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
代码语言:txt
复制
        super.afterHookedMethod(param);
代码语言:txt
复制
        Thread thread = (Thread) param.thisObject;
代码语言:txt
复制
        LjyLogUtil.i("stack " + Log.getStackTraceString(new Throwable()));
代码语言:txt
复制
    }
代码语言:txt
复制
});

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
作者已关闭评论
0 条评论
热度
最新
推荐阅读
目录
  • 启动分析
    • 启动类型
      • 启动过程
        • 启动问题分析
        • 启动优化
          • 优化工具
            • 优化方法
            • 启动进阶方法
              • 1. IO优化
                • 2. 数据重排
                  • 3. 类的加载
                    • 4. 黑科技
                      • 5. MultiDex 优化
                        • 6. 预加载优化
                          • 7. 启动阶段不启动子进程
                            • 8. CPU锁频
                            • 启动监控/耗时检测
                              • logcat
                                • adb shell
                                  • 实验室监控
                                    • 线上监控
                                    相关产品与服务
                                    数据保险箱
                                    数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
                                    领券
                                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档