拆是几天的活,守是每一天的活------难的不是拆出 40 个模块,而是让每条边界一直有人守着。
一个 40 个 Gradle 模块的 Android 工程,最初只有一个 app module。几百个 Activity 挤在同一棵源码树里,改一行公共类,全量编译 8 分钟起步。真正压垮团队的不是这 8 分钟本身,而是它带来的行为变化:没人敢动公共类了,改动被攒到下班前才编译一次,为了省掉一次编译,两个毫不相关的需求被塞进同一个分支。
于是有了第一个反常识的事实:这 40 个模块不是谁设计出来的,是一年一年长出来的。 是先有痛,才有拆;是先在某个深夜被一次编译卡住,才有人提出把地图渲染挪出去。
从单体到 40 个模块,中间只发生过五件事,每一件都在划一条新的边界:先划模块边界 (按什么拆),再划通信边界 (拆完之后模块之间怎么说话),然后是能力边界 (二十多个业务模块怎么长得一样又各自不同),接着是生命周期边界 (让一个不是 Activity 的普通类拥有自己的生命周期),最后是运行时边界(热修复与插件化怎么在一个进程里共存)。
这五条边界有个共同的性格------划下去只要几天,守住却要每一天。后面每一节都是同一个节奏:先讲这条边界怎么划,再讲它靠什么被守住,以及破了之后线上会长成什么样。
一、先看清问题:40 个模块不是设计出来的
先把工程的最终形态摆出来,因为它决定了后面所有决策的出发点:
- 约 40 个 Gradle 模块,源码文件 2300+;
- 1 个宿主壳工程(负责启动、登录、升级、支付、插件装载);
- 若干能力层模块:设备连接、地图渲染、坐标计算、数据库、网络、语音、报表导出;
- 二十来个业务模块:同一类业务按"场景"再细分,彼此可插拔;
- 1 个多品牌资源模块(一套代码交付 6 个品牌);
- 外部三件套:插件化框架(RePlugin)、热修复(Tinker)、切面(AspectJ)。
如果你做过 5 年以上 Android,一定能猜到它的前身:一个巨大的 app module,几百个 Activity,改一行代码全量编译 8 分钟。
所以第一个问题不是"怎么拆",而是------什么时候必须拆?
我的判断标准只有一条硬指标:当"编译等待"开始改变工程师的行为时(比如不敢改公共类、把改动攒到一天结束再编译、为了省一次编译而把两个无关需求塞进同一个分支),架构就必须动了。其余理由(解耦、复用、团队并行)都是这条的附属品。
这条标准之所以成立,是因为架构改造的收益和成本都是按天结算 的。拆分本身是一次性投入,但"依赖变清晰、编译变快"的收益,要在之后的每一天里被慢慢花掉。如果每天的痛还没大到影响行为,收益就抵不过维护成本。这也是为什么"为了看起来先进"而拆的工程,最后常常变成另一种灾难:模块数量上去了,依赖关系没理顺,跨模块的注解处理器和资源合并反而把编译拖得更慢------40 个模块、12 分钟编译,比单体还糟。
判断"必须拆"之后,第二个问题才是真正的技术问题:按什么拆。
二、模块边界:按变更频率切,不按功能树切
绝大多数失败的模块化,都是照着产品功能树切的:首页一个模块、设置页一个模块、某某业务一个模块。功能是会重组的,今天归在 A 页面下的能力,三个月后可能要在 B 页面复用;一重组就是跨模块搬代码,依赖关系瞬间打结。
更稳的切法看三个维度:
| 维度 | 判断问题 | 结论 |
|---|---|---|
| 变更频率 | 这个东西一年改几次? | 高频改的和三年不动的必须分开 |
| 依赖方向 | 它需要知道"业务"吗? | 不需要业务知识的,下沉到基础层 |
| 交付粒度 | 它能被单独交付/裁剪吗? | 能被某个品牌裁掉的,独立成模块 |
三个维度里,变更频率是最容易被忽略、也最有效的一条。原因很直白:模块化的收益主要来自"改得多的东西不要连带改得少的东西一起重编"。如果按功能切,一个高频迭代的页面和一个三年不动的工具类被塞进同一个模块,那种"我改的是新功能,却要重编一堆老代码"的痛就不会消失------模块拆了,编译等待却还在。按变更频率切,等于把"热的"和"冷的"物理隔离,编译收益立刻兑现。
落到依赖分层,就是这张图:
规则的唯一硬性约束:依赖只能向下,且不能跨层。 L3 的两个业务模块之间不允许 implementation project(...) 互相依赖------这条规则直接决定了后面要讲的通信方案。
为什么"向下"这么重要?因为 Gradle 的 implementation 是一条单向的编译期绳子:B 依赖 A,意味着 A 的任何签名变化都会触发 B 重编,A 依赖的三方库也会悄悄进入 B 的编译类路径。允许平级互相依赖,等于允许两条绳子任意打结------今天 A 调 B,明天 B 调 A,一个月后没人说得清改一个方法会波及多少模块。向下分层把"波及范围"限定成了一个可以人力推演的量。
宿主壳要"薄"到什么程度
宿主壳是这套架构里最容易被写胖的地方。一个健康的宿主只保留四类东西:
- 进程级初始化:Application(含热修复代理)、崩溃、多进程守卫;
- 插件装载与路由:插件安装、版本校验、启动分发;
- 横切能力:登录态、账号、支付、升级------这些是所有业务都要用、但都不该由某个业务提供的;
- 品牌配置:决定这一版要装配哪些业务模块。
判断某段代码该不该进宿主,问一句:"如果某个品牌不要这个功能,我是删一个模块依赖,还是改宿主代码?" 答案是后者,就说明它放错地方了。
用 Gradle 表达出来大致是这样:
kotlin
// 宿主模块 build.gradle(示意)
dependencies {
implementation project(":lib-base")
implementation project(":lib-map")
implementation project(":lib-device-api") // 只依赖 API,不依赖实现
// 业务模块按 flavor 装配
"${brandFlavor}Implementation" project(":module-survey-point")
"${brandFlavor}Implementation" project(":module-stake-cad")
}
注意 lib-device-api 那一行:宿主只认识接口,不认识设备模块的实现。"只依赖 API,不依赖实现" 是整条通信边界的地基,后面讲 AIDL 时会看到它如何被推到进程级别。
三个真实踩过的反模式
反模式 1:lib-common 变成垃圾桶
每个模块化工程最后都会长出一个 lib-common,然后所有团队往里塞"就一个小工具类"。半年后它依赖了 30 个三方库、编译最慢、谁都不敢动。
解法 :给公共模块设准入清单 ------只收"无业务语义 + 无三方依赖膨胀"的东西;每加一个文件要写一行注释说明为什么它属于这里。更狠一点:禁止 lib-common 依赖任何业务模块,用 CI 检查(下面会给检查脚本思路)。
反模式 2:资源 id 冲突
多模块下 R 文件不再是常量(非 transitive 的 R 类),R.id.xxx 在 when 分支里不能用常量匹配;同名 layout 还会互相覆盖。
解法:
kotlin
// 每个模块强制资源前缀,写在模块 build.gradle 里
android {
resourcePrefix "stake_"
}
跨模块引用资源统一走资源模块 ,不要直接写死另一个模块的 R 引用。
反模式 3:模块间循环依赖,用反射绕过
最典型的操作:A 模块想调 B 模块的类,发现不能依赖,于是 Class.forName("com.xxx.BActivity") 硬跳。跳过去容易,跳回来(带结果、带生命周期、带版本兼容)就全是坑。
解法 :能力抽象下沉到 L2 的 xx-api 模块,实现放在 L3。这正是下面通信边界的起点。
把这三个反模式按统一的格式压成一张表,方便对照排查:
| 现象 | 根因 | 解法 |
|---|---|---|
| 公共模块编译最慢、没人敢动 | 无准入清单,什么都往里塞 | 只收无业务语义的内容 + CI 检查依赖 |
when 里 R.id.xxx 编译不过 |
多模块 R 非 final 常量 |
每个模块 resourcePrefix,跨模块走资源模块 |
| 反射跳转后拿不到返回值 / 生命周期错乱 | 用运行时黑科技取代编译期分层 | 接口下沉到 xx-api,实现留在 L3 |
模块化的收益怎么度量
别用"感觉快了"交差。建议在 CI 上固化三个数:
| 指标 | 拆之前 | 目标 | 怎么测 |
|---|---|---|---|
| 增量编译(改 1 个业务类) | 6~8 min | < 60 s | gradlew :module-x:assembleDebug --dry-run + 实际计时 |
| 单模块 APK 体积占比 | --- | 业务模块各自 < 3 MB | APK Analyzer 按包统计 |
| 跨层违规依赖数 | --- | 0 | 自定义 Gradle 任务扫描 dependencies |
第三项最关键:架构规则不进 CI,就只是 PPT。 前两项是"感觉",第三项是"事实"------只要还能 build 成功,违规依赖就会一直长,直到某天你发现业务模块 A 悄悄依赖了模块 B,而所有人都不记得是谁加的。
一个最小可用的检查任务:
kotlin
// build-logic 里的依赖守卫(示意)
tasks.register("checkDependencyRule") {
doLast {
val forbidden = listOf("module-survey-point", "module-stake-cad")
subprojects.forEach { p ->
p.configurations.filter { it.name.contains("Implementation") }
.forEach { cfg ->
cfg.dependencies.forEach { dep ->
if (dep.group == project.group && dep.name in forbidden
&& p.name.startsWith("module-")
) {
throw GradleException("业务模块禁止互相依赖: ${p.name} -> ${dep.name}")
}
}
}
}
}
}
这段任务的精髓不在写法,而在"把架构约束变成一个会 fail 的 build step"。规则写在文档里,一周后就没人记得;规则写在 CI 里,违规者在提交那一刻就会被拦住。
模块边界这条线,最终靠四条硬规则守住:
| 铁律 | 为什么 | 破了会怎样 |
|---|---|---|
| 依赖只能向下,不能跨层 | 把波及范围限定成可推演的量 | 改一个方法牵连半个工程,编译时间失控 |
| 业务模块之间零编译期依赖 | 可插拔、可按品牌裁剪 | 想砍掉一个业务模块时,发现谁都依赖它 |
| 宿主壳只留四件事 | 宿主膨胀 = 全品牌强耦合 | 某品牌不要的功能,删不掉,只能靠开关关 |
| 规则必须进 CI | 约定不进构建,等于没有约定 | 违规依赖静默生长,半年后无人能拆 |
模块边界定好之后,立刻就撞上那个最反直觉的问题:拆开了、不能互相依赖了,那它们之间怎么说话?
三、通信边界:为什么最核心的能力跑在独立进程
先说结论,因为它违反大多数人的第一直觉:
在这个 40 模块的工程里,最核心的能力(设备连接)跑在独立进程,宿主与它之间用 AIDL 通信;而业务模块之间则走"接口下沉 + 运行时注册"。
为什么放着同一个进程里的直接调用不用,非要走 AIDL?这是一次被依赖冲突 和稳定性共同逼出来的决策。
三条候选路线与它们的真实代价
假设地图模块要用设备模块的能力,有三种做法。
路线 A:直接 implementation project(":lib-device")
kotlin
// 业务模块 build.gradle
implementation project(":lib-device")
代价:这是最诱人也最致命的一条。
- 依赖传递污染 :
lib-device依赖了蓝牙、串口、网络、定位、数据库等一堆库,一旦被业务模块直接依赖,这些依赖全部进入它的编译类路径。两个业务模块依赖了同一个库的不同版本,Gradle 解析冲突时只会静默选一个,线上表现为"某个功能在 A 品牌正常、B 品牌崩"。 - 稳定性绑定:设备模块里有大量 JNI 与硬件交互,native 崩溃会直接把整个主进程带走。直接依赖意味着没有任何隔离边界。
- 编译放大:改设备模块一行,二十来个业务模块全部重编。
第 1 条里的"静默选一个"值得单独强调,因为它是那种只在线上、只在特定品牌、只在特定机型出现的诡异 Bug。Gradle 在版本冲突时默认选最高版本,并不会报错。等到问题暴露,你手上只有一句"B 品牌崩了",而重新复现一次依赖冲突的成本极高。
路线 B:接口下沉(lib-device-api)
kotlin
// L2 能力层:纯接口,零实现
interface DeviceConnector {
fun connect(param: ConnectParam): Boolean
fun disconnect()
fun registerStateListener(listener: StateListener)
}
kotlin
// L3 实现层
internal class DeviceConnectorImpl : DeviceConnector { /* ... */ }
// 启动时注册
object DeviceService {
private var impl: DeviceConnector = NoOpConnector()
fun register(impl: DeviceConnector) { this.impl = impl }
fun get(): DeviceConnector = impl
}
代价 :实现与接口分离得靠人自觉,NoOp 兜底容易变成"线上静默失败"的温床。但它编译期无依赖、调用是同步的、没有序列化开销,适合同进程内业务模块之间的调用。
路线 C:AIDL 跨进程
aidl
// IDeviceService.aidl(示意)
interface IDeviceService {
boolean connect(in ConnectParam param);
void disconnect();
void registerCallback(IDeviceCallback cb);
}
kotlin
// 客户端绑定
class DeviceClient(context: Context) {
private var service: IDeviceService? = null
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = IDeviceService.Stub.asInterface(binder)
service?.registerCallback(callback)
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null // 关键:允许重连,不能置为不可恢复状态
}
}
fun bind() {
context.bindService(
Intent(context, DeviceService::class.java),
connection,
Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT
)
}
}
代价 :序列化开销、异步心智负担、需要处理 DeadObjectException、回调线程是 Binder 线程池(要切回主线程)。
最终的选择与判据
| 场景 | 方案 | 理由 |
|---|---|---|
| 业务模块 → 设备能力 | AIDL 跨进程 | native 崩溃隔离 + 依赖隔离 + 硬件长连接需要独立于 UI 生命周期 |
| 业务模块 → 地图/项目数据/坐标计算 | 接口下沉 + 运行时注册 | 同进程、高频调用、数据量大,序列化不划算 |
| 业务模块 ↔ 业务模块 | 宿主路由 + 事件 | 编译期零依赖,可裁剪 |
| 宿主 → 插件 | 插件框架提供的 Intent 路由 | 跨 ClassLoader |
一句话总结判据:
用"崩溃域"和"调用频率"两个维度来选:会 native 崩溃、需要长期驻留的能力 → 独立进程;高频、大数据量、纯计算的能力 → 同进程接口下沉。
把四种方式横向摊开对比,边界会更清楚:
| 方式 | 编译期依赖 | 崩溃隔离 | 序列化开销 | 顺带的问题 |
|---|---|---|---|---|
直接 project 依赖 |
有 | 无 | 无 | 依赖传递污染、编译放大 |
| 接口下沉 + 运行时注册 | 只依赖 API | 无 | 无 | 忘注册会静默失败 |
| AIDL 跨进程 | 无 | 有 | 有 | 死对象、线程切换、接口不可随意改 |
| 路由 + 事件 | 无 | 无 | 无 | 参数类型不安全、难调试 |
这个表也解释了为什么不能"一把梭"选某一种:四种方式解决的是四种不同的问题,把它们混成一个"统一通信层",只会把四份缺点叠在一起。通信边界的守边人就是这张判据表------每新增一次跨模块调用,都要先回答"崩溃域和调用频率落在哪一格"。
AIDL 这条路的完整时序是这样,注意其中两处最容易被忽略的细节:回调落在 Binder 线程池,以及进程被杀后的重连:
AIDL 落地的五个必踩的坑
坑 1:回调对象必须走 RemoteCallbackList
直接把 IDeviceCallback 存进 ArrayList,客户端进程被杀后回调永远留着,下次遍历就是 DeadObjectException。用 ArrayList 的写法还有一个更隐蔽的后果:列表只会越来越长,每次 notify 的耗时随运行时长线性增长,最后表现为"App 用了两小时之后开始卡"。
kotlin
// 服务端(示意)
private val callbacks = RemoteCallbackList<IDeviceCallback>()
fun registerCallback(cb: IDeviceCallback) {
callbacks.register(cb)
}
fun notifyState(state: DeviceState) {
val n = callbacks.beginBroadcast()
try {
for (i in 0 until n) {
callbacks.getBroadcastItem(i).onStateChanged(state)
}
} finally {
callbacks.finishBroadcast() // 必须 finally,否则泄漏
}
}
RemoteCallbackList 内部用 IBinder.linkToDeath 自动清理死掉的回调,这是它唯一的不可替代价值。另外 beginBroadcast / finishBroadcast 必须成对出现,中间不能再调用 register / unregister ,否则会抛 IllegalStateException。
坑 2:Binder 事务 1 MB 上限
一次传输超过约 1 MB 会抛 TransactionTooLargeException。注意这个限制是进程级共享的 :同一时刻所有 Binder 事务加起来超限也会炸。所以大数据(点位列表、图形数据)绝对不要走 AIDL 参数,走共享存储 + URI,或者分页。
坑 3:回调线程不是主线程
AIDL 回调默认在 Binder 线程池执行。如果你在回调里直接操作 View,会偶发 CalledFromWrongThreadException------偶发,是因为线程分配不确定。统一在回调入口切线程:
kotlin
override fun onStateChanged(state: DeviceState) {
mainHandler.post { handleState(state) }
}
坑 4:服务被杀后的重连策略
onServiceDisconnected 只会在进程被杀 时回调,正常 unbindService 不会。所以:
kotlin
override fun onServiceDisconnected(name: ComponentName?) {
service = null
mainHandler.postDelayed({ bind() }, 1000L) // 退避重连,不要立即重试
}
立即重试会在系统资源紧张时形成重试风暴------服务刚被系统杀掉说明内存已紧张,此时高频 bind 只会加速下一次被杀。工程里的实际做法是指数退避 + 上限次数:每次失败把延迟翻倍,超过上限后转为等待外部的"重新连接"指令。
坑 5:AIDL 接口一旦发布就不能随便改
AIDL 的方法签名决定事务码顺序,客户端与服务端版本不一致时,轻则调用错方法,重则崩溃。做法是:
- 接口只增不改,新增方法放最后;
- 服务端对老客户端做兼容分支(用版本号字段判断);
- 数据结构用
Parcelable且带默认值,新增字段老端反序列化时才不会错位。
第 1 条的根因在 Binder 的机制层面:AIDL 生成代码时按方法声明顺序 分配 TRANSACTION_xxx 常量,服务端和客户端各自编译,一旦有一端插入或调换了方法顺序,两边的"第 3 个方法"就不是同一个方法了。跨版本混跑时,调用会发生"静默错位"。
同进程方案里的"兜底"陷阱
回到路线 B 的 NoOpConnector。它的问题在于:当实现没有注册时,调用方拿到的是一个"成功执行但什么都没做"的对象。
kotlin
object DeviceService {
private var impl: DeviceConnector = NoOpConnector() // 静默失败源头
fun get(): DeviceConnector = impl
}
线上表现:功能点了没反应,日志里什么都没有。正确的做法是让错误尽早暴露:
kotlin
object DeviceService {
@Volatile
private var impl: DeviceConnector? = null
fun register(impl: DeviceConnector) { this.impl = impl }
fun get(): DeviceConnector =
impl ?: error("DeviceConnector 未注册:检查该品牌是否装配了设备模块")
}
在非兜底版本里,崩溃日志会明确告诉你"这个品牌漏了装配",而不是让用户在现场对着没反应的按钮发呆。空对象模式在这里是反模式:它服务的场景是"调用方需要继续运行",而装配错误的场景恰恰是"必须立刻停下"。
把通信这条边界上的坑统一成一张对照表:
| 现象 | 根因 | 解法 |
|---|---|---|
| 客户端退出后服务端遍历回调崩溃 | 回调存进 ArrayList,死对象没清理 |
用 RemoteCallbackList + linkToDeath |
| 大列表走 AIDL 直接崩 | 事务超过进程级 1 MB 上限 | 走共享存储 + URI 或分页 |
回调里刷 UI 偶发 CalledFromWrongThreadException |
回调在 Binder 线程池 | 回调入口统一切主线程 |
| 系统紧张时服务反复被杀 | 立即重试形成重试风暴 | 指数退避 + 上限次数 |
| 新老版本混跑调用到错方法 | AIDL 事务码按声明顺序分配 | 只增不改,新增放最后 + 版本兼容分支 |
| 功能点了没反应、日志空白 | NoOp 兜底掩盖装配错误 |
用 error() 崩在启动时 |
到这里,模块之间"怎么说话"有了答案。但二十多个业务模块,除了通信,还有另一个更日常的问题:它们做的事情高度相似,怎么既长得一样、又能各自不同?
四、能力边界:二十多个模块怎么长得一样又各自不同
这个工程里有二十来个业务模块,它们做的事情本质上高度相似:
- 占满一屏(地图 + 顶部信息 + 底部操作 + 右侧工具栏);
- 连接设备,接收实时数据;
- 展示实时计算结果;
- 提供一组工具栏按钮,点击触发业务动作;
- 能存取点位、能响应语音指令、能响应工程切换。
但每个模块又有差异:有的要罗盘、有的要图表、有的要相机预览、有的不需要工具栏、有的不支持语音。
如果各写各的,结果是二十份 80% 重复、20% 走样的代码------而这 20% 的走样就是 Bug 温床(改一个公共 Bug 要改二十处,还总漏)。
工程里最终的解法是两个经典模式的组合:接口能力组合 (把"这模块有哪些能力"拆成一组角色接口再拼起来)加模板方法(把"必须按顺序执行"的流程锁在基类里)。
图里有两个刻意的设计:总接口 WorkMode 只做继承组合、自己不定义任何"能力方法" ;抽象基类实现总接口,而不是让每个业务模块直接实现总接口。
能力组合:用"角色接口"替代上帝接口
先看反面:如果只有一个 WorkMode 接口承载所有能力,那么每个业务模块都要实现二十个方法,其中十五个是空实现。改接口一次,二十个模块全改。
工程里实际的做法是按角色拆接口,再组合:
kotlin
// 能力接口(示意,均带默认实现)
interface IProjectAware {
fun onProjectOpen() {}
fun onProjectClose() {}
fun onCoordinateSystemChanged() {}
}
interface IMapAware {
fun getDefaultMapType(): MapType = MapType.OSM
fun onMapTileSourceChanged() {}
fun enableBoxSelect(): Boolean = false
}
interface IDeviceAware {
fun onDeviceStateChanged(state: DeviceState) {}
fun onPositionUpdated(position: Position) {}
}
interface IVoiceAware {
fun supportVoiceControl(): Boolean = false
fun canHandleVoiceCommand(cmd: VoiceCommand): Boolean = false
fun handleVoiceCommand(cmd: VoiceCommand) {}
}
interface IStakeAware {
fun onStakeNext() {}
fun onStakeLast() {}
}
然后组合出总接口:
kotlin
interface WorkMode : IModule, IProjectAware, IMapAware, IViewAware,
IDeviceAware, IStakeAware, ILifeCycle, IVoiceAware {
fun setWorkContext(context: WorkContext)
fun onFileChooseBack(path: String)
}
关键点有三个:
- 每个方法都给默认实现 (Kotlin 接口默认方法)。新增能力时,老模块零改动------这是这套设计能撑住二十多个模块的原因。
- 接口按"角色"划分,不按"功能"划分。判断标准是"这个能力会不会被某些模块裁掉"。
- 总接口只做组合,不定义新方法,避免它自己变成上帝接口。
第 1 条是最容易被低估的。给接口方法加默认实现,意味着"新增一个能力"从"改二十个文件"变成"改一个接口";而如果接口方法没有默认实现,Kotlin 会强制所有实现类补上------二十个模块、二十次编译、二十次可能引入的笔误。默认实现买的不是便利,是变更成本。
supportXXX() 比 if (this is XXX) 更好
注意 IVoiceAware.supportVoiceControl() 这种"自述能力"的方法。另一种写法是在调用处 if (mode is IVoiceAware),但自述式有额外好处:能力可以动态开关(比如"当前未连接设备,暂不支持语音"),而类型判断是编译期写死的。
kotlin
// 推荐:能力自述,可动态
if (mode.supportVoiceControl() && mode.canHandleVoiceCommand(cmd)) {
mode.handleVoiceCommand(cmd)
}
// 不推荐:类型判断,写死
if (mode is IVoiceAware) { /* ... */ }
两者的差别在业务上很现实:语音是否可用,取决于"用户是否开了语音开关""设备是否已连接""当前页面是否在录音",这些都不是类型能表达的。类型判断只能回答"这个类有没有实现这个接口",回答不了"此刻能不能用"。
模板方法:把"必须按顺序执行"的部分锁死
二十多个模块的公共流程(装配视图 → 注册渲染器 → 注册传感器监听 → 恢复状态 → 工具栏配置)一旦顺序错了,就会出现各种诡异问题。这类问题靠注释约束不住,只能靠模板方法 + final 语义。
工程里的基类大致长这样:
kotlin
abstract class BaseWorkMode(
protected val context: WorkContext
) : WorkMode {
protected val lifecycleRegistry: LifecycleRegistry
protected lateinit var header: IHeaderView
protected var bottomView: IBottomView? = null
init {
lifecycleRegistry = LifecycleRegistry(workModeLifeCycleOwner)
header = createHeader()
bottomView = createBottomView() // 子类可返回 null
registerLayerRenderer(createRenderers())
}
// ---- 子类必须提供的差异点 ----
protected abstract fun createHeader(): IHeaderView
protected open fun createBottomView(): IBottomView? = null
protected open fun createRenderers(): List<LayerRenderer> = emptyList()
protected abstract fun getToolbarGroup(): ToolbarGroup
// ---- 框架锁死的流程 ----
@CallSuper
open fun onStart() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
initView()
header.onStart()
ToolbarManager.toolbarGroup = getToolbarGroup()
}
@CallSuper
open fun onResume() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
registerLayerRender()
bottomView?.onResume()
header.onResume()
SensorHub.addListener(this)
}
@CallSuper
open fun onClose() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
unRegisterLayerRender()
SensorHub.removeListener(this)
ToolbarManager.toolbarGroup = null
}
@CallSuper
open fun onDestroy() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
removeLayoutListeners()
}
}
三个细节值得抄:
@CallSuper:子类重写时编译器强制调用super,把"忘了调父类"从运行时问题变成编译期问题。createXxx()工厂方法而非initXxx()钩子:由基类决定"什么时候创建、创建后做什么",子类只负责"造什么"。子类的自由度被限制在安全范围内。- 生命周期事件由基类统一派发 :子类永远不要自己调
handleLifecycleEvent,避免出现"子类先 DESTROY、父类后 RESUME"的乱序。
这三个细节指向同一个思路:把易错的"时序"收进基类,把安全的"选择"留给子类。 基类对子类的授权边界画得越窄,二十多个模块行为一致的概率就越高------这也是这条能力边界唯一的守则。
这套设计真实遇到的两个麻烦
麻烦 1:模式切换时的"监听器错乱"
有个业务模块由于历史原因,没有拆成两个 WorkMode,而是在同一个对象里用布尔值区分"采集/放样"两种状态。源码注释里写得很诚实:
不得已进行的兼容:如果按既定框架多增加一个
XxxWorkMode,会遇到两个问题------
- 切换入口拿不到新类型,需要反射判断;
- 即便反射判断成功,
initObserve里 observe 的 ViewModel 是同一个,而LifecycleOwner不是同一个,会直接崩溃。
第 2 点非常有代表性:同一个 LiveData 被两个不同的 LifecycleOwner 观察不是问题,但如果在切换时旧的 owner 已经 DESTROY 而 LiveData 仍持有它,再 observe 就会抛 "Cannot add the same observer with different lifecycles"。
正确的做法恰恰是框架想让你做的:状态不同就拆成不同的 WorkMode 实例 ,切换时旧的走完整 onClose → onDestroy,新建的对象重新 observe。绕开框架的结果就是现在这段注释和那个布尔值------一个模块为了省一次重构,永久背上了一段没人敢删的兼容代码。
麻烦 2:差异化需求把基类撑爆
当某个模块需要"特殊处理"时,最简单的动作是在基类加一个 open fun,然后基类开始长 if。判断标准很简单:
这个差异只属于一个模块吗?是的话,它不该进基类。
进基类的东西必须满足"至少三个模块需要",或者"所有模块都需要且顺序敏感"。其余的差异用组合(注入一个策略对象)解决:
kotlin
// 差异用组合承载,而不是给基类加抽象方法
interface IStakeNavigator {
fun next()
fun last()
}
class BaseWorkMode(
private val navigator: IStakeNavigator? = null // 不需要的模块传 null
) : WorkMode {
override fun onStakeNext() { navigator?.next() }
override fun onStakeLast() { navigator?.last() }
}
"至少三个模块需要"这条门槛为什么定在 3 而不是 2?因为一个差异只要出现两次,通常还能用组合或重载和平解决;出现三次,说明它是这个业务域的共同规律,放进基类才划算。门槛定得太低,基类会在半年内长成第二个上帝接口。
落地检查清单
- 每个能力接口的方法都有默认实现,新增能力不改动老模块;
- 总接口只组合、不定义新方法;
- 公共流程方法上一律加
@CallSuper; - 子类覆写的入口是
createXxx()(造什么),不是initXxx()(什么时候做); - 生命周期事件只在基类派发,子类禁止调用
handleLifecycleEvent; - 只有单个模块需要的差异,用组合注入,不进基类;
- 状态不同的场景拆成不同实例,不要用布尔值在一个对象里切换。
这套框架真正的价值不是"少写代码",而是把二十多个模块的差异收敛到了可枚举的位置:要新增一个业务模块,你只需要回答"我要哪些能力接口、我的 header/bottomView/renderer 是什么、工具栏有哪些按钮"。
但基类里反复出现的 lifecycleRegistry 和 workModeLifeCycleOwner 还有一个悬念:它们属于一个既不是 Activity 也不是 Fragment 的类,那这套生命周期是谁给的?
五、生命周期边界:让普通类拥有生命周期
Android 的 Lifecycle 是 Jetpack 里最被低估的设计之一。大多数人只在 Activity / Fragment 里用它,但它的本质是:
一个可以分发状态、可以注册观察者、可以在销毁时自动清理的状态机。
任何满足下面三个条件的"东西",都值得拥有自己的生命周期:
- 它有明确的创建与销毁时机,但这个时机不是 Activity/Fragment 的时机;
- 它内部持有了需要被清理的资源(协程、监听、传感器、渲染器);
- 它想把"清理"这件事自动化,而不是靠调用方记得调
close()。
在这个工程里,WorkMode(业务工作模式)就是典型:一个 Activity 里可以先后切换十几个 WorkMode,Activity 还活着,但某个 WorkMode 早就该销毁了。如果 WorkMode 里的协程和监听挂在 Activity 的 lifecycle 上,那么:
- 切走的 WorkMode 的协程永远不会停,十几个 WorkMode 的协程一起跑;
- 传感器监听越加越多,回调里更新已经不可见的 View;
- 切回来时旧的 LiveData 观察者还活着,回调乱序。
这三条后果有个共同点:它们都不会立刻崩,而是慢慢累积。 协程泄漏表现为发热和耗电,监听堆积表现为越用越卡,LiveData 乱序表现为"偶发的数据串台"。这类问题最难排查,因为它们没有崩溃栈。"让清理自动化"不是一个优雅性问题,而是把这类无栈 Bug 从根上消掉。
最小实现:12 行代码
核心就是 LifecycleRegistry + 一个 LifecycleOwner:
kotlin
class BaseWorkMode(private val context: WorkContext) : WorkMode {
private val lifecycleRegistry: LifecycleRegistry
val workModeLifeCycleOwner: LifecycleOwner
init {
workModeLifeCycleOwner = WorkModeLifeCycleOwner()
lifecycleRegistry = LifecycleRegistry(workModeLifeCycleOwner)
}
inner class WorkModeLifeCycleOwner : LifecycleOwner {
override fun getLifecycle(): Lifecycle = lifecycleRegistry
}
}
然后由外部(宿主 Activity)在合适的时机派发事件:
kotlin
@CallSuper
open fun onStart() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
// ...
}
@CallSuper
open fun onResume() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
// ...
}
@CallSuper
open fun onClose() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
// ...
}
@CallSuper
open fun onDestroy() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
// ...
}
拿到自己的 owner 之后,这个普通类就能享受整套 Jetpack 生态了:
kotlin
// 1) 自带协程作用域,onDestroy 自动取消
workModeLifeCycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val data = repository.load()
withContext(Dispatchers.Main) { render(data) }
}
// 2) LiveData 观察,销毁自动反注册
viewModel.result.observe(workModeLifeCycleOwner) { data ->
updateUi(data)
}
// 3) 生命周期感知的监听注册
workModeLifeCycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
sensorManager.unregister(this@BaseWorkMode)
}
})
这一段的价值在于:"别忘了反注册"从一条人需要记住的规则,变成了一个自动行为。
内部的状态机可以用这张图理解,它同时解释了后面几个坑为什么存在:
注意图里 CREATED → STARTED → CREATED 是双向的,而 CREATED → DESTROYED 是单向的。这个不对称就是坑 3 的根源。
五个必须知道的坑
坑 1:LifecycleRegistry 必须在主线程操作
LifecycleRegistry 不是线程安全的,它内部有 mHandlingEvent / mAddingObserverCounter 这类非原子状态。从子线程调用 handleLifecycleEvent 会抛:
sql
java.lang.IllegalStateException: Method handleLifecycleEvent must be called on the main thread
如果派发时机来自子线程(比如设备回调线程),必须切回来:
kotlin
fun dispatchDestroy() {
mainHandler.post {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
}
坑 2:currentState 与 Event 不是一回事
Event 是"发生了什么",State 是"现在处于什么阶段"。混用会导致状态机错乱。判断"当前是否可用"用 currentState:
kotlin
// 正确
if (lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) {
updateUi()
}
// 或者用 Lifecycle 提供的便捷写法
if (workModeLifeCycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED) {
startAnimation()
}
坑 3:ON_STOP 之后再 ON_RESUME 是允许的,但 ON_DESTROY 之后不行
DESTROYED 是终态。一旦派发 ON_DESTROY,这个 registry 就废了,再派发任何事件都会抛异常,而且之前注册的观察者不会再收到任何回调。
工程里因此有一个很容易忽略的约定:
复用实例 = 找死。一个 WorkMode 实例一旦
onDestroy,必须新建,不能复活。
这条和另一类现象是同一个根因------被销毁后复用的对象,它注册的回调已经全部注销了。所以"对象可以复用,状态不需要重建"这种直觉,在生命周期语义面前是错的。
坑 4:别把 Activity 的 lifecycleScope 和自己的混用
看这段真实代码结构里容易写错的地方:
kotlin
init {
context.getActivity().lifecycleScope.launch { // 挂在 Activity 上
onResumeFlow.debounce(150).collect {
if (lifecycleRegistry.currentState == Lifecycle.State.RESUMED) {
withContext(Dispatchers.Main) { actionOnResume(flag) }
}
}
}
}
这里用 Activity 的 scope 是有意为之 (这个 onResume 防抖流需要跨越 WorkMode 切换而存活),但里面必须再判断一次自己的 currentState,否则会在 WorkMode 已关闭后仍然执行 actionOnResume。
判断依据 :这个协程的生命周期应该跟着谁?跟着 Activity 就用 Activity 的 scope(通常是全局性的流),跟着 WorkMode 就用 workModeLifeCycleOwner.lifecycleScope(99% 的情况)。
坑 5:repeatOnLifecycle 需要 STARTED,别在 CREATED 阶段收集
kotlin
// 正确:自动在 STARTED 启动、STOP 时取消
workModeLifeCycleOwner.lifecycleScope.launch {
workModeLifeCycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
注意 repeatOnLifecycle 的语义是"到达该状态时启动协程,低于该状态时取消 协程(而不是挂起)"。如果你的收集逻辑有中间状态需要保留,就得自己用 Flow.stateIn + SharingStarted 处理:
kotlin
// 需要跨越 STOP 保留中间态的写法
val uiState: StateFlow<UiState> = viewModel.uiState
.stateIn(
scope = workModeLifeCycleOwner.lifecycleScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Idle
)
WhileSubscribed(5_000) 里的 5 秒是给"配置变更 / 短暂后台"留的缓冲窗口:窗口内不取消上游,避免旋转屏幕这种瞬时事件把整个数据流重建一遍。
什么时候不该手写 LifecycleOwner
不是所有"有生命周期"的类都需要它。下面三种情况用了反而更糟:
| 情况 | 更好的选择 |
|---|---|
| 类的生命周期完全等于宿主 Activity/Fragment | 直接用宿主的 lifecycle |
| 只是需要一个能取消的协程作用域 | CoroutineScope(SupervisorJob()) + 手动 cancel() |
| 生命周期由外部不可控事件驱动(如网络断连) | 状态机 + 手动清理,不要用 lifecycle 语义硬套 |
判断标准:这个类的创建/销毁时机,是否是宿主无法感知的? 是,才需要自己一套。
第三种情况值得展开:网络断连、硬件拔出这类事件,"结束"之后随时可能"重启",而且"重启"的时机不由本地代码决定。硬套 Lifecycle 的结果是------你对一个其实是"来回切换"的状态,用了"单向终态"的语义,于是每一轮重启都得新建对象,最后变成对象无限复制。这类场景用状态机描述"当前阶段"更贴切。
一个可复用的模板
kotlin
abstract class LifecycleAwareComponent : LifecycleOwner, DefaultLifecycleObserver {
private val registry = LifecycleRegistry(this)
override fun getLifecycle(): Lifecycle = registry
fun start() { registry.handleLifecycleEvent(Lifecycle.Event.ON_START) }
fun resume() { registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) }
fun pause() { registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) }
fun stop() { registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) }
fun destroy() {
registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
protected fun ensureMainThread(action: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) action() else mainHandler.post(action)
}
companion object {
private val mainHandler = Handler(Looper.getMainLooper())
}
}
使用方只需要:
kotlin
class MyMode : LifecycleAwareComponent() {
fun load() {
lifecycleScope.launch(Dispatchers.IO) { /* 自动随 destroy 取消 */ }
}
}
模板里那个 ensureMainThread 就是把坑 1 固化成了基础设施:与其在十几个派发点各自记得切主线程,不如让所有派发路径统一经过它。
生命周期边界守住之后,还剩最后一条最难守的边界:进程启动那一瞬间------热修复、MultiDex、多进程守卫、全局注入,四件事挤在同一个顺序里,谁先谁后都有代价。
六、运行时边界:热修复与插件化共存
先看一段"能跑但顺序不能动"的初始化代码
这是宿主模块里 Application 代理类的真实结构(已脱敏):
kotlin
@DefaultLifeCycle(
application = ".HostApplication", // 真正写入 Manifest 的类名
flags = ShareConstants.TINKER_ENABLE_ALL,
loaderClass = "com.tencent.tinker.loader.TinkerLoader",
loadVerifyFlag = false
)
class HotFixApplicationLike(
application: Application?,
tinkerFlags: Int,
tinkerLoadVerifyFlag: Boolean,
applicationStartElapsedTime: Long,
result: Intent?
) : DefaultApplicationLike(
application, tinkerFlags, tinkerLoadVerifyFlag,
applicationStartElapsedTime, result
) {
override fun onBaseContextAttached(base: Context) {
super.onBaseContextAttached(base)
// 1) 必须先装 MultiDex
MultiDex.install(base)
// 2) 再初始化热修复
HotFixManager.setApplicationLike(this)
HotFixManager.setUpgradeRetryEnable(true)
HotFixManager.initHotFix(this)
// 3) 最后做需要在 attach 阶段完成的全局注入
initXmlParserProperty()
}
}
这段代码有三个时间敏感点,顺序错了不一定编译报错,但一定线上出问题。
为什么 MultiDex 必须排在热修复前面
Tinker 的原理是把补丁 dex 插到 ClassLoader 的 dex 数组最前面。它工作依赖于能枚举到宿主的所有 dex 。如果 MultiDex.install() 还没执行,主 dex 之外的类还躺在 classes2.dex 里没被加载,此时初始化 Tinker:
- 轻则补丁加载后找不到要修复的类(补丁生效一半);
- 重则在某些机型上
ClassNotFoundException直接崩在启动阶段------而且是热修复框架自己崩的,补丁再也救不回来。
最后半句是整个顺序问题里最要命的地方:热修复的唯一价值是"出事了还能救",而一旦热修复框架自己崩在启动阶段,这条救援通道就被自己堵死了。热修复的初始化代码必须做到"无论补丁内容多离谱,启动都不能挂",这也是为什么它必须排在 MultiDex 之后------先保证类能被加载,再谈能不能被替换。
所以注解处理器生成的注释里那句 you must install multiDex whatever tinker is installed! 不是客气话。
反过来,如果你把 Tinker 相关类手动塞进主 dex(multiDexKeepFile),理论上可以交换顺序。但不建议------主 dex 越大越容易踩 65536 的线性分配区(LinearAlloc)限制,尤其在 Android 4.x~5.x 的老设备上(这个工程要跑工业手持设备,系统版本跨度极大)。
groovy
// 不推荐的做法:为了让顺序可变而把 tinker 塞进主 dex
multiDexKeepFile file('tinker_keep.txt')
// com.tencent.tinker.loader.**
// com.tencent.tinker.lib.**
@DefaultLifeCycle 制造了"两个 Application"
这是最容易让人懵的一点。你写的是 HotFixApplicationLike(继承 DefaultApplicationLike),但 Manifest 里注册的是注解生成的 .HostApplication。
由此产生三个必须遵守的约定:
1. 不要在 HostApplication 里写任何代码
它是编译期生成的,每次 clean 重新生成,你写的会被覆盖。所有逻辑放 *ApplicationLike。
2. getApplication() 与 this 不是一回事
在 ApplicationLike 里,this 不是 Application 。要用 getApplication() 获取真正的 Application 实例:
kotlin
// 正确
ToastHelper.init(application)
(application as Application).registerActivityLifecycleCallbacks(callback)
// 错误:this 不是 Context 的 Application
ToastHelper.init(this as Application) // ClassCastException
3. 静态持有要拿 getApplication()
kotlin
App.onCreate(application, APP_KEY, BuildConfig.DEBUG)
KVStorage.getInstance() // 内部初始化目录,依赖 Application
KVStorage.getInstance() 这类单例如果内部缓存了 Context,缓存错对象(拿到 ApplicationLike 而不是 Application)的后果是:单例持有一个非 Application 的对象,一旦它被某个 Activity 间接引用,就是一个跨 Activity 的静态泄漏。
多进程守卫:别让每个进程都初始化一遍
这个工程有多个进程(主进程、设备服务进程、远程服务进程等)。如果不加守卫,友盟/支付/地图这些 SDK 会在每个进程各初始化一次,后果包括:重复上报、数据库锁竞争、以及最经典的 "SQLiteDatabaseLockedException"。
工程里的做法是在 onCreate 第一行判进程:
kotlin
override fun onCreate() {
super.onCreate()
// 进程无关的基础初始化(在每个进程都要做)
initStrictMode()
loadNativeLibrary()
val isMainProcess = application.packageName == getCurrentProcessName()
if (!isMainProcess) {
return // 以下是主进程专属
}
initCrashHandler()
initStatistics()
initCloud()
initRouter()
}
kotlin
private fun getCurrentProcessName(): String {
val pid = android.os.Process.myPid()
val am = application
.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val processList = am?.runningAppProcesses ?: return ""
for (info in processList) {
if (info.pid == pid) {
return info.processName
}
}
return ""
}
两个注意点:
android:process声明的进程名是包名:xxx,不是xxx,所以比较要用getPackageName().equals(processName),而不是endsWith("xxx")。- Android P 之后
getRunningAppProcesses()有权限收紧,更稳的方式是Application.getProcessName()(API 28+)或读/proc/self/cmdline。
这里的设计要点是把初始化分成两类:进程无关的(StrictMode、native 库加载)放前面,每个进程都做;进程专属的(崩溃、统计、云服务、路由)放后面,只有主进程做。这条边界不用"要不要"来判,而用"这个初始化在另一个进程里做了会不会有害"来判。数据库、上报、路由注册都属于"多做一次就出事"的那一类。
插件化与热修复的边界:哪些能修,哪些不能
这是很多人踩坑后才明白的一点:
| 代码位置 | 能否热修 | 说明 |
|---|---|---|
| 宿主 Application 自身 | 不能 | Application 先于补丁加载执行,补丁来不及生效 |
| 宿主 Manifest 里的四大组件 | 不能 | 新增组件必须发版(插件化框架另说) |
| 业务模块(插件)代码 | 视方案而定 | 插件由插件框架动态加载,通常走插件自身的更新通道 |
| 纯 Java/Kotlin 业务逻辑 | 能 | Tinker 的主战场 |
| so 库 | 能(受限) | 需 TinkerLoadLibrary.installNativeLibraryABI() 注册 ABI 路径 |
| 资源(图片/布局/字符串) | 能(受限) | 资源 id 不能新增,只能改内容 |
工程里对 so 的处理:
kotlin
fun loadNativeLibrary() {
// 将补丁中的 so 注册到系统 library path
TinkerLoadLibrary.installNavitveLibraryABI(application, "arm64-v8a")
}
结论 :热修复的定位应该是业务逻辑的紧急止血,不要指望它修 Application、清单文件和资源结构。真正需要动态下发的页面,走插件化。
这张表还有一层潜台词:热修复和插件化的分工,本质是"改什么"和"加什么"的分工。 热修复负责"改",它的边界止于已经存在的类和方法;插件化负责"加",它能带来新的组件和页面。把所有需求都往热修复里塞,会在"新增页面"这类需求上撞墙;把所有需求都往插件化里塞,又会在"改了业务逻辑想立刻生效"上付出插件下发的成本。
启动阶段还有一个"隐藏依赖":全局属性注入
这个工程在 Android 上跑 POI(读写 Excel),而 Android 的 XML 解析器实现不完整,必须在任何 XML 解析发生之前注入实现:
kotlin
private fun initXmlParserProperty() {
System.setProperty("org.apache.poi.javax.xml.stream.XMLInputFactory",
"com.fasterxml.aalto.stax.InputFactoryImpl")
System.setProperty("org.apache.poi.javax.xml.stream.XMLOutputFactory",
"com.fasterxml.aalto.stax.OutputFactoryImpl")
System.setProperty("org.apache.poi.javax.xml.stream.XMLEventFactory",
"com.fasterxml.aalto.stax.EventFactoryImpl")
}
它必须放在 onBaseContextAttached 而不是 onCreate,原因是:ContentProvider 的 onCreate 早于 Application 的 onCreate 。如果某个 ContentProvider(比如 FileProvider、或者三方 SDK 自带的初始化 Provider)间接触发了 XML 解析,System.setProperty 就来不及了。
一句话规律:
凡是"影响类加载/解析行为"的注入,放
attachBaseContext(或onBaseContextAttached);凡是"业务初始化",放onCreate。
把这条规律和前面的初始化顺序拼起来,就是完整的启动时序(注意 ContentProvider 竟然排在 Application 之前):
图里从 A 到 G 是一条顺序不能换 的链:ContentProvider 必须先于 Application 完成(这是系统行为,不受我们控制,所以才要把注入提前到 attachBaseContext);MultiDex 必须先于热修复;影响解析的注入必须先于任何业务初始化。
Debug 阶段的 StrictMode 是最好的提前预警
工程里保留了这样一段只在 Debug 生效的配置,价值极高:
kotlin
private fun initStrictMode() {
if (!BuildConfig.DEBUG) {
return
}
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectNetwork() // 主线程网络
.detectDiskReads() // 主线程磁盘读
.detectDiskWrites() // 主线程磁盘写
.penaltyLog()
.penaltyDialog()
.penaltyDeath() // Debug 下直接崩,逼你修
.build())
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectLeakedClosableObjects() // 未 close 的流
.detectActivityLeaks() // Activity 泄漏
.penaltyLog()
.build())
}
penaltyDeath() 在 Debug 下很粗暴,但它能让"主线程读写数据库"这种问题在提测前就暴露,而不是等到 ANR 线上告警。
这里的关键是 penaltyDeath() 只放在 Debug。它的作用不是"保护线上",而是把线上才会暴露的问题,提前到开发机上暴露------主线程读数据库在线上表现为偶发 ANR,难以复现;在 Debug 下直接抛异常,配合崩溃栈一秒定位。同一类问题,两种反馈速度差着几个小时和几个星期。
七、小结
五条边界画完,回头看这整套架构,真正决定成败的从来不是"拆"这个动作:
- 划边界只要几天,守边界是每一天的事 :模块边界靠 CI 守(依赖检查脚本),通信边界靠判据表守(崩溃域 + 调用频率),能力边界靠基类守(默认实现 +
@CallSuper),生命周期边界靠终态语义守(DESTROYED不可复用),运行时边界靠顺序守(MultiDex → 热修复 → 注入 → 业务初始化)。 - 每条边界的守边人,最后都必须是"会失败的东西":写在文档里的约定不算守住,编译期报错、CI 阻断、Debug 崩溃才算。
- 拆分的四个硬指标:依赖只能向下、业务模块零编译期依赖、宿主只留四件事、规则必须进 CI。
- 通信只用两个维度:会 native 崩溃、需长期驻留的能力走独立进程(AIDL);高频、大数据量、纯计算的能力走同进程接口下沉。
- 一个容易被忽略的根因贯穿始终:被销毁后复用的对象、被兜底掩盖的装配错误、被静默选中的依赖版本------它们都不会立刻崩,但都会在线上变成"点了没反应"。
系列导航:Android 技术总结系列第 1 篇(共 9 篇)。本篇为首篇,下一篇《Android 不改一行源码:用 AspectJ 做三类拦截》。
你现在的工程里,哪一个模块是所有人都不太敢动的那一个?它难动,是因为代码复杂,还是因为没人说得清它依赖了谁、又被谁依赖着?