Android 模块化-组件化-插件化-xxx 攻略

152 阅读30分钟

攻略大全

1. 粘贴攻略

1.1 Hook

说到Hook 技术得先提到逆向工程,逆向工程源于商业及军事领域中的硬件分析,其主要目的是在不能轻易获得必要的生产信息的情况下,直接从成品分析,推导出产品的设计原理。

逆向分析分为静态分析和动态分析,其中静态分析指的是一种在不执行程序的情况下对程序行为进行分析的技术;动态分析是指在程序运行时对程序进行调试的技术。

Hook技术就属于动态分析,它不仅在Android平台中被应用,早在Windows平台中就已经被应用了。

image.png

可以看到Hook可以将自己融入到它所要劫持的对象(对象B)所在的进程中,成为系统进程的一部分,这样我们就可以通过Hook来更改对象B的行为。

被劫持的对象(对象B),称作Hook点,为了保证Hook的稳定性,Hook点一般选择容易找到并且不易变化的对象,静态变量和单例就符合这一条件。

1.1.1 Hook技术分类

根据Hook的API语言划分,分为Hook Java和Hook Native。

  • Hook Java主要通过反射和代理来实现,应用于在SDK开发环境中修改Java代码。

  • Hook Native则应用于在NDK开发环境和系统开发中修改Native代码。

根据Hook的进程划分,分为应用程序进程Hook和全局Hook。

  • 应用程序进程Hook只能Hook当前所在的应用程序进程。
  • 应用程序进程是Zygote 进程fork 出来的,如果对Zygote 进行Hook,就可以实现Hook系统所有的应用程序进程,这就是全局Hook。

根据Hook的实现方式划分,分为如下两种。

  • 通过反射和代理实现,只能Hook当前的应用程序进程。
  • 通过Hook框架来实现,比如Xposed,可以实现全局Hook,但是需要root。

2. 造火箭攻略

3. 拧螺丝攻略

3.1 热修复

热修复框架的种类繁多,按照公司团队划分主要有如表所示的几种。

image.png

虽然热修复框架很多,但热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复。

其中每个核心技术又有很多不同的技术方案,每个技术方案又有不同的实现,另外这些热修复框架仍在不断地更新迭代中,可见热修复框架的技术实现是繁多可变的。作为开发者需要了解这些技术方案的基本原理,这样就可以以不变应万变。

image.png

可以发现Tinker和Amigo拥有的特性最多,是不是就选它们呢?也不尽然,拥有的特性多也意味着框架的代码量庞大,我们需要根据业务来选择最合适的,假设我们只是要用到方法替换,那么使用Tinker和Amigo显然是大材小用了。另外如果项目需要即时生效,那么使用Tinker和Amigo是无法满足需求的。对于即时生效,AndFix、Robust和Aceso都满足这一点,这是因为AndFix的代码修复采用了底层替换方案,而Robust和Aceso的代码修复借鉴了Instant Run原理。

3.1.1 资源修复

很多热修复的框架的资源修复参考了Instant Run的资源修复的原理,因此我们首先要了解Instant Run是什么。

3.1.1.1 Instant Run

3.1.1.1.1 Instant Run概述

Instant Run是Android Studio 2.0以后新增的一个运行机制,能够显著减少开发人员第二次及以后的构建和部署时间。在没有使用Instant Run前,我们编译部署应用程序的流程如图所示。

image.png

从上图可以看出,传统的编译部署需要重新安装App和重启App,这显然会很耗时,Instant Run会避免这一情况,如下图所示。

image.png

从图上可以看出Instant Run的构建和部署都是基于更改的部分的。Instant Run部署有三种方式,Instant Run会根据代码的情况来决定采用哪种部署方式,无论哪种方式都不需要重新安装App,这一点就已经提高了不少的效率。

  • Hot swap:从名称也可以看出Hot Swap是效率最高的部署方式,代码的增量改变不需要重启App,甚至不需要重启当前的Activity。修改一个现有方法中的代码时会采用Hot Swap。

  • Warm Swap:App不需重启,但是Activity需要重启。修改或删除一个现有的资源文件时会采用Warm Swap。

  • Cold Swap:App需要重启,但是不需要重新安装。采用Cold Swap的情况很多,比如添加、删除或修改一个字段和方法、添加一个类等。

