事件机制 - Gh0u1L5/WechatSpellbook GitHub Wiki
在正式开始之前,先为不了解Xposed的读者简单讲解一下Xposed。这是之后所有内容的基础,已经了解Xposed的读者可以直接跳过。
Xposed作为一款久负盛名的DIY框架,其主要功能就是让使用者能够篡改任意一款Java应用的内部逻辑。具体来说,它能够劫持任意一个Java函数,注入你想注入的Java代码,随意篡改参数、返回值或者直接跳过执行。
让我们用Xposed最常用的劫持方式findAndHookMethod举个栗子。
findAndHookMethod(
"org.chromium.chrome.browser.util.UrlUtilities", loader, "isHttpOrHttps", String::class.java,
object : XC_MethodHook() {
override fun afterHookedMethod(param: ) {
val url = param.args[0]
log("Chrome.UrlUtils.isHttpOrHttps => $url")
}
})
这段代码的前四个参数都是用来定位到某个具体的方法的,具体来说,就是在UrlUtilities这个类里面寻找一个名叫"isHttpOrHttps"的函数。因为Java语言中存在“函数重载”这个特性,同名的函数可能不止一个,所以又提供了一个String::class.java
来指明我们要找的函数只有一个参数,其类型为String。
而最后的参数 object : XC_MethodHook() { ... }
就是实际注入的代码了,这个XC_MethodHook
类提供了两个函数供你填入代码,分别是"beforeHookedMethod"和"afterHookedMethod",其字面意义就是”在目标函数调用前执行的代码“和”在目标函数调用后执行的代码“。
这两个回调函数都只有一个参数,类型为MethodHookParam。这个MethodHookParam的成员变量名和方法名非常直白,基本是一看就懂的,就不多做赘述了。但有一点是需要着重提一下的,一个被劫持的函数执行结束的时候有两种可能:一是返回了一个返回值,二是抛出了一个异常。因此Xposed在MethodHookParam中提供了两对函数:setResult / getResult 和 setThrowable / getThrowable,分别用来处理这两种情况。
正如前面所讲的,Xposed所提供的接口,是一套非常泛用,但是使用起来不太友好的接口。用户需要自己去确认参数在args数组里的位置、不停地写代码完成this对象、参数、返回值等各个变量的类型转换。想要为类似微信的大型应用写Xposed插件的话,就需要先花大量的时间逆向或者查阅不知道什么年代的过时资料,然后摸着石头过河去调用Xposed接口实现各种功能。
因此,Spellbook首要想解决的,就是封装出一套简单好用的接口,让开发者能够轻松地基于事件驱动的模型来开发自己的微信插件,这也就是EventCenter机制的初衷。
想要使用EventCenter,每个插件类要实现com.gh0u1l5.wechatmagician.spellbook.interfaces之中的一个或多个接口。实现了这些接口之后,将插件类传递给Spellbook的初始化函数startup,就能够在相应事件发生的时候得到调用。
interfaces中的大部分的接口都很简单直白,比如 IXLogHook 和 ISearchBarConsole,基本上一看注释甚至一看定义就能看明白。但是也有的情况下我们既想要封装出漂亮简洁的接口,又想使用Xposed中一些稍微复杂的功能,这就需要定义一些稍微复杂的返回值和结构,比如 IXmlParserHook
package com.gh0u1l5.wechatmagician.spellbook.interfaces
import com.gh0u1l5.wechatmagician.spellbook.base.Operation
import com.gh0u1l5.wechatmagician.spellbook.base.Operation.Companion.nop
interface IXmlParserHook {
/**
* Called when the XML parser is going to parse a XML string.
*
* @param xml the XML string given by the caller.
* @param root the tag name of the section the caller want to parse.
* @return to bypass the original method, return a MutableMap<String, String> object wrapped by
* [Operation.replacement], or a throwable wrapped by [Operation.interruption], otherwise return
* [Operation.nop].
*/
fun onXmlParsing(xml: String, root: String): Operation<MutableMap<String, String>?> = nop()
/**
* Called when the XML parser has parsed a XML string.
*
* @param xml the XML string given by the caller.
* @param root the tag name of the section the caller want to parse.
* @param result the map generated from the XML string.
*/
fun onXmlParsed(xml: String, root: String, result: MutableMap<String, String>) { }
}
首先可以注意到,这两个函数的名字中,分别出现了同一个动词的现在进行时和过去时。现在进行时对应Xposed中的 beforeHookedMethod ,过去时对应Xposed中的 afterHookMethod 。
其次,onXmlParsing的返回值是一个叫Operation的类,在它的定义里,你能看到三种常见操作:
- nop,即“no operation”的缩写,表示什么操作也不做。
- interruption,中断,对应Xposed中的setThrowable操作。
- replacement,替换,对应Xposed中的setResult操作。
到这里,你就已经掌握使用EventCenter所需要的全部基础了。只要再读一下com.gh0u1l5.wechatmagician.spellbook.interfaces里提供的那些接口和相关注释,就能写出一些很不错的东西。但是随着你的目标变得越来越高远,目前Spellbook提供的接口可能很快就不足以满足你的需求,这就需要再进一步借助HookProvider的力量。
显然,EventCenter对细节的简化和封装意味着灵活性上的牺牲。当然,因为Xposed框架还在,开发者完全可以像平时写Xposed插件一样自己调用findAndHookMethod等方法劫持函数。但是WechatSpellbook与其前身WechatMagician核心的设计风格就是,让每个功能都干净利落地封装成独立的插件类,而不是一团乱糟糟的findAndHookMethod。
因此在Spellbook中,如果一个插件想要单独以Xposed的风格劫持函数的话,建议实现一个名为HookProvider的接口。这个接口里有两个函数: provideStaticHookers 和 provideEventHooker 。 provideEventHooker 是用来写自定义的EventCenter用的(对,EventCenter也是一种特殊的HookerProvider),因此这个函数我们会放到后面讲怎么拓展EventCenter的时候再去讲。
一个插件想使用Xposed的话,其实只需要实现 provideStaticHookers 就可以,我们以WeChatMagician中出现的一段代码为例讲解一下这个设计:
object Limits {
override fun provideStaticHookers(): List<Hooker>? {
return listOf(onCheckSelectLimitHooker, onSelectAllContactHooker)
}
// Hook SelectConversationUI to bypass the limit on number of recipients.
private val onCheckSelectLimitHooker = Hooker {
hookMethod(SelectConversationUI_checkLimit, object : XC_MethodHook() {
@Throws(Throwable::class)
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = false
}
})
}
// Hook SelectContactUI to help the "Select All" button.
private val onSelectAllContactHooker = Hooker {
findAndHookMethod(
SelectContactUI, "onActivityResult",
C.Int, C.Int, C.Intent, object : XC_MethodHook() {
@Throws(Throwable::class)
override fun beforeHookedMethod(param: MethodHookParam) {
val requestCode = param.args[0] as Int
val resultCode = param.args[1] as Int
val data = param.args[2] as Intent?
if (requestCode == 5) {
val activity = param.thisObject as Activity
activity.setResult(resultCode, data)
activity.finish()
param.result = null
}
}
})
}
}
在Spellbook启动的时候,会调用插件的 provideStaticHookers 获取一份Hooker列表。每一个Hooker其实都相当于一个函数,函数里写着Xposed风格的劫持代码。除此之外Hooker还有一个隐藏的属性 hasHooked ,用于标记自己是不是已经执行过一次了。这个隐藏属性是为了避免同样的劫持代码被反复执行,造成一些很难调试的Bug;为了能够有效的利用好这个隐藏属性,Hooker最好不要放在本地变量里,尽量作为成员变量或静态变量保存,一样的代码也尽量不要放在两个不同的Hooker变量里。
另外还有一个有趣的特性,就是Spellbook引擎会根据当前Xposed的SDK和版本决定是多线程执行这些Hooker还是单线程执行。因为一些比较新的Xposed版本里,多线程调用findAndHookMethod会造成很凄惨的崩溃,所以只有部分Xposed框架才能受益于多线程加速。