Android 模块化三件套:轻量组件化、宿主插件双形态、AIDL 隔离

一条「边界」主线串起三件事:模块之间的边界、运行时的边界、进程的边界。

前言

做 Android 久了,几乎都会经历:功能全塞进一个 module,改一处牵动一片;拆成多个 module 后,却发现模块之间还在互相 new 实现类,边界名存实亡。再往后,代码要既能单独跑成 App、又能被别的 App 当插件加载,运行环境一变,原本好好的 Class.forName 就莫名其妙崩。

还有一种更隐蔽:一段判断"授权能不能用"的敏感逻辑写在主进程里,业务一崩溃它跟着没了,谁都能调到它。

三件事乍看分散,问的却是同一个问题------边界怎么划、划在哪一层、怎么守住。 我分别做过一遍、也踩过坑,后来发现它们共享一条主线:模块之间、运行时、进程之间都要有边界。这篇就把三者放在一起讲。

一、三件套其实是一条线:边界

模块的边界 是编译期可见性边界:A 模块只能看到 B 暴露的接口,看不到实现。运行时的边界 是同一份代码面对不同宿主时的差异边界:独立跑和当插件加载,类加载器、资源 ID、Application 实例全不同。进程的边界是 OS 物理边界:两个进程各有各的地址空间,只能靠 Binder 通信。

三者的"病"是同一个:相邻两方直接依赖了对方的细节 ;解法也一样------把一方变成另一方看不见的内部,只留一条约定好的窄通道 ,代价只是多一层间接或一次 IPC。边界要划在变化最剧烈、最需要保护的那条缝上。

二、模块的边界:轻量组件化(清单 + 接口路由)

2.1 组件就是一个普通的类

很多团队谈组件化第一反应是上重框架:注解处理器、字节码插桩、编译器插件一整套;可一旦出包,光是"谁编译谁"就让人头疼。我见过一个反其道而行的实现:整个组件框架只有几个类,不依赖任何编译期代码生成 ------组件就是一个普通的类,没有注解,职责只有一条:启动时把业务能力注册进框架。

kotlin 复制代码
class DeviceConnectionModule : Module {
    override fun init(context: Context) {
        // 注册对外服务实现
        serviceRegistry.register(GnssDeviceService::class) { GnssDeviceServiceImp() }
        // 初始化设备管理层
        DeviceManager.start(context)
        // 初始化本地数据库
        AppDatabase.init(context)
    }
}

没有 @AutoService、没有 @Router,就是实现了 Module 接口的 init------契约只有一个方法签名,通道极窄。

2.2 清单驱动装配:把装配关系数据化

框架怎么知道有这些组件?靠一份清单文件:启动时读固定路径下的文本清单(每行一个组件全限定名),逐个反射加载、实例化,再调 init。

kotlin 复制代码
object ModuleRegistry {
    private val moduleMap = LinkedHashMap<String, Module>()

    fun loadFromManifest(context: Context, assetPath: String) {
        context.assets.open(assetPath).bufferedReader().useLines { lines ->
            lines.filter { it.isNotBlank() }
                .forEach { className ->
                    val module = Class.forName(className).getDeclaredConstructor().newInstance() as Module
                    module.init(context)
                    moduleMap[className] = module
                }
        }
    }

    fun unRegister(moduleName: String): Boolean {
        if (moduleName.isNotEmpty() && moduleMap.containsKey(moduleName)) {
            moduleMap.remove(moduleName)
            return true
        }
        return false
    }
}

这里值得讲透:为什么用 Class.forName 而不是 ClassLoader.loadClass。 Class.forName(name) 等价于 Class.forName(name, true, 调用方的 ClassLoader),不只加载类、还会初始化类 (执行静态初始化块);而 ClassLoader.loadClass(name) 默认只加载 、不触发初始化,静态块要等第一次真正用到这个类时才执行。两者都走双亲委派 :loadClass 先把请求交给 parent,parent 加载不到才轮到自己的 findClass。Android 上 App 的类由 PathClassLoader 加载,其 parent 是负责 android.* 框架类的 BootClassLoader,所以 App 的类加载器只认得自己 classpath 上的类------这也是下一章"插件加载不到宿主类"的根因。

清单是"弱契约",没有编译期校验 :写错类名 → ClassNotFoundException;无无参构造 → InstantiationException;没实现 Module → ClassCastException。框架另提供 unRegister,可运行期摘掉模块。

2.3 服务路由表:调用方不 import 实现

组件之间不能互相 new 对方的实现类,否则边界立刻崩。第二块拼图是一张接口路由表:

kotlin 复制代码
object ServiceRegistry {
    private val registry = HashMap<Class<*>, () -> Any>()

    fun <T : Any> register(api: Class<T>, factory: () -> T) {
        registry[api] = factory
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> get(api: Class<T>): T? {
        return registry[api]?.invoke() as? T
    }
}

调用方只依赖接口,运行期才取实现:

kotlin 复制代码
val service = ServiceRegistry.get(GnssDeviceService::class)
service?.queryState()

这一层让"谁实现、何时实现"对上层透明,本质上是依赖倒置 :调用方依赖抽象,实现运行期注册。注意 get 返回 T?,服务未装配时返回 null ;改成 service!! 则模块没加载就 NPE。

2.4 为什么不上注解处理器 + 编译器插件

注解处理 + 编译器插件能在编译期生成路由表、把错误提前,代价是:构建链路变复杂 、组件必须参与同一编译单元 (做不到运行时才知道有没有这个模块),对多形态场景编译期绑定反成负担。而清单文件的本质是把"装配关系"从代码挪到数据 :改清单不必改代码,不同产品线换一份 modules.list 就能拼出不同 App------这正是"一套内核、多产品"的诉求。

2.5 适合什么规模,怎么守住边界

它适合 模块数量十几个、关系树稳定的中型工程;不适合 需要动态下发代码、热替换的插件化大型 App。清单加载是启动期一次性 的,较重的模块要做延迟初始化,否则拖慢冷启动。守住边界靠几条检查:实现类用 internal 或包私有、对外只暴露接口;build.gradle 对外只 api 出接口 module、实现用 implementation 引入;调用方直接依赖实现 module 就说明边界被绕过了。

三、运行时的边界:宿主与插件双形态

3.1 两种形态到底差在哪

写 Android 库的人常碰到一个尴尬:本地跑得好好的功能,一被集成进别人的 App 就莫名崩溃。更麻烦的是,这块能力既想独立发布成 App 、又想作为插件被宿主加载 ,同一份代码要面对两种运行环境。插件形态下运行时会"不打招呼"地变化:类加载器不是系统默认的 ,而是插件自己的,Class.forName 可能找不到宿主类;资源 ID 被重新分配 ,R.string.xxx 在宿主动态加载时可能失效;Application 实例 是宿主的,但库代码拿到的不一定是它。而独立运行时全按 Android 默认规则来------同一份代码要兼容两套规则,这就是难点。

3.2 根因:运行时是隐式的,代码必须显式感知

崩溃的根因是"代码假设了某种运行环境",而环境其实隐式给定------context.applicationContext 就假设进程里只有一个 App。解法不是绕开这些 API,而是在代码最开头先问一句"我现在是什么形态",再按形态选路径:

kotlin 复制代码
object RuntimeEnv {
    // 插件框架是否在进程中生效
    fun inPlugin(): Boolean = ... // 探测插件运行时标志
    fun inHost(): Boolean = !inPlugin()

    // 取宿主 Application:插件形态从插件框架拿,宿主形态直接用系统
    fun hostApp(): Context = if (inPlugin()) pluginContext() else systemApp()
}

关键一句话:把"我是谁、我在哪"提前显式化,后面的分支都基于这个判断。

3.3 环境门面:把差异收敛到一层

更进一步的封装,是把需要感知环境的操作全收敛进一个门面:

kotlin 复制代码
object EnvFacade {
    fun appContext(): Context =
        if (RuntimeEnv.inPlugin()) {
            // 插件框架提供的真实宿主 Context
            PluginBridge.hostContext()
        } else {
            // 独立运行时,直接返回应用上下文
            ApplicationProvider.getApplicationContext()
        }

