模块边界定义与职责
模块边界是架构设计的核心骨架。边界不清晰时,调用链会无规则地穿越模块内部,导致主线程阻塞难以定位,ANR 治理沦为“打地鼠”游戏。本章从划分原则、契约机制、接口抽象、依赖倒置四个维度,构建可落地的模块边界体系,使其能承载 ANR 调用链的追踪与阻断需求。
4.1 模块划分原则
模块划分决定了 ANR 调用链的可追踪粒度。原则如下:
- 单一职责:一个模块只承担一个业务或技术关注点,避免“上帝模块”拖慢主线程。
- 按线程域分层:UI 模块、工作线程模块、后台任务模块严格分离,阻断阻塞传播。
- 按变化速率切分:业务变化快的模块与基础设施稳定模块分属不同边界,降低回归风险。
- 高内聚低耦合:模块内部高度相关,对外暴露最小依赖,减少调用链穿透深度。
实践准则:每个模块的对外可见类型不应超过 5 个;超过时说明边界过宽,需要拆分子模块。
4.2 边界契约
边界契约是模块间协作的“法律文件”。它定义了三要素:
| 契约要素 | 说明 | ANR 关联 |
|---|---|---|
| 接口定义 | 方法签名、参数类型、返回值、异常声明 | 明确线程归属,标注 @MainThread / @WorkerThread |
| 数据格式 | 传输对象的字段、序列化方式、空值策略 | 减少跨线程数据拷贝导致的卡顿 |
| 行为约束 | 超时阈值、重试策略、幂等性、降级开关 | 防止长时间等待触发 ANR |
契约版本化:采用
@Contract(version = 1) 注解标记接口版本,变更时强制走兼容性评估,避免调用方未适配导致运行时异常。
4.3 接口抽象
接口抽象是隔离变化、阻断 ANR 传播的关键手段。核心设计原则:
- 面向接口编程:调用方只依赖抽象,不依赖具体实现类。
- 接口隔离:客户端不应依赖它不需要的方法;胖接口拆分为多个细粒度接口。
- SPI / API 分离:服务提供接口(SPI)供实现方继承,应用编程接口(API)供调用方使用,两类接口分属不同包路径。
// 边界契约示例:异步任务执行器接口
public interface TaskExecutor {
@WorkerThread
void execute(@NonNull Runnable task,
@Nullable TaskCallback callback);
boolean isCurrentThreadWorker();
long getMaxExecutionTimeMs();
}
抽象泄漏陷阱:接口中包含具体实现类的依赖(如直接使用 OkHttp 的 Request 类型)会导致底层变更波及所有调用方。接口参数和返回值必须使用抽象类型或纯数据对象。
4.4 依赖倒置
依赖倒置原则(DIP)是模块边界解耦的底层保障。其核心含义是:
- 高层模块不依赖低层模块:两者都依赖抽象接口。
- 抽象不依赖细节:细节依赖抽象。
在 Android 架构中,这意味着 ViewModel 不直接依赖 OkHttp 或 Room,而是依赖 UserRepository 接口;具体实现通过依赖注入框架(如 Hilt)在模块初始化时提供。
// 依赖倒置:ViewModel 只依赖抽象
public class HomeViewModel extends ViewModel {
private final UserRepository userRepo;
@Inject
public HomeViewModel(UserRepository userRepo) {
this.userRepo = userRepo; // 运行时注入具体实现
}
}
对 ANR 的价值:依赖倒置让主线程不再直接绑定耗时实现。替换或降级底层模块时,调用方无需修改,可快速切换为异步方案或熔断回退。
4.5 数据流 / 控制流 / 信任链路
模块边界内三种流的设计决定了 ANR 调用链的可观测性:
| 流类型 | 定义 | 边界策略 |
|---|---|---|
| 数据流 | 跨模块传输的 DTO / VO / 事件 | 使用不可变对象,限制数据体积,避免主线程序列化 |
| 控制流 | 调用发起、回调、异步消息的传递方向 | 明确标注线程归属,回调必须超时兜底 |
| 信任链路 | 模块间安全信任假设与降级条件 | 信任边界处设置校验、熔断、降级开关 |
实践建议:在模块边界设置统一的“流量仪表盘”——通过 AOP 切面记录每次跨边界调用的耗时、线程 ID、调用堆栈,为 ANR 调用链分析提供精准数据。
4.6 选型权衡
模块边界的具体实现需要根据项目阶段和团队规模做出选择:
- 接口 vs 消息队列:同步接口适合低延迟、强一致场景;消息队列适合解耦、削峰场景,但增加异步追踪复杂度。
- 编译期依赖 vs 运行时路由:编译期依赖(Java 接口)类型安全、重构成本低;运行时路由(ARouter / 事件总线)更灵活,但调用链隐式,不易追踪。
- 集中式 DI 容器 vs 手动注入:大型项目推荐 Hilt / Dagger 统一管理依赖;小型项目手动注入更轻量,避免编译期开销。
过度设计警示:不要为每一个内部类都加抽象接口。边界只存在于模块之间;模块内部的类直接依赖即可,过早抽象会增加维护成本和调用链长度。
4.7 风险点
- 循环依赖:模块 A 依赖 B,B 依赖 A 导致编译失败或运行时栈溢出。必须通过引入抽象层或事件总线打破循环。
- 边界泄漏:实现类被错误地暴露给外部模块,导致调用方直接依赖具体实现,绕过契约。
- 抽象泄漏:接口中抛出底层异常(如 SQLException)或使用底层类型,导致调用方感知实现细节。
- 信任链断裂:跨模块调用时未做降级处理,底层故障直接导致上层 ANR。
4.8 落地步骤
| 步骤 | 动作 | 产出物 |
|---|---|---|
| Step 1 | 识别现有模块边界,绘制模块依赖图(推荐使用 ArchUnit 验证) | 模块依赖矩阵 + 反向依赖报告 |
| Step 2 | 定义边界契约:接口声明 + 超时 / 线程 / 数据格式约束 | 契约定义文档 + 注解标记 |
| Step 3 | 抽取抽象接口,将跨模块调用替换为接口依赖 | 抽象接口列表 + 依赖倒置改造 commit |
| Step 4 | 实现依赖注入或服务注册,确保运行时实现可替换 | DI 模块配置 + 降级策略实现 |
| Step 5 | 添加边界测试(契约测试 + 线程归属检测) | 自动化测试套件 + 准入 gate |
4.9 验收标准
- 依赖方向验证:使用 ArchUnit 检查所有模块依赖方向与架构图一致,不存在反向依赖或循环依赖。
- 接口稳定性:契约接口在 2 个迭代周期内无破坏性变更(新增方法不破坏兼容性)。
- 边界测试通过率 100%:所有跨模块调用的契约测试、线程归属测试、超时熔断测试全部通过。
- 调用链可追踪:在模块边界处打印跨模块调用摘要日志,可在 5 分钟内定位到具体模块及调用堆栈。
最终检查清单:(1)每个模块对外暴露类型 ≤ 5;
(2)跨模块引用均为接口类型,无直接类引用;
(3)所有跨模块调用都有超时降级方案;
(4)模块依赖图无环。
(2)跨模块引用均为接口类型,无直接类引用;
(3)所有跨模块调用都有超时降级方案;
(4)模块依赖图无环。