阅读 318

Android 插件化

什么是插件化

通常,我们的app只有一个apk文件。而插件化是一种将app拆分为多个apk的技术,每一个apk都是app功能的一部分。一般,我们把app启动时由系统加载的apk称之为宿主。而宿主是可以加载其他的apk的,这些被宿主加载的apk我们称为插件。

插件化的用途

在学习一项新技能之前,我们首先要弄清楚它能为我们带来什么。那插件化能给我们带来什么呢?

  • 从项目管理角度来说:

    1. 插件化将应用的不同功能划分到一个个的插件中,便于程序的维护。
    2. 每个插件可以单独运行调试极大的提高了开发效率。
    3. 每个插件互不影响,多人协同开发更加高效。
  • 从程序运行角度来说:

    1. 按需加载插件,占用内存更少。
    2. 按需更新,更省流量。
  • 从公司运营角度来说:

    1. 实时更新,便于运营推广。
    2. 修复线上BUG,将风险降到最低。

插件化需要哪些内功

  • ClassLoader

    ClassLoader是由JVM平台提供的类加载器。它允许程序从网络、硬盘甚至是内存加载Class,这就为Android插件化提供了最基础的技术保障。我们知道Android平台对字节码文件作了优化,摒弃了传统JVM需要的.jar文件,而是采用体积更小的.dex文件。因此,Android自定义了一系列ClassLoader以满足对dex加载。在这些ClassLoader中,有一个DexClassLoader,它允许我们的app加载外部的.jar和.apk文件。

  • 反射和Hook技术

    Hook的翻译是"钩子"。我们知道Android操作系统有一套自己的机制,例如,Activity启动流程、事件分发机制、资源管理机制等。有时候,这些机制无法满足我们的需求,通过系统的API也无能为力。这时候就需要Hook技术对原有流程进行拦截,然后将系统流程替换成我们自己的流程。而反射是Hook技术的一种必要手段。

  • APP安装过程

    我们知道apk文件中包含了app运行需要的Activity、Service等信息。这些信息是在程序安装过程中通过PackageManagerService解析AndroidManifest文件取得的。只有了解了这些原理,我们才能知道如何解析插件中的Activity、Service等信息。

  • 四大组件的启动流程

    单纯解析出插件的Activity、Service等信息是不行,我们期望的是这些Activity、Service等可以像我们普通App中的一样可以正常运行。这就需要我们去熟悉四大组件的启动流程,然后通过Hook技术加入宿主启动插件的Activity、Service等逻辑。

  • 资源管理

    插件化会给资源管理带来两个问题:

    1. 在Android打包编译时,gradle会为图片、文案等资源生成一个唯一的Id。在运行期,通过对资源Id的引用来查找相应的资源。由于宿主apk和各插件apk打包不是在一次gradle assemble命令中完成的,这就有可能造成多个apk中产生的资源id相同,我们称之为资源冲突

    2. 各apk中的资源是无法共享的,例如在宿主中无法引用插件中的资源。

    为了解决这两个问题,我们有必要掌握Android平台的资源管理机制。

Android的ClassLoader机制

类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校检、转换解析和初始化的,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时进行链连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以同代拓展的语言特性就是依赖运行期动态加载和动态链接这个特点实现的。例如,如果编写一个面相接口的应用程序,可以等到运行时在制定实际的实现类;用户可以通过Java与定义的和自定义的类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为代码的一部分,这种组装应用程序的方式目前已经广泛应用于Java程序之中。从最基础的Applet,JSP到复杂的OSGi技术,都使用了Java语言运行期类加载的特性。
复制代码

Java虚拟机类加载分为5个过程:加载、验证、准备、解析和初始化。

在加载阶段,虚拟机需要完成以下3件事情:

1. 通过一个类的全限定名来获取定义此类的二进制字节流。
2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
复制代码

而加载阶段的第一步通过一个类的全限定名来获取定义此类的二进制字节流被放到了JVM外部去实现,这就给了我们决定如何去获取所需要类的权利。实现这个动作的代码模块我们称为ClassLoader。

双亲委派模型