3.1.1.1.2 Instant Run的资源修复

Instant Run资源修复的核心逻辑在MonkeyPatcher的monkeyPatchExistingResources方法中,如下所示:

image.png

image.png

image.png

在注释1处创建一个新的AssetManager,

在注释2和注释3处通过反射调用addAssetPath方法加载外部(SD卡)的资源。

在注释4处遍历Activity列表,得到每个Activity的Resources,

在注释5处通过反射得到Resources的AssetManager类型的mAssets字段,并在注释6处改写mAssets字段的引用为新的AssetManager。

采用同样的方式,在注释7处将Resources.Theme的mAssets字段的引用替换为新创建的AssetManager。紧接着根据SDK 版本的不同,用不同的方式得到Resources 的弱引用集合,再遍历这个弱引用集合,将弱引用集合中的Resources的mAssets字段引用都替换成新创建的AssetManager。

Instant Run中的资源热修复可以简单地总结为两个步骤:

(1)创建新的AssetManager,通过反射调用addAssetPath方法加载外部的资源,这样新创建的AssetManager就含有了外部资源。

(2)将AssetManager类型的mAssets字段的引用全部替换为新创建的AssetManager。

3.1.2 代码修复

代码修复主要有3个方案,分别是底层替换方案、类加载方案和Instant Run方案。

3.1.2.1 类加载方案

类加载方案基于Dex分包方案,什么是Dex分包方案呢?

这个得先从65536限制和LinearAlloc限制说起。

1.65536限制

随着应用功能越来越复杂,代码量不断地增大,引入的库也越来越多,可能会在编译时提示如下异常:

image.png

这说明应用中引用的方法数超过了最大数65536个。产生这一问题的原因就是系统的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用65535个方法。

2.LinearAlloc限制

在安装应用时可能会提示INSTALL_FAILED_DEXOPT,产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数超出了缓存区的大小时会报错。

为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态地加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。

Dex分包方案主要有两种,分别是Google官方方案、Dex自动拆包和动态加载方案。

根据ClassLoader的加载过程,其中一个环节就是调用DexPathList的findClass的方法,如下所示:

image.png

Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。多个Element组成了有序的Element数组dexElements。

当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),

注释2处调用Element的findClass 方法,其方法内部会调用DexFile 的loadClassBinaryName 方法查找类。

如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。

根据上面的查找流程,我们将有Bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在Bug的Key.class,排在数组后面的dex文件中存在Bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案,如下图所示。

image.png

类加载方案需要重启App 后让ClassLoader 重新加载新的类,为什么需要重启呢?这是因为类是无法被卸载的,要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。

虽然很多热修复框架采用了类加载方案,但具体的实现细节和步骤还是有一些区别的,比如QQ空间的超级补丁和Nuwa 是按照上面说的将补丁包放在Element数组的第一个元素得到优先加载。微信Tinker将新旧APK 做了diff,得到patch.dex,再将patch.dex与手机中APK的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。饿了么的Amigo则是将补丁包中每个dex对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element数组。

采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的Amigo和Nuwa等。

3.1.2.2 底层替换方案

与类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于在原有类进行修改限制会比较多,且不能增减原有类的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。

底层替换方案和反射的原理有些关联,就拿方法替换来说,方法反射我们可以调用java.lang.Class.getDeclaredMethod,假设我们要反射Key 的show方法,会调用如下所示的代码:

image.png

Android 8.0的invoke方法,如下所示:

image.png

invoke方法是一个native方法,对于Jni层的代码为:

image.png

在Method_invoke函数中又调用了InvokeMethod函数:

image.png

注释1处获取传入的javaMethod(Key的show方法)在ART虚拟机中对应的一个ArtMethod指针,ArtMethod结构体中包含了Java 方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等,ArtMethod结构如下所示:

image.png

在ArtMethod结构中比较重要的字段是注释1处的dex_cache_resolved_methods和注释2处的entry_point_from_quick_compiled_code,它们是方法的执行入口,当我们调用某一个方法时(比如Key的show方法),就会取得show方法的执行入口,通过执行入口就可以跳过去执行show方法。

替换ArtMethod结构体中的字段或者替换整个ArtMethod结构体,这就是底层替换方案。

AndFix采用的是替换ArtMethod结构体中的字段,这样会有兼容问题,因为厂商可能会修改ArtMethod结构体,导致方法替换失败。Sophix采用的是替换整个ArtMethod 结构体,这样不会存在兼容问题。底层替换方案直接替换了方法,可以立即生效不需要重启。采用底层替换方案主要是阿里系为主,包括AndFix、Dexposed、阿里百川、Sophix。

3.2.2.3 Instant Run方案

ASM 是一个Java 字节码操控框架,它能够动态生成类或者增强现有类的功能。ASM可以直接产生clsss文件,也可以在类被加载到虚拟机之前动态改变类的行为。

Instant Run在第一次构建APK时,使用ASM在每一个方法中注入了类似如下的代码:

image.png

image.png

其中注释1处是一个成员变量localIncrementalChange,它的值为changechange,change实现了IncrementalChange 这个抽象接口。当我们点击InstantRun 时,如果方法没有变化则$change为null,就调用return,不做任何处理。如果方法有变化,就生成替换类。

这里我们假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivityoverride,这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivityoverride,这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivity的change设置为MainActivityoverride,因此满足了注释2的条件,会执行MainActivityoverride,因此满足了注释2的条件,会执行MainActivityoverride的accessdispatch方法,在accessdispatch 方法,在accessdispatch 方法中会根据参数"onCreate.(Landroid/os/Bundle;)V"执行MainActivity$override的onCreate方法,从而实现了onCreate方法的修改。借鉴Instant Run的原理的热修复框架有Robust和Aceso。

3.1.3 动态链接库的修复

热修复框架的so修复的主要是更新so,换句话说就是重新加载so,因此so的修复的基础原理就是加载so。

3.1.3.1 System的load和loadLibarary方法

加载so主要用到了System类的load和loadLibarary方法,如下所示:

image.png

image.png

System的load方法传入的参数是so在磁盘的完整路径,用于加载指定路径的so。

System的loadLibrary方法传入的参数是so的名称,用于加载App安装后自动从apk包中复制到/data/data/packagename/lib 下的so。

目前so 的修复都是基于这两个方法,这里分别对这两个方法进行讲解。

3.1.3.1.1 System的load方法

注释1处的Runtime.getRuntime()会得到当前Java 应用程序的运行环境Runtime,Runtime的load0方法如下所示:

image.png

在注释1处调用了doLoad方法,并将加载该类的类加载器作为参数传入进去:

image.png

image.png

doLoad方法会调用native方法nativeLoad。

3.1.3.1.2 System的loadLibrary方法

我们接着来查看System的loadLibrary方法,其中会调用Runtime的loadLibrary0方法:

image.png

image.png

loadLibrary0方法分为两个部分,一个是传入的ClassLoader不为null的部分,另一个是ClassLoader 为null 的部分。

我们先来看ClassLoader为null的部分。在注释3处遍历getLibPaths方法,这个方法会返回java.library.path选项配置的路径数组。在注释4处拼接出so路径并传入注释5处调用的doLoad方法中。

当ClassLoader不为null时,在注释2处同样调用了doLoad方法,其中第一个参数是通过注释1处的ClassLoader的findLibrary方法来得到的,findLibrary方法在ClassLoader的实现类BaseDexClassLoader中实现。

image.png

在findLibrary方法中调用了DexPathList的findLibrary方法:

image.png

这和上文讲到的DexPathList的findClass方法类似,在NativeLibraryElement数组中的每一个NativeLibraryElement对应一个so库,在注释1处调用NativeLibraryElement的findNativeLibrary方法就可以返回so的路径。

