美团App 插件化实践

标签: 美团 app 插件 | 发表时间:2017-10-12 23:06 | 作者:美团点评技术团队
出处:https://tech.meituan.com/

背景

在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技术、逆向研究,习惯从源码中寻找解决方案。

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

相关 [美团 app 插件] 推荐:

美团App 插件化实践

- - 美团点评技术团队
在Android开发行业里,插件化已经不是一门新鲜的技术了,在稍大的平台型App上早已是标配. 进入2017年,Atlas、Replugin、VirtualAPK相继开源,标志着插件化技术进入了成熟阶段. 但纵观各大插件框架,都是基于自身App的业务来开发的,目标或多或少都有区别,所以很难有一个插件框架能一统江湖解决所有问题.

App-Folders:一个模拟 iOS 文件夹效果的 jQuery 插件

- - 我爱水煮鱼
App-Folders 介绍. App-Folders 是一个可以模拟 iOS 文件夹操作的 jQuery 插件,点击文件夹,将周围的元素虚化(通过加深透明度实现),然后显示文件夹中的内容,并且这个插件可以同时在桌面和移动设备上浏览器上工作,适配性非常好. App-Folders 的文件夹元素中可以包含任何 HTML 元素,包括图片,文本,视频等等,并且每个文件夹都可以有自己的 URL 实现直接点击.

苏宁 Android App 插件化应用实践

- - IT瘾-dev
从大团队并肩作战到小团队带头冲锋,高效的研发模式使得 App 本身的整体崩溃率始终维持在 0.02% 以下. 从大团队并肩作战到小团队带头冲锋,高效的研发模式使得 App 本身的整体崩溃率始终维持在 0.02% 以下. 本着以用户为中心、以开发者为出发点,根据现有开源方案取长补短,苏宁易购移动开发部于 2017 年初自主研发出了新型插件化技术——APNP(Android Plugin And Play),旨在让研发更敏捷,让发布更灵活,最终满足用户对产品的极速体验、按需下载、动态更新.

App 和 iCloud

- 笑炊 - 爱范儿 · Beats of Bits
iCloud 的技术细节还在 NDA 的保护下. 但是大家的好奇心不能等到 NDA 失效再满足. 本文基于对 iCloud 的猜测写成,靠谱与否,等待时间检验. 打开浏览器,嗯,今天用 Safari , Chrome , IE 或者 Firefox. 输入 Twiter.com ,啊,不对,是 Twitter.com.

App Internet 革命

- Cary - Mr. Jamie 看網路與創投
Apple 公布最新一季的財報,3 個月賣出了破紀錄的 3,500 萬台 iDevices (iPhone, iPad & iPods). Google 公布最新數字,全球有 1.9 億支 Android 已經被啟用. 大家很興奮「智慧型手機」、「行動裝置」革命終於來到,我卻隱隱感覺到另一件更重大的事情正在發生,我們所熟知的「網路」,即將經歷另一次大幅度的轉變.

浅析App Engine

- - 搜索研发部官方博客
在国内外,云计算正在大步的走向商业化的道路,也得到了越来越多公司的重视. 其中平台即服务(Platform-as-a-Service  PaaS)已经称为业界探讨云计算的热点方式之一,采用PaaS模式来构建应用运行平台App Engine是一种重要的实现方式. 本文主要是对App Engine的背景、特点、需求等进行分析整理,并据此对业界主要的App Engine进行了调研分析.

Mobile App 将死?!

- - Tech2IPO
日前,Mozilla 产品副总监 Jay Sullivan 称移动应用不久即将成为历史,未来将是移动 Web 应用的天下. 光盘好歹还能当杯垫,可怜 Mobile App,难道就这样一下跌落进历史的垃圾堆. Mozilla 的产品副总监杰 • 沙利文 (Jay Sullivan, 上图) 日前表示,移动终端应用(Mobile App)没有未来,真正有前途的是移动 Web 应用(Mobile Web App).

APP已死?

- - 商业不靠谱
APP目前面临的几大窘境将促使搜索引擎由Search向Service、Getting 转变以适应用户在APP时代养成的简洁、高效等习惯. 《未来移动终端应用 C/S Vs B/S 架构》 许永硕——物联网智库. 参照PC软件的发展历程,B/S架构或许是破解APP难题的出路,目前,微信开放平台、手机QQ等在尝试扮演Browser(http://open.weixin.qq.com).

欺诈 app 追杀 — 给 App Store 的信

- Webto - Wangling
感谢 @apple4us 的建议. 我深知如果等着别人相助,此事大概会不了了之,届时只徒留一篇愤概文章. 所谓“追杀”,敌未死,我未停,正如给“动车追尾”事件的受害人追讨公道,公道未到,追讨不止. 于是,我刚给 App Store 发了信,如下:. 每人干掉一个坏蛋…,坏蛋没那么多;每一百个人、每一千个人、甚至每一万个人干掉一个坏蛋,世界都会美好许多.

Web App和Native App 谁将是未来

- - 互联网旁观者
未来是Web App的天下,还是Native App的天下. 作为设计师,我们是应该努力把客户端的体验提升到最优,还是在网页应用层面上做更多的设计. 那么,我们首先应该立体的认识一下Web App和Native App. Web 无需安装,对设备碎片化的适应能力优于App,它只需要通过XHTML、CSS和JavaScript就可以在任意移动浏览器中执行.