关于MultiDex方案的一点研究与思考

    • 背景
    • 产生65535问题的原因
    • LinearAlloc问题的原因
    • Google提出的MultiDex方案
      • MultiDex实现原理
      • 缺点
    • 美团的多Dex分包动态异步加载方案
      • 多Dex分包
      • 异步加载方案
    • 参考资料
    • 关于我

背景

目前来说,对于使用Android Studio的朋友来说,MultiDex应该不陌生,就是Google为了解决『65535天花板』问题而给出的官方解决方案,但是这个方案并不完美,所以美团又给出了异步加载Dex文件的方案。今天这篇文章是我最近研究MultiDex方案的一点收获,最后还留了一个没有解决的问题,如果你有思路的话,欢迎交流!

产生65535问题的原因

单个Dex文件中,method个数采用使用原生类型short来索引,即4个字节最多65536个method,field、class的个数也均有此限制,关于如何解决由于引用过多基础依赖项目,造成field超过65535问题,请参考@寒江不钓的这篇文章『当Field邂逅65535』

对于Dex文件,则是将工程所需全部class文件合并且压缩到一个DEX文件期间,也就是使用Dex工具将class文件转化为Dex文件的过程中, 单个Dex文件可被引用的方法总数(自己开发的代码以及所引用的Android框架、类库的代码)被限制为65536。

这就是65535问题的根本来源。

LinearAlloc问题的原因

这个问题多发生在2.x版本的设备上,安装时会提示INSTALL_FAILED_DEXOPT,这个问题发生在安装期间,在使用Dalvik虚拟机的设备上安装APK时,会通过DexOpt工具将Dex文件优化为ODex文件,即Optimised Dex,这样可以提高执行效率。

在Android版本不同分别经历了4M/5M/8M/16M限制,目前主流4.2.x系统上可能都已到16M, 在Gingerbread或以下系统LinearAllocHdr分配空间只有5M大小的, 高于Gingerbread的系统提升到了8M。Dalvik linearAlloc是一个固定大小的缓冲区。dexopt使用LinearAlloc来存储应用的方法信息。Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB或16MB。当应用的方法信息过多导致超出缓冲区大小时,会造成dexopt崩溃,造成INSTALL_FAILED_DEXOPT错误。

Google提出的MultiDex方案

当App不断迭代的时候,总有一天会遇到这个问题,为此Google也给出了解决方案,具体的操作步骤我就不多说了,无非就是配置Application和Gradle文件,下面我们简单看一下这个方案的实现原理。

MultiDex实现原理

实际起作用的是下面这个jar包

~/sdk/extras/android/support/multidex/library/libs/android-support-multidex.jar

不管是继承自MultiDexApplication还是重写attachBaseContext(),实际都是调用下面的方法

public class MultiDexApplication extends Application {
    protected void attachBaseContext(final Context base) {
        super.attachBaseContext(base);
        MultiDex.install((Context)this);
    }
}

下面重点看下MutiDex.install(Context)的实现,代码很容易理解,重点的地方都有注释

static {
    //第二个Dex文件的文件夹名,实际地址是/date/date/<package_name>/code_cache/secondary-dexes
        SECONDARY_FOLDER_NAME = "code_cache" + File.separator + "secondary-dexes";
        installedApk = new HashSet<String>();
        IS_VM_MULTIDEX_CAPABLE = isVMMultidexCapable(System.getProperty("java.vm.version"));
    }

