Android插件化开发指南》笔记
序
DroidPlugin
DroidPlugin使用了一些比较hack的技巧,但是总结起来也就是一句话“利用hook技术实现欺上瞒下,从而达到免安装运行的目的”。因为Android系统出于安全考虑,系统服务与App进程采用分进程设计,它们之间通讯使用binder技术,系统服务实际上是不知道App进程中运行的具体代码,这为我们实现“欺上瞒下”提供了可能。所谓“欺上瞒下”中的“欺上”是指我们可以通过某种技术手段,拦截所有插件发向系统服务的通信,让系统不知道插件在我们的宿主App中运行;“瞒下”是指通过拦截并模拟系统服务发向插件的通信,让插件“以为”自己已经被安装。这样我们就可以模拟一个环境,让插件运行在宿主的模拟进程中。
要做到欺上瞒下,hook技术不可缺。hook这个词是我从Window安全技术中借用而来的,这实际上是一种函数拦截技术。在某个函数调用流程中插入我们自己的函数,实现对目标函数的参数、返回值的修改。
DroidPlugin中的Hook技术实际上是使用了Java中的动态代理技术,它只是在一些关键点通过动态代理生成的对象替换掉系统原来的对象,从而完成了对系统通信的hook。这其中最关键的是对AMS(Activity Manage Service)和PMS(Package Manage Service)以及Handler的hook。AMS负责管理Android中Activity、Service、Content Provider、Broadcast四大组件的生命周期以及各种调度工作,我们hook它可以实现对插件四大组件的管理及调度;PMS负责管理系统中安装的所有App,我们hook它是为了让插件以为自己已经被安装;Handler是系统向插件传递消息的一个桥梁,我们hook它则是为了把系统发向宿主的消息转发给插件。至于在DroidPlugin中看到的对其他系统服务的hook,在大多数情况下都是为了“欺上”——让系统感知不到插件的运行。
除此之外,DroidPlugin的另外一个特色是“占位”操作。
如何面对Android P的限制
- 把通过反射调用的系统内部类改为直接调用。具体操作办法是,在Android项目中新建一个库,把要反射的类的方法和字段复制一份到这个库中,App对这个库的引用关系设置为provided。那么我们就可以在App中直接调用这个类和方法,同时,在编译的时候,又不会把这些类包含到apk中。但是这种解决方案,仅限于Android系统中标记为public的方法和字段,对于protected和private就无能为力了。比如AssetsManager的addAssetPath方法,ActivityThread的currentActivityThread方法。
- 类的每个方法和字段都有一个标记,表明它是不是hide类型的。我们只要在jni层,把这个标记改为不是hide的,就可以绕过检查了。
资源问题
资源加载机制
资源分类
Android资源文件分为两类:
- 第一类是res目录下存放的可编译的资源文件。编译时,系统会自动在R.java中生成资源文件的十六进制值
- 第二类是assets目录下存放的原始资源文件。因为apk在编译的时候不会编译assets下的资源文件,所以我们不能通过R.xx的方式访问它们。
Resources和AssetManager
Resources类就像公司的销售,而AssetManager类就像公司的研发。销售对外,研发基本不对外。如图所示,我们看到Resources类对外提供getString, getText, getDrawable等各种方法,但其实都是间接调用AssetManager的私有方法,AssetManager负责向Android系统要资源。
AssetManager中有一个addAssetPath(String path)方法,App启动的时候,会把当前apk的路径传进去,接下来AssetManager和Resources就能访问当前apk的所有资源了。addAssetPath方法是不对外的,我们可以通过反射的方式,把插件apk的路径传入这个方法,那么就把插件资源添加到一个资源池中了。当前App的资源已经在这个池子中了。
AssetManager内部有一个NDK的方法,用于访问资源文件。apk打包时,对于每个资源,都会在R中生成一个十六进制值,但在App运行的时候,我们怎么知道这个十六进制值对应于res目录下的哪个资源文件中的哪个资源呢?apk打包时会生成一个resources.arsc文件,它就是一个Hash表,存放着每个十六进制值和资源的对应关系。
资源打包
Android打包流程
- aapt。为res目录下的资源生成R.java文件,同时为AndroidManifest.xml生成Manifest.java文件。
- aidl。把项目中自定义的aidl文件生成相应的Java代码文件。
- javac。把项目中所有的Java代码编译成class文件。包括三部分Java代码,自己写的业务逻辑代码,aapt生成的Java文件,aidl生成的Java文件。
- proguard。混淆同时生成proguardMapping.txt。这一步是可选的。
- dex。把所有的class文件(包括第三方库的class文件)转换为dex文件。
- aapt。还是使用aapt,这里使用它的另一个功能,即打包,把res目录下的资源、assets目录下的文件,打包成一个.ap_文件。
- apkbuilder。将所有的dex文件、ap_文件、AndroidManifest.xml打包为.apk文件,这是一个未签名的apk包。
- jarsigner。对apk进行签名
- zipalign。对要发布的apk文件进行对齐操作,以便在运行时节省内存。
资源ID冲突
插件中的资源id有可能会和宿主中的资源id是同一个值,为了解决这个资源id冲突的问题,有相应的3种解决方案:
- 第1种,修改Android打包流程中使用到的aapt命令,为插件的资源id指定0x71之类的前缀,就可避免与宿主资源id冲突。
- 第2种,仍然是修改插件的资源id前缀为0x71,但在Android打包生成resources. arsc文件之后,对这个resources.arsc文件进行修改。
- 第3种,进入到哪个插件,就为这个插件生成新的AssetManager和Resources对象,使用这两个新对象加载的资源,就只能是插件中的资源,永远不会和宿主冲突。
资源ID
res目录下的所有资源会生成一个R.java文件,R文件的结构每个资源都对应一个R中的十六进制整数变量
这些十六进制的变量,由三部分组成,即PackageId+TypeId+EntryId:
- PackageId。apk包的id,默认为0x7f。
- TypeId。资源类型Id值。我们比较熟悉的有layout, id, string, drawable等等,它们是按顺序从1开始递增的,attr=0x01, drawable=0x02。
- EntryId。这个类型TypeId下的资源的id值,从0开始递增。
public.xml固定资源id值
有这样一种场景,多个插件都需要一个自定义控件,于是我们就把这个自定义控件写在了宿主App中,插件调用宿主的Java代码,使用宿主的资源(有控件就肯定有资源)。考虑到App,在每次打包后,会随着资源的增减,同一个资源的id值也会发生变化。如果宿主App的某个资源id被插件使用,那么为了避免下次打包后因为资源值变化而导致插件找不到这个资源,我们要把这个资源id值固定写死,而这个固定值就保存在public. xml文件中。
总结
本章给出了插件化中资源id冲突的各种解决方案。
- 方案1:把宿主和插件的资源都合并到一起,通过AssetManager的addAssetPath来实现。对于此方案,势必会产生资源id冲突的问题,于是又有以下几种解决方案。
- 方案1.1:重写AAPT命令,在插件apk打包过程中,通过指定资源id的前缀,比如0x71,来保证宿主和插件的资源id永远不会冲突。
- 方案1.2:在插件apk打包后,修改R.java和resources.arsc中存储的资源id值,比如默认的0x7f前缀,修改为0x71,这样就保证了证宿主和插件的资源id永远不会冲突。
- 方案1.3:在public.xml中指定apk中所有资源的id值。但这样做是很麻烦的,每增加一个资源,都要维护public.xml。所以这种解决方案只能用于固定几个特定的值。
- 方案2:如果不事先合并资源,那就为每个插件创建一个AssetManager,每个AssetManager都是通过反射调用addAssetPath方法,把插件自己的资源添加进去,从而,当从宿主进入一个插件的时候,就把AssetManager切换为插件的AssetManager;反之,当从插件回到宿主的时候,再把AssetManager切换回宿主的AssetManager。
插件化解决方案
Application的插件化解决方案
在插件中也可以有自定义的Application,插件会在这个自定义Application的onCreate函数中,做一些初始化的工作。插件Application的onCreate是没有机会被执行的。除非我们在宿主的自定义Application的onCreate方法中,手动把这些插件Application都反射出来,执行它们的onCreate方法。但是这样一来,插件Application就没有生命周期了,它彻底沦为一个普普通通的类。
Activity的插件化解决方案
对于Activity的插件化解决方案有很多种,大致分为两个方向:
- 以张勇的DroidPlugin框架为代表的动态替换方案,提供对Android底层的各种类进行Hook,来达到加载插件中的四大组件的目的。
- 以任玉刚的that框架为代表的静态代理方案,通过ProxyActivity统一加载插件中的所有Activity。
加载插件中类的方案
为每个插件创建一个ClassLoader
合并多个dex
Android系统对dexpath的处理,在BaseDexClassLoader和DexPathList这两个类中
我们可以把插件的dex,手动添加到宿主的dexElements数组中,这就又用到Hook技术了。步骤如下:
- 根据宿主的ClassLoader,获取宿主的dexElements字段:
- 首先反射出BaseDexClassLoader的pathList字段,它是DexPathList类型的。
- 然后反射出DexPathList的dexElements字段,这是个数组。
- 根据插件的apkFlie,反射出一个Element类型的对象,这就是插件dex。
- 把插件dex和宿主dexElements合并成一个新的dex数组,替换宿主之前的dexElements字段。
Nuwa的思路更进一步,它发现把热修复dex和宿主dex合并成一个新的Elements数组,如果这两个dex有相同的类和方法,那么位于数组前面的dex中的类和方法将生效,而后面那个不会生效。于是,我们就要把从服务器下载的热修复dex,刻意放到新的Elements数组前面,这就实现了热修复的思想:旧的方法有bug,新的方法覆盖掉它。
修改APP原生的ClassLoader
startActivity方法的两种形式
对Activity的startActivity方法进行Hook
startActivity的时序图之上半场
- 重写Activity的startActivityForResult方法
- 对Activity的mInstrumentation字段进行Hook
- 对AMN的getDefault方法进行Hook
startActivity的时序图之下半场
- 对H类的mCallback字段进行Hook
- 再次对Instrumentation字段进行Hook
对Context的startActivity方法进行Hook
- 对ActivityThread的mInstrumentation字段进行Hook
- 对AMN的getDefault方法进行Hook,对Context的startActivity方法也是适用的。殊途同归。为了只Hook一个地方,就能使得Activity的startActivity()和Context的startActivity()都能生效,即对AMN的getDefault方法进行Hook,是一劳永逸的办法。
动态替换方案
启动没有在Androidmanifest中声明的Activity
"欺骗AMS"策略分析
启动Activity的时序图;App与AMS的交互
AMS对Activity是否在AndroidManifest中声明的检查,是在第2步完成的。这时候,我们就需要“欺骗AMS”,要它检查不到我们要启动的Activity。难道要对AMS进行Hook?答案是不可以。我们知道,App开发人员是没有权限对AMS系统进程进行Hook的,否则,随便下载一个App,然后把AMS系统进程Hook了,要知道AMS还管理着其他App,那么所有的App都会受到影响,
既然第2步不能Hook,那我们就只能在第1步(检查前)和第5步(即将启动Activity)上做文章。我们只要“欺骗AMS”,要启动的Activity在AndroidManifest中存在就好了。
基本思路是:
- 在第1步,发送要启动的Activity信息给AMS之前,把这个Activity替换为一个在AndroidManifest中声明的StubActivity,这样就能绕过AMS的检查了。在替换的过程中,要把原来的Activity信息存放在Bundle中。
- 在第5步,AMS通知App启动StubActivity时,我们自然不会启动StubActivity,而是在即将启动的时候,把StubActivity替换为原先的Activity。原先的Activity信息存放在Bundle中,取出来就好了。修改Activity启动流程如图5-11所示。
Hook Activity的启动流程
Hook的上半场在
在App发送启动Activity的信息给AMS之前,可以Hook 3个地方:
- 对Activity的mInstrumentation字段进行Hook,适用于Activity的startActivity方法。
- 对ActivityThread的mInstrumentation字段进行Hook,适用于Context的startActivity方法。
- 对AMN进行Hook可以同时适用于Activity和Context的startActivity方法。条条大路通罗马。我们这里选择对AMN进行Hook,把TargetActivity替换为StubActivity。这种写法的代码量是最少的。
Hook的下半场:
- 对ActivityThread的Instrumentation字段进行Hook
- 对H类的mCallback字段进行Hook
"欺骗AMS"的弊端
这种欺骗手段有个大大的问题——AMS会认为每次要打开的都是StubActivity。在AMS端有个栈,会存放每次要打开的Activity,那么现在这个栈上就都是StubActivity了。这就相当于那些没有在AndroidManifest中声明的Activity的LaunchMode就只能是默认的类型,即使为此设置了singleTask或singleTop,也不会生效。
对LaunchMode的支持
前面介绍的Activity插件化的技术,对于LaunchMode都是standard的情况,是完全适用的。然后LaunchMode还有其他3种,分别是SingleTop、SingleTask和SingleInstance。想解决插件化其他3种LaunchMode的问题,使用的是占位Activity的思想,即事先为这3种LaunchMode创建很多StubActivity
静态代理方案
静态代理的思想
我们知道,在主App中使用反射加载插件中的类A, A是没有生命周期的,就是一个普普通通的类而已。比如插件里的Activity,就算我们“欺骗了”AMS检查AndroidManifest的过程,这个Activity也启动不了,类似onResume、onPaused这些生命周期函数都不能被正常调用,因为主App根本就不把它当作Activity来对待。为此,我们在主App中设计一个代理类ProxyActivity,这是一个Activity,是主App所认识的。让ProxyActivity内部有一个对插件ActivityA的引用,让ProxyActivity的任何生命周期函数都调用ActivityA中同名函数。
that框架中的代理思想
从宿主Activity跳转到插件Activity
宿主和插件中类的关系
在HostApp中,ProxyActivity是所有插件Activity的代理。想打开插件中的任何一个页面,都是打开ProxyActivity,只是传递给ProxyActivity的参数不一样。
Service的插件化解决方案
四大组件中的Service,就是一个后台进程。它的特点在于startService和bindService两套机制。Service的插件化,就是要这两套方案都能正常工作。与Activity一样,Service插件化也有动态替换和静态代理两种解决方案。
Service和Activity
Context家族世系图
二者的区别也很明显:
- Activity是面向用户的,有大量的用户交互的方法;而Service是后台运行的,生命周期函数很少。
- Activity中有LaunchMode的概念,每一个Activity都会被放到栈顶,对于默认的LaunchMode,即使是同一个Activity被启动多次,也会在栈顶放置这个Activity的多个实例。所以在插件化编程中,我们可以使用一个StubActivity来“欺骗AMS”。而Service组件则不同。对同一个Service调用多次startService并不会启动多个Service实例,只会有一个实例。所以只用一个StubService是应付不了多个插件Service的。
- ActivityThread最终是通过Instrumentation启动一个Activity的。而ActivityThread启动Service并不借助于Instrumentation,而是直接把Service反射出来就启动了。Instrumentation只给Activity提供服务。
Service有两种形式
- 由startService启动的服务。
- 由bindService绑定的服务。
二者的区别在于,startService以及对应的stopService,就是简单地启动和停止Service,这类似于音乐类App,在Activity中点击播放按钮,通知后台Service播放一首歌。bindService执行时会传递一个ServiceConnection对象给AMS。接下来Service在执行onBind时,可以把生成的binder对象返回给App调用端,这个值存于ServiceConnection对象的onServiceConnected回调函数的第二个参数中。
动态替换方案
预先占位
上一节我们介绍了对同一个Service调用多次startService并不会启动多个Service实例,只会有一个实例。所以只用一个StubService是应付不了多个插件Service的。
接下来就是让每一个插件Service匹配一个宿主中的StubService了。有两种匹配方式:
- 服务器下发一个JSON字符串,给出二者的一一对应关系,每次宿主App启动的时候,就从服务器取一次这个JSON字符串。
- 在每个插件App的assets目录中,创建一个plugin_config配置文件,把这个JSON字符串放进去,若插件App中没定义Service,则这个配置为空或者不存在。
startService的解决方案
- Hook AMN,让AMS启动StubService。代码的实现在类MockClass1上,这次要拦截的是startService和stopService这两个方法,不过,这次不再需要把Intent缓存了,因为有了UPFApplication中的pluginServices,我们可以根据插件Service找到StubService,也可以根据StubService反向找到插件Service:
- AMS被“欺骗”后,它原本会通知App启动StubService,而我们要Hook掉ActivityThread的mH对象的mCallback对象,仍然截获它的handleMessage方法,只不过这次截获的是114这个CREATE_SERVICE分支,这个分支执行ActivityThread中的handleCreateService方法。在handleCreateService方法中,并不能获取App发送给AMS的Intent。AMS要启动哪个Service,该信息存在handleCreateService方法的dat参数中,是CreateServiceData类型的。
bindService的解决方案
静态代理方案
that框架对Activity的支持,是通过一个ProxyActivity来控制插件中的每个Activity。这是一个一对多的关系。that对Service的支持也可以按照这种思想来实现。基于牵线木偶的插件化设计思想,插件中的所有组件都是木偶,没有生命周期,所以插件Service不应该存在于ActivityThread的mServices集合中。
动静结合方案
ProxyService与ServiceManager
startService的流程
MockClass1负责拦截startService和stopService, Hook掉AMN,把原本要启动/终止的插件MyService1替换为启动ProxyService。ProxyService就这样启动了,但是它无法同时面对多个插件Service,所以要引入ServiceManager这个单例,它负责管理多个插件Service。ProxyService的onStartCommand生命周期函数,会调用ServiceManager单例的onStartCommand方法,ServiceManager会先取出Intent参数中的真正要启动的MyService1,然后检查内部的mServiceMap集合,看是否有MyService1,如果没有(第一次启动),那么就通过proxyCreateService方法,把MyService1反射出来,并调用MyService1的onCreate方法。这样创建出来的MyService1就是一个普通的类,它是不会自动地执行onCreate、onStartCommand、onDestory这些生命周期函数的,所以我们在最后,手动调用MyService1的onStartCommand方法。
stopService的流程
bindService的流程图
unbindService的流程图
总结
这个方案可以实现ProxyService和插件Service的一对多关系,当然这是借助于ServiceManager类来辅助实现的。
bindService和unbindService的插件化流程如图14-4和图14-5所示。[插图]图14-4
BroadcastReceiver的插件化解决方案
Receiver概述
Receiver分为静态广播和动态广播两种。我们简单讨论一下它们的区别:
- 静态广播需要在AndroidManifest中注册。因为安装和Android系统重启时,PMS都会解析App中的AndroidManifest文件,所以静态广播的注册信息存在于PMS中。
- 动态广播是通过写代码的方式进行注册,Context的registerReceiver方法,最终调用AMN.getDefault().registerReceiver方法,所以动态广播的注册信息存在于AMS中。
动态广播的插件化解决方案
动态广播不需要和AMS打交道,所以,它就是一个类。我们只需要确保宿主App能加载插件中的这个动态广播类。
静态广播的插件化解决方案
静态广播必须在AndroidManifest中声明,这就类似于Activity。
我们尝试在插件的AndroidManifest中将声明的静态广播当作动态广播来处理:
- PMS只能读取宿主App的AndroidManifest文件,读取其中的静态广播并注册。我们可以通过反射,手动控制PMS读取插件的AndroidManifest中声明的静态广播列表。
- 遍历这个静态广播列表。使用插件的classLoader加载列表中的每个广播类,实例化成一个对象,然后作为动态广播注册到AMS中。
静态广播的插件化终极解决方案
我们继续探索支持静态广播的插件化解决方案。上节介绍的方案,把插件中的静态广播转化为动态广播,这使得丧失了静态广播的特性——不需要启动App就可以启动一个静态广播。
对于静态广播,也可以借助于占位StubReceiver。
静态广播中,可以为一个广播设置多个Action。这样,我们就不需要预先创建几百个StubReceiver用来面对插件中的静态广播了,只需要一个StubReceiver,为它配置几百个Action就好了。
ContentProvider的插件化解决方案
方案
- 延用Activity插件化的第二种方案,把宿主App和插件App的dex合并到一起。参见BaseDexClassLoaderHookHelper类。
- 读取插件中的ContentProvider信息,借助PackageParser的parsePackage方法,然后提供generateProviderInfo方法,把得到的Package对象转换为我们需要的ProviderInfo类型对象。
- 我们准备把插件中的ContentProvider放入宿主中,这样宿主就能认识它们了。在此之前,要把这些插件ContentProvider的packageName设置为当前apk的packageName
- 通过反射执行ActivityThread的installContentProviders方法,把ContentProvider作为插件的参数,相当于把这些插件ContentProvider“安装”到了宿主App中:
ContentProvider的转发机制
让外界App直接去调用当前App的插件里定义的ContentProvider,并不是一个理想的解决方案。不如在当前App中定义一个StubContentProvider作为中转,让外界App调用当前App的StubContentProvider,然后在StubContentProvider中,再调用插件里的ContentProvider。