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

背景

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容