Android插件化系列三:技术流派和四大组件支持

Hello,各位朋友们,我们继续插件化系列的学习吧。下面是我这个系列文章的行文思路,

Android插件化文章框架

本篇文章是本系列比较核心的一篇文章,我计划这篇文章把插件化的大体技术给讲清楚。期间会涉及到系列的前两篇文章的内容,推荐先阅读前面的两篇基础文章Android插件化系列一: 开篇前言,Binder机制,ClassLoaderAndroid插件化系列二: 资源与打包流程

本篇文章预计需要半小时以上时间阅读。读完本篇文章,你将会了解:
1.插件化的发展和流派
2.插件化技术

  • 如何加载插件中的类和资源
  • 如何解析插件中的信息
  • 如何利用aapt等方法解决宿主和插件资源冲突的问题
  • 如何支持四大组件的插件化

1.发展历史和流派

先稍微介绍一下插件化的发展历史。插件化技术,主要用在新闻,电商,阅读,出行,视频等领域,可以看到包含了我们生活的很多场景。在应用迭代的过程中,1.能快速的修复应用出问题的部分,2.为了抢占市场,快速的根据市场反应进行迭代,3.将不常用功能模块做成插件,减少包体积,这几点对于应用的发展都是相当重要的事情。在这种背景下,插件化技术应运而生。

下面是比较出名的几个插件化框架,根据出现的时间排序,通过研究他们的原理,可以把发展历史大概分成三代。

时代 代表库 特点
远古 AndroidDynamicLoader(屠毅敏) adl基于动态替换Fragment来实现页面的切换,虽然局限大,但是给我们提供了想象的基础
第一代 dynamic-load-apk(任玉刚),DroidPlugin(张勇) dla通过创建ProxyActivity来进行分发,插件必须继承ProxyActivity, 侵入性强且必须小心处理context。DroidPlugin是通过hook系统服务来进行Activity跳转,缺点是hook太多,代码复杂且不够稳定。
第二代 VirtualApk, Small(林光亮),RePlugin 为了同时达到插件开发的低侵入性(像开发普通app一样开发插件)和框架的稳定性,在实现原理上都是趋近于选择尽量少的hook,并通过在manifest中预埋一些组件实现对四大组件的插件化。
第三代 VirtualApp,Atlas 在这一代中,插件兼容性,稳定性提升到更高的层次。同时,容器化框架的概念越来越流行。

2015年及以前,插件化技术分成了明显的两派:以DroidPlugin为代表的动态替换方案和以dynamic-load-apk为代表的静态代理方案。后来动态替换方案因为侵入性低,灵活稳定,逐步得到了更多人的支持。而从热修复方案和react native开始应用以来,插件化技术不再是唯一的选择,而是进入慢慢完善的阶段,到2017年以后插件化技术基本成熟,兼容性和稳定性也达到了较高的层次。大家有兴趣的可以看看上面讲到的几个开源库,体会插件化技术的发展历程。

2.插件化技术

插件化技术的技术主要可以概括为以下几点:
1.插件和宿主之间的代码和资源互用
2.插件的四大组件支持和跳转

这里我们说到了插件和宿主之间的代码和资源互用。其实这里也是有学问的。插件根据是否需要共享资源代码分为独立插件和耦合插件。独立插件是单独运行在一个进程中的,与宿主完全隔离,崩溃不会影响到宿主。但是耦合插件却是和宿主运行在一个进程中,所以插件崩溃,宿主也崩溃了。所以一般业务要根据资源和代码的耦合程度,插件的可靠性等综合考虑插件类型。

我们接下来慢慢讲解。

2.1 代码和资源互通

插件与dex

因为可能看我文章的还有没接触过插件化的同学,所以增加这一部分讲解插件和dex到底是怎么一种存在形式,插件,我们可以理解为一个单独打包出来的apk。在项目中我们可以建立module并且在模块的build.gradle中把apply plugin: 'com.android.library'改为apply plugin: 'com.android.application'。这样对这个模块打包的产物就是apk。

apk在打包的过程中,有一个class文件打入dex的操作,最终Apk中存在的是dex。加载这种dex中的类,使用的ClassLoader也很有讲究。前面我们在Android插件化系列一: 开篇前言,Binder机制,ClassLoader中讲到过,Android常用的就是PathClassLoader和DexClassLoader。PathClassLoader适用于已经安装了的apk,一般作为默认加载器。而这里插件的apk是没有安装的,所以我们需要使用DexClassLoader来加载插件dex中的类。下面是一段基本代码,演示了如何从插件apk的dex中读取类。