public static void install(final Context context) {
    //在使用ART虚拟机的设备上(部分4.4设备,5.0+以上都默认ART环境),已经原生支持多Dex,因此就不需要手动支持了
        if (MultiDex.IS_VM_MULTIDEX_CAPABLE) {
            Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
            return;
        }
        if (Build.VERSION.SDK_INT < 4) {
            throw new RuntimeException("Multi dex installation failed. SDK " + Build.VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
        }
        try {
            final ApplicationInfo applicationInfo = getApplicationInfo(context);
            if (applicationInfo == null) {
                return;
            }
            synchronized (MultiDex.installedApk) {
                  //如果apk文件已经被加载过了,就返回
                final String apkPath = applicationInfo.sourceDir;
                if (MultiDex.installedApk.contains(apkPath)) {
                    return;
                }
                MultiDex.installedApk.add(apkPath);
                if (Build.VERSION.SDK_INT > 20) {
                    Log.w("MultiDex", "MultiDex is not guaranteed to work in SDK version " + Build.VERSION.SDK_INT + ": SDK version higher than " + 20 + " should be backed by " + "runtime with built-in multidex capabilty but it‘s not the " + "case here: java.vm.version=\"" + System.getProperty("java.vm.version") + "\"");
                }
                ClassLoader loader;
                try {
                    loader = context.getClassLoader();
                }
                catch (RuntimeException e) {
                    Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", (Throwable)e);
                    return;
                }
                if (loader == null) {
                    Log.e("MultiDex", "Context class loader is null. Must be running in test mode. Skip patching.");
                    return;
                }
                try {
                //清楚之前的Dex文件夹,之前的Dex放置在这个文件夹
                //final File dexDir = new File(context.getFilesDir(), "secondary-dexes");
                    clearOldDexDir(context);
                }
                catch (Throwable t) {
                    Log.w("MultiDex", "Something went wrong when trying to clear old MultiDex extraction, continuing without cleaning.", t);
                }
                final File dexDir = new File(applicationInfo.dataDir, MultiDex.SECONDARY_FOLDER_NAME);
                //将Dex文件加载为File对象
                List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false);
                //检测是否是zip文件
                if (checkValidZipFiles(files)) {
                    //正式安装其他Dex文件
                    installSecondaryDexes(loader, dexDir, files);
                }
                else {
                    Log.w("MultiDex", "Files were not valid zip files.  Forcing a reload.");
                    files = MultiDexExtractor.load(context, applicationInfo, dexDir, true);
                    if (!checkValidZipFiles(files)) {
                        throw new RuntimeException("Zip files were not valid.");
                    }
                    installSecondaryDexes(loader, dexDir, files);
                }
            }
        }
        catch (Exception e2) {
            Log.e("MultiDex", "Multidex installation failure", (Throwable)e2);
            throw new RuntimeException("Multi dex installation failed (" + e2.getMessage() + ").");
        }
        Log.i("MultiDex", "install done");
    }

从上面的过程来看,只是完成了加载包含着Dex文件的zip文件,具体的加载操作都在下面的方法中

installSecondaryDexes(loader, dexDir, files);

下面重点看下

private static void installSecondaryDexes(final ClassLoader loader, final File dexDir, final List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
        if (!files.isEmpty()) {
            if (Build.VERSION.SDK_INT >= 19) {
                install(loader, files, dexDir);
            }
            else if (Build.VERSION.SDK_INT >= 14) {
                install(loader, files, dexDir);
            }
            else {
                install(loader, files);
            }
        }
    }

到这里为了完成不同版本的兼容,实际调用了不同类的方法,我们仅看一下>=14的版本,其他的类似

private static final class V14
    {
        private static void install(final ClassLoader loader, final List<File> additionalClassPathEntries, final File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
            //通过反射获取loader的pathList字段,loader是由Application.getClassLoader()获取的,实际获取到的是PathClassLoader对象的pathList字段
            final Field pathListField = findField(loader, "pathList");
            final Object dexPathList = pathListField.get(loader);
            //dexPathList是PathClassLoader的私有字段,里面保存的是Main Dex中的class
            //dexElements是一个数组,里面的每一个item就是一个Dex文件
            //makeDexElements()返回的是其他Dex文件中获取到的Elements[]对象,内部通过反射makeDexElements()获取
            //expandFieldArray是为了把makeDexElements()返回的Elements[]对象添加到dexPathList字段的成员变量dexElements中
            expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
        }

        private static Object[] makeDexElements(final Object dexPathList, final ArrayList<File> files, final File optimizedDirectory) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
            final Method makeDexElements = findMethod(dexPathList, "makeDexElements", (Class<?>[])new Class[] { ArrayList.class, File.class });
            return (Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory);
        }
    }

PathClassLoader.java

public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
}

BaseDexClassLoader的代码如下,实际上寻找class时,会调用findClass(),会在pathList中寻找,因此通过反射手动添加其他Dex文件中的class到pathList字段中,就可以实现类的动态加载,这也是MutiDex方案的基本原理。

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn‘t find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }
}

缺点

通过查看MultiDex的源码,可以发现MultiDex在冷启动时,因为会同步的反射安装Dex文件,进行IO操作,容易导致ANR

  1. 在冷启动时因为需要安装Dex文件,如果Dex文件过大时,处理时间过长,很容易引发ANR
  2. 采用MultiDex方案的应用因为linearAlloc的BUG,可能不能在2.x设备上启动

