其实你不知道MultiDex到底有多坑

作者: Android_Jieyao | 来源:发表于2018-09-12 01:14 被阅读99次

    前言:在android5.0之前,每一个android应用中只会含有一个dex文件,但是这个dex的方法数量被限制在65535之内,这就是著名的64K(64*1024)事件。为了解决这个问题,Google官方推出了这个类似于补丁一样的support-library,MultiDex。上一篇文章我们已经了解了Multidex的使用及原理,详见Android使用Multidex突破64K方法数限制原理解析。本篇文章我会将在使用Multidex的过程中遇到的一些坑点进行总结。

    MultiDex引发的问题

    周二的晚上愉快地写着Android代码,往工程里引入了一个默默无闻的jar然后Run了一下, 经过漫长的等待AndroidStudio构建失败了。wtf ? 发生了什么?
    emmm......带着疑惑查看错误信息:

    UNEXPECTED TOP-LEVEL EXCEPTION: java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536 
        at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501) 
        at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:276) 
        at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490) 
        at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167) 
        at com.android.dx.merge.DexMerger.merge(DexMerger.java:188) 
        at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439) 
        at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287) 
        at com.android.dx.command.dexer.Main.run(Main.java:230) 
        at com.android.dx.command.dexer.Main.main(Main.java:199) 
        at com.android.dx.command.Main.main(Main.java:103):Derp:dexDerpDebug FAILED
    

    看起来是:在试图将 classes和jar塞进一个Dex文件的过程中产生了错误。

    早期的Dex文件保存所有classes的方法个数的范围在0~65535之间。业务一直在增长,写(copy)的代码越来越长引入的库越来越多,超过这个范围只是时间问题。这个问题怎么破??太阳底下木有新鲜事,淡定先google一发,找找已经踩过坑的小伙伴。StackOverflow 的网友们对该问题表示情绪稳定,谈笑间抛出multiDex

    我们来看看Android官方文档对此是如何解释的:

    1.Dalvik Executable (DEX)文件的总方法数限制在65536以内,其中包括Android framwork method, lib method (后来发现仅仅是Android 自己的框架的方法就已经占用了1w多),还有你的 code method ,所以请使用MultiDex。
    2.对于5.0以下版本,请使用multidex support library (这个是我们的补丁包!build tools 请升级到21)。
    3.而5.0及以上版本,由于ART模式的存在,app第一次安装之后会进行一次预编译(pre-compilation) ,如果这时候发现了classes(..N).dex文件的存在就会将他们最终合成为一个.oat的文件(嗯看起来很厉害的样子)。

    同时Google建议review代码的直接或者间接依赖,尽可能减少依赖库,设置proguard参数进一步优化去除无用的代码。嗯,这两个实施起来倒是很简单,但是治标不治本,躲得过初一躲不过十五。

    在Google给出这个解决方案之前,他们的开发人员先给了一个简易版本的multiDex。(怀疑后来的官方解决方案就有这家伙参与)。简单地说就是:1.先把你的app 的class 拆分成主次两个dex。2.你的程序运行起来后,自己把第二个dex给load进来。看就这么简单!

    第一回合 天真的官方补丁方案

    还是先解决打包问题,回头再研究那些高深的动态化加载技术。考虑到投入产出比,决定使用Google官方的multiDex解决。(Google的补丁方案啊,不会再有坑了吧?后面才发现还是太天真) 该方案有两步:

    • 1.修改gradle脚本来产生多dex。
    • 2.修改manifest 使用MulitDexApplication。

    步骤1.在gradle脚本里写上:

    android {
        compileSdkVersion 21
        buildToolsVersion "21.1.0"
    
        defaultConfig {
            ...
            minSdkVersion 14
            targetSdkVersion 21
            ...
    
            // Enabling multidex support.
            multiDexEnabled true
        }
        ...
    }
    
    dependencies {
    compile 'com.android.support:multidex:1.0.0'
    }
    

    步骤2. manifest声明修改

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.multidex.myapplication">
    <application
    ...
    android:name="android.support.multidex.MultiDexApplication">
    ...
    </application>
    </manifest>
    

    如果有自己的Application,继承MulitDexApplication。如果当前代码已经继承自其它Application没办法修改那也行,就重写 Application的attachBaseContext()这个方法。

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(this);     
    }
    

    使用起来还是挺简单的嘛, run一下,可以了!但是等等。。。dex过程好像变慢了。。。这是怎么肥事?

    官方文档还写明了multiDex support lib 的使用局限。瞄一下是什么:

    1.在应用安装到手机上的时候dex文件的安装是复杂的(complex)有可能会因为第二个dex文件太大导致ANR。请用proguard优化你的代码。(呵呵...)
    2.使用了mulitDex的App有可能在4.0(api level 14)以前的机器上无法启动,因为Dalvik linearAlloc bug。请多多测试自祈多福。用proguard优化你的代码将减少该bug几率。(呵呵...)
    3.使用了mulitDex的App在runtime期间有可能因为Dalvik linearAlloc limit Crash。该内存分配限制在 4.0版本被增大,但是5.0以下的机器上的Apps依然会存在这个限制。
    4.主dex被dalvik虚拟机执行时候,哪些类必须在主dex文件里面这个问题比较复杂。build tools 可以搞定这个问题。但是如果你代码存在反射和native的调用也不保证100%正确。(呵呵...)

    感觉这就是个坑啊。补丁方案又引入一些其他问题。但是插件化方案要求对现有代码有比较大的改动,代价太大,而且动态化加载框架意味着维护成本更高,会有更多潜在bug。所以先测试,遇到有问题的版本再解决。

    第二回合 啥?dexopt failed?

    呵呵,部分低端2.3机型(话说2.3版本的android机有高端机型么)安装失败!INSTALL_FAILED_DEXOPT。

    apk是一个zip压缩包,dalvik每次加载apk都要从中解压出class.dex文件,加载过程还涉及到dex的classes需要的杂七杂八的依赖库的加载,真耗时间。于是Android决定优化一下这个问题,在app安装到手机之后,系统运行dexopt程序对dex进行优化,将dex的依赖库文件和一些辅助数据打包成odex文件。存放在cache/dalvik_cache目录下。保存格式为apk路径 @ apk名 @ classes.dex。这样以空间换时间大大缩短读取/加载dex文件的过程。
    那刚才那个bug是啥问题呢,原来dexopt程序的dalvik分配一块内存来统计你的app的dex里面的classes的信息,由于classes太多方法太多超过这个linearAlloc 的限制 。那减小dex的大小就可以咯。

    于是,我们来修改一下gradle脚本:

    android.applicationVariants.all {
        variant ->
            dex.doFirst{
                dex->
                if (dex.additionalParameters == null) {
                    dex.additionalParameters = []
                }
                    dex.additionalParameters += '--set-max-idx-number=48000'
           }
    }
    

    --set-max-idx-number= 用于控制每一个dex的最大方法个数,如果写小一点可能会产生好几个dex。好了 现在2.3的机器可以安装run起来了!

    第三回合 ANR的意思就是Application Not Responding

    问题又来了!这次不仅仅是2.3 的机型!还有一些中档配置的4.x系统的机型。问题现象是:第一次安装后,点击图标,1s,2s,3s... 程序没有任何反应就好像你没点图标一样。5s过去。。。程序ANR!

    其实不仅仅这个App存在这个问题,其他很多App也存在首次安装运行后几秒都无任何响应的现象或者最后ANR了。唯一的例外是美团App,点击图标立马就出现界面。唉要不就算啦?反正就一次。。。不行,这可是产品给用户的第一印象啊太重要了,而且美团搞得定就说明这问题有解决方案。

    ANR了是不是局限1描述的现象??不过也不重要...因为Google只是告诉你说第二个dex太大了导致的。并没有进一步解释根本原因。怎么办?Google一发?搜索点击图标 然后ANR?怎么可能有解决方案嘛。ANR就意味着UI线程被阻塞了,老老实实查看log吧。
    adb logcat -v time > log.txt于是发现 是 install dex + dexopt 时间太长!

    梳理一下流程:

    • 安装完app点击图标之后,系统木有发现对应的process,于是从该apk抽取classes.dex(主dex) 加载,触发 一次dexopt。
    • App 的laucherActivity准备启动 ,触发Application启动, Application的 onattach()方法调用,这时候MultiDex.install()调用,classes2.dex 被install,再次触发dexopt。
    • 然后Applicaition onCreate()执行。然后 launcher Activity真的起来了。

    这些必须在5s内完成不然就ANR给你看!有点棘手。

    首先主dex是无论如何都绕不过加载和dexopt的。如果主dex比较小的话可以节省时间。主dex小就意味着后面的dex大啊,MultiDex.install()是在主线程里做的,总时间又没有实质性改变。install() 能不能放到线程里做啊?貌似不行。。。如果异步化,什么时候install完成都不知道。这时候如果进程需要seconday.dex里的classes信息不就悲剧?主dex越小这个错误几率就越大。要悲剧啊。

    对于这个问题美团的主要思路是:精简主dex+异步加载secondary.dex 。对异步化执行速度的不确定性,他们的解决方案是重写Instrumentation execStartActivity 方法,hook跳转Activity的总入口做判断,如果当前secondary.dex 还没有加载完成,就弹一个loading Activity等待加载完成,如果已经加载完成那最好不过了。

    那我们照搬美团的解决方案不就好了咯?说是这么说, 但是在照搬方案之前,我们需要考虑以下几个方面的问题:

    • 1..分析主dex需要的classes这个脚本比较难写。。。
      Google文档说过这个问题比较复杂, 而且buildTools 不是已经帮我们搞定了吗?去瞄一下主dex的大小:8M 以及secondary.dex 3M 。 它是如何工作的?文档说dx的时候,先依据manifest里注册的组件生成一个 main-list,然后把这list里的classes所依赖的classes找出来,把他们打成classes.dex就是主dex。剩下的classes都放clsses2.dex(如果使用参数限制dex大小的话可能会有classe3.dex 等等) 。主dex至少含有main-list 的classes + 直接依赖classes ,使用mini-main-list参数可以仅仅包含刚才说的classes。
      关于写分析脚本的思路是:直接使用mini-main-list参数获取build目录下的main-list文件,这样manifest声明的类和他们的直接依赖类搞定的了,那后者的直接依赖类怎么解?这些在dvk runtime也是必须的classes。一个思路是解析class文件获得该class的依赖类。还一个思路是自己使用Dexclassloader 加载dex,然后hook getClass()方法,调用一次就记录一个。都挺折腾的。

    • 2..由于历史客观原因,公司项目在维护的App的manifest注册的组件的那些类,承载业务太多,依赖很多三方jar,导致直接依赖类非常多,而且短时间内无法梳理精简,没办法mini化主dex。

    • 3..Application的启动入口太多。。。
      Appication初始化未必是由launcher Activity的启动触发,还有可能是因为Service ,Receiver ,ContentProvider 的启动。 靠拦截重写Instrumentation execStartActivity 解决不了问题。要为 Service ,Receiver ,ContentProvider 分别写基类,然后在oncreate()里判断是否要异步加载secondary.dex。如果需要,弹出Loading Acitvity?用户看到这个会感觉比较怪异。

    结合自身App的实际情况来看美团的拆包方案虽然很美好然但是不能照搬啊。此时此刻,我的心情是拔凉拔凉的!┭┮﹏┭┮

    第四回合 换一种思路解决

    考虑到前面的种种困难, 还是不要写分析脚本了吧。。毕竟投入产出严重失衡啦~~现在我们的问题变成了:既希望在Application的attachContext()方法里同步加载secondary.dex,又不希望卡住UI线程。如果思路限制在线程异步化上,确实不可能实现。

    对此, FB的解决思路特别赞,让Launcher Activity在另外一个进程启动!当然这个Launcher Activity就是用来load dex 的 ,load完成就启动Main Activity。
    app在安装完成之后第一次启动时,是secondary.dex的dexopt花费了更多的时间,认识到这点非常重要,使得问题又转化为:在不阻塞UI线程的前提下,完成dexopt,以后都不需要再次dexopt,所以可以在UI线程install dex 了!

    因此,以下给出对FB解决方案的改进版:
    先来看一下解决问题的思路流程图:

    MultiDex解决方案

    上最终解决问题版的代码!

    • 在Application里面(这里不要再继承自MultiApplication了,我们要手动加载Dex):
    import java.util.Map;
    import java.util.jar.Attributes;
    import java.util.jar.JarFile;
    import java.util.jar.Manifest;
    
    public class App extends Application {
        // 标记
        public static final String KEY_DEX2_SHA1 = "dex2-SHA1-Digest";
        @Override
        protected void attachBaseContext(Context base) {
            super .attachBaseContext(base);
            LogUtils.d( "loadDex", "App attachBaseContext ");
            //版本在5.0以下并且未执行过dexopt
            if (!quickStart() && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {//>=5.0的系统默认对dex进行oat优化
                if (needWait(base)){ // 需要等待
                    waitForDexopt(base); // 等待
                }
                MultiDex.install (this );
            } else {
                return;
            }
        }
    
        @Override
        public void onCreate() {
            super .onCreate();
            if (quickStart()) {
                return;
            }
            ...
        }
    
        // 是否执行过dexopt
        public boolean quickStart() {
            if (StringUtils.contains( getCurProcessName(this), ":mini")) {
                LogUtils.d( "loadDex", ":mini start!");
                return true;
            }
            return false ;
        }
        //是否需要等待dexopt完成
        private boolean needWait(Context context){
            String flag = get2thDexSHA1(context);
            LogUtils.d( "loadDex", "dex2-sha1 "+flag);
            SharedPreferences sp = context.getSharedPreferences(
                    PackageUtil.getPackageInfo(context). versionName, MODE_MULTI_PROCESS);
            String saveValue = sp.getString(KEY_DEX2_SHA1, "");
            return !StringUtils.equals(flag,saveValue);
        }
        /**
         * Get classes.dex file signature
         * @param context
         * @return
         */
        private String get2thDexSHA1(Context context) {
            ApplicationInfo ai = context.getApplicationInfo();
            String source = ai.sourceDir;
            try {
                JarFile jar = new JarFile(source);
                Manifest mf = jar.getManifest();
                Map<String, Attributes> map = mf.getEntries();
                Attributes a = map.get("classes2.dex");
                return a.getValue("SHA1-Digest");
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null ;
        }
        // optDex 操作完成
        public void installFinish(Context context){
            SharedPreferences sp = context.getSharedPreferences(
                    PackageUtil.getPackageInfo(context).versionName, MODE_MULTI_PROCESS);
            sp.edit().putString(KEY_DEX2_SHA1,get2thDexSHA1(context)).commit();
        }
        // 获取当前进程名字
        public static String getCurProcessName(Context context) {
            try {
                int pid = android.os.Process.myPid();
                ActivityManager mActivityManager = (ActivityManager) context
                        .getSystemService(Context. ACTIVITY_SERVICE);
                for (ActivityManager.RunningAppProcessInfo appProcess : mActivityManager
                        .getRunningAppProcesses()) {
                    if (appProcess.pid == pid) {
                        return appProcess. processName;
                    }
                }
            } catch (Exception e) {
                // ignore
            }
            return null ;
        }
    
        // 等待 进入LoadDexActivity 
        public void waitForDexopt(Context base) {
            Intent intent = new Intent();
            ComponentName componentName = new
                    ComponentName( "com.zongwu", LoadResActivity.class.getName());
            intent.setComponent(componentName);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            base.startActivity(intent);
            long startWait = System.currentTimeMillis ();
            long waitTime = 10 * 1000 ;
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB_MR1 ) {
                waitTime = 20 * 1000 ;//实测发现某些场景下有些2.3版本有可能10s都不能完成optdex
            }
            while (needWait(base)) {
                try {
                    long nowWait = System.currentTimeMillis() - startWait;
                    LogUtils.d("loadDex" , "wait ms :" + nowWait);
                    if (nowWait >= waitTime) {
                        return;
                    }
                    Thread.sleep(200 );
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    

    其中PackageUtil的方法getPackageInfo

     public static PackageInfo getPackageInfo(Context context){
            PackageManager pm = context.getPackageManager();
            try {
                return pm.getPackageInfo(context.getPackageName(), 0);
            } catch (PackageManager.NameNotFoundException e) {
                LogUtils.e(e.getLocalizedMessage());
            }
            return  new PackageInfo();
        }
    

    在Application启动的时候会检测dexopt是否已经完成过,(检测方式是查看sp文件是否有dex文件的SHA1-Digest记录,这里要两个进程读取该sp,读取模式是MODE_MULTI_PROCESS)。如果没有就启动LoadDexActivity(属于:mini进程) 。否则就直接install dex !对,直接install。通过日志发现,已经dexopt的dex文件再次install的时候 只耗费几十毫秒。

    • LoadDexActivity 的逻辑比较简单,启动AsyncTask 来install dex 这时候会触发dexopt 。
    public class LoadResActivity extends Activity {
        @Override
        public void onCreate(Bundle savedInstanceState) {
            requestWindowFeature(Window.FEATURE_NO_TITLE);
            super .onCreate(savedInstanceState);
            getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN , WindowManager.LayoutParams.FLAG_FULLSCREEN );
            overridePendingTransition(R.anim.null_anim, R.anim.null_anim);
            setContentView(R.layout.layout_load);      
            // 执行dexopt操作
            new LoadDexTask().execute();
        }
        class LoadDexTask extends AsyncTask {
            @Override
            protected Object doInBackground(Object[] params) {
                try {
                    MultiDex.install(getApplication());
                    LogUtils.d("loadDex" , "install finish" );
                    ((App) getApplication()).installFinish(getApplication());
                } catch (Exception e) {
                    LogUtils.e("loadDex" , e.getLocalizedMessage());
                }
                return null;
            }
            @Override
            protected void onPostExecute(Object o) {
                LogUtils.d( "loadDex", "get install finish");
                finish();
                System.exit(0); // 退出当前进程
            }
        }
        @Override
        public void onBackPressed() {
            //cannot backpress
        }
    
    • Manifest.xml 里面指定LoadResActivity启动模式和运行进程
    <activity
        android:name= "com.zongwu.LoadResActivity"
        android:launchMode= "singleTask"
        android:process= ":mini"
        android:alwaysRetainTaskState= "false"
        android:excludeFromRecents= "true"
        android:screenOrientation= "portrait" />
    
    <activity
        android:name= "com.zongwu.WelcomeActivity"
        android:launchMode= "singleTop"
        android:screenOrientation= "portrait">
        <intent-filter >
            <action android:name="android.intent.action.MAIN"/>
            <category android:name="android.intent.category.LAUNCHER"/>
        </intent-filter >
    </activity>
    

    替换Activity默认的出现动画 R.anim.null_anim 文件的定义:

    <set xmlns:android="http://schemas.android.com/apk/res/android">
        <alpha
            android:fromAlpha="1.0"
            android:toAlpha="1.0"
            android:duration="550"/>
    </set>
    

    application启动了LoadDexActivity之后,自身不再是前台进程所以怎么hold 线程都不会ANR。Perfect !!!

    总结~~

    OK. Multidex使用过程中遇到的坑,总算是完美解决了。感谢cctv...

    • MultiDex的问题难点在:要持续解决好几个bug才能最终解决问题。进一步的,想要仔细分辨且解决这些bug,就必须持续探索一些关联性的概念和原理。

    • 耗费了这么多时间来解决了Android系统的缺陷是不是有点略伤心。这不应该是Google给出一个比较彻底的解决方案吗?

    • 时间不早了, 我该睡觉了~~ O(∩_∩)O哈哈~

    相关文章

      网友评论

      本文标题:其实你不知道MultiDex到底有多坑

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