// 生成ClassLoader
File apkFile = File(apkPath, apkName);
String dexPath = apkFile.getPath();
File releaseFile = context.getDir("dex", 0);
DexClassLoader loader = new DexClassLoader(dexPath, releaseFile.getAbsolutePath(), null, getClassLoader());

// 加载类,使用类的方法
Class bean = loader.loadClass("xxx.xxx.xxx")  // 填入类的包名
Object obj = bean.newInstance();
Method method = bean.getMethod("xxx")  // 填入方法名
method.setAccessible(true);
method.invoke(obj)

这样,我们就可以通过反射来获取到类,并使用相应的方法了。

面向接口编程

大家会看到,如果像上面那样大量的使用反射,代码是相当丑陋的,扩展性能也差。这让我们想到了,能不能参考依赖倒置原则中的面向接口或抽象编程的思想,预先定义好接口。这样等需要使用的时候,就只需要把对象转换为接口,就能调用接口的方法了。

比如我们app模块和插件模块plugin依赖了接口模块interface, interface中定义了接口IPlugin。IPlugin的定义是

interface IPlugin {
    void sayHello(String name)
}

plugin中就可以定义实现类

class PluginImpl implement IPlugin {
    @override
    void sayHello(String name) {
        Log.d("log","hello world" + name);
    }
}

这样,我们就可以在宿主app模块中去使用。具体的使用方法可以有反射和服务发现机制。为了简单,这里只用反射来调用具体的实现类。

Class pluginImpl = loader.loadClass("xxx.xxx.xxx")  // PluginIMpl 类的包名
Object obj = pluginImpl.newInstance();              // 生成PluginImpl对象
IPlugin plugin = (IPlugin)obj;
plugin.sayHello("AndroidEarlybird");

既然接口都给出了,我们想做别的事情肯定就得心应手了。但是值得注意的是这里的前提是宿主和插件都需要依赖接口模块,也就是说双方是有代码和资源依赖的,因此这种方法只适用于耦合插件,独立插件的话就只能用反射来调用了。

PMS

在插件化技术中,ActivityManagerServiche(AMS)和PackageManagerService(PMS)都是相当重要的系统服务。AMS自不用说,四大组件各种操作都需要跟它打交道,PMS也十分重要,完成了诸如权限校捡(checkPermission,checkUidPermission),Apk meta信息获取(getApplicationInfo等),四大组件信息获取(query系列方法)等重要功能。

使用PMS

android一般使用PMS来进行应用安装,安装的时候PMS需要借助于PackageParser进行apk解析工作,主要负责解析出一个PackageParser.Package对象,这个对象还是很大用途的。下面是这个Package对象的一些属性值。

image

可以看到我们通过这个类可以拿到apk中的四大组件,权限等信息,在插件化中,我们有时候会需要利用这个类去拿到广播的信息来处理插件中的静态广播

那么如何使用PackageParser这个类呢?下面是VirtualApk的一些使用

    public static final PackageParser.Package parsePackage(final Context context, final File apk, final int flags) throws PackageParser.PackageParserException {
        if (Build.VERSION.SDK_INT >= 24) {
            return PackageParserV24.parsePackage(context, apk, flags);
        } else if (Build.VERSION.SDK_INT >= 21) {
            return PackageParserLollipop.parsePackage(context, apk, flags);
        } else {
            return PackageParserLegacy.parsePackage(context, apk, flags);
        }
    }

    private static final class PackageParserV24 {
        static final PackageParser.Package parsePackage(Context context, File apk, int flags) throws PackageParser.PackageParserException {
            PackageParser parser = new PackageParser();
            PackageParser.Package pkg = parser.parsePackage(apk, flags);
            ReflectUtil.invokeNoException(PackageParser.class, null, "collectCertificates",
                    new Class[]{PackageParser.Package.class, int.class}, pkg, flags);
            return pkg;
        }
    }

因为PackageParser针对系统版本变化很大,所以VirtualApk对这个类做了多版本的适配,我们这里只展示了一种。

Hook PMS

正如我们需要hook AMS去进行一些插件化的一些工作,有时候我们也得对PMS进行hook。通过看源码,我们知道PMS的获取也是通过Context获取的,直奔ContextImpl类的getPackageManager方法。

public PackageManager getPackageManager() {
    if (mPackageManager != null) {
        return mPackageManager;
    }

    IPackageManager pm = ActivityThread.getPackageManager();
    if (pm != null) {
        // Doesn't matter if we make more than one instance.
        return (mPackageManager = new ApplicationPackageManager(this, pm));
    }
    return null;
}

// 继续跟进到ActivityThread的getPackageManager方法中
public static IPackageManager getPackageManager() {
    if (sPackageManager != null) {
        return sPackageManager;
    }
    IBinder b = ServiceManager.getService("package");
    sPackageManager = IPackageManager.Stub.asInterface(b);
    return sPackageManager;
}

