Android 插件化架构:40 个 Gradle 模块的拆分、通信与共存

拆是几天的活,守是每一天的活------难的不是拆出 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 页面复用;一重组就是跨模块搬代码,依赖关系瞬间打结。

更稳的切法看三个维度:

维度 判断问题 结论
变更频率 这个东西一年改几次? 高频改的和三年不动的必须分开
依赖方向 它需要知道"业务"吗? 不需要业务知识的,下沉到基础层
交付粒度 它能被单独交付/裁剪吗? 能被某个品牌裁掉的,独立成模块

三个维度里,变更频率是最容易被忽略、也最有效的一条。原因很直白:模块化的收益主要来自"改得多的东西不要连带改得少的东西一起重编"。如果按功能切,一个高频迭代的页面和一个三年不动的工具类被塞进同一个模块,那种"我改的是新功能,却要重编一堆老代码"的痛就不会消失------模块拆了,编译等待却还在。按变更频率切,等于把"热的"和"冷的"物理隔离,编译收益立刻兑现。

落到依赖分层,就是这张图:

flowchart TD L4["L4 宿主壳 启动 登录 升级 插件装载"] B1["L3 业务模块 A"] B2["L3 业务模块 B"] L2["L2 能力层 地图 设备 坐标 语音 导出"] L1["L1 基础层 工具 UI 数据库 网络 切面"] L4 --> B1 L4 --> B2 L4 --> L2 B1 --> L1 B2 --> L1 L2 --> L1 B1 -.->|"禁止平级互调"| B2

规则的唯一硬性约束:依赖只能向下,且不能跨层。 L3 的两个业务模块之间不允许 implementation project(...) 互相依赖------这条规则直接决定了后面要讲的通信方案。

为什么"向下"这么重要?因为 Gradle 的 implementation 是一条单向的编译期绳子:B 依赖 A,意味着 A 的任何签名变化都会触发 B 重编,A 依赖的三方库也会悄悄进入 B 的编译类路径。允许平级互相依赖,等于允许两条绳子任意打结------今天 A 调 B,明天 B 调 A,一个月后没人说得清改一个方法会波及多少模块。向下分层把"波及范围"限定成了一个可以人力推演的量。

宿主壳要"薄"到什么程度

宿主壳是这套架构里最容易被写胖的地方。一个健康的宿主只保留四类东西:

  1. 进程级初始化:Application(含热修复代理)、崩溃、多进程守卫;
  2. 插件装载与路由:插件安装、版本校验、启动分发;
  3. 横切能力:登录态、账号、支付、升级------这些是所有业务都要用、但都不该由某个业务提供的;
  4. 品牌配置:决定这一版要装配哪些业务模块。

判断某段代码该不该进宿主,问一句:"如果某个品牌不要这个功能,我是删一个模块依赖,还是改宿主代码?" 答案是后者,就说明它放错地方了。

用 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")

代价:这是最诱人也最致命的一条。

  1. 依赖传递污染 :lib-device 依赖了蓝牙、串口、网络、定位、数据库等一堆库,一旦被业务模块直接依赖,这些依赖全部进入它的编译类路径。两个业务模块依赖了同一个库的不同版本,Gradle 解析冲突时只会静默选一个,线上表现为"某个功能在 A 品牌正常、B 品牌崩"。
  2. 稳定性绑定:设备模块里有大量 JNI 与硬件交互,native 崩溃会直接把整个主进程带走。直接依赖意味着没有任何隔离边界。
  3. 编译放大:改设备模块一行,二十来个业务模块全部重编。

第 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 线程池,以及进程被杀后的重连:

sequenceDiagram participant Biz as 业务模块 participant Client as DeviceClient participant Svc as 设备服务进程 Biz->>Client: bind() Client->>Svc: bindService 绑定服务 Svc-->>Client: onServiceConnected 返回 IBinder Client->>Svc: registerCallback 注册回调对象 Svc-->>Client: onStateChanged 落在 Binder 线程池 Note over Client: 必须切回主线程再操作 View Note over Svc: 客户端进程被杀 回调需自动清理 Svc-->>Client: onServiceDisconnected 进程被杀 Client->>Client: 指数退避 延迟后重连

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() 崩在启动时

到这里,模块之间"怎么说话"有了答案。但二十多个业务模块,除了通信,还有另一个更日常的问题:它们做的事情高度相似,怎么既长得一样、又能各自不同?

四、能力边界:二十多个模块怎么长得一样又各自不同

这个工程里有二十来个业务模块,它们做的事情本质上高度相似:

  1. 占满一屏(地图 + 顶部信息 + 底部操作 + 右侧工具栏);
  2. 连接设备,接收实时数据;
  3. 展示实时计算结果;
  4. 提供一组工具栏按钮,点击触发业务动作;
  5. 能存取点位、能响应语音指令、能响应工程切换。

但每个模块又有差异:有的要罗盘、有的要图表、有的要相机预览、有的不需要工具栏、有的不支持语音。

如果各写各的,结果是二十份 80% 重复、20% 走样的代码------而这 20% 的走样就是 Bug 温床(改一个公共 Bug 要改二十处,还总漏)。

工程里最终的解法是两个经典模式的组合:接口能力组合 (把"这模块有哪些能力"拆成一组角色接口再拼起来)加模板方法(把"必须按顺序执行"的流程锁在基类里)。

classDiagram class IProjectAware { +onProjectOpen() +onProjectClose() } class IMapAware { +getDefaultMapType() +enableBoxSelect() } class IDeviceAware { +onDeviceStateChanged() +onPositionUpdated() } class IVoiceAware { +supportVoiceControl() +handleVoiceCommand() } class WorkMode { +setWorkContext() +onFileChooseBack() } class BaseWorkMode { <<abstract>> +createHeader() +onStart() } WorkMode --|> IProjectAware WorkMode --|> IMapAware WorkMode --|> IDeviceAware WorkMode --|> IVoiceAware BaseWorkMode ..|> WorkMode