无论是JVM还是Android,它们在加载类的时候都遵循双亲委派模型。双亲委派模型是这样的,每一个类加载器都有一个父加载器,如果某个类加载器收到了加载类的请求,它不会自己处理,而是交给父加载处理。每一层的类加载器都会这样向上传递,因此所有的类加载请求都会到达顶层的根加载器。只有父加载器不能处理加载请求时,子加载器才会尝试处理。具体代码如下:

public abstract class ClassLoader {

	private ClassLoader parent;

	protected ClassLoader(ClassLoader parentLoader) {
        this.parent = parentLoader;
    }

    public Class<?> loadClass(String className) throws ClassNotFoundException {
        return loadClass(className, false);
    }

    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
      	//查找类是否已经加载过
        Class<?> clazz = findLoadedClass(className);
	//类没有加载过
        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
              	//交给父加载器处理
                clazz = parent.loadClass(className, false);
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }
	    //父加载器不能处理加载请求
            if (clazz == null) {
                try {
                    //当前类加载器处理加载请求
                    clazz = findClass(className);
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }
        return clazz;
    }

    protected Class<?> findClass(String className) throws ClassNotFoundException {
        throw new ClassNotFoundException(className);
    }
}
复制代码

这段代码的解释如下:

  1. ClassLoader是一个抽象类。它的构造方法需要传入一个父加载器。
  2. ClassLoader提供一个public的loadClass方法,这个方法的作用就是根据类的全限定名加载类,它的内部调用了protected的loadClass。
  3. protected的loadClass是双亲委派模型的核心。先检查是否已经加载过,若没有加载过则调用父加载器加载,若父加载器加载失败,则调用自己的findClas()方法加载。

ClassLoader为我们提供了两个protected的方法loadeClass(string className, boolean resolve)和findClass(String className)。你也许会有疑惑,loadeClass(string className, boolean resolve)为什么要声明为protected的呢,这样子类岂不是可以重写这个方法从而绕过了双亲委派模型。其实,这是由于历史原因造成的。在Java初期,开发JDK的大脑袋们并没有提供findClass()方法,双亲委派模型需要开发者自己去维护。Java 1.2时,这些大脑袋们为了重构了ClassLoader,才有了findClass()方法,但是为了兼容之前的版本,loadClass()方法保留了protected声明。所以,为了安全起见,我们还是老老实实的重写findClass()方法吧。

Android中的ClassLoader

为了能够加载dex/apk文件,Android重新定义了一系列的ClassLoader。其中的PathClassLoader和DexClassLoader是本文分析的对象。

PathClassLoader

PathClassLoader是什么呢?要弄清楚这个问题需要对app的启动流程有个简单的认识。首先,apk在安装成功后会被存储在data/app/的目录下。然后,app启动时,系统会去data/app/目录下找到相应的apk加载到内存中。而这个加载动作就是通过PathClassLoader完成的。因此,PathClassLoader只能加载系统中已经安装过的apk,对应到插件化技术中就是加载宿主apk。

/**
 * Provides a simple {@link ClassLoader} implementation that operates on a list
 * of files and directories in the local file system, but does not attempt to
 * load classes from the network. Android uses this class for its system class
 * loader and for its application class loader(s).
 */
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);
    }
}
复制代码

上面这段代码是PathClassLoader的全部代码了,我们可以看到它单纯的继承了BaseDexClassLoader。关于BaseDexClassLoader我们一会再说。

DexClassLoader

DexClassLoader是一种允许app运行期间加载外部jar/apk文件的加载器。因此我们用它来加载插件。

/**
 * A class loader that loads classes from {@code .jar} and {@code .apk} files
 * containing a {@code classes.dex} entry. This can be used to execute code not
 * installed as part of an application.
 *
 * <p>This class loader requires an application-private, writable directory to
 * cache optimized classes. Use {@code Context.getCodeCacheDir()} to create
 * such a directory: <pre>   {@code
 *   File dexOutputDir = context.getCodeCacheDir();
 * }</pre>
 *
 * <p><strong>Do not cache optimized classes on external storage.</strong>
 * External storage does not provide access controls necessary to protect your
 * application from code injection attacks.
 */