上面的代码结合上文的类加载方案,就可以得到so的修复的一种方案,就是将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回,并调用Runtime的doLoad方法进行加载,在doLoad方法中会调用native方法nativeLoad。

看来System的load方法和loadLibrary方法在Java FrameWork层最终调用的都是nativeLoad方法。我们接着来分析nativeLoad方法。

3.1.3.2 nativeLoad方法分析

nativeLoad方法对应的JNI层函数如下所示:

image.png

在Runtime_nativeLoad函数中调用了JVM_NativeLoad函数:

image.png

在注释1处获取当前运行时的JavaVMExt类型指针,JavaVMExt用于代表一个虚拟机实例,紧接着调用JavaVMExt的LoadNativeLibrary函数来加载so。

3.1.3.2.1 LoadNativeLibrary函数part1

image.png

在注释1处根据so的名称从libraries_中获取对应的SharedLibrary类型指针library,如果满足注释2处的条件就说明此前加载过该so。在注释3处如果此前加载用的ClassLoader和当前传入的ClassLoader不相同的话,就会返回false,在注释4处判断上次加载so的结果,如果有异常也会返回false,中断so加载。如果满足了注释2、注释3、注释4处的条件就会返回true,不再重复加载so。

3.1.3.2.2 LoadNativeLibrary函数part2

image.png

image.png

在注释1处根据so的路径path_str来打开该so,并返回得到so句柄,在注释2处如果获取so句柄失败就会返回false,中断so加载。在注释3处新创建SharedLibrary,并将so句柄作为参数传入进去。在注释4处获取传入path对应的library,如果library为空指针,就将新创建的SharedLibrary赋值给library,并将library存储到libraries_中。

3.1.3.2.3 LoadNativeLibrary函数part3

image.png

image.png

在注释1处查找JNI_OnLoad函数的指针并赋值给空指针sym,而JNI_OnLoad函数是用于native方法的动态注册。

在注释2处如果没有找到JNI_OnLoad函数就将was_successful赋值为true,说明已经加载成功,没有找到JNI_OnLoad函数也算加载成功,这是因为并不是所有so都定义了JNI_OnLoad函数,因为native方法除了动态注册,还有静态注册。如果找到了JNI_OnLoad函数,就在注释3处执行JNI_OnLoad函数并将结果赋值给version,如果version为JNI_ERR 或者BadJniVersion,说明没有执行成功,was_successful的值仍旧为默认的false,否则就将was_successful赋值为true,最终会返回该was_successful。

3.1.3.2.4 LoadNativeLibrary函数总结

LoadNativeLibrary 函数的行数很多,这里来做一个总结,LoadNativeLibrary 函数主要做了如下3方面工作:

(1)判断so是否被加载过,两次ClassLoader是否是同一个,避免so重复加载。

(2)打开so并得到so句柄,如果so句柄获取失败,就返回false。创建新的SharedLibrary,如果传入path 对应的library为空指针,就将新创建的SharedLibrary赋值给library,并将library存储到libraries_中。

(3)查找JNI_OnLoad的函数指针,根据不同情况设置was_successful的值,最终返回该was_successful。

image.png

3.1.3.2.5 so的主要修复方案

(1)将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载。

(2)调用System的load方法来接管so的加载入口。

3.2 插件化

随着应用开发的规模和复杂度越来越高,插件化技术被广泛地应用在各个较大规模的应用开发中。插件化技术和热修复技术都属于动态加载技术,从普及率的角度来看,插件化技术没有热修复的普及率高,主要原因是占大多数的中小型应用很少也没有必要去采用插件化技术。虽然插件化技术普及率现在不算高,但是插件化的原理对于应用开发的技术提升有很大的帮助,可以使你更好地理解系统的源码,并将系统源码和应用开发相结合。

3.2.1 动态加载技术

在讲到插件化原理之前,需要先了解它的前身:动态加载技术。

