背景

在Android开发行业里,插件化已经不是一门新鲜的技术了,在稍大的平台型App上早已是标配。进入2017年,Atlas、Replugin、VirtualAPK相继开源,标志着插件化技术进入了成熟阶段。但纵观各大插件框架,都是基于自身App的业务来开发的,目标或多或少都有区别,所以很难有一个插件框架能一统江湖解决所有问题。最后就是绕不开的兼容性问题,Android每次版本升级都会给各个插件化框架带来不少冲击,都要费劲心思适配一番,更别提国内各个厂商对在ROM上做的定制了,正如VirtualAPK的作者任玉刚所说:完成一个插件化框架的 Demo 并不是多难的事儿,然而要开发一款完善的插件化框架却并非易事。

早在2014年美团移动技术团队就开始关注插件化技术了,并且意识到插件化架构是美团这种平台型App最好的集成形式。但由于业务增长、迭代、演化太快,受限于业务耦合和架构问题,插件化一直无法落地。到了2016年底,经过一系列的代码架构调整、技术调研,我们终于能腾出手来让插件化技术落地了。

美团平台(与点评平台一起)目前承载了美团所有事业群近20条业务线的业务。其中有相对成熟的业务,比如外卖、餐饮,他们对插件的要求是稳定性高,不能因为上了插件导致业务出问题;也有迭代变化很快的业务,如交通、跑腿、金融等,他们要求能快速迭代上线;此外,由于美团App采用的二进制AAR依赖方式集成已经运转了两年,各种基础设施都很成熟了,我们不希望换成插件形式的接入之后还要改变开发模式。所以,美团平台对插件的诉求主要集中在兼容性和不影响开发模式这两个点上。

美团插件化框架的原理和特点

插件框架的兼容性体现在多个方面,由于Android机制的问题,有些写法在插件化之前运行的很正常,但是接入插件化之后就变得不再有效。如果不解决兼容性问题,插件化的口碑和推广都会很大阻碍。兼容性不仅仅指的是对Android系统、Android碎片化的兼容,还要对已有基础库和构建工具的兼容。特别是后者,我们经常看到Github上开源的插件化框架里面有大量Crash的Issue,就是这个方面原因导致的。每个App的基础库和既有构建工具都不太一样,所以为自己的App选择合适的方案显得尤为重要。

为了保证插件的兼容性,并能无缝兼容当前AAR开发模式,美团的插件化框架方案主要做了以下几点::

  • 插件的Dex加载使用类似MultiDex方案,保证对反射的兼容
  • 替换所有的AssetManager,保证对资源访问的兼容
  • 四大组件预埋,代理新增Activity
  • 让构建系统来抹平AAR开发模式和插件化开发模式的差异

MultiDex和组件代理这里不细说,网上有很多这方面的博客可以参考。下面重点说一下美团插件化框架对资源的处理和支持AAR、插件一键切换的构建系统。

资源处理

了解插件化的读者都知道:如果希望访问插件的资源,需要使用AssetManager把插件的路径加入进去。但这样做是远远不够的。这是因为如果希望这个AssetManager生效,就得把它放到具体的Resources或ResourcesImpl里面,大部分插件化框架的做法是封装一个包含插件路径AssetManager的Resources,然后插件中只使用这一个Resources。

这样的做法大多数情况是有效的,但是有至少3个问题: 1. 如果在插件中使用了宿主Resources,如:getApplicationContext().getResources()。 这个Resources就无法访问插件的资源了 2. 插件外的Resources 并不唯一,需要全局查找和替换 3. Resoureces在使用的过程中有很多中间产物,例如Theme、TypedArray等等。这些都需要清理才能正常使用

要完全解决这些问题,我们另辟蹊径,做了一个全局的资源处理方式: * 新建或者使用已有AssetManger,加载插件资源 * 查找所有的Resources/Theme,替换其中的AssetManger * 清理Resources缓存,重建Theme * AssetManager的重建保护,防止丢失插件路径

