Android Service插件化实战:ClassLoader合并与AMS代理全解析

Android Service插件化实战:ClassLoader合并与AMS代理全解析 简介面向Android中高级开发者的Service插件化技术方案聚焦如何在不修改宿主App的前提下动态加载并运行插件Service适用于模块解耦、功能动态更新及插件化架构设计等场景。文档围绕系统类加载机制与Service启动链路展开详细分析了Activity与Service启动方式的差异并重点讲解如何通过反射修改PathClassLoader中的dexElements数组将插件dex注入系统搜索路径同时给出BaseDexClassLoaderHookHelper的具体实现包含patchClassLoader方法的字段获取、数组扩容、Element构造与回写步骤以及预先创建约10个StubService占用服务槽位、配合AMSHookHelper实现“欺上瞒下”的代理启动等关键设计。资料为docx文档共1个文件包体仅115KB轻量便于快速阅读。目前已有64人学习浏览适合正在研究插件化框架、热修复或系统服务代理的开发者既可作为原理笔记参考也可直接借鉴其中的反射Hook思路与代码片段来排查宿主插件加载问题。1. 为什么 Service 插件化比 Activity 更绕做 Android 插件化的人都知道Activity 可以通过 ActivityThread 的 mInstrumentation 做前置拦截ActivityManagerProxy 做 AMS 端的欺骗两个钩子一挂插件 Activity 就能在宿主的壳里跑起来。但 Service 不同ActivityThread 启动 Service 走的是直接反射路线processRecord 拿到 ServiceInfo 后直接把 Class 反射出来 newInstance中间没有 Instrumentation 这一层。也就是说你无法像 Activity 那样靠替换一个公开组件来接管启动流程只能从 ClassLoader 和 Binder 代理两条路下手。这个方案的核心是三段式把插件 dex 合并进宿主 ClassLoader 的 dexElements用 IActivityManager 代理把插件的 Service 请求替换成宿主的 StubService再在 ActivityThread.mH 的 CREATE_SERVICE 消息里把 StubService 的名字偷换回真实 Service。适合自己动手写插件化框架、而不是直接接框架的工程师也适合想搞懂 Android 类加载与系统服务代理关系的读者。2. 第一道坎把插件 dex 塞进 PathClassLoader 的 dexElements2.1 为什么必须动 dexElementsAndroid 应用进程里默认的 ClassLoader 是 PathClassLoader它继承自 BaseDexClassLoader。BaseDexClassLoader 持有一个 DexPathList 对象DexPathList 内部有一个 Element[] dexElements 数组。ClassLoader 加载类时会按数组顺序逐个查找 dex 文件命中率高的类放在前面就能优先加载。这个设计本来是给系统热修复用的但插件化同样适用插件 apk 里的 dex 不在数组里ClassLoader 永远找不到插件类所以第一步是把插件的 dex 包装成一个 Element 追加到数组末尾。注意是追加到末尾不是插到最前面如果插件包含和宿主同名的类这种插入方式可以保证宿主类优先生效插件只做增量。提示这个方案在 Android 8.0 之前直接反射私有字段可行8.0 之后 hidden API 限制会拦截 getField 这类反射需要在工程里加白名单声明。2.2 patchClassLoader 的完整实现下面这段代码是把这个过程实际写出来的工具类。原项目的类名虽然长但方法结构很清晰先取 pathList再取 dexElements新建一个长度加一的数组把插件 Element 复制到末尾最后把新数组写回 pathList。public final class BaseDexClassLoaderHookHelper { public static void patchClassLoader(ClassLoader cl, File apkFile, File optDexFile) throws IllegalAccessException, NoSuchMethodException, IOException, InvocationTargetException, InstantiationException, NoSuchFieldException { // 1. 获取 BaseDexClassLoader 的 pathList 字段 Object pathListObj RefInvoke.getFieldObject( DexClassLoader.class.getSuperclass(), cl, pathList); // 2. 从 pathList 中拿出 Element[] dexElements Object[] dexElements (Object[]) RefInvoke.getFieldObject( pathListObj, dexElements); // 3. 取得 Element 的具体类型用于构造新数组 Class? elementClass dexElements.getClass().getComponentType(); // 4. 新数组长度 原有长度 1 Object[] newElements (Object[]) Array.newInstance( elementClass, dexElements.length 1); // 5. 为插件 apk 构造一个 Element 对象 Class[] p1 {File.class, boolean.class, File.class, DexFile.class}; Object[] v1 { apkFile, // file false, // isDirectory apkFile, // zip 路径 DexFile.loadDex(apkFile.getCanonicalPath(), optDexFile.getAbsolutePath(), 0) }; Object pluginElement RefInvoke.createObject(elementClass, p1, v1); // 6. 把原数组内容复制到新数组 System.arraycopy(dexElements, 0, newElements, 0, dexElements.length); // 7. 把插件 Element 追加到末尾 newElements[dexElements.length] pluginElement; // 8. 新数组替换回 pathList RefInvoke.setFieldObject(pathListObj, dexElements, newElements); } }这段代码里RefInvoke 是一个反射工具类getFieldObject 传的是父类类型和对象实例适用于访问 BaseDexClassLoader 里的私有字段。DexFile.loadDex 的第三个参数是 optimize 标志传 0 表示默认optDexFile 是 odex 文件的输出路径这个路径必须是一个应用可写且不被人随意清理的目录常见做法是context.getDir(plugin_odex, Context.MODE_PRIVATE)。如果不给 optDexFile 指定持久化目录每次启动都重新 loadDex虽然能跑但首次加载会明显变卡。2.3 版本差异与参数陷阱反射 Field 的名字在 Android 4.x 和 5.x 之后没有变但 Element 的构造函数在不同版本上发生过变化。4.4 之前 Element 的构造参数和 7.0 之后的实现不一样老代码里直接用new Element(file, false, file, dexFile)这一套反射构造在新版本上会抛 NoSuchMethodException。上面代码里使用反射创建对象而不是直接 new就是为了在不同版本之间绕开构造函数的差异。以下是我整理的常见版本对比Android 版本dexElements 字段名Element 构造可用性建议做法4.x ~ 6.x都是 pathList.dexElements反射构造稳定直接用上面的代码7.x同上构造参数仍是 File, boolean, File, DexFile继续可用8.x ~ 9.x同上但 hidden API 限制反射受限需要在 manifest 里声明白名单或使用双亲委托10字段名未变但 Element 内部结构变化反射风险高建议改用 InMemoryDexClassLoader 或直接整体替换 ClassLoader这个表的信息来源于项目原文的经验痕迹具体到你的测试机上最好先用一个小 demo 打印 dexElements 的组件类型和构造器再决定反射的入参。3. 第二道坎用 AMS 代理把 startService 偷换成 StubService3.1 为什么需要 AMS 代理dexElements 合入成功后宿主进程里的 ClassLoader 已经能找到插件 Service 类了。但这里有一个坑你在宿主代码里调用context.startService(intent)时intent 里指向的是插件包名和插件 Service 完整类名例如jianqiang.com.testservice1.MyService1。AMS 是 system_server 进程里的系统服务它拿到这个 Intent 后会在系统的 PackageManager 里查找这个组件是否已安装。插件 apk 没有安装startService调用会直接扔出 ServiceNotFound 异常。所以你还需要在 AMS 这一侧做欺骗让提交给 AMS 的 Intent 指向宿主里已经注册好的一个空壳 Service即 StubService。这个替身方案就是 10 个 StubService 占位的原因。系统规定同一进程里注册的 Service 数量没有硬性限制但每个 Intent 启动的 Service 都要在 manifest 中显式声明预先声明 10 个不同类名的 StubService就相当于预留了 10 个坑位。插件 Service 每次启动时会从这 10 个坑里取一个分配给当前请求用完再还回去从而做到不修改宿主 manifest 也能动态新增服务。提示10 个是为了覆盖绝大多数并发场景如果你的插件可能同时启动超过 10 个 Service需要把占位数量扩大否则队列超出时没有坑可用。3.2 hookAMN 的代理实现AMS 在应用端的代理对象由ActivityManagerNative.gDefault这个单例持有gDefault 是android.util.SingletonT里面有mInstance字段。要做的是把 mInstance 替换成一个动态代理对象这个代理拦下 startService、stopService 等关键方法。下面这个类是 AMSHookHelper 的核心public class AMSHookHelper { public static final String EXTRA_TARGET_INTENT extra_target_intent; public static void hookAMN() throws Exception { // 获取 ActivityManagerNative 的 gDefault 单例 Object gDefault RefInvoke.getStaticFieldObject( android.app.ActivityManagerNative, gDefault); // gDefault 是 SingletonT取出里面的 mInstance 字段 Object mInstance RefInvoke.getFieldObject( android.util.Singleton, gDefault, mInstance); // 动态代理 IActivityManager 接口 Class? iActivityManagerClass Class.forName(android.app.IActivityManager); Object proxy Proxy.newProxyInstance( Thread.currentThread().getContextClassLoader(), new Class?[] { iActivityManagerClass }, new MockClass1(mInstance)); // 用代理对象替换掉 Singleton 里的 mInstance RefInvoke.setFieldObject(android.util.Singleton, gDefault, mInstance, proxy); } }这段 hook 逻辑里最该注意的是先把 mInstance 取出再传进 MockClass1 作为 mBase。这样代理对象在拦截到方法时通过method.invoke(mBase, args)仍然能调到原始的 AMS 流程只是参数被替换了而已。顺序不能反如果你直接把 gDefault 整个替换成代理递归就会无限下去。MockClass1 的方法核心是判断方法名只对 startService 和 stopService 做 Intent 参数替换其他方法一律透传。替换 Intent 时必须先在 args 数组里找到 Intent 对象的下标然后再构造一个新的 Intent 指向 StubService。class MockClass1 implements InvocationHandler { private static final String stubPackage jianqiang.com.activityhook1; Object mBase; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (startService.equals(method.getName()) || stopService.equals(method.getName())) { int index -1; for (int i 0; i args.length; i) { if (args[i] instanceof Intent) { index i; break; } } if (index ! -1) { Intent rawIntent (Intent) args[index]; String rawServiceName rawIntent.getComponent().getClassName(); // 从注册表里查出对应 StubService 的类名 String stubServiceName UPFApplication.pluginServices.get(rawServiceName); ComponentName componentName new ComponentName(stubPackage, stubServiceName); Intent newIntent new Intent(); newIntent.setComponent(componentName); args[index] newIntent; } return method.invoke(mBase, args); } return method.invoke(mBase, args); } }这里的UPFApplication.pluginServices是一个 Mapkey 是插件 Service 的完整类名value 是宿主 StubService 的完整类名。这个 Map 必须在宿主初始化和插件加载阶段填充好并且在调用 hookAMN 之前完成否则当你真正调 startService 时找不到对应的占位服务。另外拦截时为什么要手动找 Intent 下标而不直接用 args[0]因为不同方法的参数布局不一样写完 startService 分支后后面 bindService 的参数列表也是IBinder, Intent, String, ...Intent 同样不在 0 号位。所以用 instanceof 扫描是更稳的方式。3.3 startService 到 stopService 的完整链路宿主里启动一个插件 Service 时调用的代码看起来和平常无差别Intent intent new Intent(); intent.setComponent(new ComponentName( jianqiang.com.testservice1, jianqiang.com.testservice1.MyService1)); startService(intent); // 后续关闭 stopService(intent);实际发生的流程是宿主进程的 startService 走到 AMS被 MockClass1 换成了指向 StubService 的 IntentAMS 返回 ibinder 后ActivityThread 收到 CREATE_SERVICE 消息此时消息里的 serviceInfo.name 还是 StubService 的名字如果直接创建最终运行的只是一个空壳。所以还需要下一步——在 ActivityThread 的 mH 回调里把它改名换回来。下面这张表展示了从宿主调用到 Service 真正创建每一层看到的组件名变化环节组件名内容作用宿主调用处插件 Service 类名开发者真正想启动的服务AMS 代理拦截后StubService 类名让 AMS 能通过 manifest 校验ActivityThread 收到消息StubService 类名消息载荷还是替身MockClass2 处理之后插件 Service 类名反射创建真正的插件服务如果某个环节丢了最终表现为要么 SystemService 抛异常要么跑起来的是空 Service。排查时直接在 MockClass1 和 MockClass2 里打 Log.d 把类名打出来对比这张表就能很快锁住是哪一段失效。4. 第三道坎在 ActivityThread 的 mH 里把替身换回真身4.1 为什么 mH 是最后一道关口AMS 不直接和 Service 类打交道它只负责调度 ServiceRecord。当 AMS 决定启动一个 Service 时它通过 ApplicationThread 的 binder 回调到宿主进程这个回调消息被封装成一个 Message 发到 ActivityThread 里的 mH Handler。mH 处理 CREATE_SERVICE 消息时会取出 Message.obj 里的 CreateServiceData 对象里面有 ServiceInfo然后根据这个 ServiceInfo 去反射创建 Service 实例。如果我们已经把 Intent 换成了 StubService 的名字这里反射出来的就是 StubService插件逻辑永远不会执行。所以要在 mH 的 callback 里抢在默认 handler 之前把 CreateServiceData 里的 serviceInfo.name 从 StubService 改回真实的插件 Service 类名。ActivityThread 的 mH 是 HandlerHandler 有一个 mCallback 字段。Hook Handler 的 mCallback 是 Android 开发里的经典做法mCallback 如果返回 trueHandler 就不会再走自身的 handleMessage。因此我们既要在 callback 里做替换又要调mBase.handleMessage(msg)继续走原来流程最后返回 true 表示已处理。4.2 MockClass2 的 handleCreateService 实现下面这段代码接管了 CREATE_SERVICE 消息。先判断 msg.what 是否是 114在 Android 源码里CREATE_SERVICE 114这个值从 4.x 到 12 都没变但稳妥起见仍建议用反射拿ActivityThread.CREATE_SERVICE字段而不是硬编码。class MockClass2 implements Handler.Callback { Handler mBase; MockClass2(Handler base) { mBase base; } Override public boolean handleMessage(Message msg) { switch (msg.what) { case 114: // ActivityThread.CREATE_SERVICE handleCreateService(msg); break; } mBase.handleMessage(msg); return true; } private void handleCreateService(Message msg) { Object obj msg.obj; ServiceInfo serviceInfo (ServiceInfo) RefInvoke.getFieldObject(obj, info); String realServiceName null; for (String key : UPFApplication.pluginServices.keySet()) { String value UPFApplication.pluginServices.get(key); if (value.equals(serviceInfo.name)) { realServiceName key; break; } } // 替换为插件 Service 的真实类名 serviceInfo.name realServiceName; } }这个实现里有一个关键点为什么要遍历 Map 而不是直接pluginServices.get(serviceInfo.name)因为 pluginServices 的 key 是插件 Service 名value 是 StubService 名。而消息里的 serviceInfo.name 此时是 StubService 名所以需要反查 key。如果再把 value 写回 serviceInfo.name就会把真正的插件类名丢掉导致反射时找不到类。反查的循环虽然时间复杂度是 O(n)但插件 Service 数量一般只有个位数性能可以忽略。4.3 hookActivityThread 的挂载点挂载这个 callback 的入口是 AMSHookHelper.hookActivityThreadpublic static void hookActivityThread() throws Exception { // 拿到当前进程的 ActivityThread 单例 Object currentActivityThread RefInvoke.getStaticFieldObject( android.app.ActivityThread, sCurrentActivityThread); // 取出 mH Handler Handler mH (Handler) RefInvoke.getFieldObject(currentActivityThread, mH); // 把 mCallback 替换成 MockClass2 RefInvoke.setFieldObject(Handler.class, mH, mCallback, new MockClass2(mH)); }sCurrentActivityThread是 ActivityThread 的一个静态字段进程启动时就会初始化用它拿到当前线程的 Handler。注意 mH 的实例必须从 sCurrentActivityThread 上去取而不是 getMainLooper() 自己 new 一个 Handler只有原来的 mH 才和 ApplicationThread 的消息路由绑定。setFieldObject 传的是 Handler.class这里传父类类型是为了兼容不同 ROM 上 mH 的实例类可能不一致用父类 Field 反射更通用。4.4 bindService 场景为何不需要二次切换bindService 是 startService 的变种流程是宿主调用 bindService - MockClass1 把 Intent 换成 StubService - AMS 创建 StubService 的进程和实例 - ActivityThread 同样走 CREATE_SERVICE - 等 Service 实例创建完成后再走 handleBindService 回调。因为 MockClass2 在 CREATE_SERVICE 阶段已经把 StubService 换回了真实 Service所以 handleBindService 拿到的已经是插件 Service 实例自然不需要再做一次替换。如果你在回调里看到的仍是 StubService 类名多半是 MockClass2 的替换没执行成功检查反查逻辑是否命中。注意bindService 的 ServiceConnection 是按连接对象区分服务的unbindService(conn) 不会传 Intent所以不需要为 unbind 准备代理替换。这也是为什么本项目在 MockClass1 里只加了 startService、stopService、bindService 三个方法拦截。5. bindService 的完整接入与一个验证技巧5.1 在 MockClass1 里补上 bindService 拦截bindService 的拦截逻辑和 startService 几乎一样只是方法名不同。因为在 MockClass1 中之前只判断了 startService 和 stopService所以需要再增加一个 else if 分支代码片段else if (bindService.equals(method.getName())) { int index 0; for (int i 0; i args.length; i) { if (args[i] instanceof Intent) { index i; break; } } Intent rawIntent (Intent) args[index]; String rawServiceName rawIntent.getComponent().getClassName(); String stubServiceName UPFApplication.pluginServices.get(rawServiceName); ComponentName componentName new ComponentName(stubPackage, stubServiceName); Intent newIntent new Intent(); newIntent.setComponent(componentName); args[index] newIntent; return method.invoke(mBase, args); }宿主里调用 bindService 时只需把 Intent 指向插件 Service与正常写法没有差别Intent intent new Intent(); intent.setComponent(new ComponentName( jianqiang.com.testservice1, jianqiang.com.testservice1.MyService2)); bindService(intent, conn, Service.BIND_AUTO_CREATE); // 解绑 unbindService(conn);这里有一个容易忽略的细节bindService 的签名是bindService(Intent, ServiceConnection, int)Intent 在 Java 层调用时是第 0 个参数但 AMS 的 binder 方法签名不一样在 IActivityManager 里是bindService(IApplicationThread caller, IBinder token, Intent service, ...)Intent 并不是第 0 个参数。所以循环查找 Intent 下标不是多余动作而是必须的。5.2 unbindService 为什么不用 Hook这个问题原文的注解里解释得很清楚unbindService(conn) 的入参只有 ServiceConnectionAMS 拿到 conn 后会通过它的 binder 对象找到对应的 ServiceRecord不再需要解析 Intent 组件名。也就是说即便你传给 AMS 的 Intent 是 StubService只要 ServiceConnection 已经绑定到了真实插件 Service 上解绑时系统按连接对象索引即可不会去查找未安装的插件类。这个机制的副作用是同一个 ServiceConnection 不能同时绑定两个不同的 Service否则后绑定的会覆盖前一个连接导致解绑错乱。这是所有插件化方案都会踩的一条线不是本方案特有的。5.3 用 ClassLoader 验证插件是否真的被加载代码能跑通后建议在插件 Service 的 onCreate 里打印当前类的 ClassLoader并和宿主 Application 的 ClassLoader 做一次相等判断确认类是从合并后的 dexElements 加载出来的。我一般会这样写Override public void onCreate() { super.onCreate(); ClassLoader pluginLoader getClass().getClassLoader(); ClassLoader appLoader getApplicationContext().getClassLoader(); Log.d(PluginCheck, plugin loader is app loader: (pluginLoader appLoader)); }输出 true 说明插件类确实由宿主 PathClassLoader 加载即 patchClassLoader 的替换生效输出 false 且插件方法仍能执行说明框架内部可能又新建了另一个 ClassLoader此时要检查 MockClass2 里反射创建 Service 时用的其实是谁。用这个验证可以快速定位「类找不到」和「类找错了」两类问题。本文还有配套的精品资源点击获取