动态加载技术不只应用在Android 开发领域,在很多开发领域都应用过,这里我们讨论的只是动态加载技术在Android开发领域的应用。

在Android传统开发中,一旦应用的代码被打包成APK并被上传到各个渠道市场,我们就不能修改应用的源码了,只能通过服务器来控制应用中预留的分支代码。但是很多时候我们无法提前预知需求和突然发生的情况,也就不能提前在应用代码中预留分支代码,这时就需要采用动态加载技术。

在应用程序运行时,动态加载一些程序中原本不存在的可执行文件并运行这些文件里的代码逻辑。可执行文件总的来说分为两种,一种是动态链接库so,另一种是dex相关文件(dex以及包含dex的jar/apk文件)。

随着应用开发技术和业务的逐步发展,动态加载技术派生出两个技术,分别是热修复技术和插件化技术。

其中热修复技术主要用来修复Bug,插件化技术则主要用于解决应用越来越庞大以及功能模块的解耦,围绕着两个技术出现了很多的热修复框架和插件化框架。需要注意的是,动态加载技术本身并没有被官方认可,并且是一个非常规的技术,在国外这门技术关注度并不高,它的产生更多的是国内的业务需求和产品的驱动。

3.2.2 插件化的诞生

3.2.2.1 应用开发的痛点和瓶颈

1.业务复杂,模块耦合

随着业务越来越复杂也越来越多,导致应用程序体积越来越大,应用程序的工程和功能模块数量越来越多,一个应用可能是由几十、几百人协同开发的,很多工程和功能模块都是由一个小组进行开发维护的,如果功能模块间的耦合度比较高,修改一个功能模块会影响其他功能模块,势必会极大地增加沟通成本。

2.应用间的接入

一个应用不再是单独的应用,它可能需要接入其他的应用。拿手机淘宝来说,它的流量非常大,其他的淘宝应用或者业务比如:聚划算、淘宝书城、飞猪旅游、淘宝拍卖、淘宝外卖(口碑外卖)等都希望接入到淘宝客户端,这样既能获取到流量,同时也可以将用户引流到自己的应用中,如果使用常规的技术手段,会产生两个问题。

  • 比如淘宝外卖需要接入到淘宝客户端中,那么淘宝外卖团队可能需要维护两个版本,一个自身版本,另一个是淘宝客户端版本,这样维护成本和沟通成本会比较高。况且淘宝外卖不只是接入淘宝客户端,它还可以接入到其他应用中,比如支付宝应用,那么淘宝外卖团队维护的就不仅仅是两个版本了。
  • 比如淘宝客户端接入了很多其他的应用,势必会使应用的体积急剧变大,编译时间会变得非常长,一个Bug和功能就会由组内的开发协作变为了组和组之间甚至是部门间的开发协作,极大地增加了开发测试成本和沟通成本,新功能的添加牵扯得越多,版本发布的时间变得越不可控。

3.65536限制,内存占用大

随着应用的代码量不断增大,引入的库也越来越多,特别是应用需要接入其他应用,那么方法数很容易超过65536个。应用代码量的增加同时也导致了应用占用大量的内存。

3.2.2.2 插件化思想

image.png

插件化的客户端由宿主和插件两个部分组成,宿主就是指先被安装到手机中的APK,就是平常我们加载的普通APK。插件一般是指经过处理的APK、so和dex等文件,插件可以被宿主进行加载,有的插件也可以作为APK独立运行。

可以看出采用插件化的淘宝客户端分为了两大部分,一部分是宿主部分,也就是淘宝主客户端,其内部包含了主界面模块;另一部分是插件部分,不仅包括了外接的其他应用业务,比如聚划算和飞猪旅行,同时也包括了淘宝自身的业务模块,比如消息和搜索。需要注意的是,这里的举例更多是为了便于理解,只是淘宝客户端演进过程中的一个非常缩略的框架,和真实的淘宝客户端有非常大的区别。

讲到这里就可以引出插件化的定义:将一个应用按照插件的方式进行改造的过程就叫作插件化。