美团的多Dex分包、动态异步加载方案

首先我们要明白,美团的这个动态异步加载方案,和插件化的动态加载方案要解决的问题不一样,我们这里讨论的只是单纯的为了解决65535问题,并且想办法解决Google的MutiDex方案的弊端。

多Dex分包

首先,采用Google的方案我们不需要关心Dex分包,开发工具会自动的分析依赖关系,把需要的class文件及其依赖class文件放在Main Dex中,因此如果产生了多个Dex文件,那么classes.dex内的方法数一般都接近65535这个极限,剩下的class才会被放到Other Dex中。如果我们可以减小Main Dex中的class数量,是可以加快冷启动速度的。

美团给出了Gradle的配置,但是由于没有具体的实现,所以这块还需要研究。

tasks.whenTaskAdded { task ->
    if (task.name.startsWith(‘proguard‘) && (task.name.endsWith(‘Debug‘) || task.name.endsWith(‘Release‘))) {
        task.doLast {
            makeDexFileAfterProguardJar();
        }
        task.doFirst {
            delete "${project.buildDir}/intermediates/classes-proguard";

            String flavor = task.name.substring(‘proguard‘.length(), task.name.lastIndexOf(task.name.endsWith(‘Debug‘) ? "Debug" : "Release"));
            generateMainIndexKeepList(flavor.toLowerCase());
        }
    } else if (task.name.startsWith(‘zipalign‘) && (task.name.endsWith(‘Debug‘) || task.name.endsWith(‘Release‘))) {
        task.doFirst {
            ensureMultiDexInApk();
        }
    }
}

实现Dex自定义分包的关键是分析出class之间的依赖关系,并且干涉Dex文件的生成过程。

Dex也是一个工具,通过设置参数可以实现哪一些class文件在Main Dex中。

afterEvaluate {
    tasks.matching {
        it.name.startsWith(‘dex‘)
    }.each { dx ->
        if (dx.additionalParameters == null) {
            dx.additionalParameters = []
        }
        dx.additionalParameters += ‘--multi-dex‘
        dx.additionalParameters += ‘--set-max-idx-number=30000‘
        println("dx param = "+dx.additionalParameters)
        dx.additionalParameters += "--main-dex-list=$projectDir/multidex.keep".toString()
    }
}
  • –multi-dex 代表采用多Dex分包
  • –set-max-idx-number=30000 代表每个Dex文件中的最大id数,默认是65535,通过修改这个值可以减少Main Dex文件的大小和个数。比如一个App混淆后方法数为48000,即使开启MultiDex,也不会产生多个Dex,如果设置为30000,则就产生两个Dex文件
  • –main-dex-list= 代表在Main Dex中的class文件

需要注意的是,上面我给出的gredle task,只在1.4以下管用,在1.4+版本的gradle中,app:dexXXX task 被隐藏了(更多信息请参考Gradle plugin的更新信息),jacoco, progard, multi-dex三个task被合并了。

The Dex task is not available through the variant API anymore….

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks, and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) have all moved to this new mechanism already in 1.5.0-beta1.

所以通过上面的方法无法对Dex过程进行劫持。这也是我现在还没有解决的问题,有解决方案的朋友可以指点一下!

异步加载方案

其实前面的操作都是为了这一步操作的,无论将Dex分成什么样,如果不能异步加载,就解决不了ANR和加载白屏的问题,所以异步加载是一个重点。

异步加载主要问题就是:如何避免在其他Dex文件未加载完成时,造成的ClassNotFoundException问题?

美团给出的解决方案是替换Instrumentation,但是博客中未给出具体实现,我对这个技术点进行了简单的实现,Demo在这里MultiDexAsyncLoad,对ActivityThread的反射用的是携程的解决方案。

首先继承自Instrumentation,因为这一块需要涉及到Activity的启动过程,所以对这个过程不了解的朋友请看我的这篇文章【凯子哥带你学Framework】Activity启动过程全解析

/**
 * Created by zhaokaiqiang on 15/12/18.
 */
public class MeituanInstrumentation extends Instrumentation {

    private List<String> mByPassActivityClassNameList;

    public MeituanInstrumentation() {
        mByPassActivityClassNameList = new ArrayList<>();
    }