这里我们可以看到,要想hook PMS需要把这两个地方都hook住:

  • ActivityThread的静态字段sPackageManager
  • 通过Context类的getPackageManager方法获取到的ApplicationPackageManager对象里面的mPM字段。

示例代码如下:

// 获取全局的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 获取ActivityThread里面原始的 sPackageManager
Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
sPackageManagerField.setAccessible(true);
Object sPackageManager = sPackageManagerField.get(currentActivityThread);

// 准备好代理对象, 用来替换原始的对象
Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
Object proxy = Proxy.newProxyInstance(iPackageManagerInterface.getClassLoader(),
        new Class<?>[] { iPackageManagerInterface },
        new HookHandler(sPackageManager));

// 1. 替换掉ActivityThread里面的 sPackageManager 字段
sPackageManagerField.set(currentActivityThread, proxy);

// 2. 替换 ApplicationPackageManager里面的 mPM对象
PackageManager pm = context.getPackageManager();
Field mPmField = pm.getClass().getDeclaredField("mPM");
mPmField.setAccessible(true);
mPmField.set(pm, proxy);

管理ClassLoader

上面我们讲到了如何利用ClassLoader来加载dex中的类,现在我们再来深入聊聊这个话题。首先,需要明确的是,因为我们插件的类都是位于没有安装的apk的dex中,所以我们不能直接使用主app的ClassLoader。那么就会有多种解决方案。

比较直接的思想是通过对每一个插件都新建一个ClassLoader来做加载。那么如果我们插件很多的时候,我们需要做的就是把每个插件的ClassLoader给记录下来,当使用某个插件的类的时候,用它对应的ClassLoader去加载。正如我们上节的例子中展示的那样。

另一种思想是直接操作dex数组。宿主和插件的ClassLoader都会对应一个dex数组。那么我们如果能把插件的dex数组合并到宿主的dex数组里面去的话,我们就能用宿主的ClassLoader来反射加载插件的dex数组中的类了。这样做的目的是不需要管理插件的ClassLoader,只要用宿主的ClassLoader就行了。比如我们曾经在Android插件化系列一: 开篇前言,Binder机制,ClassLoader中讲到DexClassLoader的源代码。

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
        String libraryPath, ClassLoader parent) {
        super(parent);  //见下文
        //收集dex文件和Native动态库【见小节3.2】
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
}

public class DexPathList {
    private Element[] dexElements;

    public DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory) {
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext);
    }

    private static List<File> splitDexPath(String path) {
       return splitPaths(path, false);
    }

    private static List<File> splitPaths(String searchPath, boolean directoriesOnly) {
        List<File> result = new ArrayList<>(); 
        if (searchPath != null) {
            for (String path : searchPath.split(File.pathSeparator)) {
                // 省略
            }
        }
        return result;
     }
}

从上面我们可以看出,dexPath字符串是由多个分号分割的。拆分成字符串数组以后,每个path都是一个外部的dex/apk路径。那么我们很自然的想到,能不能把插件的dex路径手动添加到宿主的dexElements数组中呢?答案当然是ok的,方案就是使用Hook。我们可以先反射获取到ClassLoader的dexPathList,然后再获取这个list的dexElements数组,然后手动把插件构建出Element,再拷贝到dexElements数组中。热修复框架Nuwa也是使用这种思想。

第三种思路是ClassLoader delegate。本文推荐这种方法。首先我们自定义ClassLoader,取代原先宿主的ClassLoader,并且把宿主作为Parent,同时在自定义的ClassLoader中用一个集合放置所有插件的ClassLoader,然后这个自定义ClassLoader在加载任何一个类的时候,依据双亲委托机制,加载类都会先从宿主的ClassLoader中寻找,没有的话再遍历ClassLoader集合寻找能加载这个类的插件ClassLoader。当然这里又会有提高效率的优化点,比如遍历集合的方式可以改为先从已加载过的集合中寻找,再从未加载过的集合中寻找。下面是示例代码。

class PluginManager {
    public static void init(Application application) {
        //初始化一些成员变量和加载已安装的插件
        mPackageInfo = RefInvoke.getFieldObject(application.getBaseContext(), "mPackageInfo");
        mBaseContext = application.getBaseContext();
        mNowResources = mBaseContext.getResources();

        mBaseClassLoader = mBaseContext.getClassLoader();
        mNowClassLoader = mBaseContext.getClassLoader();
        
        ZeusClassLoader classLoader = new ZeusClassLoader(mBaseContext.getPackageCodePath(), mBaseContext.getClassLoader());

        File dexOutputDir = mBaseContext.getDir("dex", Context.MODE_PRIVATE);
        final String dexOutputPath = dexOutputDir.getAbsolutePath();
        for(PluginItem plugin: plugins) {
            DexClassLoader dexClassLoader = new DexClassLoader(plugin.pluginPath,
                    dexOutputPath, null, mBaseClassLoader);
            classLoader.addPluginClassLoader(dexClassLoader);
        }
        // 替换原有的宿主的ClassLoader为自定义ClassLoader,将原来的宿主ClassLoader作为自定义ClassLoader的
        RefInvoke.setFieldObject(mPackageInfo, "mClassLoader", classLoader);
        Thread.currentThread().setContextClassLoader(classLoader);
        mNowClassLoader = classLoader;
    }
}

