美团App插件化实践

背景

在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最近的几个版本上,搜索、收藏、订单等重要模块都是插件形式加载的。

原文发布于微信公众号 - 美团点评技术团队(meituantech)

原文发表时间:2017-10-12

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏数据和云

自相矛盾:Null is Not Null引发的成本误区

黄玮(Fuyuncat) 资深Oracle DBA,个人网www.HelloDBA.com,致力于数据库底层技术的研究,其作品获得广大同行的高度评价. 在SQL...

2834
来自专栏phodal

我是如何Hack掉一个机器人!

在Hack Day这样的伟大节日里,还是应该做一点Hack的事。很久没有干过这么刺激的事,想想也觉得有点小激动。 Blabla,当然这个Robot可能没有你想的...

20110
来自专栏腾讯Bugly的专栏

全系统栈崩溃是什么鬼?手机管家高级工程师 jaylin,教你如何抓鬼!

Jaylin 腾讯手机管家团队,高级研发工程师,5年以上Android开发经验,擅长终端架构设计、性能和稳定性优化。 前言 Android的严重碎片化,通常会给...

3304
来自专栏Android常用基础

Tinker-自定义扩展与流程分析(下)

上一篇我们讲解了Tinker的使用,现在我们讲解下一些功能的扩展与从源码角度查看流程分析。

731
来自专栏美团技术团队

美团外卖Android Crash治理之路

Crash率是衡量一个App好坏的重要指标之一。如果你忽略了它的存在,它就会得寸进尺,愈演愈烈,最后造成大量用户的流失,进而给公司带来无法估量的损失。本文讲述美...

992
来自专栏偏前端工程师的驿站

Thinking in React Implemented by Reagent

前言  本文是学习Thinking in React这一章后的记录,并且用Reagent实现其中的示例。 概要 构造恰当的数据结构 从静态非交互版本开始 追加交...

19010
来自专栏数据和云

拨云见日 - 深入解析Oracle TX行锁(下)

优化的核心思想:Balance is the ONLY key to Optimizer. 上期回顾:拨云见日—深入解析Oracle TX 行锁(上) 前文中我...

3199
来自专栏進无尽的文章

聊聊程序设计思想之面向接口编程IOP

我们在一般实现一个系统的时候,通常是将定义与实现合为一体,不加分离的,但是有时候最为理想的系统设计规范应是所有的定义与实现分离,尽管这可能对系统中的某些情况有点...

832
来自专栏Jerry的SAP技术分享

观察者模式在One Order回调函数中的应用

例如需求是搞清楚function module CRM_PRODUCT_I_A_CHANGE_ORGM_EC在什么样的场景下会被调用。当然最费时间的做法是设一个...

3458
来自专栏walterlv - 吕毅的博客

当我们使用 MVVM 模式时,我们究竟在每一层里做些什么?

2017-11-29 17:29

671

扫码关注云+社区