    @Override
    public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {

        if (intent.getComponent() != null) {
            className = intent.getComponent().getClassName();
        }

        boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
        if (mByPassActivityClassNameList.contains(className)) {
            shouldInterrupted = false;
        }
        if (shouldInterrupted) {
            className = WaitingActivity.class.getName();
        } else {
            mByPassActivityClassNameList.add(className);

        }
        return super.newActivity(cl, className, intent);
    }

}

至于为什么重写了newActivity(),是因为在启动Activity的时候,会经过这个方法,所以我们在这里可以进行劫持,如果其他Dex文件还未异步加载完,就跳转到Main Dex中的一个等待Activity——WaitingActivity。

 private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
        ActivityInfo aInfo = r.activityInfo;
        if (r.packageInfo == null) {
            r.packageInfo = getPackageInfo(aInfo.applicationInfo, r.compatInfo,
                    Context.CONTEXT_INCLUDE_CODE);
        }

      Activity activity = null;
        try {
            java.lang.ClassLoader cl = r.packageInfo.getClassLoader();

            activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);

         } catch (Exception e) {
        }
   }

在WaitingActivity中可以一直轮训,等待异步加载完成,然后跳转至目标Activity。

public class WaitingActivity extends BaseActivity {

    private Timer timer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_wait);
        waitForDexAvailable();
    }

    private void waitForDexAvailable() {

        final Intent intent = getIntent();
        final String className = intent.getStringExtra(TAG_TARGET);

        timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                while (!MeituanApplication.isDexAvailable()) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    Log.d("TAG", "waiting");
                }
                intent.setClassName(getPackageName(), className);
                startActivity(intent);
                finish();
            }
        }, 0);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (timer != null) {
            timer.cancel();
        }
    }
}

异步加载Dex文件放在什么时候合适呢?

我放在了Application.onCreate()中

public class MeituanApplication extends Application {

    private static final String TAG = "MeituanApplication";
    private static boolean isDexAvailable = false;

    @Override
    public void onCreate() {
        super.onCreate();
        loadOtherDexFile();
    }

    private void loadOtherDexFile() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                MultiDex.install(MeituanApplication.this);
                isDexAvailable = true;
            }
        }).start();
    }

    public static boolean isDexAvailable() {
        return isDexAvailable;
    }
}

那么替换系统默认的Instrumentation在什么时候呢?

当SplashActivity跳转到MainActivity之后,再进行替换比较合适,于是

public class MainActivity extends BaseActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        MeituanApplication.attachInstrumentation();
    }
}

MeituanApplication.attachInstrumentation()实际就是通过反射替换默认的Instrumentation

“`

public class MeituanApplication extends Application {

public static void attachInstrumentation() {
    try {
        SysHacks.defineAndVerify();
        MeituanInstrumentation meiTuanInstrumentation = new MeituanInstrumentation();
        Object activityThread = AndroidHack.getActivityThread();
        Field mInstrumentation = activityThread.getClass().getDeclaredField("mInstrumentation");
        mInstrumentation.setAccessible(true);
        mInstrumentation.set(activityThread, meiTuanInstrumentation);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

}

“`

至此,异步加载Dex方案的一个基本思路就通了,剩下的就是完善和版本兼容了。

参考资料

关于我

江湖人称『凯子哥』,Android开发者,喜欢技术分享,热爱开源。

时间: 2024-10-03 13:39:53

关于MultiDex方案的一点研究与思考的相关文章

discuz 7.2 faq.php sql注入的一点研究

6.2号(可能更早)看到网上这个exp,是一个discuz 7.2的sql注射漏洞 经过多番考证,网上多数exp中都存在这些或者那些的问题,我自己利用和修改后总结,利用方法如下: Discuz 7.2 /faq.php SQL注入漏洞 1.获取数据库版本信息 faq.php?action=grouppermission&gids[99]='&gids[100][0]=) and (select 1 from (select count(*),concat(version(),floor(r

关于Hadoop生态中的HA方案的一点思考

在给学生授课和搭建Hadoop生态实验环境的过程中,我发现无论是网络上的参考资料.还是来自大数据服务功供应商的运维文档,给出Hadoop的HA解决方案都如出一辙--使用 ZooKeeper 加 Quorum Journal Manager 方案. 诚然,这一方案久经考验,是十分成熟的可靠方案.与NFS方案相比较,它解除了大量写场景下NFS仅支持单个共享编辑目录的系统可用性限制:与Federation方案相比较,则较好地解决了单个joint-namespace中的单点故障问题,因为篱笆内的各nam