class ZeusClassLoader extends PathClassLoader {
    private List<DexClassLoader> mClassLoaderList = null;

    public ZeusClassLoader(String dexPath, ClassLoader parent, PathClassLoader origin) {
        super(dexPath, parent);

        mClassLoaderList = new ArrayList<DexClassLoader>();
    }

    /**
     * 添加一个插件到当前的classLoader中
     */
    protected void addPluginClassLoader(DexClassLoader dexClassLoader) {
        mClassLoaderList.add(dexClassLoader);
    }

    @Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = null;
        try {
            //先查找parent classLoader,这里实际就是系统帮我们创建的classLoader,目标对应为宿主apk
            clazz = getParent().loadClass(className);
        } catch (ClassNotFoundException ignored) {
        }

        if (clazz != null) {
            return clazz;
        }

        //挨个的到插件里进行查找
        if (mClassLoaderList != null) {
            for (DexClassLoader classLoader : mClassLoaderList) {
                if (classLoader == null) continue;
                try {
                    //这里只查找插件它自己的apk,不需要查parent,避免多次无用查询,提高性能
                    clazz = classLoader.loadClass(className);
                    if (clazz != null) {
                        return clazz;
                    }
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        throw new ClassNotFoundException(className + " in loader " + this);
    }
}

资源

Resources&AssetManager

android中的资源大致分为两类:一类是res目录下存在的可编译的资源文件,比如anim,string之类的,第二类是assets目录下存放的原始资源文件。因为Apk编译的时候不会编译这些文件,所以不能通过id来访问,当然也不能通过绝对路径来访问。于是Android系统让我们通过Resources的getAssets方法来获取AssetManager,利用AssetManager来访问这些文件。

Resources resources = context.getResources();
AssetManager manager = resources.getAssets();
InputStream is = manager.open("filename");

Resources和AssetManager的关系就像销售和研发。Resources负责对外,外部需要的getString, getText等各种方法都是通过Resources这个类来调用的。而这些方法其实都是调用的AssetManager的私有方法。所以最终两类资源都是AssetManager在兢兢业业的向Android系统要资源,为外界服务着。

AssetManager里有个很重要的方法addAssetPath(String path)方法,App启动的时候会把当前apk的路径传进去,然后AssetManager就能访问这个路径下的所有资源也就是宿主apk的资源了。那么idea就冒出来了,如果我们把插件的地址也传进这个方法去,是不是就能得到一个能同时访问宿主和插件的所有资源的“超级”AssetManager了呢?答案是肯定的,这也是插件化对资源的一种解决方案

下面是一段示例代码展示了获取宿主的Resources中的AssetManager,然后调用addAssetPath添加插件路径,最后生成一个新的Resources的方法

// 新生成AssetManager,调用addAssetPath
AssetManager assetManager = resources.getAssets();  // 先通过Resources拿到示例代码
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath1);
mAssetManager = assetManager;

// 根据新生成的AssetManager生成Resources
mResources = new Resources(mAssetManager, super.getResources().getDisplayMetrics(), super.getResources().getConfiguration());

接下来我们要分别将宿主和插件的原有Resources替换成我们上面生成的Resources。注意这里传入的application应该是宿主和插件对应的Application。

Object contextImpl = RefInvoke.getFieldObject("android.app.ContextImpl", application, "getImpl")  // 获取Application的context
LoadedApk loadedApk = (LoadedApk)RefInvoke.getFieldObject(contextImpl, "mPackageInfo")
RefInvoke.setFieldObject(loadedApk, "mResources", resources);
RefInvoke.setFieldObject(application.getBaseContext(), "mResources", resources);

除了需要替换Application的Resources对象,我们也需要替换Activity的Resources对象,宿主和插件的Resources都需要替换。这是因为他们都是Context,只替换Application的并不能影响到Activity。我们可以在Instrumentation回调callActivityOnCreate的时候去替换。这点在后面Activity插件化处理部分再详细讲解。

上面只是展示了使用,想了解更多信息的可以查看VirtualApk

解决资源冲突

Android插件化系列二: 资源与打包流程中我们提到了插件和宿主分别打包的时候可能会存在资源id冲突的情况,上面我们使用了一个超级Resource之后,id如果重复了,运行的时候使用id来查找资源就会报错。

为了解决id冲突的问题一般有三种方案:

  1. 修改android打包的aapt工具,将插件资源前缀改为0x02 - 0x7e之间的数值
  2. 进入到哪个插件,就为哪个插件生成新的AssetManager和Resources

其中,方案二比较复杂,并且不利于宿主和插件资源的互相调用。所以我们在上节采用的是超级Resources的方案,所以这里我们介绍一下方案一,也就是修改aapt工具。

aapt是android打包资源的处理工具,大多数的插件话开源库对齐进行改造无外乎都是两种方式:

可以看到aapt(1)处理插件化的资源并不是很友好,开发和维护难度都比较大,后来google推出了Android App Bundle这个和插件很类似的feature,就推出了aapt2来支持了资源分包。我们注意官网上这几个aapt2的打包参数:
[图片上传失败...(image-cd46ee-1570975257347)]

是不是发现官方已经给我们支持好了按package区分资源前缀id,多美好啊哈哈。

当然这里也是有坑的。那就是需要buildTools版本大于28.0.0
在buildTools 28.0.0以前,aapt2自带了资源分区,通过–package-id参数指定。但是该分区只支持>0x7f的PP段,而在Android 8.0之前,是不支持>0x7f的PP段资源的,运行时会抛异常。但是当指定了一个<0x7f的PP段资源后,编译资源时却会报错

error: invalid package ID 0x15. Must be in the range 0x7f-0xff..

所以对于Android P之前使用的buildTools版本(<28.0.0),我们必须通过修改aapt2的源码达到资源分区的目的。而在28.0.0以后,aapt2支持了<0x7f预留PP段分区的功能,只需要指定参数--allow-reserved-package-id即可。

--allow-reserved-package-id --package-id package-id
插件使用宿主资源

在我们为宿主开发插件的时候,经常不可避免的出现插件要使用宿主中资源的情况,如果我们把宿主的资源copy一份放在插件中,那无疑会大大增加包的大小,并且这些都是重复资源,是不应该在App中存在的。那么我们就得想办法让插件使用宿主的资源。比如这样

image

前面已经讲到了,我们可以通过为插件和宿主一起构建一个超级Resources,包括了插件和宿主所有的资源,理论上可以通过资源id获取到所有的资源,那么问题来了,插件中的R文件是不包含宿主的R文件的,我们在编码的时候怎么使用呢?

下面分代码使用xml使用两种使用方式来说解决方案:
代码使用:在插件资源打包任务processResourcesTask完成后将宿主的R.txt文件(打包过程中产生,位置在build/intermediates/symbols/xx/xx/R.txt)合并到插件的R.txt文件,然后再生成R.java,这样就可以正常的使用R文件来索引资源了

xml使用:我们需要在aapt2打包的时候指定-I参数。

image

这样,我们通过-I指定宿主的资源包,就可以在xml中使用宿主的资源了。

总结

本节我们首先介绍插件代码的dex加载,给出了利用反射和面向接口编程来获取插件中的代码的方法,然后介绍了通过自定义delegate ClassLoader的方法来更好的加载插件和宿主中的类,接下来介绍了PMS如何获取插件的信息以及如何进行自定义hook,最后讲到了插件使用宿主资源的一些知识。到了这一步,我们已经可以获取到插件的各种信息,可以实现宿主和插件中的代码互通,可以实现插件调用宿主的资源,基本上算是迈出了一大步!但是只有代码和资源是不够的,接下来我们看看怎么处理android的四大组件,这一块才是重头戏,也是插件化的精髓

2.2 四大组件支持

android的四大组件其实有挺多的共通之处,比如他们都接受ActivityManagerService(AMS)的管理,都需要通过Binder机制请求AMS服务。并且他们的请求流程也是基本相通的,其中Activity又是最重要的组件,出镜最多,同时也是日常开发接触最多的组件,我们将会主要以Activity为例,讲解插件化对四大组件的支持,其余三个组件有不同或值得注意的地方我们会另外指出来。当然,针对四大组件的解决方案有很多种,本文限于篇幅只介绍DroidPlugin的动态替换方案。

Activity

AndroidManifest.xml预占位

相信做过Android开发的都知道,四大组件基本都是要在AndroidManifest.xml中定义的,不然系统就会报错,然后问你 have you declared this activity in your AndroidManifest.xml?
【必须在AndroidMainfest.xml中定义四大组件】这一点对插件化确实是比较严重的限制,毕竟我们并没有办法提前就把插件中的Activity声明进去,但是这个限制也并不是没办法解决的。比如DroidPlugin就采用了预占位Activity到AndroidManifest.xml中的方案。

DroidPlugin的方案思想很简单,先在AndroidManifest.xml中预定义好各种LaunchMode的占位Activity和其余三大组件。比如

<activity
    android:name=".StubSingleTaskActivity1"
    android:exported="true"
    android:launchMode="singleTask"
    android:theme="@style/Theme.NoActionBar"
    android:screenOrientation="portrait" />

<activity
    android:name=".StubSingleTopActivity1"
    android:exported="true"
    android:launchMode="singleTop"
    android:theme="@style/Theme.NoActionBar"
    android:screenOrientation="portrait" />

这样的话,我们就要实行狸猫换太子的方法,把本来想要开启的Activity换成StubActivity,然后躲过了系统对【必须在AndroidMainfest.xml中定义四大组件】的审查真正的开始start Activity的时候再去打开真正的目的Activity。那么我们怎么去实现这个想法呢,这就要求我们熟悉Activity的启动流程了。

startActivity流程

startActivity的流程比较繁杂,甚至可以作为一篇单独的文章来讲解。网上有很多的文章在讲解,比较详细牛逼的是老罗的Android应用程序的Activity启动过程简要介绍和学习计划。大家如果有兴趣的话可以参考。我这里只简明扼要的讲解部分的流程。

首先先看一个流程图


image

首先我们都是从startActivity进去的,辗转发现它调用了Instrumentation的execStartActivity方法,接着在这个函数里面调用了ActivityManagerNative类的startActivity方法,请求到了ActivityManagerService的服务。这一点就是我们在Android插件化系列一: 开篇前言,Binder机制,ClassLoader讲到过的Binder机制在Activity启动过程中的体现。可以看到就是在AMS的startActivity的方法中校验了Activity是否注册,确定了Activity的启动模式,AMS我们没办法改啊,所以咱们得出个结论一定要在校验前的流程里把Activity给替换掉。继续往下看,可以看到ActivityStackSupervisor把启动的重任最终委托给了ApplicationThread。

我们在前面的系列一中说过,Binder机制其实是互为Client和Server的,在app申请AMS服务的时候,AMS是Server,AMP是AMS在app的代理。而在申请到AMS服务以后,AMS需要请求App进行后续控制的时候,ApplicationThread就是Server,ApplicationThreadProxy就是ApplicationThread在AMS侧的代理。

image

继续往下看,可以看到ActivityThread调用了H类,最终调用了handleLaunchActivity方法,由Instrumentation创建出了Activity对象,启动流程结束。

“狸猫换太子”

看完了上面的启动流程,大家可以想到,在这个流程中我只要在调用AMS前把目标Activity替换成StubActivity(上半场),在AMS校验完,马上要打开Activity的时候替换为目标Activity(下半场),这样就可以达到“狸猫换太子”启动目标Activity的目的了啊。因为流程较长,参与的类较多,所以我们可以选择的hook点也是相当多的,但是我们越早hook,后续的操作越多越容易出问题,所以我们选择比较后面的流程去hook。这里选择:

  • 上半场,hook ActivityManagerNative对于startActivity方法的调用
  • 下半场,hook H.mCallback对象,替换为我们的自定义实现,

hook AMN
下面是一些示例代码,可以看到我们替换掉交给AMS的intent对象,将里面的TargetActivity的暂时替换成已经声明好的替身StubActivity。

        if ("startActivity".equals(method.getName())) {
            // 只拦截这个方法
            // 替换参数, 任你所为;甚至替换原始Activity启动别的Activity偷梁换柱

            // 找到参数里面的第一个Intent 对象
            Intent raw;
            int index = 0;

            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Intent) {
                    index = i;
                    break;
                }
            }
            raw = (Intent) args[index];

            Intent newIntent = new Intent();

            // 替身Activity的包名, 也就是我们自己的包名
            String stubPackage = raw.getComponent().getPackageName();

            // 这里我们把启动的Activity临时替换为 StubActivity
            ComponentName componentName = new ComponentName(stubPackage, StubActivity.class.getName());
            newIntent.setComponent(componentName);

            // 把我们原始要启动的TargetActivity先存起来
            newIntent.putExtra(AMSHookHelper.EXTRA_TARGET_INTENT, raw);

            // 替换掉Intent, 达到欺骗AMS的目的
            args[index] = newIntent;

            Log.d(TAG, "hook success");
            return method.invoke(mBase, args);

        }

hook H.mCallback
前面我们说过,ActivityThread是借助于H这个类完成四大组件的操作管理。H继承自Handler,我们看看Handler处理消息的dispatchMessage方法。

public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

