美文网首页Android
Android应用启动优化

Android应用启动优化

作者: JunL_Dev | 来源:发表于2019-11-03 19:56 被阅读0次
    Start

    应用启动类型

    冷启动

    冷启动指的是:用户在 Launcher 程序里点击应用图标时,会通知ActivityManagerService 启动应用的入口 Activity,ActivityManagerService 发现这个应用还未启动,则会通知 Zygote 进程孵化出应用进程,然后在这个 dalvik 应用进程里执行 ActivityThread 的 main 方法。应用进程接下来通知 ActivityManagerService 应用进程已启动,ActivityManagerService 保存应用进程的一个代理对象,这样 ActivityManagerService 可以通过这个代理对象控制应用进程,然后 ActivityManagerService 通知应用进程创建入口 Activity 的实例,并执行它的生命周期方法。

    1. Launcher startActivity
    2. AMS startActivity
    3. Zygote fork 进程
    4. ActivityThread main()
      4.1. ActivityThread attach
      4.2. handleBindApplication
      4.3. attachBaseContext
      4.4. installContentProviders
      4.5. Application onCreate
    5. ActivityThread 进入 loop 循环
    6. Activity 生命周期回调,onCreate、onStart、onResume...

    整个启动流程我们能干预的主要是 4.3、4.5 和6,应用启动优化主要从这三个地方入手。

    热启动

    与冷启动相比,热启动应用程序要简单得多,开销更低。在热启动,系统会把你活动放到前台,如果所有应用程序的活动仍驻留在内存中,那么应用程序可以避免重复对象初始化,UI 的布局和渲染。
    热启动显示与冷启动场景相同的屏幕行为:系统进程显示空白屏幕,直到应用程序完成呈现活动。

    温启动
    • 用户退出您的应用,但随后重新启动。该过程可能已继续运行,但应用程序必须通过调用 onCreate()从头开始重新创建活动。
    • 系统从内存中驱逐您的应用程序,然后用户重新启动它。进程和 Activity 需要重新启动,但任务可以从保存的实例状态包传递到 onCreate()中。

    启动优化

    • 闪屏页优化
    • MultipDex 优化
    • 第三方库懒加载
    • WebView 优化
    • 线程优化
    • 系统调用优化
    2.1 闪屏页优化

    原因:
    启动应用程序时,空白的启动窗口会保留在屏幕上,直到系统首次完成绘制应用程序为止。此时,系统进程会换出您应用程序的启动窗口,从而允许用户开始与应用程序进行交互。
    解决方式:
    消除启动时的白屏/黑屏,市面上大部分 App 都采用了这种方法,非常简单,是一个障眼法,不会缩短实际冷启动时间,简单贴下实现方式吧。

    <application
        android:name=".MainApplication"
        ...
        android:theme="@style/AppThemeWelcome>
    

    styles.xml 增加一个主题叫 AppThemeWelcome

    <style name="AppThemeWelcome" parent="Theme.AppCompat.NoActionBar">
        ...
        <item name="android:windowBackground">@drawable/logo</item>  <!-- 默认背景-->
    </style>
    

    闪屏页设置这个主题,或者全局给 Application 设置

    <activity android:name=".ui.activity.DemoSplashActivity"
           android:configChanges="orientation|screenSize|keyboardHidden"
           android:theme="@style/AppThemeWelcome"
           android:screenOrientation="portrait">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />
    
               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
    </activity>
    

    这样的话启动 Activity 之后背景会一直在,所以在 Activity 的 onCreate 方法中切换成正常主题

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        setTheme(R.style.AppTheme); //切换正常主题
        super.onCreate(savedInstanceState);
    

    这样打开桌面图标会马上显示 logo,不会出现黑/白屏,直到 Activity 启动完成,替换主题,logo 消失,但是总的启动时间并没有改变。

    2.2 MultiDex 优化
    2.2.1 Apk 编译流程
    1. 打包资源文件,生成 R.java 文件(使用工具 AAPT)
    2. 处理 AIDL 文件,生成 java 代码 (没有 AIDL 则忽略)
    3. 编译 java 文件,生成对应 .class 文件 (java compiler)
    4. .class 文件转换成 dex 文件 (dex)
    5. 打包成没有签名的 Apk (使用工具 aokbilder)
    6. 使用签名工具给 Apk 签名 (使用工具 Jarsinger)
    7. 对签名后的 .apk 文件进行对齐处理,不进行对齐处理不能发布到 Google Marker (使用工具 zipalign)

    在第4步,将 class 文件转换成 dex 文件,默认只会生成一个 dex 文件,单个 dex 文件中的方法数不能超过 65536,不然编译会报错:

    Unable to execute dex: method ID not in [0, 0xffff]: 65536

    App 集成一堆库之后,方法数一般都是超过 65536 的,解决办法就是:一个 dex 装不下,用多个 dex 来装,gradle 增加一行配置即可。

    multiDexEnabled true

    这样解决了编译问题,在5.0以上手机运行正常,但是5.0以下手机运行直接crash,报错 Class NotFound xxx。
    Android 5.0以下,ClassLoader加载类的时候只会从class.dex(主dex)里加载,ClassLoader不认识其它的class2.dex、class3.dex、...,当访问到不在主dex中的类的时候,就会报错:Class NotFound xxx,因此谷歌给出兼容方案,MultiDex

    2.2.2 MultiDex 原理

    MultiDex install :

    public static void install(Context context) {
            Log.i("MultiDex", "Installing application");
            if (IS_VM_MULTIDEX_CAPABLE) { //5.0 以上VM基本支持多dex,啥事都不用干
                Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
            } else if (VERSION.SDK_INT < 4) { // 
                throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
            } else {
                ...
                doInstallation(context, new File(applicationInfo.sourceDir), new File(applicationInfo.dataDir), "secondary-dexes", "", true);
                ...
                Log.i("MultiDex", "install done");
            }
        }
    

    从入口的判断来看,如果虚拟机本身就支持加载多个dex文件,那就啥都不用做;如果是不支持加载多个dex(5.0以下是不支持的),则走到 doInstallation 方法。

    private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix, boolean reinstallOnPatchRecoverableException) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException, InstantiationException {
    ...
                        //获取非主dex文件
                        File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
                        MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
                        IOException closeException = null;
    
                        try {
    
                            // 1. 这个load方法,第一次没有缓存,会非常耗时
                            List files = extractor.load(mainContext, prefsKeyPrefix, false);
    
                            try {
                                //2. 安装dex
                                installSecondaryDexes(loader, dexDir, files);
                            } 
                            ...
    
                    }
                }
            }
        }
    

    先看注释1,MultiDexExtractor#load

        List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload) throws IOException {
            if (!this.cacheLock.isValid()) {
                throw new IllegalStateException("MultiDexExtractor was closed");
            } else {
                List files;
                if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
                    try {
                        //读缓存的dex
                        files = this.loadExistingExtractions(context, prefsKeyPrefix);
                    } catch (IOException var6) {
                        Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var6);
                        //读取缓存的dex失败,可能是损坏了,那就重新去解压apk读取,跟else代码块一样
                        files = this.performExtractions();
                        //保存标志位到sp,下次进来就走if了,不走else
                        putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
                    }
                } else {
                    //没有缓存,解压apk读取
                    files = this.performExtractions();
                    //保存dex信息到sp,下次进来就走if了,不走else
                    putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
                }
    
                Log.i("MultiDex", "load found " + files.size() + " secondary dex files");
                return files;
            }
        }
    

    查找dex文件,有两个逻辑,有缓存就调用 loadExistingExtractions 方法,没有缓存或者缓存读取失败就调用 performExtractions 方法,然后再缓存起来。使用到缓存,那么 performExtractions 方法想必应该是很耗时的,分析一下代码:

    private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {
            //先确定命名格式
            String extractedFilePrefix = this.sourceApk.getName() + ".classes";
            this.clearDexDir();
            List<MultiDexExtractor.ExtractedDex> files = new ArrayList();
            ZipFile apk = new ZipFile(this.sourceApk); // apk转为zip格式
    
            try {
                int secondaryNumber = 2;
                //apk已经是改为zip格式了,解压遍历zip文件,里面是dex文件,
                //名字有规律,如classes1.dex,class2.dex
                for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
                    //文件名:xxx.classes1.zip
                    String fileName = extractedFilePrefix + secondaryNumber + ".zip";
                    //创建这个classes1.zip文件
                    MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);
                    //classes1.zip文件添加到list
                    files.add(extractedFile);
                    Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
                    int numAttempts = 0;
                    boolean isExtractionSuccessful = false;
    
                    while(numAttempts < 3 && !isExtractionSuccessful) {
                        ++numAttempts;
                        //这个方法是将classes1.dex文件写到压缩文件classes1.zip里去,最多重试三次
                        extract(apk, dexFile, extractedFile, extractedFilePrefix);
    
                     ...
                    }
            //返回dex的压缩文件列表
            return files;
        }
    

    这里的逻辑就是解压 apk,遍历出里面的 dex 文件,例如class1.dex,class2.dex,然后又压缩成 class1.zip,class2.zip...,然后返回 zip 文件列表。
    思考为什么这里要压缩呢? 后面涉及到 ClassLoader 加载类原理的时候会分析 ClassLoader 支持的文件格式。

    第一次加载才会执行解压和压缩过程,第二次进来读取 sp 中保存的 dex 信息,直接返回 file list,所以第一次启动的时候比较耗时。

    dex 文件列表找到了,回到上面 MultiDex#doInstallation 方法的注释2,找到的 dex 文件列表,然后调用 installSecondaryDexes 方法进行安装,怎么安装呢?方法点进去看 SDK 19 以上的实现。

    private static final class V19 {
            private V19() {
            }
    
            static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
                Field pathListField = MultiDex.findField(loader, "pathList");//1 反射ClassLoader 的 pathList 字段
                Object dexPathList = pathListField.get(loader);
                ArrayList<IOException> suppressedExceptions = new ArrayList();
                // 2 扩展数组
                MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
               ...
            }
    
            private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
                Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class);
                return (Object[])((Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions));
            }
        }
    
    1. 反射 ClassLoader 的 pathList 字段
    2. 找到pathList 字段对应的类的 makeDexElements 方法
    3. 通过 MultiDex.expandFieldArray 这个方法扩展 dexElements 数组,怎么扩展?看下代码:
        private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
            Field jlrField = findField(instance, fieldName);
            Object[] original = (Object[])((Object[])jlrField.get(instance)); //取出原来的dexElements 数组
            Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length)); //新的数组
            System.arraycopy(original, 0, combined, 0, original.length); //原来数组内容拷贝到新的数组
            System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); //dex2、dex3...拷贝到新的数组
            jlrField.set(instance, combined); //将dexElements 重新赋值为新的数组
        }
    

    就是创建一个新的数组,把原来数组内容(主dex)和要增加的内容(dex2、dex3...)拷贝进去,反射替换原来的 dexElements 为新的数组,如下图

    看起来有点眼熟,Tinker热修复的原理也是通过反射将修复后的dex添加到这个dex数组去,不同的是热修复是添加到数组最前面,而MultiDex是添加到数组后面。这样讲可能还不是很好理解?来看看ClassLoader怎么加载一个类的就明白了~

    2.2.3 ClassLoader 加载类原理

    不管是 PathClassLoader 还是 DexClassLoader ,都继承自 BaseDexClassLoader ,加载类的代码在BaseDexClassLoader 中。

    2.2.4 源码

    /dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

    1. 构造方法通过传入dex路径,创建了 DexPathList
    2. ClassLoader 的 findClass 方法最终是调用 DexPathList 的 findClass 方法

    接着看 DexPathList 源码 /dalvik/src/main/java/dalvik/system/DexPathList.java

    DexPathList 里面定义了一个 dexElements 数组,findClass 方法中用到,看下

    findClass方法逻辑很简单,就是遍历dexElements 数组,拿到里面的DexFile对象,通过DexFile的loadClassBinaryName方法加载一个类。

    那么问题来了,5.0以下这个 dexElements 里面只有主dex(可以认为是一个bug),没有 dex2、dex3...,MultiDex 是怎么把 dex2 添加进去呢?
    答案就是反射 DexPathListdexElements 字段,然后把我们dex2 添加进去,当然,dexElements 里面放的是 Element 对象,我们只有 dex2 的路径,必须转换成 Element 格式才行,所以反射 DexPathList里面的 makeDexElements 方法,将 dex 文件转换成 Element 对象即可。

    dex2、dex3...通过 makeDexElements 方法转换成要新增的 Element 数组,最后一步就是反射DexPathListdexElements 字段,将原来的 Element 数组和新增的 Element 数组合并,然后反射赋值给dexElements 变量,最后 DexPathListdexElements 变量就包含我们新加的 dex 在里面了。

    2.2.5 原理小结

    ClassLoader 加载类原理:

    ClassLoader.loadClass -> DexPathList.loadClass -> 遍历dexElements数组 ->DexFile.loadClassBinaryName

    通俗点说就是:ClassLoader加载类的时候是通过遍历dex数组,从dex文件里面去加载一个类,加载成功就返回,加载失败则抛出Class Not Found 异常。

    MultiDex原理:

    在明白ClassLoader加载类原理之后,我们可以通过反射dexElements数组,将新增的dex添加到数组后面,这样就保证ClassLoader加载类的时候可以从新增的dex中加载到目标类,经过分析后最终MultipDex原理图如下:
    2.2.6 MultiDex 优化(两种方案)

    知道了MultiDex 原理之后,可以理解 install 过程为什么耗时,因为涉及到解压 apk 取出 dex、压缩 dex、将 dex 文件通过反射转换成 DexFile 对象、反射替换数组。

    那么MultiDex到底应该怎么优化呢,放子线程可行吗?

    方案1:子线程install(不推荐)
    在闪屏页开一个子线程去执行 MultiDex.install,然后加载完才跳转到主页。需要注意的是闪屏页的 Activity,包括闪屏页中引用到的其它类必须在主 dex 中,不然在 MultiDex.install 之前加载这些不在主 dex 中的类会报错 Class Not Found。这个可以通过 gradle 配置,如下:

    defaultConfig {
            //分包,指定某个类在main dex
            multiDexEnabled true
            multiDexKeepProguard file('multiDexKeep.pro') // 打包到main dex的这些类的混淆规制,没特殊需求就给个空文件
            multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到main dex
    }
    

    maindexlist.txt 文件指定哪些类要打包到主 dex 中,内容格式如下

    com/lanshifu/launchtest/SplashActivity.class
    

    但是,编译运行在4.4的机器上,启动闪屏页,加载完准备进入主页直接崩掉了。


    报错 NoClassDefFoundError,一般都是该类没有在主 dex 中,要在 maindexlist.txt 将配置指定在主 dex。
    第三方库中的 ContentProvider 必须指定在主 dex 中,否则也会找不到,为什么?文章开头说过应用的启动流程,ContentProvider 初始化时机如下图:

    ContentProvider初始化太早了,如果不在主dex中,还没启动闪屏页就已经crash了。

    所以这种方案的缺点很明显:

    1. MultiDex加载逻辑放在闪屏页的话,闪屏页中引用到的类都要配置在主dex。
    2. ContentProvider必须在主dex,一些第三方库自带ContentProvider,维护比较麻烦,要一个一个配置。

    MultiDex优化方案2:今日头条方案
    今日头条没有加固,反编译后很容易通过关键字搜索找到 MultidexApplication 这个类,

    看注释1的 d.a(this); 这个方法,代码虽然混淆了,但是方法内部的代码还是可以看出是干嘛的,继续跟这个方法,为了不影响阅读,我对混淆做了一些处理,改成正常的方法名。


    每个方法开头都有 PatchProxy.isSupport 这个 if 判断,这个是美团 Robust 热修复生成的代码,今日头条没有自己的热修复框架,没有用Tinker。Robust直接跳过,看else代码块即可。
    继续看 loadMultiDex 方法

    逻辑如下:

    1. 创建临时文件,作为判断 MultiDex 是否加载完的条件
    2. 启动 LoadDexActivity 去加载 MultiDex(LoadDexActivity 在单独进程),加载完会删除临时文件
    3. 开启 while 循环,直到临时文件不存在才跳出循环,进入 Application 的 onCreate。

    创建临时文件代码


    while循环代码


    LoadDexActivity 只有一个加载框,加载完再跳转到闪屏页


    dex 加载完应该要 finish 掉当前 Activity

    按照上面代码分析,今日头条在 5.0 以下手机首次启动应该是这样:

    1. 打开桌面图标
    2. 显示默认背景
    3. 跳转到加载 dex 的界面,展示一个 loading 的加载框几秒钟
    4. 跳转到闪屏页

    再次梳理一下这种方式:
    在主进程 Application 的 attachBaseContext 方法中判断如果需要使用 MultiDex,则创建一个临时文件,然后开一个进程(LoadDexActivity),显示 Loading,异步执行 MultiDex.install 逻辑,执行完就删除临时文件并 finish 自己。
    主进程 Application 的 attachBaseContext 进入while 代码块,定时轮循临时文件是否被删除,如果被删除,说明 MultiDex 已经执行完,则跳出循环,继续正常的应用启动流程。
    注意 LoadDexActivity 必须要配置在 main dex 中。

    MultiDex优化总结
    方案1:直接在闪屏页开个子线程去执行 MultiDex 逻辑,MultiDex 不影响冷启动速度,但是难维护。
    方案2:今日头条的 MultiDex 优化方案:

    在 Application 的 attachBaseContext 方法里,启动另一个进程的 LoadDexActivity 去异步执行 MultiDex 逻辑,显示 Loading。
    然后主进程 Application 进入 while 循环,不断检测 MultiDex 操作是否完成
    MultiDex 执行完之后主进程Application 继续走,ContentProvider 初始化和 Application onCreate 方法,也就是执行主进程正常的逻辑。

    预创建 Activity

    这段代码是今日头条里面的,Activity 对象预先 new 出来,

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

    第三方库懒加载

    很多第三方开源库都说在 Application 中进行初始化,十几个开源库都放在 Application 中,肯定对冷启动会有影响,所以可以考虑按需初始化,例如 Glide,可以放在自己封装的图片加载类中,调用到再初始化,其它库也是同理,让 Application 变得更轻。

    WebView 启动优化
    1. WebView第一次创建比较耗时,可以预先创建WebView,提前将其内核初始化。
    2. 使用WebView缓存池,用到WebView的地方都从缓存池取,缓存池中没有缓存再创建,注意内存泄漏问题。
    3. 本地预置html和css,WebView创建的时候先预加载本地html,之后通过js脚本填充内容部分。

    在实际开发中,遇到过这样一个情况,初始化的时候一定要初始化 webview 用于展示第三方的广告,由于 Splash 广告需要第一时间来展示,所以优先级很高,可以试一下下面这个方法:

    private void startWebViewFactoryEngines(){
            try {
                Class<?> factoryClass = Class.forName("android.webkit.WebViewFactory");
                Method method = factoryClass.getDeclaredMethod("getProvider",(Class<?>)null);
                method.setAccessible(true);
                Object webViewChromiumFactoryProvider = method.invoke(null,(Object)null);
                Method startMethod = webViewChromiumFactoryProvider.getClass().getDeclaredMethod("startYourEngines",boolean.class);
                startMethod.setAccessible(true);
                startMethod.invoke(webViewChromiumFactoryProvider,false);
            } catch (Exception e) {
                e.printStackTrace();
                Log.d("DEBUGPROVIDER","error in start the WebViewFactory Engines:" + e.getMessage());
            }
        }
    

    可以参考(手机打开):mp.weixin.qq.com/s/KwvWURD5W…

    2.6 数据预加载

    这种方式一般是在主页空闲的时候,将其它页面的数据加载好,保存到内存或数据库,等到打开该页面的时候,判断已经预加载过,直接从内存或数据库读取数据并显示。

    2.7 线程优化

    线程是程序运行的基本单位,线程的频繁创建是耗性能的,所以大家应该都会用线程池。单个 cpu 情况下,即使是开多个线程,同时也只有一个线程可以工作,所以线程池的大小要根据 cpu 个数来确定。

    启动优化方式就先介绍到这里,常见的就是这些,其它的可以作为补充。

    相关链接:
    蓝师傅 --- 面试官:今日头条启动很快,你觉得可能是做了哪些优化?
    Developers

    申明:开始和结束的图片来自网络,侵删

    End

    相关文章

      网友评论

        本文标题:Android应用启动优化

        本文链接:https://www.haomeiwen.com/subject/psktbctx.html