图里有两个刻意的设计:总接口 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)
}

关键点有三个:

  1. 每个方法都给默认实现 (Kotlin 接口默认方法)。新增能力时,老模块零改动------这是这套设计能撑住二十多个模块的原因。
  2. 接口按"角色"划分,不按"功能"划分。判断标准是"这个能力会不会被某些模块裁掉"。
  3. 总接口只做组合,不定义新方法,避免它自己变成上帝接口。

第 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()
    }
}

三个细节值得抄:

  1. @CallSuper :子类重写时编译器强制调用 super,把"忘了调父类"从运行时问题变成编译期问题。
  2. createXxx() 工厂方法而非 initXxx() 钩子:由基类决定"什么时候创建、创建后做什么",子类只负责"造什么"。子类的自由度被限制在安全范围内。
  3. 生命周期事件由基类统一派发 :子类永远不要自己调 handleLifecycleEvent,避免出现"子类先 DESTROY、父类后 RESUME"的乱序。

这三个细节指向同一个思路:把易错的"时序"收进基类,把安全的"选择"留给子类。 基类对子类的授权边界画得越窄,二十多个模块行为一致的概率就越高------这也是这条能力边界唯一的守则。

这套设计真实遇到的两个麻烦

麻烦 1:模式切换时的"监听器错乱"

有个业务模块由于历史原因,没有拆成两个 WorkMode,而是在同一个对象里用布尔值区分"采集/放样"两种状态。源码注释里写得很诚实:

不得已进行的兼容:如果按既定框架多增加一个 XxxWorkMode,会遇到两个问题------

  1. 切换入口拿不到新类型,需要反射判断;
  2. 即便反射判断成功,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 里用它,但它的本质是:

一个可以分发状态、可以注册观察者、可以在销毁时自动清理的状态机。

任何满足下面三个条件的"东西",都值得拥有自己的生命周期:

  1. 它有明确的创建与销毁时机,但这个时机不是 Activity/Fragment 的时机;
  2. 它内部持有了需要被清理的资源(协程、监听、传感器、渲染器);
  3. 它想把"清理"这件事自动化,而不是靠调用方记得调 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)
    }
})

这一段的价值在于:"别忘了反注册"从一条人需要记住的规则,变成了一个自动行为。

内部的状态机可以用这张图理解,它同时解释了后面几个坑为什么存在:

stateDiagram-v2 [*] --> CREATED CREATED : 已创建 可注册观察者 CREATED --> STARTED : ON_START STARTED : 可见 可启动协程 STARTED --> RESUMED : ON_RESUME RESUMED : 前台 可操作视图 RESUMED --> STARTED : ON_PAUSE STARTED --> CREATED : ON_STOP CREATED --> DESTROYED : ON_DESTROY DESTROYED : 终态 不可复用 DESTROYED --> [*]

注意图里 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。

flowchart TD M[&#34;Manifest 注册 .HostApplication&#34;] --> G[&#34;注解处理器编译期生成 HostApplication&#34;] G --> T[&#34;HostApplication 继承 TinkerApplication&#34;] T -->|&#34;运行时委托&#34;| L[&#34;HotFixApplicationLike 真正写逻辑的地方&#34;]

由此产生三个必须遵守的约定:

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 之前):

flowchart TD A[&#34;进程启动&#34;] --> B[&#34;ContentProvider onCreate 早于 Application&#34;] B --> C[&#34;Application attachBaseContext&#34;] C --> D[&#34;ApplicationLike onBaseContextAttached&#34;] D --> E[&#34;MultiDex.install 展开所有 dex&#34;] E --> F[&#34;HotFixManager.initHotFix 装补丁&#34;] F --> G[&#34;System.setProperty 注入 XML 解析实现&#34;] G --> H[&#34;Application onCreate&#34;] H --> I[&#34;判当前进程名&#34;] I --> J[&#34;主进程 崩溃 统计 云服务 路由&#34;] I --> K[&#34;非主进程 直接 return&#34;]

图里从 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 做三类拦截》。

你现在的工程里,哪一个模块是所有人都不太敢动的那一个?它难动,是因为代码复杂,还是因为没人说得清它依赖了谁、又被谁依赖着?

相关推荐
mmsx1 小时前
Android 不改一行源码:用 AspectJ 做三类拦截
android·kotlin
TST-魚爷1 小时前
易视讯会议摄像头 + 全向麦克风实操讲解稿
android·电脑·钉钉·腾讯会议·zoom会议
嘟哩DuliDuli1 小时前
AI创作工作区的项目上下文与多模态任务边界
android·人工智能·安全·ai·软件工程
imDwAaY3 小时前
MySQL 存储引擎是什么?
android·数据库·mysql
熊猫钓鱼>_>4 小时前
开源鸿蒙平台 KMP 三方库 kotlin-csv 适配全流程:从编码假设到沙箱真机互通验证
开发语言·人工智能·华为·kotlin·harmonyos·鸿蒙
Meteors.4 小时前
Android Compose学习02:基础组件与 Modifier
android·学习
其实防守也摸鱼5 小时前
存储管理:页面置换算法学习任务(测验题要求给出置换页面顺序,缺页次数,缺页率)
android·前端·数据库·学习·oracle·自动化
mmsx6 小时前
Android 地图上绘制 KML:解析、样式与渲染
android·xml
运行时异常7 小时前
【无标题】
android