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

0.204字数 2180阅读 9096

背景

目前来说,对于使用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开发者,喜欢技术分享,热爱开源。

推荐阅读更多精彩内容