而H的handleMessage方法中正是处理LAUNCH_ACTIVITY,CREATE_SERVICE等消息的地方。所以我们就会想,在mCallback.handleMessage中替换回原来的Activity应该就是最晚的时间点了吧。下面是自定义的Callback类,反射设置为ActivityThread的H的mCallback就行了。

class MockClass2 implements Handler.Callback {

    Handler mBase;

    public MockClass2(Handler base) {
        mBase = base;
    }

    @Override
    public boolean handleMessage(Message msg) {

        switch (msg.what) {
            // ActivityThread里面 "LAUNCH_ACTIVITY" 这个字段的值是100
            // 本来使用反射的方式获取最好, 这里为了简便直接使用硬编码
            case 100:
                handleLaunchActivity(msg);
                break;

        }

        mBase.handleMessage(msg);
        return true;
    }

    private void handleLaunchActivity(Message msg) {
        // 这里简单起见,直接取出TargetActivity;
        Object obj = msg.obj;

        // 把替身恢复成真身
        Intent intent = (Intent) RefInvoke.getFieldObject(obj, "intent");

        Intent targetIntent = intent.getParcelableExtra(AMSHookHelper.EXTRA_TARGET_INTENT);
        intent.setComponent(targetIntent.getComponent());
    }
}
替换Resources

还记得我们在第一节留下了一个问题吗,就是Activity的资源替换要在Instrumentation回调callActivityOnCreate的时候进行。这个时间点比较临近onCreate,Instrumentation也比较方便去hook。下面展示这个技术,需要传入超级Resources。

public void hookInstrumentation(){
    Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread");
    // 拿到原始的 mInstrumentation字段
    Instrumentation mInstrumentation = (Instrumentation) RefInvoke.getFieldObject(currentActivityThread, "mInstrumentation");
    // 创建代理对象
    Instrumentation evilInstrumentation = new EvilInstrumentation(mInstrumentation, resources);  // 这里的resources是我们的超级Resources
    RefInvoke.setFieldObject(currentActivityThread, "mInstrumentation", evilInstrumentation);
}

// 这里的Activity是在
public class EvilInstrumentation extends Instrumentation {
    Instrumentation mBase;
    Resources mRes;

    public EvilInstrumentation(Instrumentation base,Resources res) {
        mBase = base;
        mRes = res;
    }

    @override
    public void callActivityOnCreate(Activity activity, Bundle bundle) {
        // 替换Resources
        if (mRes != null) {
            RefInvoke.setFieldObject(activity.getBaseContext().getClass(), activity.getBaseContext(), "mResources", mRes);
        }
        super.callActivityOnCreate(activity, bundle);
    }
}

Service

Service的处理和Activity的基本一样,区别是调用多次startService并不会启动多个Service实例,而是只有一个实例,所以我们的占位Service得多定义一些。

BroadcastReceiver

BroadcastReceiver的插件化和Activity的不太一样。Android中的广播分为两种:静态广播和动态广播,动态广播不需要和AMS交互,就是一个普通类,只要按照前面的ClassLoader方案保证他能加载就行了。但是静态广播比较麻烦,除了需要在AndroidManifest.xml中进行注册以外,他和Activity不一样的是,他还附加了IntentFilter信息。而IntentFilter信息是随机的,无法被预占位的。这个时候就只能把取出插件中的静态广播改为动态广播了。虽然会有一些小问题,但是影响不大