采用了插件化的淘宝主客户端在协作方面,插件可以由一个人或者一个小组来进行开发,这样各个插件之间,以及插件和宿主之间的耦合度会降低。应用间的接入和维护也变得便捷,每个应用团队只需要负责自己的那一部分就可以了。应用以及主dex的体积也会相应变小,间接地避免了65536限制。第一次加载到内存的只有淘宝主客户端,当使用到其他插件时才会加载相应插件到内存,这样就减少了内存的占用。

3.2.3 插件化框架对比

image.png

image.png

如果加载的插件不需要和宿主有任何耦合,也无须和宿主进行通信,比如加载第三方App,那么推荐使用RePlugin,其他的情况推荐使用VirtualApk。由于VirtualApk在加载耦合插件方面是插件化框架的首选,具有普遍的适用性,本文会结合VirtualApk来讲解插件化的原理。

3.2.4 Activity插件化

四大组件的插件化是插件化技术的核心知识点,而Activity 插件化更是重中之重。

Activity插件化主要有3种实现方式,分别是反射实现、接口实现和Hook技术实现。

反射实现会对性能有所影响,主流的插件化框架没有采用此方式,关于接口实现可以阅读dynamic-load-apk的源码,这里不做介绍,目前Hook技术实现是主流,因此本文主要介绍Hook技术实现。

Hook技术实现主要有两种解决方案,一种是通过Hook IActivityManager来实现,另一种是Hook Instrumentation实现。

3.2.4.1 Hook IActivityManager方案实现

image.png

image.png

AMS存在于SystemServer进程中,我们无法直接修改,只能在应用程序进程中做文章。可以采用预先占坑的方式来解决没有在AndroidManifest.xml中显式声明的问题,具体做法就是在普通Activity的启动过程示意图中所示的步骤1之前使用一个在AndroidManifest.xml中注册的Activity来进行占坑,用来通过AMS的校验。接着在步骤2之后用插件Activity替换占坑的Activity。

3.2.4.2 Hook Instrumentation方式实现

Hook Instrumentation实现同样也需要用到占坑Activity,与Hook IActivityManager实现不同的是,用占坑Activity替换插件Activity以及还原插件Activity的地方不同。

3.2.4 Service插件化

3.2.4.1 插件化方面Service与Activity的不同

ContextImpl到AMS的调用过程:

image.png

ActivityThread启动Service:

image.png

可见Service的启动和Instrumentation完全没有关联,因此Service插件化不能通过Hook Instrumentation来实现。Service插件化可以用Hook IActivityManager的方案来实现吗?带着这个疑问,我们需要了解在插件化方面Activity和Service有何不同,主要有以下3点:

  • Activity是基于栈管理的,一个栈中的Activity的数量不会太多,因此插件化框架处理的插件Activity 数量是有限的,可以声明有限的占坑Activity 来实现。除去硬件和系统限制,插件化框架处理的插件Service的数量可以是近乎无限的,无法用有限的占坑Service来实现。

  • 在Standard模式下多次启动同一个占坑Activity可以创建多个Activity实例,但是多次启动占坑Service并不会创建多个Service实例。

  • 用户和界面的交互会影响到Activity 的生命周期,因此插件Activity 的生命周期需要交由系统来管理,Hook IActivityManager方案中还原插件Activity就是为了这一点。Service的生命周期不受用户影响,可以由开发者管理生命周期,没有必要还原插件。

综合上面3点得出的结论就是,Service插件化不可以用Hook IActivityManager方案来实现,我们需要找到一个新的方案。

3.2.4.2 代理分发实现

Activity插件化的重点在于要保证它的生命周期,而Service插件化的重点是保证它的优先级,这就需要用一个真正的Service来实现,而不是像占坑Activity那样起一个占坑的作用。当启动插件Service时,就会先启动代理Service,当这个代理Service运行起来之后,在它的onStartCommand等方法里面进行分发,执行插件TargetService的onCreate等方法,这一方案就叫作代理分发。

3.2.5 ContentProvider插件化