这个方案和InstantRun有点类似,但是原生InstantRun有太多的问题: * 清理顺序错误,应该先清理Applicaiton后清理Activity * Resources/Theme找不全,没有极端情况应对机制 * Theme光清理不重建 * 完全不适配 Support包里面自己埋的“雷” 等等

举个例子Theme找不全:InstantRun会替换Theme中的AssetManager,做法是从每个Activity里面获取。

for (Activity activity : activities) {
    ... // 省略部分代码
    Resources.Theme theme = activity.getTheme();
    try {
        try {
            Field ma = Resources.Theme.class.getDeclaredField("mAssets");
            ma.setAccessible(true);
            ma.set(theme, newAssetManager);
        } catch (NoSuchFieldException ignore) {
            Field themeField = Resources.Theme.class.getDeclaredField("mThemeImpl");
            themeField.setAccessible(true);
            Object impl = themeField.get(theme);
            Field ma = impl.getClass().getDeclaredField("mAssets");
            ma.setAccessible(true);
            ma.set(impl, newAssetManager);
        }
        ...
    } catch (Throwable e) {
        Log.e(LOG_TAG, "Failed to update existing theme for activity " + activity,
                e);
    }
    pruneResourceCaches(resources);
}

这个思路是对的,但是远不够。例如,Google 自己的Support包里面的一个类 android.support.v7.view.ContextThemeWrapper会生成一个新的Theme保存:

public class ContextThemeWrapper extends ContextWrapper {
    private int mThemeResource;
    private Resources.Theme mTheme;
    private LayoutInflater mInflater;
    ...
    private void initializeTheme() {
        final boolean first = mTheme == null;
        if (first) {
            mTheme = getResources().newTheme();
            final Resources.Theme theme = getBaseContext().getTheme();
            if (theme != null) {
                mTheme.setTo(theme);
            }
        }
        onApplyThemeResource(mTheme, mThemeResource, first);
    }
    ...
}

如果没有替换了这个ContextThemeWrapper的Theme,假如配合它使用的Reources/AssetManager是新的,就会导致Crash: java.lang.RuntimeException: Failed to resolve attribute at index 0 这是大部分开源框架都存在的Issue。 为了解决这个问题,我们不仅清理所有Activity的Theme,还清理了所有View的Context。

try {
    List<View> list = getAllChildViews(activity.getWindow().getDecorView());
    for (View v : list) {
        Context context = v.getContext();
        if (context instanceof ContextThemeWrapper
                && context != activity
                && !clearContextWrapperCaches.contains(context)) {
            clearContextWrapperCaches.add((ContextThemeWrapper) context);
            pruneSupportContextThemeWrapper((ContextThemeWrapper) context, newAssetManager); // 清理Theme
        }
    }
} catch (Throwable ignore) {
    Log.e(LOG_TAG, ignore.getMessage());
}

但是这些做法还是不能解决所有问题,有时候为了实现一个产品需求,Android工程师可能会采取一些非常规写法,导致变成插件之后资源加载失败。比如在一个自己的类里面保存了Theme。这种问题不可能一个个改业务代码,那能不能让插件兼容这种写法呢? 我们对这种行为也做了兼容:修改字节码

了解虚拟机指令的同学都知道,如果要保存一个类变量,对应的虚拟机的指令是PUTFIELD/PUTSTATIC,以此为突破口,用ASM写一个MethodVisitor:

static class MyMethodVisitor extends MethodVisitor {
    int stackSize = 0;

    MyMethodVisitor(MethodVisitor mv) {
        super(Opcodes.ASM5, mv);
    }