    fun loadClass(name: String): Class<*> =
        if (RuntimeEnv.inPlugin()) {
            PluginBridge.classLoader().loadClass(name)
        } else {
            Class.forName(name)
        }
}

业务代码只调 EnvFacade.appContext(),不关心底层是插件还是宿主 。注意 loadClass 分支:宿主形态用 Class.forName,插件形态必须换成插件自己的 ClassLoader.loadClass,用错就是 ClassNotFoundException------这正是 2.2 双亲委派的落地。这套"门面 + 环境感知"本质就是依赖倒置。

3.4 把机制讲透与失败路径

Context.createPackageContext(packageName, flags) 的局限 :"为另一个已安装 的包创建 Context",前提是包已被 PackageManager 知道;而动态加载的插件包没走安装流程,PackageManager 里根本没有它,这时要么抛 NameNotFoundException,要么拿不到你要的包------所以插件方案宁可自己维护 ClassLoader 和 Resources。

资源 ID 为什么会错位 :独立打包时 R.string.xxx 是编译期常量,指向当前 APK 资源表的固定位置;被插件框架加载时资源往往被合并进宿主资源表或被重新分配 ID,原常量就对不上------轻则拿到错误资源,重则 Resources.NotFoundException。Application 实例的坑 类似:插件形态下进程里的是宿主 Application,强转 applicationContext 会 ClassCastException。这些失败的修法都指向同一条:让高危操作走 EnvFacade。这套双形态思维也适用于独立 SDK/集成 Demo、单机/联机、灰度/正式等场景。

四、进程的边界:AIDL 隔离鉴权

4.1 为什么鉴权要单独占一个进程

多进程能隔离崩溃、稳定长活,但 IPC 序列化、Binder 回调、进程被杀重连都是真实成本。鉴权逻辑往往是 App 里最"金贵"的一段------判断"当前产品序列号是否够格使用某高级功能""某模块是否已授权",特点是敏感 、要被多方复用 、可能长期存活 。放在主进程,业务一旦崩溃、回收就可能把它一并拖死;放进独立进程 + 系统绑定服务,就能得到 OS 层面的生命周期保证,调用方也只能通过定义好的接口访问。

在 Android 上让组件进独立进程靠清单里的 android:process。:remote 这种冒号开头的是该 App 私有的进程 ,别的 App 起不了也进不去。要区分的是 android:isolatedProcess="true":它让 service 跑在隔离进程里,拥有独立 UID、默认没有任何权限、也访问不到 App 自己的数据。

4.2 结构:一个 Binder 服务 + 死亡通知

跨进程第一步是定义接口契约,用 AIDL 描述远端服务能干什么:

kotlin 复制代码
// 接口契约:自启、设置自启信息、保持一次存活
interface ISystemGuard {
    fun autoStart()
    fun setAutoStartInfo(name: String, flag: Boolean)
    fun keepAliveOnce()
}

主进程通过绑定拿到代理,调用就像本地方法:

kotlin 复制代码
class GuardClient(private val context: Context) {
    private var service: ISystemGuard? = null
    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
            service = ISystemGuard.Stub.asInterface(binder)
            // 注册死亡通知:远端崩了立刻重连
            binder?.linkToDeath({ reconnect() }, 0)
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            service = null
            reconnect()
        }
    }
}

关键点在 linkToDeath:远端进程一旦死掉,本地立刻知道并自动重连 ,而不是等下次调用才发现接口失效------这是长活服务必须处理的"死亡感知"。两个边界事实:bindService 默认带 BIND_AUTO_CREATE(服务不存在则自动创建);onServiceDisconnected 只在服务进程意外死亡时回调,主动 unbindService 时不会回调 ;binderDied 回调的执行线程以系统实现为准,回调里别做重活。

4.3 鉴权判定:一端是服务端,一端是客户端

真正干活的校验逻辑,还在"拥有授权数据的一端"------常说的 VIP 鉴权中心。它对外暴露几个判定接口:

kotlin 复制代码
interface LicenseChecker {
    fun isProductAvailable(productNum: String): Boolean
    fun isModuleFree(module: String): Boolean
}

客户端要的不只是布尔值,往往还带有效期 / 剩余次数,于是返回值封装成状态对象:

kotlin 复制代码
data class LicenseResult(val granted: Boolean, val expireAt: Long, val reason: String)

"授权数据放哪、谁来算、怎么防篡改"都在服务端进程内完成,客户端只做"问一下、拿结果"。即使宿主通过动态化改造了界面逻辑,也无法绕开这条鉴权线------这正是"隔离"最实在的价值。

4.4 AIDL 机制讲透:Stub、Proxy、线程与开销

Stub 与 Proxy。 生成的 ISystemGuard.Stub 继承 Binder 并实现 ISystemGuard,服务端写一个它的子类就是服务实现。Stub.asInterface(binder) 是入口:service 与调用方在同一进程 时通过 queryLocalInterface 直接返回本地 Stub,调用走普通方法、不经过 IPC ;只有跨进程才返回 Proxy。Proxy 同样实现 ISystemGuard,每个方法把参数写进 Parcel、调用 transact(code, ...) 发出 Binder 事务,服务端由 Stub.onTransact 按 code 分发回具体方法------"同进程短路"由 asInterface 帮你做掉。

onTransact 的线程模型。 服务端 onTransact 运行在 Binder 线程池的线程上,不是主线程 ;Binder 驱动会并发派发事务,多个客户端同时调用时服务端实现可能被并发进入,共享状态要自己加锁。

