4. 车载应用分层架构设计
车载应用与手机应用最大的差异,不在于界面是否好看,而在于它运行在一个多屏、多信号、多生命周期约束的封闭系统里。仪表、中控、HUD、副驾屏可能同时存在,CAN 信号、车辆属性、音频焦点、电源状态随时变化,应用还要面对开机冷启动、熄火延迟下电、行车限制等特殊场景。如果所有逻辑都堆在 Activity 或 Fragment 里,代码会迅速变成“信号回调 + UI 刷新 + 业务判断”的混合体,既无法测试,也无法在多个车型配置间复用。分层架构要解决的正是这个问题:把变化频率不同、职责不同的代码分开,让上层只依赖抽象,让底层可以替换。
五层职责与依赖方向
本节采用表现层、领域层、数据层、服务层四层主体,加上贯穿其中的跨层通信机制。需要先明确一点:分层不是按“文件夹”分,而是按“依赖方向”分。依赖必须从外向内,即表现层依赖领域层,领域层依赖抽象接口,数据层和服务层实现这些接口。领域层不依赖 Android SDK,也不依赖具体的数据来源,这是整个架构可测试、可替换的关键。
表现层负责 UI 渲染、用户输入、生命周期感知和状态订阅。它只做“把状态画出来、把事件传下去”,不写业务规则。典型对象是 Activity、Fragment、Compose 页面和 ViewModel。ViewModel 持有 UI 状态,通过领域层暴露的 UseCase 触发业务动作。
领域层是业务规则的核心,包含实体、值对象、UseCase 和仓库接口。例如“根据车速决定是否允许打开视频”“根据挡位决定是否显示键盘”,这些规则属于领域层。它用纯 Kotlin 编写,不引用 Context,因此可以在 JVM 单元测试中直接验证。
数据层负责数据的获取、缓存和持久化,实现领域层定义的仓库接口。数据来源可能包括 VehiclePropertyManager、CAN 网关、本地 Room 数据库、远程 TSP 接口。数据层要处理线程切换、缓存策略、数据映射,把原始信号转换成领域模型。
服务层提供跨应用能力,例如车辆属性服务、音频服务、语音服务、账号服务。它通常以 AIDL、Binder 或系统 SDK 形式存在,是应用与整车能力之间的边界。服务层不承载业务规则,只负责能力暴露和权限校验。
跨层通信解决的是“下层如何通知上层”的问题。领域层不能直接持有 UI 引用,因此需要事件流、状态流或消息总线。推荐使用 Kotlin Flow 或 StateFlow,让数据层发出信号变化,领域层转换为业务状态,表现层订阅并渲染。
依赖倒置在车载场景中的落地
依赖倒置不是一句口号,它决定了代码能否在车型配置之间复用。假设我们要实现“行车限制视频播放”这一规则。如果直接在 Activity 里读取车速信号并判断,那么这条规则就绑死在 UI 上,换一个屏幕就要重写。正确做法是:领域层定义 DriveRestrictionRepository 接口,声明 fun observeSpeed(): Flow<Float>;数据层用 CarPropertyManager 实现该接口,把 PERF_VEHICLE_SPEED 转换为 Flow<Float>;领域层再定义 CanPlayVideoUseCase,组合车速和挡位,输出 Boolean 状态;表现层只订阅这个状态并控制播放器。
// 领域层:只依赖接口,不依赖 Android
interface DriveRestrictionRepository {
fun observeSpeed(): Flow<Float>
fun observeGear(): Flow<Gear>
}
class CanPlayVideoUseCase(
private val repo: DriveRestrictionRepository
) {
operator fun invoke(): Flow<Boolean> =
combine(repo.observeSpeed(), repo.observeGear()) { speed, gear ->
speed < 5f && gear == Gear.PARK
}
}
// 数据层:实现接口,处理信号映射
class CarDriveRestrictionRepository(
private val car: Car
) : DriveRestrictionRepository {
override fun observeSpeed(): Flow<Float> =
car.propertyManager
.observeFloatProperty(Car.PERF_VEHICLE_SPEED)
.map { it / 3.6f } // km/h 转 m/s,示例换算
}
这段代码的关键在于:CanPlayVideoUseCase 完全不知道车速来自 CAN 还是 Mock,测试时只需传入一个返回固定值的假仓库即可。数据层负责单位换算、线程切换和异常兜底,领域层只关心“车速小于 5 m/s 且挡位在 P 挡”这一业务语义。
跨层通信:状态流而非回调链
车载场景中,信号变化频繁且来源分散。如果每一层都向上层注册回调,会形成难以追踪的回调链,生命周期稍有不慎就内存泄漏。推荐统一使用 Flow 作为跨层通信契约:数据层发出冷流或热流,领域层用 combine、map、debounce 做业务转换,表现层用 repeatOnLifecycle 订阅。这样每一层只暴露“数据流”,不暴露“谁在监听”,依赖方向始终向内。
需要注意,服务层的 Binder 回调不能直接暴露给表现层。正确做法是在数据层把 Binder 回调桥接为 callbackFlow,并处理 awaitClose 注销监听,避免 Binder 泄漏。对于高频信号(如车速),还应在数据层做采样或去抖,例如 100 ms 节流,防止 UI 过度重组。
模块边界划分与包结构规范
分层最终要落到 Gradle 模块和包结构上。建议按“功能 + 层”双维度划分::app 负责组装和导航;:feature:media、:feature:setting 各自包含自己的表现层和 ViewModel;:domain 存放所有 UseCase 和仓库接口;:data 存放仓库实现和数据源;:service 封装车辆能力。依赖关系必须是单向的:feature → domain ← data,feature → service,禁止 domain 反向依赖 data 或 feature。
| 模块 | 允许依赖 | 禁止依赖 | 典型内容 |
|---|---|---|---|
| :app | 所有 feature、domain、data、service | — | Application、导航图、DI 装配 |
| :feature:* | domain、service、UI 库 | 其他 feature、data 实现 | 页面、ViewModel、UI 状态 |
| :domain | 仅 Kotlin 标准库、协程 | Android SDK、data、feature | UseCase、实体、仓库接口 |
| :data | domain、service、Room、网络库 | feature、UI 库 | 仓库实现、数据源、Mapper |
| :service | Car SDK、AIDL | domain、feature | 车辆属性封装、音频能力 |
包结构上,每个模块内部按 ui、domain、data 再分,但不要为了分层而分层。一个只有两个页面的 feature 模块,不必强行拆出独立 data 包,可以直接依赖 :data 中的仓库实现。判断标准是:当某部分代码需要被多个 feature 复用,或需要独立测试时,才值得提升为独立模块。
dependencies 任务或 ArchUnit 规则,禁止 :domain 出现 android. 开头的 import。这条规则能有效防止分层在迭代中被侵蚀。总结来看,车载应用分层架构的核心不是“画几层框”,而是用依赖倒置把业务规则从平台细节中隔离出来,用 Flow 统一跨层通信方向,用模块边界约束代码的可见性。做到这三点,应用才能在多屏、多车型、多信号源的复杂环境中保持可维护和可测试。