public class DexClassLoader extends BaseDexClassLoader {
  
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}
复制代码

和PathClassLoader一样,DexClassLoader也只是继承了BaseDexClassLoader。看来,只要弄清楚了BaseDexClassLoader就能理解PathClassLoader和DexClassLoader的加载机制了。

BaseDexClassLoader

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) {
            throw new ClassNotFoundException();
        }
        return c;
    }
}
复制代码

上面这段代码是BaseDexClassLoader加载类的关键代码,它还是非常简单的。

构造方法有四个参数,含义如下:

  • dexPath: 包含目标类或资源的apk/jar列表;当有多个路径则采用:分割。
  • optimizedDirectory: 优化后dex文件存在的目录, 可以为null。
  • libraryPath: native库所在路径列表;当有多个路径则采用:分割。
  • Parent:父类的类加载器。

在构造方法中通过this、dexpath、libraryPath、optimizedDirectory生成了一个DexPathList对象,并保存在pathList中。

重写的findClass()方法中,将加载类的具体逻辑交给了pathList对象。

我们接着了解DexPathList。

public class DexPathList{

    private final Element[] dexElements;
    private final ClassLoader definingContext;
  
    public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        this.definingContext = definingContext;
        // save dexPath for BaseDexClassLoader
        this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory);
    }

    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        return null;
    }
}    
复制代码

DexPathList类的代码很长,上面这段只列出了我们需要关心的,它的意思就是在new DexPathList时,会通过构造方法中的dexPath,optimizedDirectory生成一个Element[]数组,并保存在dexElements中。在真正加载类的findClass()方法中,遍历dexElements,通过Element的loadClassBinaryName加载Class。这里请记住dexElements,因为它在后文中很重要。

Android ClassLoader机制在插件化中的应用

插件化是将一个apk拆分成一个宿主和多个插件的技术。那必然有以下三个问题需要考虑:

  1. 插件如何加载宿主中的类。
  2. 宿主如何加载插件中的类。
  3. 一个插件如何加载其它插件中的类。

第一个问题比较简单,我们只需要在构造插件DexClassLoader时,把宿主PathClassLoader作为parent传递进去(双亲委托模型)。

第二个问题比较复杂,因为宿主PathClassLoade没办法直接拿到插件的信息。那有没有办法在运行期间动态向宿主PathClassLoader添加插件apk信息呢?答案是肯定的,它要靠上文提到的dexElements完成。我们在原理部分分析了宿主PathClassLoader加载类的动作实际上是遍历DexPathList的dexElements完成的,如果我们将插件DexClassLoader中的dexElements添加到宿主PathClassLoader中去,是不是宿主PathClassLoader也有了插件的信息了呢。

由于双亲模型,第二个问题解决了,第三个问题也自然就解决了。

具体的代码逻辑如下:

 protected ClassLoader createClassLoader(Context context, File apk, ClassLoader parent) throws Exception {

        File dexOutputDir = getDir(context, Constants.OPTIMIZE_DIR);
        String dexOutputPath = dexOutputDir.getAbsolutePath();
        File nativeLibDir = getDir(context, Constants.NATIVE_DIR);
        DexClassLoader loader = new DexClassLoader(apk.getAbsolutePath(), dexOutputPath, nativeLibDir.getAbsolutePath(), parent);
       
        DexUtil.insertDex(loader, parent);
        return loader;
    }
