一条「边界」主线串起三件事:模块之间的边界、运行时的边界、进程的边界。
前言
做 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