Xposed搭车客指南 - 免重启调试

What

xposed 模块调试需要重启手机一直是一个令人头疼的问题,浪费大量宝贵的开发时间。再遇上 android studio 这个"编码五分钟、编译两小时"的家伙,开发体验差到极点。所以有没有解决方案呢?答案是有。

Why

先来看看为什么。以下代码分析基于 XposedBridge/a535c02 。Xposed 在安装的过程中,将可执行文件 app_process(xposed定制版) 拷贝到 /system/bin 中,代替 android 本身的 app_process 来实现对整个系统的 hook。它会在手机启动过程中加载 XposedBridge.jar,然后用 XposedBridge.jar 来进行一些必要的初始化并加载 xposed modules。

我们姑且猜测:xposed 在启动过程中扫描 app 的 manifest 来找到合法的 xposed_module,然后解包找到 assets/xposed_init 文件,并通过某种方式来进行 xposed_module 的初始化。and 可能由于某些原因这些初始化只在开机过程中执行一次,所以如果能理清楚 xposed_module 的初始化流程,然后重放 xposed_module.init() 不就可以解决我们的问题么。

老规矩,知己知彼,百战不殆。我们首先分析一下,XposedBridge 是如何加载 xposed_module 的 (注: 以下代码均有删减,请参考源代码)
.
.
.
先从 XposedBridge.main() 开始

protected static void main(String[] args) {
    ...
        if (isZygote) {
            XposedInit.hookResources();
            XposedInit.initForZygote();
        }

        XposedInit.loadModules();
    ...
}

上面进行了一些初始化、然后紧接着开始加载 xposed_module

/*package*/ static void loadModules() throws IOException {
        final String filename = BASE_DIR + "conf/modules.list";
        ClassLoader topClassLoader = XposedBridge.BOOTCLASSLOADER;
        String apk;

        while ((apk = apks.readLine()) != null) {
            loadModule(apk, topClassLoader);
        }
        apks.close();
    }

从 BASE_DIR + "conf/modules.list" 也就是 XposedInstaller 的配置文件中读取已安装的 xposed_module,
这个配置会在安装了新的 xposed_module 之后进行更新,形如

/data/app/com.youzan.mobile.hook-1/base.apk
/data/app/com.gh0u1l5.wechatmagician-1/base.apk

里面记录了 xposed_module 的 apk 文件路径。解析之后循环进行 xposed_module 的初始化,传入 classloader 和 apk 路径。

private static void loadModule(String apk, ClassLoader topClassLoader) {
    ...
        ZipEntry zipEntry = zipFile.getEntry("assets/xposed_init");
        is = zipFile.getInputStream(zipEntry);
        BufferedReader moduleClassesReader = new BufferedReader(new InputStreamReader(is));

        // 通过apk路径构造出ClassLoader
        ClassLoader mcl = new PathClassLoader(apk, XposedBridge.BOOTCLASSLOADER); 

        Class moduleClass = mcl.loadClass(moduleClassName);
        final Object moduleInstance = moduleClass.newInstance();

        if (moduleInstance instanceof IXposedHookLoadPackage)
                            XposedBridge.hookLoadPackage(new IXposedHookLoadPackage.Wrapper((IXposedHookLoadPackage) moduleInstance));
    ...
}

剥去一些校验 Instant Run、Xposed 依赖检测等多余的代码后,剩下的逻辑就很清晰了。loadModule 函数先通过找到 assets/xposed_init 中定义的 module_entry (钩子函数),然后通过反射拿到 module_entry 对象,并调用 XposedBridge.hookLoadPackage(XC_LoadPackage callback) 方法

public static void hookLoadPackage(XC_LoadPackage callback) {
        synchronized (sLoadedPackageCallbacks) {
            sLoadedPackageCallbacks.add(callback);
        }
}

这里将 callback 放入了一个 set 中,那么 set 里面的钩子什么时候才会被调用呢?
回到 XposedBridge.main() 函数,里面调用了

XposedInit.initForZygote();

initForZygote 中又 Hook 了 handleBindApplication

findAndHookMethod(ActivityThread.class, "handleBindApplication",
                "android.app.ActivityThread.AppBindData", new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                ActivityThread activityThread = (ActivityThread) param.thisObject;
                ApplicationInfo appInfo = (ApplicationInfo) getObjectField(param.args[0], "appInfo");
                String reportedPackageName = appInfo.packageName.equals("android") ? "system" : appInfo.packageName;
            
                LoadedApk loadedApk = activityThread.getPackageInfoNoCheck(appInfo, compatInfo);
                XResources.setPackageNameForResDir(appInfo.packageName, loadedApk.getResDir());

                XC_LoadPackage.LoadPackageParam lpparam = new XC_LoadPackage.LoadPackageParam(XposedBridge.sLoadedPackageCallbacks);
                lpparam.packageName = reportedPackageName;
                lpparam.processName = (String) getObjectField(param.args[0], "processName");
                lpparam.classLoader = loadedApk.getClassLoader();
                lpparam.appInfo = appInfo;
                lpparam.isFirstApplication = true;
                XC_LoadPackage.callAll(lpparam);

                if (reportedPackageName.equals(INSTALLER_PACKAGE_NAME))
                    hookXposedInstaller(lpparam.classLoader);
            }
        });