复制代码
public class DexUtil {
    //将dexClassLoader的dexElements添加到baseClassLoader的dexElements中去。
    public static void insertDex(DexClassLoader dexClassLoader, ClassLoader baseClassLoader) throws Exception {
        Object baseDexElements = getDexElements(getPathList(baseClassLoader));
        Object newDexElements = getDexElements(getPathList(dexClassLoader));
        Object allDexElements = combineArray(baseDexElements, newDexElements);
        Object pathList = getPathList(baseClassLoader);
        Reflector.with(pathList).field("dexElements").set(allDexElements);
    }
    //通过反射获取dexElements
    private static Object getDexElements(Object pathList) throws Exception {
        return Reflector.with(pathList).field("dexElements").get();
    }
    //通过反射获取PathClassLoader/DexClassLoader中的pathList
    private static Object getPathList(ClassLoader baseDexClassLoader) throws Exception {
        return Reflector.with(baseDexClassLoader).field("pathList").get();
    }
    //合并数组
    private static Object combineArray(Object firstArray, Object secondArray) {
        Class<?> localClass = firstArray.getClass().getComponentType();
        int firstArrayLength = Array.getLength(firstArray);
        int secondArrayLength = Array.getLength(secondArray);
        Object result = Array.newInstance(localClass, firstArrayLength + secondArrayLength);
        System.arraycopy(firstArray, 0, result, 0, firstArrayLength);
        System.arraycopy(secondArray, 0, result, firstArrayLength, secondArrayLength);
        return result;
    }
}
复制代码

Android插件化之启动Activity

我们知道,如果要启动一个Activity,那么这个Activity必须在AndroidManifest.xml中注册。因此,如果我们要启动插件中的Activity,那么这个Activity事先必须在宿主的AndroidManifest.xml中预注册。这样一来就会有两个问题:

  1. 插件中的Activity在宿主的AndroidManifest.xml中注册,开发不太友好。
  2. 只能启动插件中事先定义好的Activity,没有办法动态增加Activity。

因此,现有的插件化框架都会有一套越过AndroidManifest.xml注册而启动Activity的机制。本文的目的就是分析这套机制。

Activity的启动流程

不考虑多进程的情况,Activity的启动是Binder双向通信的一个过程,它由我们的app进程和AMS所在的system_server进程共同完成。system_server是一个系统级的进程,其内部的AMS负责管理所有app的Activity状态。因此,Android是不允许我们对AMS进行修改的。那么,插件化技术只能在我们app进程里做文章了。

下图我根据6.0的源码画出了Activity的启动流程图,我省略了我们不需要关心的AMS部分。

image.png

正如上图所示,我将Activity的启动流程分为了两部分。上部分描述了app从发起启动activity请求到AMS接受到该请求的过程。下部分描述了AMS处理完成后响应app进程启动Activity的过程。

请求阶段

image.png

上图无论是Activity的statActivity()还是startActivityForResult()最终都调用了Instrumentation.execStartActivity()。我们继续定位到Instrumentation.execStartActivity()。

image.png

execStartActivity()非常简单。它将传进来的参数进一步包装后传给ActivityManagerNative.getDefault()的startActivity()。ActivityManagerNative.getDefault()返回的其实是一个ActivityManagerProxy对象。ActivityManagerProxy是ActivityManagerService在app进程中的Binder代理对象。调用ActivityManagerProxy.startService()最后会调用ActivityManagerService.startService()。这样请求就到了ActivityManagerService。ActivityManagerNative.getDefault()如下:

image.png

到此,请求启动Activity的过程就分析完了。

响应阶段

前面我们提到过,在不考虑多进程的情况下,Activity的启动过程是一个Binder双向通信的过程。AMS要主动与app进程通信要依靠请求启动Activity阶段传过来的IBinder对象,这个IBinder对象就是上面介绍过的Instrumentation.execStartActivity()中的 whoThread对象,它实际上是一个ApplicationThreadProxy对象,用来和ApplicationThread通信。AMS通知app进程启动Activity是通过调用ApplicationThreadProxy.scheduleLaunchActivity()完成的。根据Binder通信,ApplicationThread.scheduleLaunchActivity()会被调用。我们就从ApplicationThread.scheduleLaunchActivity()开始分析。

image.png

scheduleLaunchActivity()将从AMS中传过来的参数封装成ActivityClientRecord对象,然后将消息发送给mH,mH是一个Handler对象。

image.png

H是ActivityThread的内部类,继承自Handler,它在收到LAUNCH_ACTIVITY的消息后,会调用ActivityThread.handlerLaunchActivity()。

image.png

handleLaunchActivity()主要调用了两个方法:performLaunchActivity()和handleResumeActivity()。performLaunchActivity()会完成Activity的创建,以及调用Activity的onCreate()、onStart()等方法。handleResumeActivity()会完成Activity.onResume()的调用。我们继续跟踪performLaunchActivity()。

