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,既用于上行状态匹配,也用于基类 onHandlePropertyChangeareaId 校验(父类 :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含义
OFFFOLD_TO_OFF0收起态 / 正在折叠到收起
UNFOLD_TO_PROJECTION_STATUSPROJECTION_STATUS1投影态 / 正在展开到投影
SCREEN_STATUSUNFOLD_TO_SCREEN_STATUS2屏风态 / 正在展开到屏风
UNFOLD_TO_SUSPENSION_STATUSFOLD_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 配独立”不可用原因”信号精确反映各模式各自的故障(如床板折叠时投影不可用)
禁用 TaballowClickWhenDisabled=true + 点击弹原因可发现性——用户知道”为什么不能点”而非一脸懵
过渡态isProcessing + “调整中” toast全链路可感知,避免用户以为卡死
依赖信号刷新super 之前主动读主信号缓存重算补基类”只认主信号”的缺口
异常 TTS监听防夹/座椅联动失败信号弹 toast安全相关事件主动告知用户

本类的角色:UI 渲染交给 ImageTabPreference;信号收发底层交给 SettingsCarPropertyManager;埋点/语音描述交给基类。本类只做”信号↔Tab 翻译 + 业务异常处理”,职责单一清晰。