    @Override
    public void visitFieldInsn(int opcode, String owner, String name, String desc) {
        if (opcode == Opcodes.PUTFIELD || opcode == Opcodes.PUTSTATIC) {
            if ("Landroid/content/res/Resources$Theme;".equals(desc)) {
                stackSize = 1;
                visitInsn(Opcodes.DUP);
                super.visitMethodInsn(Opcodes.INVOKESTATIC,
                        "com/meituan/hydra/runtime/Transformer",
                        "collectTheme",
                        "(Landroid/content/res/Resources$Theme;)V",
                        false);
            }
        }
        super.visitFieldInsn(opcode, owner, name, desc);
    }

    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
        super.visitMaxs(maxStack + stackSize, maxLocals);
        stackSize = 0;
    }
}

这样可以保证所有被类保存的Theme都会被收集起来,在插件安装后,统一清理、重建就行了。

插件的构建系统

为了实现在AAR集成方式和插件集成方式之间一键切换,并解决插件化遇到的“API陷阱”的问题,我们把大量的时间花在构建系统的建设上面,我们的构建系统除了支持常规的构建插件之外,还支持已有构建工具和未来可能存在的构建工具。 我们将正常构建过程分为4个阶段: 1. 收集依赖 2. 处理资源 3. 处理代码 4. 打包签名

那么如何保证对已有Gradle插件的支持?最好的方式是不对这个构建过程做太多干涉,保证它们的正常、按顺序执行。所以我们的构建系统在不干扰这个顺序的基础上,把插件的构建过程插入进去,对应正常构建的4个阶段,主要做了如下工作。

  • 宿主解析依赖之后,分析插件的依赖,进行依赖仲裁和引用计数分析
  • 宿主处理资源之前,处理插件资源,规避了资源访问的陷阱,生成需要Merge的资源列表给宿主,开发 美团AAPT 处理插件资源
  • 宿主处理代码之中,规避插件API使用的陷阱,复用宿主的Proguard和Gradle插件,做到对原生构建过程的最大兼容。我们也修复了Proguard Mapping的问题,后续会有专门的博客介绍
  • 宿主打包签名之前,构建插件APK,计算升级兼容的Hash特征,使用V2签名加快运行时的验证

构建系统的流程如下图

API陷阱

我们做插件化构建系统还有另外一个非常重要的目的,就是规避“API陷阱”。下面是接入Atlas所需要注意的部分问题,我们称为“API陷阱” 1. Activity通过overridePendingTransition使用的切换动画的文件要放在主APK中; 2. Bundle内如果有用到自定义style,那么style的parent如果也是自定义的话,parent的定义必须位于主APK中,这是由于5.0以后系统内style查找的固有逻辑导致的,容器内暂不能完全兼容 3. Bundle内部如果有so,则安装时so由于无法解压到APK lib目录中,对于直接通过native层使用dlopen来使用so的情况,会存在一定限制,且会影响后续so动态部署,所以目前bundle内so不建议使用dlopen的方式来使用

那我们是怎么做的呢? 我们用构建工具自动对插件资源进行处理。先把插件独有的依赖从宿主处理的依赖里面抽离,然后为宿主单独准备一份资源目录,这个目录只包括需要merge的资源。 那么怎么抽离呢?我们看下处理资源的task是如何获得这些资源的。代码在com.android.build.gradle.tasks.MergeResources$ConfigAction

ConventionMappingHelper.map(mergeResourcesTask, "inputResourceSets",
        new Callable<List<ResourceSet>>() {
            @Override
            public List<ResourceSet> call() throws Exception {
                List<File> generatedResFolders = Lists.newArrayList(
                        scope.getRenderscriptResOutputDir(),
                        scope.getGeneratedResOutputDir());
                if (variantData.getExtraGeneratedResFolders() != null) {
                    generatedResFolders.addAll(
                            variantData.getExtraGeneratedResFolders());
                }
                if (scope.getMicroApkTask() != null &&
                        variantData.getVariantConfiguration().getBuildType()
                                .isEmbedMicroApp()) {
                    generatedResFolders.add(scope.getMicroApkResDirectory());
                }

                return variantData.getVariantConfiguration().getResourceSets(
                        generatedResFolders, includeDependencies, validateEnabled);
            }
        });

