事件机制 - Gh0u1L5/WechatSpellbook GitHub Wiki

Xposed的事件机制

在正式开始之前,先为不了解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,分别用来处理这两种情况。

避免自己造轮子:EventCenter

正如前面所讲的,Xposed所提供的接口,是一套非常泛用,但是使用起来不太友好的接口。用户需要自己去确认参数在args数组里的位置、不停地写代码完成this对象、参数、返回值等各个变量的类型转换。想要为类似微信的大型应用写Xposed插件的话,就需要先花大量的时间逆向或者查阅不知道什么年代的过时资料,然后摸着石头过河去调用Xposed接口实现各种功能。

因此,Spellbook首要想解决的,就是封装出一套简单好用的接口,让开发者能够轻松地基于事件驱动的模型来开发自己的微信插件,这也就是EventCenter机制的初衷。

想要使用EventCenter,每个插件类要实现com.gh0u1l5.wechatmagician.spellbook.interfaces之中的一个或多个接口。实现了这些接口之后,将插件类传递给Spellbook的初始化函数startup,就能够在相应事件发生的时候得到调用。

interfaces中的大部分的接口都很简单直白,比如 IXLogHookISearchBarConsole,基本上一看注释甚至一看定义就能看明白。但是也有的情况下我们既想要封装出漂亮简洁的接口,又想使用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的类,在它的定义里,你能看到三种常见操作:

  1. nop,即“no operation”的缩写,表示什么操作也不做。
  2. interruption,中断,对应Xposed中的setThrowable操作。
  3. replacement,替换,对应Xposed中的setResult操作。

到这里,你就已经掌握使用EventCenter所需要的全部基础了。只要再读一下com.gh0u1l5.wechatmagician.spellbook.interfaces里提供的那些接口和相关注释,就能写出一些很不错的东西。但是随着你的目标变得越来越高远,目前Spellbook提供的接口可能很快就不足以满足你的需求,这就需要再进一步借助HookProvider的力量。

有时还得自己造轮子:HookerProvider

显然,EventCenter对细节的简化和封装意味着灵活性上的牺牲。当然,因为Xposed框架还在,开发者完全可以像平时写Xposed插件一样自己调用findAndHookMethod等方法劫持函数。但是WechatSpellbook与其前身WechatMagician核心的设计风格就是,让每个功能都干净利落地封装成独立的插件类,而不是一团乱糟糟的findAndHookMethod。

因此在Spellbook中,如果一个插件想要单独以Xposed的风格劫持函数的话,建议实现一个名为HookProvider的接口。这个接口里有两个函数: provideStaticHookersprovideEventHookerprovideEventHooker 是用来写自定义的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框架才能受益于多线程加速。

⚠️ **GitHub.com Fallback** ⚠️