query方法到AMS的调用过程:

image.png

ContentProvider插件化的关键在于将ContentProvider插件共享给整个系统。和Service插件化类似,需要注册一个真正的ContentProvider作为代理ContentProvider,并把这个代理ContentProvider 共享给整个系统,对于插件ContentProvider 的请求会全部交由代理ContentProvider处理并分发给对应的插件ContentProvider。

3.2.5.1 VirtualApk的实现

3.2.6 BroadcastReceiver的插件化

BroadcastReceiver的注册分为两种,分别是静态注册和动态注册。

动态注册通过AMS来完成,动态注册的信息会存在AMS中。

静态注册需要在AndroidManifest.xml中注册,应用在安装时,PackageManagerService (PMS)会调用PackageParser的parsePackage方法来解析APK,通过解析APK中的AndroidManifest.xml文件的标签得到APK 中的各种信息并封装成相应的信息类,比如ApplicationInfo、ProviderInfo和ActivityInfo等,因此静态注册的信息会存在PMS中。

静态注册的BroadcastReceiver 会在AndroidManifest.xml 中设置<intent-filter>标签,BroadcastReceiver 根据这个标签中的值来接收“感兴趣”的广播。如果采用类似Activity插件化的Hook IActivityManager方案,用一个占坑BroadcastReceiver来接收广播是不可行的,因为我们无法预料插件中静态注册的BroadcastReceiver 的<intent-filter>标签,这样占坑BroadcastReceiver 无法接收到“感兴趣”的广播。静态注册的BroadcastReceiver 的<intent-filter>标签无法动态设置,但是动态注册的BroadcastReceiver 是可以动态设置IntentFilter 的,讲到这里我们得到了一个新思路,那就是将静态注册的BroadcastReceiver全部转换为动态注册来处理,虽然静态和动态的BroadcastReceiver的生命周期不同,但是为了实现插件化,这个缺点显然不是关键问题。

/**
 * 通过PMS的源码解析,是通过PackageParser对apk文件进行解析的
 *
 * PackageParser pp = new PackageParser();
 * pr.pkg = parsePackage(pp, scanFile, parseFlags);
 * pms解析一个apk的过程
 * 应用也想解析    解析apk    换肤   热修复   插件
 */
public class MyPackageParser {

    /**
     * 模拟pms的解析过程
     * 解析从服务端下载下来的apk文件
     * 将apk文件中的广播动态注册到当前app中
     *
     * @param context
     * @param apkFile apk文件所在路径
     * @throws Exception
     */
    public void parserReceivers(Context context, File apkFile) throws Exception {

        // 注意,当前反射调用是针对API29,不同版本有不同的实现细节

        // 通过反射调用系统类
        Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser");

        // 反射获取parsePackage()
        Method parsePackageMethod = packageParserClass.getDeclaredMethod("parsePackage", File.class, int.class,boolean.class);

        // 反射实例化PackageParser对象
        Object packageParser = packageParserClass.newInstance();
        
        // 打开访问权限
        parsePackageMethod.setAccessible(true);
        
        // 反射调用parsePackage()
        // 通过源码解析可知,此时解析返回后将返回一个Package对象
        Object packageObj = parsePackageMethod.invoke(packageParser, apkFile, PackageManager.GET_RECEIVERS, false);
        // 从Package对象中获取receivers字段
        // 该字段存储了插件apk文件中所生命的广播
        Field receiversField = packageObj.getClass().getDeclaredField("receivers");
        // 取出该字段所对应的组合
        List receivers = (List) receiversField.get(packageObj);
        
        // dex加载的优化
        // 注意,插件apk中的广播全类名,宿主apk并不知道。
        // 可通过DexClassLoader解析插件apk,输出对应的dex文件     
        DexClassLoader dexClassLoader = new DexClassLoader(apkFile.getAbsolutePath(),
                context.getDir("plugin", Context.MODE_PRIVATE).getAbsolutePath(),
                null, context.getClassLoader());
        
        Class<?> componentClass = Class.forName("android.content.pm.PackageParser$Component");
        Field intentsField = componentClass.getDeclaredField("intents");

        // 遍历存储receiver缩略信息的集合
        for (Object receiverObject : receivers) {
            
            // 获取缩略信息实例对象中的全雷鸣字段
            String name = (String) receiverObject.getClass().getField("className").get(receiverObject);
            
            // DexClassLoader已解析插件apk文件并缓存生成了对应的dex文件
            // 此时可通过dexClassLoader将所要动态注册的实际广播类,加载进内存中
            Class clazz = dexClassLoader.loadClass(name);
            
            // 从receiver的缩略信息对象中获取其所规定的IntentFilter
            List<? extends IntentFilter> filters = (List<? extends IntentFilter>) intentsField.get(receiverObject);
            for (IntentFilter filter : filters) {
                // 动态注册的广播类已被加载进内存
                // 直接反射实例化该广播对象
                BroadcastReceiver broadcastReceiver = (BroadcastReceiver) clazz.newInstance();
                
                // 最终动态注册该广播对象
                context.registerReceiver(broadcastReceiver, filter);
            }
        }
    }
    
}