oneway 的语义。 给方法加 oneway,调用是异步 的:发出事务后立刻返回,不等结果,也没有返回值、不会抛回异常 ,适合"通知类、不关心结果"的调用;但事务仍按同一个 binder 上先进先出的顺序投递,保序性还在。

一次事务的大小限制。 Binder 驱动为每个进程分配一块缓冲区承载 Parcel,大小在约 1MB 这个量级 (1 * 1024 * 1024 字节左右,长期沿用的经验值,不同版本/厂商可能有差异,并非 AIDL 规范的固定常量)。超过就抛 TransactionTooLargeException,所以大块数据绝不能塞进一次 Binder 事务,应走文件或共享内存,事务里只传路径或句柄。

Messenger 还是 AIDL。 Messenger 底层同样是 Binder,但对外只有 send(Message) 一个入口、服务端用 Handler 串行处理------天然线程安全,却只能串行、只传 Message、没有返回值;AIDL 支持多方法、并发与返回值。

4.5 失败路径

远端进程被杀靠 linkToDeath + binderDied 感知、或 onServiceDisconnected 兜底再重连,调用到已死的 binder 会抛 DeadObjectException;Service 未声明或权限不匹配时 bindService 返回 false,先确认 android:exported 与权限------设成 false 时只有同 App(同 UID)能绑。

五、三条边界放在一起看

边界 解决什么问题 主要手段 主要成本 典型失效场景
模块边界 模块之间互相 new 实现类 清单装配 + 接口路由表 无编译期校验,错误后移 清单写错类名、服务未装配返回 null
运行时边界 同一份代码适配多种宿主 环境感知层(门面) 多一层环境判断 用错 ClassLoader、资源 ID 错位
进程边界 敏感逻辑与业务耦合 独立进程 + AIDL IPC 序列化、样板代码、跨进程回调 binder 死亡、事务过大、权限不匹配

三条边界并不互斥,实际项目常叠加:一个模块可能既走模块路由表拿服务,又通过 EnvFacade 感知环境,再经 AIDL 调宿主进程里的鉴权服务------边界必须先定窄、想全失败路径,才不会在组合处漏。

结论

  • 边界三态:模块靠清单 + 接口路由划编译期边界,运行时靠环境门面划隐式边界,进程靠 AIDL 划物理边界。
  • 组件化不一定等于注解处理器 + 字节码插桩,一份清单文件就能完成运行时装配。
  • 装配关系数据化,是"一套内核、多形态产品"的廉价实现路径;代价是失去编译期校验。
  • 接口路由表 让组件之间只依赖抽象,调用方不必 import 实现,但服务缺失会返回 null,要显式处理。
  • 双形态的根因是"运行环境隐式不同":先显式问一句"我现在是什么形态",再把拿上下文/加载类/解析资源的高危操作收敛进一层门面;这层抽象可注入,利于测试(可 mock)。
  • AIDL 的价值是拿代理不碰实现 ,天然的"防误改";asInterface 还会替你做进程内短路。
  • 长活服务必须处理死亡感知 :linkToDeath + 断线自动重连;onServiceDisconnected 只管意外断开。
  • 多进程有真实成本(IPC、样板代码、1MB 事务上限、跨进程回调),先判断"值不值得隔离"再动手。

这三条边界,你在项目里踩过哪一条的坑?更想先看哪一块的完整落地代码?欢迎在评论区说说你的场景。

关键词标签 :#Android #模块化 #组件化 #插件化 #AIDL #跨进程 #架构设计 #ClassLoader

相关推荐
临期冰淇淋1 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
czt_java1 小时前
网络基础入门:IPv4、IPv6、私网公网、MAC、以太网帧与 DNS 学习笔记
网络·学习·macos
是个西兰花1 小时前
http讲解
网络·网络协议·http
橙橙笔记1 小时前
QT安卓软件的制作
android·git·qt·软件
tianbin9112 小时前
中小团队大模型训练落地实战:如何借助API网关降低工程负担
网络·后端
2401_868534782 小时前
VXLAN虚拟可扩展局域网
服务器·网络
mmsx2 小时前
Android 架构分层:MVVM 与 MVP 共存,网络层怎么切
android·策略模式·mvvm·mvp
烟雨醉沉浮2 小时前
VLAN 网络划分与安全管控
网络·安全·智能路由器
企业解惑小能手-域智盾系统2 小时前
手机偷拍电脑屏幕怎么防范?光学泄密攻防实战与落地选型
网络·智能手机·域智盾