RearProjectionModeTabController 深度拆解
源码:
settingsPage/micarDisplaySettings/src/main/java/com/android/car/settings/miauto/display/RearProjectionModeTabController.kt父类:base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/BaseVehicleImageTabPreferenceController.kt
一、一句话定位
后排投影幕布「收起 / 投影模式 / 屏风模式」三种模式的核心控制器。它本身不绘制 UI,只做车辆信号 ↔ Tab的双向翻译:用户点 Tab → 下发命令信号;车辆状态回包 → 刷新 Tab 选中态/可用态/loading 态;并负责各种异常态(故障、防夹、座椅联动失败)的 toast 提示。
二、继承链与定位
classDiagram class CarPropertyMgrPreferenceController { <<车辆信号管理>> +getProperty()/getIntProperty() +setVehicleProperty() #registerPropertyCallback() #areaId } class BaseVehicleImageTabPreferenceController { <<abstract 模板方法>> +buildTabData()* +propValToTabIndex()* +setCarProperty()* +getTabPropId()* #updateTabByPropertyChange() #notifyUIByPropertyChange() #onHandlePropertyChange() onHandlePropertyManagerPrepared() } class RearProjectionModeTabController { 后排投影幕布 收起 / 投影模式 / 屏风模式 上下行信号分离 } CarPropertyMgrPreferenceController <|-- BaseVehicleImageTabPreferenceController BaseVehicleImageTabPreferenceController <|-- RearProjectionModeTabController
模板方法模式:基类 BaseVehicleImageTabPreferenceController 把「Tab 初始化、信号注册、点击埋点、语音可见即可说、车模联动」全部定死成骨架,子类只需填空几个钩子(buildTabData / propValToTabIndex / setCarProperty / getTabPropId 等)。所以读本类,本质是读它对基类模板做了哪些定制。
父类三个核心模板方法详见本目录
03-基类模板.md(待汇总时填充)。
三、核心设计:上下行信号分离 ⭐
类头注释(RearProjectionModeTabController.kt:20-23)开宗明义:
上行监听状态信号
PROJECTION_SCREEN_STATUS,下发使用命令信号SCREEN_CONTROL_COMMAND
这是本类最关键的设计决策——很多同类控制器上下行共用一个信号,但本类故意拆开:
| 方向 | 信号 ID | 用途 | 取值范围 |
|---|---|---|---|
| ⬆️ 上行(读) | PROJECTION_SCREEN_STATUS | 反映幕布当前真实状态,驱动 Tab 选中态 | OFF / FOLD_TO_OFF / PROJECTION_STATUS / SCREEN_STATUS / 各种过渡态(共 8 种) |
| ⬇️ 下行(写) | SCREEN_CONTROL_COMMAND | 用户点击时下发指令 | CLOSE / PROJECTION / SCREEN(仅 3 个目标态) |
为什么拆? 状态信号有 8 种值(含展开/折叠/悬浮的过渡态),命令只有 3 个目标态。用状态信号去写命令会丢信息,也容易误写过渡态。
为配合这套拆分,本类 override 了三个相关方法(:84-94):
flowchart LR A["obtainSetPropertyId()"] -->|"返回"| CMD["SCREEN_CONTROL_COMMAND"] B["getTabPropId()"] -->|"返回"| STS["PROJECTION_SCREEN_STATUS"] C["setCarProperty(tab)"] -->|"下发"| CMD D["基类 areaId 校验"] -->|"用 getTabPropId"| STS
obtainSetPropertyId()(:84-86):返回SCREEN_CONTROL_COMMAND,供基类changeCarModelWhenClick等场景使用。getTabPropId()(:92-94):返回PROJECTION_SCREEN_STATUS,既用于上行状态匹配,也用于基类onHandlePropertyChange的 areaId 校验(父类:118)。setCarProperty(tab)(:99-111):重写下发逻辑,用命令信号而非状态信号。
四、方法逐个拆解
4.1 onCreateInternal —— 初始化标题图标(:31-35)
override fun onCreateInternal() {
super.onCreateInternal()
preference.setTitleIconResId(R.drawable.micar_ic_exclamation_point)
preference.setTabItemImageHeight(context.resources.getDimensionPixelSize(R.dimen.micar_basic_dp_156))
}- 标题栏右侧放一个感叹号图标(点击弹说明)。
- 把 Tab 图片高度设为 156dp(
micar_basic_dp_156),控制投影示意图的显示尺寸。
4.2 buildTabData —— 三个 Tab 的定义(:37-58)
构造收起/投影模式/屏风模式三个 CarUiTabLayout.Tab,每个都绑定了图标(withIconRes)、背景(withBgRes)、缩放方式(CENTER_INSIDE)和命令值(setData)。
flowchart LR subgraph Tab定义 T0["Tab 0 收起<br/>icon: rear_projection_close<br/>data: ScreenControlCommand.CLOSE"] T1["Tab 1 投影模式<br/>icon: rear_projection_mode<br/>data: ScreenControlCommand.PROJECTION"] T2["Tab 2 屏风模式<br/>icon: rear_projection_screen<br/>data: ScreenControlCommand.SCREEN"] end
关键点:三个 Tab 都 .setAllowClickWhenDisabled(true)(:44,50,56)——即使 Tab 被禁用(灰色),用户依然能点,点击会走 onDisabledTabClicked 弹出”为什么不可用”的 toast。这是良好的可发现性设计。
4.3 propValToTabIndex —— 状态值→Tab 索引映射(:60-74)
把上行状态信号的 8 种值压成 3 个 Tab + 1 个”无对应”(-1):
| 状态值 | → Tab | 含义 |
|---|---|---|
OFF、FOLD_TO_OFF | 0 | 收起态 / 正在折叠到收起 |
UNFOLD_TO_PROJECTION_STATUS、PROJECTION_STATUS | 1 | 投影态 / 正在展开到投影 |
SCREEN_STATUS、UNFOLD_TO_SCREEN_STATUS | 2 | 屏风态 / 正在展开到屏风 |
UNFOLD_TO_SUSPENSION_STATUS、FOLD_TO_SUSPENSION_STATUS | -1 | 悬浮过渡态,无对应 Tab |
返回 -1 时,基类 updateTabByPropertyChange(父类 :156-160)的边界检查会让所有 Tab 都不被选中——这正是”幕布正在收起到悬浮态”该有的表现:没有 Tab 高亮。
4.4 getPropertyIdSet —— 主信号集合(:76-78)
override fun getPropertyIdSet() = ArraySet<Int>().apply {
add(MiCarPropertyIds.Display.PROJECTION_SCREEN_STATUS)
}只包含主状态信号。这是 Controller 的”主信号”,基类用它做初始化读取、Tab 选中态驱动。
4.5 setCarProperty —— 下发命令(:99-111)
override fun setCarProperty(tab: CarUiTabLayout.Tab<*>) {
val aId = areaId
...
setVehicleProperty(
MiCarPropertyIds.Display.SCREEN_CONTROL_COMMAND,
getPropValueByTab(tab), // 默认取 tab.data as Int,即 CLOSE/PROJECTION/SCREEN
aId
)
}点击 Tab 时由基类 onSelectTabChanged(父类 :217)调用,下发 SCREEN_CONTROL_COMMAND + 命令值 + areaId。getPropValueByTab 用基类默认实现(父类 :182-184),直接取 tab.data。
4.6 getObservedPropertyIdSet —— 扩展监听集合(:116-123)
在主信号基础上额外监听 5 个信号:
flowchart TB OBS["getObservedPropertyIdSet()"] OBS --> M["PROJECTION_SCREEN_STATUS (主状态)"] OBS --> D1["OFF_MODE_NOT_AVAILABLE_REASON"] OBS --> D2["PROJECTOR_NOT_AVAILABLE_REASON"] OBS --> D3["SCREEN_NOT_AVAILABLE_REASON"] OBS --> T1["TEXT_TO_SPEECH_FOR_ANTI_COLLISION_OR_ANTI_PINCH"] OBS --> T2["TEXT_TO_SPEECH_FOR_SEAT_LINKAGE_FAILED"]
基类默认 getObservedPropertyIdSet = getPropertyIdSet(父类 :199),这里扩展了 3 个不可用原因信号 + 2 个 TTS 信号。所有这些信号的任何变化都会触发 onHandlePropertyChange。
4.7 isTabDependencyAvailable —— 依赖信号决定可用性(:139-162)⭐
每个 Tab 是否可点,不取决于主状态信号,而是取决于各自独立的”不可用原因”信号:
flowchart LR subgraph 判定逻辑["判定逻辑: 信号 available 且 value==INVALID → 可用"] R0["OFF_MODE_NOT_AVAILABLE_REASON"] -->|"INVALID"| 可点关闭Tab R1["PROJECTOR_NOT_AVAILABLE_REASON"] -->|"INVALID"| 可点投影Tab R2["SCREEN_NOT_AVAILABLE_REASON"] -->|"INVALID"| 可点屏风模式Tab end
逻辑统一:VehiclePropertyUtil.isPropertyAvailable(信号) && 信号.value == INVALID → Tab 可用;否则禁用(:146,152,158)。
- 投影/屏风共用
ProjectorNotAvailableReason枚举(INVALID判定)。 - 关闭用
ProjectionCommandExecutionFailedReason枚举(INVALID判定)。
INVALID表示”无不可用原因”,即一切正常。
4.8 onHandlePropertyChange —— 信号变化统一处理(:164-206)⭐
本类逻辑最密集的方法,做三件事:
sequenceDiagram participant V as 车辆信号 participant C as Controller participant U as UI/Toast V->>C: 依赖信号(NOT_AVAILABLE_REASON)变化 Note over C: super 之前: 用主信号缓存值刷新 Tab 可用性 C->>U: preference.notifyDataChanged() C->>C: super.onHandlePropertyChange() (基类处理主信号选中态) V->>C: 主信号 = 过渡态(FOLD_TO_OFF 等) C->>U: toast "幕布正在调整中" V->>C: 防夹 TTS 信号 = REQUEST C->>U: toast "触发防碰撞/防夹" V->>C: 座椅联动失败 TTS = REQUEST C->>U: toast "座椅联动失败"
第一个分支的设计精妙处(:164-181):依赖信号变化时,基类 notifyUIByPropertyChange(父类 :135-149)对非主信号默认 notifyChanged=false,不刷新 UI。所以子类抢在 super 之前主动读主信号缓存值 getProperty(PROJECTION_SCREEN_STATUS),重新跑一遍 updateTabByPropertyChange,刷新三个 Tab 的可用态。这是对基类”只认主信号”行为的补丁。
第二分支(:183-192):主信号处于任意过渡态时,弹”正在调整中” toast。
4.9 onDisabledTabClicked → showDisableReason —— 点击灰 Tab 的原因提示(:134-136, 211-247)
因为 Tab 设了 allowClickWhenDisabled,禁用时也能点。showDisableReason 根据 Tab 找到对应不可用原因信号,按枚举值弹具体 toast:
| 不可用原因枚举值 | toast 文案(string name) |
|---|---|
PROJECTION_SYSTEM_FAILURE | ..._disable_reason_system_failure |
BED_BOARD_FOLD | ..._disable_reason_bed_board_fold |
SPACE_IS_NOT_ENOUGH | ..._disable_reason_space_not_enough |
4.10 onTitleIconClicked —— 说明弹窗(:125-129)
点击标题栏感叹号,弹出自动消失的说明对话框,文案 settings_display_rear_projection_dialog_description。给用户解释”这个设置是干嘛的”。
4.11 isTabProcessing —— 中间态/loading(:249-258)
主信号处于过渡态时返回 true。基类 updateTabByPropertyChange(父类 :168)据此给选中 Tab 打 isProcessing=true,UI 上表现为 loading 转圈,等回包到稳态后自然清除。
配合基类 onSelectTabChanged(父类 :211-215):用户点击瞬间立即给该 Tab 打 isProcessing 做即时反馈,状态信号回包后由本方法接管判断是否还在转。
五、关键交互场景:用户点击「投影」Tab
sequenceDiagram autonumber participant U as 用户 participant T as Tab UI participant Base as 基类模板 participant Ctrl as RearProjectionModeTabController participant Car as 车辆信号层 U->>T: 点击「投影」Tab T->>Base: onSelectTabChanged(projectionTab) Base->>Base: 投影Tab.isProcessing=true(即时loading) Base->>Ctrl: setCarProperty(tab) Ctrl->>Car: setProperty(SCREEN_CONTROL_COMMAND, PROJECTION, areaId) Note over Car: 幕布开始展开... Car-->>Ctrl: PROJECTION_SCREEN_STATUS = UNFOLD_TO_PROJECTION_STATUS(过渡态) Ctrl->>Ctrl: onHandlePropertyChange: 弹"正在调整中"toast Ctrl->>Ctrl: isTabProcessing=true (保持loading) Car-->>Ctrl: PROJECTION_SCREEN_STATUS = PROJECTION_STATUS(稳态) Ctrl->>Ctrl: propValToTabIndex→1, isTabProcessing→false Ctrl->>T: 投影Tab 选中高亮, loading消失
六、设计要点总结
| 维度 | 做法 | 设计意图 |
|---|---|---|
| 上下行信号 | 状态读(PROJECTION_SCREEN_STATUS)+ 命令写(SCREEN_CONTROL_COMMAND)分离 | 状态有 8 种过渡态,命令只有 3 个目标态,拆开避免误写 |
| 可用性判定 | 每个 Tab 配独立”不可用原因”信号 | 精确反映各模式各自的故障(如床板折叠时投影不可用) |
| 禁用 Tab | allowClickWhenDisabled=true + 点击弹原因 | 可发现性——用户知道”为什么不能点”而非一脸懵 |
| 过渡态 | isProcessing + “调整中” toast | 全链路可感知,避免用户以为卡死 |
| 依赖信号刷新 | super 之前主动读主信号缓存重算 | 补基类”只认主信号”的缺口 |
| 异常 TTS | 监听防夹/座椅联动失败信号弹 toast | 安全相关事件主动告知用户 |
本类的角色:UI 渲染交给
ImageTabPreference;信号收发底层交给SettingsCarPropertyManager;埋点/语音描述交给基类。本类只做”信号↔Tab 翻译 + 业务异常处理”,职责单一清晰。