3.2.6.1 VirtualApk的实现

3.2.7 资源的插件化

3.2.7.1 系统资源加载

启动Activity 时会调用performLaunchActivity 方法,其内部会调用LoadedApk 的makeApplication方法:

image.png

在注释1处创建Application,ContextImpl的createAppContext方法用于创建应用的Context:

image.png

在注释1处调用LoadedApk的getResources方法得到Resources,并将Resources赋值给ContextImpl。LoadedApk的getResources方法如下所示:

image.png

getResources 方法会调用ResourcesManager 的getResources 方法,其内部会返回getOrCreateResources方法:

image.png

在注释1处创建ResourcesImpl,它用于具体实现Resources,在Resources创建后,会调用Resources的setImpl方法将ResourcesImpl 设置进去。在注释2处创建Resources。createResourcesImpl方法如下所示:

image.png

在注释1处创建AssetManager,在注释2处新建ResourcesImpl对象,并将AssetManager作为参数传进去,这是因为Resources会依赖AssetManager来加载资源。

3.2.7.2 VirtualApk实现

资源的插件化方案主要有两种:一种是合并资源方案,将插件的资源全部添加到宿主的Resources中,这种方案插件可以访问宿主的资源。另一种是构建插件资源方案,每个插件都构造出独立的Resources,这种方案插件不可以访问宿主资源。

VirtualApk采用了以上两种方案,具体的代码逻辑在LoadedPlugin中,如下所示:

image.png image.png

createResources方法用于创建Resources,如果是合并资源方案,会调用ResourcesManager的createResources方法,其内部会先得到包含宿主资源的AssetManager,再通过反射调用AssetManager的addAssetPath来添加插件资源,返回新的Resources,在注释1处通过Hook的方式用新的Resources替换此前的Resources。

如果是构建插件资源方案,会在注释2处先创建AssetManager,再创建Resources并将AssetManager作为参数传进去。createAssetManager方法如下所示:

image.png 首先动态创建AssetManager,再反射调用AssetManager的addAssetPath方法来加载插件,这个AssetManager只包含了插件的资源,因此createResources方法的注释3处新创建的Resources是插件的资源。

3.2.8 so的插件化

so热修复主要有两种方案:

  • 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载。
  • 调用System的load方法来接管so的加载入口。

so 的插件化的方案和so热修复第一种方案类似,简单来说就是将so插件插入到NativeLibraryElement数组中,并且将存储so插件的文件添加到nativeLibraryDirectories集合中就可以了。

4. 复制攻略

4.1 《安卓进阶解密》

4.2 Android组件化最佳实践

4.3 “终于懂了” 系列:Android组件化,全面掌握! | 掘金技术征文-双节特别篇

4.4 【Android 修炼手册】常用技术篇 -- Android 插件化解析

4.5 Android增量更新

4.6 ASM hook隐私方法调用,防止App被下架