image.png

上述代码在关键位置都加了注释。通过注释我们明白了Activity的创建以及onCreate()的调用都是在Instrumentation中完成的。这里需要注意一下Activtiy.attach()的调用时机,我们会在下文中用到。Instrumentation具体的代码如下:

image.png

到此AMS通知app进程启动Activity的流程就结束了。

偷天换日,Hook Instrumentation越过AndroidManifest检测

要启动没有在AndroidManifest.xml中注册的Activity,其核心是就是偷天换日。怎么做呢?通过一个例子说明。

假如在插件中有一个未在AndroidManifest.xml注册的TargetActivity,我们想启动它,可以分为三步。

  1. 在AndroidManifest.xml中预先注册一个我们项目中没有的Activity,例如ProxyActivity。我们把这种行为称为插桩。
  2. 在请求启动Activity阶段,我们把TargetActivity替换成AndroidManifest中预先注册的ProxyActivity。
  3. 在AMS响应阶段,Activity实例产生之前,我们再做一个完全相反的动作。即把响应信息中要启动的ProxyActivity替换回TargetActivity。

第一步十分简单,没什么好说的。要实现第二步和第三步就需要用到Activity启动流程的知识了。

在Activity启动流程中,Instrumentation无论在请求阶段还是响应阶段都扮演着重要的角色。在请求阶段Instrumentation.execStartActivity()会被调用,而在响应阶段Instrumentation.newActivity()会被调用。因此如果我们可以Hook Instrumentation,那么我们就可以在execStartActivity()和newActivity()分别完成第二步和第三步中的功能。

再谈Instrumentation

ActivityThread中的Instrumentation

image.png

在main()方法中,ActivityThread会被初始化并最终把对象保存在静态的sCurrentActivityThread中。在一个app进程中只有一个ActivityThread实例sCurrentActivityThread。sCurrentActivityThread可以通过ActivityThread.currentActivityThread()拿到。 attach()中,mgr.attachApplication(mAppThread)这段代码又是一个Binder双向通信的过程,它主要为创建Application对象服务。整个通信过程和Activity启动过程类似,我就不再详细介绍了。在通信的最后,ActivtiyThread.handleBindApplication()被调用,而在方法内部,Instrumentation被初始化。

image.png

总结一下,一个App进程,只有一个ActivityThread对象,这个对象保存在sCurrentActivityThread中,可以通过ActivityThread.currentActivityThread()获取。ActivityThread的mInstrumentation会在Application创建之前初始化。

Activity中的Instrumentation

Activtiy中的Instrumentation是通过Activity.attach()传进来的。

image.png

Activity.attach()在介绍Activity启动流程时提到过。它会在ActivityThread.performLaunchActivity()中被调用。

image.png

这样ActivtyThread把自己内部的Instrumentation传递到了Activity中。

Hook Instrumentation

通过以上分析,我们知道,要Hook app的Instrumentation,只需要替换掉ActivityThread的Instrumentation即可。但是,Android SDK没有为我们提供任何关于ActivityThread的api。要访问Android SDK中不存在的类或方法,我们学习一下VirtualAPK是怎么做的。

在VirtualAPK中有个叫AndroidStub的module。它的结构如下:

image.png

VirtualAPK又重新声明了这些Android SDK没有提供的Framework层的类。这些类只有方法的声明,如ActivityThread中的内容如下:

image.png

这样我们就可以使用这些Android SDK没有提供的类或隐藏的方法了。需要注意的一点是,AndroidStub应该只参与编译过程,这很简单,用compileOnly依赖就可以了。

接下来,我们就可以通过反射替换ActivitThread的Instrumentation了。代码如下:

image.png

上面的VAInstrumentation是对系统Instrumentation的代理类。在VAInstrumentation的内部我们可以加入任何我们想要的逻辑。

在Instrumentation.execStartActivity()执行前将我们要启动的Activity替换成预注册的ProxyActivity。

image.png

在Instrumentation.newActivity()执行前将预注册的ProxyActivity替换回我们要启动的Activity。

image.png

文章分类
Android
文章标签