Spark版本定制八:Spark Streaming源码解读之RDD生成全生命周期彻底研究和思考

本期内容: 1.DStream与RDD关系彻底研究 2.Streaming中RDD的生成彻底研究 一.DStream与RDD关系彻底研究 课前思考: RDD是怎么生成的? RDD依靠什么生成?根据DStream来的 RDD生成的依据是什么? Spark Streaming中RDD的执行是否和Spark Core中的RDD执行有所不同? 运行之后我们对RDD怎么处理? ForEachDStream不一定会触发Job的执行,但是它一定会触发job的产生,和Job是否执行没有关系: 对于DStream

关于卷积神经网络旋转不变性的一点研究

今天一直在思考CNN的旋转不变性,众所周知,CNN具有平移不变性,但是是否具有旋转不变性呢.我们来研究下吧. 1 查阅资料 查阅了许多国内外资料,在解释旋转不变性的时候,普遍得出来,CNN具有一定的旋转不变性,但是这个旋转不变性是有一定的角度控制的,当然起作用的是maxpooling 层,当我们正面拍一些照片的时候,在某些地方会得到activation.然后旋转一定的角度之后,这个依然在相同的点得到activation区域.当然决定这个区域的是maxpooling 层,所以说maxpooling

【原创】Android selector选择器无效或无法正常显示的一点研究

想将LinearLayout作为一个按钮,加上一个动态背景,按下的时候,背景变色,这个理所当然应该使用selector背景选择器来做: <LinearLayout android:id="@+id/btn_user_profit_record" android:layout_width="0dp" android:layout_height="130dp" android:layout_weight="1" androi

Spark发行版笔记9:Spark Streaming源码解读之Receiver生成全生命周期彻底研究和思考

本节的主要内容: 一.Receiver启动的方式设想 二.Receiver启动源码彻底分析 Receiver的设计是非常巧妙和出色的,非常值得我们去学习.研究.借鉴. 在深入认识Receiver之前,我们有必要思考一下,如果没有Spark.Spark Streaming,我们怎么实现Reciver?数据不断接进来,我们该怎么做?该怎么启动Receiver呢?...... 首先,我们找到数据来源的入口,入口如下: 数据来源kafka.socket.flume等构建的都是基于InputDStream

Spark 定制版:008~Spark Streaming源码解读之RDD生成全生命周期彻底研究和思考

本讲内容: a. DStream与RDD关系的彻底的研究 b. Streaming中RDD的生成彻底研究 注:本讲内容基于Spark 1.6.1版本(在2016年5月来说是Spark最新版本)讲解. 上节回顾 上节课,我们重点给大家揭秘了JobScheduler内幕:可以说JobScheduler是整个Spark Streming的调度的核心,其地位相当于Spark Core中的DAGScheduler. JobScheduler是SparkStreaming 所有Job调度的中心,内部有两个重

Spark Streaming源码解读之生成全生命周期彻底研究与思考

本期内容 : DStream与RDD关系彻底研究 Streaming中RDD的生成彻底研究 问题的提出 : 1. RDD是怎么生成的,依靠什么生成 2.执行时是否与Spark Core上的RDD执行有什么不同的 3. 运行之后我们要怎么处理 为什么有第三点 : 是因为Spark Streaming 中会随着相关触发条件,窗口Window滑动的时候都会不断的产生RDD , 从最基本的层次考虑,RDD也是基本对象,每秒会产生RDD ,内存能不能完全容纳,每个处理完成后怎么进行管理? 一. 整个Spa

关于重连测试的一点研究

在最近的异常测试中,发现长连接协议的客户端存在较多的坑点,除了需要关注一般的网络错误.超时之外,长连接本身就具有无连接时创建连接,连接异常时重连这样的特性,是额外需要关注的地方.如果处理不好,往往会造成无限重连socket占满,或者是网络断开没有触发重连导致后续请求全都发不出去这样的大问题 然而我在做这类测试的时候也是一头雾水,尝试用iptables reject或者drop了连接,触发了几次报错或者重连是否就真的对这样的场景验证完全了呢?到底应该iptables掉的是客户端发送的包还是客户端接