了解Groovy的同学都知道,设置这个inputResourceSets,其实就是重写了这个mergeResourcesTask的getInputResourceSets方法。那么我们也这可以这么做:

ConventionMapping conventionMapping =
                (ConventionMapping) ((GroovyObject) variantData.mergeResourcesTask).getProperty("conventionMapping");
def srcMethod = conventionMapping._mappings.get("inputResourceSets");

conventionMapping.map("inputResourceSets", new Callable<List<ResourceSet>>() {
    @Override
    public List<ResourceSet> call() throws Exception {
        List<ResourceSet> res = srcMethod.getValue(null, null)
        ... // 处理这个res
        return res
    }
})

对于第一个问题:前面提到的插件为宿主提供的资源文件夹,如果是一个空的没有任何意义。我们会分析插件的AndroidManifest.xml文件,以此作为root,遍历被它引用的所有的资源,不管是文件,还是values文件夹下面的单个value,全部merge进这个文件夹。 但是只是AndroidManifest.xml文件是不够的,所有传给系统的文件,比如提到的“Activity通过overridePendingTransition使用的切换动画的文件”,也一并放进这个文件夹。这里需要使用ASM扫描插件的所有API调用,类似上面的Theme查找,不细展开了。

第二个问题:把插件values里面style的parent也作为检索的root,遍历merge。

第三个问题:API陷阱除了资源,还有大量的代码级别的,上面的插件so加载问题就是很典型的一个例子,正常使用System.loadLibrary(path)是不行的,但是可以把它转化成下面的写法:我们发现,如果插件dlopen来加载的so之前被加载过,就不会出现这个问题。

private static Pattern compile = Pattern.compile("dlopen failed: library \"lib(.+).so\" not found");
public static void system_loadLibrary(String libname) {
    LinkedList<String> list = new LinkedList<>();
    list.add(libname);
    while (list.size() > 0) {
        try {
            System.loadLibrary(list.peekFirst());
            list.pop();
        } catch (UnsatisfiedLinkError error) {
            // dlopen failed: library "libglog_init.so" not found
            Matcher matcher = compile.matcher(error.getMessage());
            if (matcher.matches()) {
                String group = matcher.group(1);
                list.addFirst(group);
            } else {
                throw error;
            }
        }
    }
}

当然需要替换的API很多,如 getIdentifier、Notification、Glide等等,不一一列举。

总结

本文主要介绍美团插件化的设计思路和一些实现。经过我们这些努力,美团平台的业务集成模式可以平滑的在AAR集成模式和插件化集成模式之间无缝切换,且上线几乎没出现兼容问题。目前在美团App最近的几个版本上,搜索、收藏、订单等重要模块都是插件形式加载的。

作者简介

李挺,美团技术专家,2014年加入美团。先后负责过多个业务项目和技术项目,致力于推动AOP和字节码技术在美团的应用。曾独立负责美团App预装项目并推动预装实现自动化。主导了美团插件化框架的设计和开发工作,目前工作重心是美团插件化框架的布道和推广。

夏伟,美团资深工程师,2017年加入美团。目前从事美团插件化开发,美团平台的一些底层工具优化,如AAPT、ProGuard等,专注于Hook技术、逆向研究,习惯从源码中寻找解决方案。

美团平台客户端技术团队,负责美团平台的基础业务和移动基础设施的开发工作。基于海量用户的美团平台,支撑了美团多条业务线的快速发展。同时,我们也在移动开发技术方面做了一些积极的探索,在动态化、质量保障、开发模型等方面有一定积累。客户端技术团队积极采用开源技术的同时,也把我们的一些积累回馈给开源社区,希望跟业界一起推动移动开发效率、质量的提升。