前面我们讲到了PackageParser可以获取到插件的四大组件的信息,存储到Package对象中,那么我们就有个思路,通过PMS获取到BroadcastReceiver,然后把其中的静态广播改为动态广播.

    public static void preLoadReceiver(Context context, File apkFile) {
        // 首先调用parsePackage获取到apk对象对应的Package对象
        Object packageParser = RefInvoke.createObject("android.content.pm.PackageParser");
        Class[] p1 = {File.class, int.class};
        Object[] v1 = {apkFile, PackageManager.GET_RECEIVERS};
        Object packageObj = RefInvoke.invokeInstanceMethod(packageParser, "parsePackage", p1, v1);

        // 读取Package对象里面的receivers字段,注意这是一个 List<Activity> (没错,底层把<receiver>当作<activity>处理)
        // 接下来要做的就是根据这个List<Activity> 获取到Receiver对应的 ActivityInfo (依然是把receiver信息用activity处理了)
        List receivers = (List) RefInvoke.getFieldObject(packageObj, "receivers");

        for (Object receiver : receivers) {
            registerDynamicReceiver(context, receiver);
        }
    }

    // 解析出 receiver以及对应的 intentFilter
    // 手动注册Receiver
    public static void registerDynamicReceiver(Context context, Object receiver) {
        //取出receiver的intents字段
        List<? extends IntentFilter> filters = (List<? extends IntentFilter>) RefInvoke.getFieldObject(
                "android.content.pm.PackageParser$Component", receiver, "intents");

        try {
            // 把解析出来的每一个静态Receiver都注册为动态的
            for (IntentFilter intentFilter : filters) {
                ActivityInfo receiverInfo = (ActivityInfo) RefInvoke.getFieldObject(receiver, "info");

                BroadcastReceiver broadcastReceiver = (BroadcastReceiver) RefInvoke.createObject(receiverInfo.name);
                context.registerReceiver(broadcastReceiver, intentFilter);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

ContentProvider

ContentProvider的插件化方法和BroadcastReceiver的很像,但是和BroadcastReceiver不同的是,BroadcastReceiver中的广播叫做注册,但ContentProvider是要“安装”。方案是:
首先,调用PackageParser的parsePackage方法,把得到的Package对象通过generateProviderInfo转换为ProviderInfo对象。

    public static List<ProviderInfo> parseProviders(File apkFile) throws Exception {

        //获取PackageParser对象实例
        Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser");
        Object packageParser = packageParserClass.newInstance();

        // 首先调用parsePackage获取到apk对象对应的Package对象
        Class[] p1 = {File.class, int.class};
        Object[] v1 = {apkFile, PackageManager.GET_PROVIDERS};
        Object packageObj = RefInvoke.invokeInstanceMethod(packageParser, "parsePackage",p1, v1);

        // 读取Package对象里面的services字段
        // 接下来要做的就是根据这个List<Provider> 获取到Provider对应的ProviderInfo
        List providers = (List) RefInvoke.getFieldObject(packageObj, "providers");

        // 调用generateProviderInfo 方法, 把PackageParser.Provider转换成ProviderInfo

        //准备generateProviderInfo方法所需要的参数
        Class<?> packageParser$ProviderClass = Class.forName("android.content.pm.PackageParser$Provider");
        Class<?> packageUserStateClass = Class.forName("android.content.pm.PackageUserState");
        Object defaultUserState = packageUserStateClass.newInstance();
        int userId = (Integer) RefInvoke.invokeStaticMethod("android.os.UserHandle", "getCallingUserId");
        Class[] p2 = {packageParser$ProviderClass, int.class, packageUserStateClass, int.class};

        List<ProviderInfo> ret = new ArrayList<>();
        // 解析出intent对应的Provider组件
        for (Object provider : providers) {
            Object[] v2 = {provider, 0, defaultUserState, userId};
            ProviderInfo info = (ProviderInfo) RefInvoke.invokeInstanceMethod(packageParser, "generateProviderInfo",p2, v2);
            ret.add(info);
        }

        return ret;
    }

然后我们需要调用ActivityThread的installContentProviders方法把这些ContentProvider“安装”到宿主中。

    public static void installProviders(Context context, File apkFile) throws Exception {
        List<ProviderInfo> providerInfos = parseProviders(apkFile);

        for (ProviderInfo providerInfo : providerInfos) {
            providerInfo.applicationInfo.packageName = context.getPackageName();
        }

        Object currentActivityThread = RefInvoke.getStaticFieldObject("android.app.ActivityThread", "sCurrentActivityThread");

        Class[] p1 = {Context.class, List.class};
        Object[] v1 = {context, providerInfos};

        RefInvoke.invokeInstanceMethod(currentActivityThread, "installContentProviders", p1, v1);
    }

ContentProvider的插件化还需要注意:

  1. App安装自己的ContentProvider是在进程启动时候进行,比Application的onCreate还要早,所以我们要在Application的attachBaseContext方法中手动执行上述操作。
  2. 让外界App直接调用插件的App,并不是一件特别好的事情,最好是由App的ContentProvider作为中转。因为字符串是ContentProvider的唯一标志,转发机制就特别适用。
image

总结

本文首先介绍了插件化中宿主和插件代码和资源互通的方式,然后介绍了四大组件的插件化方法,因为插件化技术太过繁杂,并没有把所有的细节都覆盖到,所介绍的方案也只是当今比较实用,经受过考验的一套,并没有介绍太多的方法。目的是让读者们和我一起,先从整体上理解插件化的机制,然后就容易去区分各种开源库的原理和思路了。

参考

感谢下面的各位老师的书籍或文章,让我受益匪浅。
1.包建强《Android插件化开发指南》
2.田维术的博客
3.插件化加载dex跟资源原理
4.深入理解Android插件化技术
5.插件化-解决插件资源ID与宿主资源ID冲突的问题
6.官网aapt2
7.再谈 aapt2 资源分区

我是Android笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一起慢慢变强。

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

推荐阅读更多精彩内容