handleBindApplication 是 android application 初始化最为重要的函数,这里可以拿到 packageName、processName、classLoader、appInfo 等一些我们熟悉的参数。然后 XposedBridge 会遍历 set 中的所有钩子函数,并进行回调。

简单地理一下流程


Xposed搭车客指南 - 免重启调试_第1张图片
XposedBridge

这里要注意的是,XposedBridge 只在 android 系统加载的时候初始化一次,之后就将钩子函数放入 set 中,之后所有的application 加载行为都回调第一次初始化的 callBack。
所以回到本文的问题: "为什么 xposed 覆盖安装需要重启手机"?我想大家已经知道了答案。钩子函数在 android 初始化的时候被放入 set 中,并且这个钩子函数在系统重启之前都不会被更新。所以我们必须通过重启系统来更新钩子函数。

How

1、既然 XposedInit.loadModules() 只在 XposedBridge 初始化的时候才被调用,那我们能不能通过 hack 的方式来强行调用XposedInit.loadModules()来达到我们刷新钩子函数的目的呢?
可以参考这篇帖子,在每次 handleBindApplication 的时候调用 loadModules() 函数。这样就可以强行刷新钩子函数。但是这样也有弊端,就是操作起来比较复杂,需要重新编译 XposedBridge.jar 并安装到系统框架中,而且每当新的 app 启动都会重新刷新一次钩子函数,性能稍微差了点,不过用来调试的话可以忽略。

2、我们可以将 hook 的逻辑写到 hook_app里面去,然后写个启动这个 hook_app 的壳,传入需要的
XC_LoadPackage.LoadPackageParam 参数。然后通过反射加载 hook_app。加载 hook_app 可以通过 apk 路径来构造 PathClassLoader,再然后用 PathClassLoader 来查找需要加载的钩子函数。并通过 newInstance() 来加载目标 hook_app,来打到我们的免重启调试
xposed 模块的目的。避免了修改 Xposed 框架的源码。

为了方便我们把壳和真正的 hook_app 都写到我们的模块中去,并通过 debug 来判断正常加载/反射调试。

// 查找apk路径
    private fun getApplicationApkPath(context: Context, packageName: String): String {
        val pm = context.packageManager
        val apkPath = pm.getApplicationInfo(packageName, 0)?.publicSourceDir
        return apkPath ?: throw Error("Failed to get the APK path of $packageName")
    }

    // 真正的钩子函数
    private fun readHandler(lpparam: XC_LoadPackage.LoadPackageParam, context: Context) {
        XposedBridge.log("load realHandler(), packageName = ${lpparam.packageName}")
    }

    // 通过反射来调用钩子函数
    private fun loadRealHandlerByReflect(lpparam: XC_LoadPackage.LoadPackageParam, context: Context) {
        val apkPath = getApplicationApkPath(context, "com.youzan.mobile.hook")
        if (!File(apkPath).exists()) {
            XposedBridge.log("Cannot load handler: APK not found")
            return
        }
        
        // 通过apk来构造PathClassLoader
        val pathClassLoader = PathClassLoader(apkPath, ClassLoader.getSystemClassLoader())
        
        // 找到真正的入口并反射调用
        val hookEntryClazz = Class.forName("com.youzan.mobile.hook.HookEntry", true, pathClassLoader)
        val realHandlerMethod = hookEntryClazz.getDeclaredMethod("readHandler", lpparam::class.java, Context::class.java)
        realHandlerMethod.isAccessible = true
        realHandlerMethod.invoke(hookEntryClazz.newInstance(), lpparam, context)
    }

    // entry
    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        tryVerbosely {
            when (lpparam.packageName) {
                TARGET_PACKAGE ->
                    hookApplicationAttach(lpparam.classLoader, { context ->
                        if (BuildConfig.DEBUG) {
                            loadRealHandlerByReflect(lpparam, context)
                        } else {
                            readHandler(lpparam, context)
                        }
                    })
            }
        }
    }

重启之后再修改安装就可以立即生效啦。

Last

想实现免重启调试 xposed module 有俩种方法

  • 改 XposedBridge 的代码,在合适的时机刷新钩子函数。
  • 不刷新钩子函数,写一个可以加载 xposed module 的壳,在 xposed module 更新之后加载真正需要加载的钩子函数。

参考方案

  • WechatMagician
  • Xposed注入实现分析及免重启定制
  • Xposed模块开发,免重启改进方案

你可能感兴趣的:(Xposed搭车客指南 - 免重启调试)