02 · 手势语义与取整机制(核心篇)

能量回收自定义滑块的全部「玄学」都在这一篇:拖到 67 松手就是 67,点一下轨道却跳到 70(Normal 主题),GT 主题点轨道则什么都不发生。三种手势三种语义,由 SliderPreference 里一个计数器(mProgressChangeTimes)+ 组件库 isIgnoreClick() 共同判定。 修复历经 V1(→提测回归)→V2→V3 三轮,本篇描述 V3 最终态。

代码基线:fuben23_micarsettings/MiCarSettings @ rls-xcd-u-mp-26-v7.0 HEAD e8b5a7ba5(V3/PS4 最终内容,同一 Change-Id 的 dev=387202 / rls=387204); 修复前基底 f63fe1cdc 的行号在对比处单独标注。dev 分支同文件有 1~3 行漂移。 核心文件:base/settingsBaseUi/src/main/java/com/android/car/settings/common/SliderPreference.java (下文简称 SliderPreference 不再写全路径)。

1. 产品设计:一个滑块,两种语义(2026-10-13 产品最终裁决)

手势语义出处
拖动 thumb跟手逐值下发,松手停在原值,不取整(拖到 67 就是 67)SLIDER_NORMAL 工作模式(见 §4)
点击轨道(仅 Normal 主题)跳到点击位置后吸附到最近 10 的倍数,且吸附必须可见(thumb+数值标签立即跳);贴 thumb ±1 点击也必须取整(点击数值只能是整十)N801PROB-86420(2025-12-05 关单 By Design)+ 2026-10-13 产品最终裁决(见 §3 末)
点击轨道(GT 主题)不生效:仅按压放大缩小动画,无值变化,不取整组件库 GT 强制 ignoreClick=true(08 篇 §3.4)+ 2026-10-13 产品最终裁决
摸 thumb 不动完全无操作(误触豁免)修复明确的行为(V0 出厂包会被误取整,见 §3)

86420 的边界要说清:它确认的设计是「点击可见地跳到整十位置」——「跳」和「让人看见跳」 是不可拆的整体。2026-07 的 N801PROB-132401(GT 主题下数值与显示不对应)不是对这个设计的 否定,而是它的可见性半边失效了:值被静默改写、标签滞留旧值。修复不是移除取整,而是 恢复「可见」+ 收窄触发面 + GT 主题按裁决关闭取整。完整复盘与三轮修复史见 05-排障实战-N801PROB-132401。

2. 手势状态机:按下 → 移动 → 松手

所有手势事件都进 SliderPreference.mSliderChangeListener(:166-241):

按下   onStartTrackingTouch(:191-199)
         mIsTrackingTouch = true            (:194)
         mOriginProgress = fusionSlider.getValue()   (:195,记下起始值)

移动   onValueChange(slider, value, fromUser)(:202-240)
         ① 非取整模式的同值过滤:mSliderValue == 新值 && !mIsRoundUpWhenClick → return(:205-208)
         ② mOriginProgress = Float.MIN_VALUE(:209,任何一次变值回调都清掉起始值,
            防止来回拖动落回起点时漏掉最后一次回调)
         ③ 置灰保护:!isEnabled() && fromUser → return(:210-220,注释解释:置灰与 setValue
            都走 notifyChanged(),rebind 前 slider 未置灰仍会回调 fromUser,不下发是为了
            让 UI 停在信号设定的位置而非手的位置)
         ④ 取整分支(mIsRoundUpWhenClick=true,:221-236)——V2 起「先计数、后下发」:
              if (mSliderValue != getIntSliderValue(value)) → mProgressChangeTimes++(:229-231)
                  —— 只有整数值真实变化才计数;按下 thumb 瞬间的同值回调不计
                  —— ★必须在下发之前:下发链 syncValueInternal→setValueInternal 会把
                     mSliderValue 覆盖为当前值(:933-934),置后比较恒同值 → 计数恒 1 →
                     ==1 门恒真 → 任意拖动松手必取整(V1 回归,见 05 篇 §9.2)
              if (mProgressChangeTimes > 1) → onSliderProgressChanged(下发)(:234-235)
                  —— 「首个变值回调(times=1)不下发」,沿用原「首次点击不下发」语义,
                     避免点击轨道时先泄漏一帧原值;times>1 起跟手下发
         ⑤ 非取整分支:直接 onSliderProgressChanged(:237-238)

松手   onStopTrackingTouch(:169-188)——固定四步,顺序不可换:
         a. mIsTrackingTouch = false              (:172)
         b. alignAdjustProgress(fusionSlider)     (:173,取整判定,见 §3)——无条件调用
         c. onSliderStopTrackingTouch(fusionSlider)(:174,兜底下发,见 §5)
         d. 复位:mOriginProgress = MIN、mProgressChangeTimes = 0(:175-176)

关键计数器语义:

  • MAX_PROGRESS_CHANGE_TIMES = 1(:81,注释:「判断是否静止时进度最大改变次数」)—— 「这次触摸是不是一次单纯点击」的判定门
  • mProgressChangeTimes(:139)只在取整模式下计数(:229-231);非取整模式根本不计
  • 三个能量回收 controller 都开了取整:setValueRoundUp(true) (BrakingStrengthPreferenceController.java:112、CustomEnergyRecoveryStrengthController.kt:58、 MainCustomEnergyRecoveryStrengthController.kt:60);粒度 mRoundUpStep = 10(:143, setValueRoundUp javadoc :505-509 写明「默认取整粒度为10」),无人改默认值

3. 取整判定表:三道门,缺一不可(V3 最终态)

alignAdjustProgress()(:336-362)在松手时执行,V3 取整门(:345-346):

// SliderPreference.java:345-346(V3/PS4 最终)
if (mIsRoundUpWhenClick && !slider.isIgnoreClick()
        && mProgressChangeTimes == MAX_PROGRESS_CHANGE_TIMES) {

三个条件:① controller 开了取整(86420 需求);② 组件库非 ignoreClick——GT 主题 组件库强制 ignoreClick=true(点轨道不跳值、只能拖 thumb,08 篇 §3.4),V3 起 GT 一律 不取整;isIgnoreClick() 经 IFusionSlider.kt:54(默认 false)→ MiCarIconSlider.kt:64 / MiCarSlider.kt:40 override 透传组件库真实值; ③ 恰好 1 次真实 int 位移(点击轨道的指纹)。

判定面演进(V0 出厂 → V1 → V3 最终):

手势(当前值 67)真实 int 变值V0 出厂(门 <=1,无同值过滤,计数后置)V1(PS1:同值过滤+==1,计数仍后置)V3 最终(PS4:==1 + 计数前置 + !isIgnoreClick)
摸 thumb 不动0count=1(同值回调计入),命中 → 67 被静默改写成 70,标签滞留 67(=N801PROB-132401 原案)count=0,不命中 → 不取整同 V1,不取整 ✅
点轨道 79(Normal)1count=1,命中 → 79→80,标签滞留 79(旧 bug 半面)count=1,命中 → 80,标签立即跟随,但先泄漏一帧 79count=1,命中 → 先 setValue 再刷新,无泄漏帧,标签立即 80 ✅
点轨道(GT)0(手势不存在:库把 DOWN 平移到当前值,不派发)同「摸 thumb」→ 误取整微抖跨 int 边界时 count=1 → 偶发吸附跳变(第三轮「点击抖动」)isIgnoreClick() 门关闭取整,点按永不跳值 ✅
正常拖动(如 30→67,多值)≥2count>1,不命中 → 不取整计数被下发覆盖锁死在 1,==1 恒真 → 松手必取整(V1 回归,05 篇 §9.2)count>1,不命中 → 不取整 ✅
摸 thumb + 只拖 1 格(67→68)1count=2(同值计入),不命中 → 不取整count=1,命中 → 取整 68→70Normal:同 V1,取整(产品裁决:贴 thumb ±1 点击也必须取整);GT:不取整

产品最终裁决(2026-10-13):GT 主题只支持拖动、点击不生效(仅按压动画,无值变化), 取整彻底关闭;普通主题拖动随手变化 + 点击定位到手位置 + 数值只能固定整十, 贴 thumb ±1 点击也必须取整。该边界否决了「净位移」判别方案(±1 会被净零位移判掉, 与需求冲突),选定 isIgnoreClick 主题门控——即 PS4 内容。详见 05 篇 §9.4。

修复 commit(同一 Change-Id I4a34b69c3943fb0574e6fca89b181850db0e766e, Gerrit dev=387202 / rls=387204,查询时点 2026-10-13 均为 NEW; 本地 rls commit e8b5a7ba5,基底 f63fe1cdc)累计四点: ① onValueChange 计数加 mSliderValue != getIntSliderValue(value) 条件且前置到下发之前 (:229-231);② 下发门 >0→>1(:234,保持首个变值不下发);③ alignAdjustProgress 取整门 <=1 → ==1 且加 !slider.isIgnoreClick()(:345-346),并改为先 setValue(:357) 再手动回调刷新(:360);④ MiCarIconSeekBarPreference updateThumbText() → updateThumbText(progress)(:121,见 §5)。

4. 取整公式:为什么是「余数 ≥5 进位」

alignAdjustProgress() :347-349:

originProgress = originProgress / mRoundUpStep * mRoundUpStep +
        (originProgress % mRoundUpStep >= (mRoundUpStep / 2 + mRoundUpStep % 2) ?
                mRoundUpStep : 0);

推导(step = mRoundUpStep = 10):

  • v / 10 * 10 = 向下取整到 10 的倍数(整数除法截断)→ 得「地板」
  • 进位阈值 step/2 + step%2 = ceil(step/2):step=10 → 5 + 0 = 5 (写成 + step%2 是为了奇数步长也向上取整,如 step=5 → 阈值 3)
  • v % 10 >= 5 时 +10 → 得「天花板」——标准的 half-up(四舍五入到 10)
  • 最后 clamp 到滑块范围:Math.max(…, mSeekBarData.mMin) / Math.min(…, mSeekBarData.mMax) (:350-351)

全量例值表(step=10;范围按主页/M1 页 min=0、max=100):

输入地板余数≥5?结果输入地板余数≥5?结果
33303否3065605是70
44404否4067607是70
4550→405是5079709是80
47407是5080800否80(不动)
202否0(clamp 至 min=0)97907是100(clamp 至 max=100)

⚠️ 主自定义页(MainCustomEnergyRecoveryStrengthController.kt:41)下限是 1: 点 3 → 0 → 被 :340 clamp 回 1。分析「低值点不动」反馈时先确认在哪个页面。

5. 「先 setValue、再手动刷新」:本 KB 最重要的时序(V2 起)

⚠️ 顺序在 V2(PS3)反转过:V0/V1 是「先手动回调、后 setValue」,它同时制造了两个缺陷—— ① 回调里读 mSlider 拿到取整前旧值(标签滞留,V1 用 updateThumbText(progress) 参数化修); ② 回调里的 syncValueInternal 从 slider 读到取整前原值并下发一帧原值泄漏 (如 45→50 先把 45 发给车辆)。V2 起改为 先 setValue(:357)、后手动回调(:360), 一处顺序同时消掉两个缺陷;updateThumbText(progress) 参数化保留作为硬防护。

取整命中后,:357-361 的执行顺序:

slider.setValue(originProgress);                                           // :357 先写控件值
if (adjusted) {
    // Slider.setValue()方法会判断是否与当前一致,如果一致,不会回调onValueChange()方法,需要手动调用一次onSliderProgressChanged()刷新滑块刻度
    onSliderProgressChanged(slider, getIntSliderValue(originProgress), true);  // :360 再手动回调
}

此时 onSliderProgressChanged 链路读到的 slider 当前值已经是取整后值:

onSliderProgressChanged(slider, 80, fromUser=true)
 ├─ SliderPreference.onSliderProgressChanged(:364-373)
 │    └─ fromUser && (mIsContinuousUpdate || !mIsTrackingTouch) (:370)
 │         → syncValueInternal(slider, false) (:372)  ← 读 slider 当前值(已是 80)
 │              → callChangeListener(80) (:1006) → controller 下发(见下方链路)
 │              → setValueInternal(80) (:1007→:931)  ← mSliderValue 同步为 80
 └─ MiCarIconSeekBarPreference.onSliderProgressChanged(:113-122)
      └─ updateThumbText(progress=80) (:121)  ← 数值标签刷新用传入值

完整手势时序(V3 最终,Normal 主题点击轨道 79,当前值 30):

手指        FusionSlider            SliderPreference                     车辆/标签
 │ 按下       onStartTrackingTouch →  mOriginProgress=30, tracking=true(:194-195)
 │ 触点跳79   onValueChange(79)     →  先计数 0→1(:229-231),>1 门不下发(:234)
 │ 松手       onStopTrackingTouch  →  tracking=false(:172)
 │            alignAdjustProgress →  门:roundup && !isIgnoreClick && count==1(:345-346)
 │                                  →  79→80(:347-349),clamp(:350-351)
 │                                  →  slider.setValue(80)(:357)            → thumb 跳 80
 │                                  →  手动 onSliderProgressChanged(80)(:360)
 │                                     ├─ syncValueInternal → SET 80        → 车辆
 │                                     └─ updateThumbText(80)(:121)         → 标签=80
 │            onSliderStopTrackingTouch → mOriginProgress 已清空≠80(:390)
 │                                     →  syncValueInternal(force=true)(:397)
 │                                        →  同值 80 兜底 SET                → 车辆
 │            复位 count=0、mOriginProgress=MIN(:175-176)

V1 顺序(先回调后 setValue)下,手动回调里 syncValueInternal(slider, false) 读到的是 点击落点 79(slider 尚未 setValue)→ 会先下发一帧 79,随后兜底再下发 80(原值泄漏帧, V2 已消除)。摸 thumb 场景:GT 主题组件库不派发同值回调(08 篇 §3),无微抖的纯点 mOriginProgress 保持等于当前值 → :390 纯点击判断命中直接 return,连兜底 SET 都没有 (三轮 bugreport 实证:132 次无跟手触摸中 126 次值零变化——118 次全程零 SET、8 次仅一次 同值兜底 SET,见 05 篇 §9.3);Normal 主题同值回调会清掉 mOriginProgress(:209)→ :390 不命中 → 兜底补发一次同值 SET(无害)。

6. V3 后的已知边角(诚实记录)

  1. 「摸 thumb + 拖 1 格」会取整(Normal 主题):1 次真实位移使 count==1 命中取整门 (67→拖一格 68→松手吸附 70)。2026-10-13 产品裁决已把这定性为需求: 「贴 thumb ±1 点击也必须取整,点击数值只能是整十」(见 §3 末);GT 主题由 isIgnoreClick 门整体关闭取整,不适用。
  2. 取整模式拖动时,第一个变值不 SET(:234 的 >1 门):第二个变值起 才跟手下发;松手时 onSliderStopTrackingTouch 的 syncValueInternal(slider, true) (:397)force 兜底,终值必达——兜底同时负责「跟手下发时最后一次信号回弹 未松手导致回弹失败」的场景(:396 注释)。
  3. 摸 thumb 松手的兜底差异(Normal vs GT):Normal 主题按下瞬间的同值 onValueChange 会把 mOriginProgress 清空(:209),导致 :390「纯点击无位移」判断不成立,走到 force 兜底补发一次当前值 SET(无害,别误判为「值被改写」);GT 主题组件库不派发 同值回调(08 篇 §3),无微抖的纯点 :390 命中直接 return,零 SET;若按下期间有 float 微抖(int 同值),mOriginProgress 被清 → 仍会兜底一次同值 SET(三轮实证: 132 次触摸中 8 例,05 篇 §9.3)。
  4. 置灰保护两处:拖动中被信号置灰 → onValueChange 的 !isEnabled() && fromUser 直接 return(:210-220);松手时 onSliderStopTrackingTouch disabled 直接 return (:385-387),不下发。
  5. 往返净零位移误判点击(理论级,未处理):53→54→53 这类「起点=终点但中间有 1 次 真实变值」的往返,count==1 仍命中取整门(Normal 主题下 53 会被吸到 50)。 更优解是「与上次回调值比较」的 mLastCallbackValue 方案,但被 2026-10-13 产品裁决 否决(它会把「贴 thumb ±1 点击」判成拖动而不取整,违背「点击必须整十」), 见 05 篇 §9.4。GT 主题由 isIgnoreClick 门覆盖,不受影响。

7. 工作模式与下发链路(为什么拖动是跟手的)

  • SliderPreference 有两种工作模式(:77-78):SLIDER_NORMAL(0,滑动中一直下发)/ SLIDER_ADAPTER_VEHICLE(1,抬起时才保证回调)
  • 基类 BaseVehicleProgressPrefController.onCreateInternal() 默认设的是 SLIDER_ADAPTER_VEHICLE(:43,注释:「在slider拖动过程中不会直接下发车控信号」), 同时 setContinuousUpdate(true)(:36)
  • 三个能量回收 controller 都在 super 之后改回 SLIDER_NORMAL (BrakingStrengthPreferenceController.java:113,注释:「滑动过程中下发信号」; CustomEnergyRecoveryStrengthController.kt:59;MainCustomEnergyRecoveryStrengthController.kt:61)
  • 下发判定在 BaseVehicleProgressPrefController.handlePreferenceChanged() :217-231: workMode == SLIDER_NORMAL || !isTrackingTouch() 才 updateVehicleProperty() (:233-237,setVehicleProperty 同步下发,「保证先后顺序」)
  • 完整链路:onValueChange → onSliderProgressChanged(:370 条件:fromUser && (mIsContinuousUpdate || !mIsTrackingTouch))→ syncValueInternal(slider, false)(:372) → callChangeListener(:1006)→ handlePreferenceChanged → updateVehicleProperty → 车控属性

8. 语音(可见即可说)路径:基础设施在,但本功能已关闭

  • SliderPreference 内置语音监听器 mSliderVoiceAssistListener(:243 起), OPERATION_SET_PROGRESS(:252-268)直接 setValueInternal(targetValue) + callChangeListener(targetValue)——不经过 alignAdjustProgress,语音设值不取整
  • 步长注释(:83):「默认的Slider步长,如果设置为10,云端会进行四舍五入」; 三个 controller 都设了 STEP_FOR_VOICE_ASSIST = 10 (BrakingStrengthPreferenceController.java:57/:111 等)——取整语义在语音链路由云端实现
  • ⚠️ 但三处滑块 XML 都写了 settings:supportVoiceAssist="false" (miauto_driving_settings_fragment.xml:113、micar_custom_steering_mode_fragment.xml:37、 micar_main_custom_steering_mode_fragment.xml:15;消费点 VoiceSearchManager.java:471) ——当前版本语音无法调节能量回收滑块。分析「语音调能量回收」类需求/问题时, 先看这个属性是否被打开。

9. 一句话记忆

拖动跟手不取整;Normal 点击跳整十且必须看见跳;GT 点击不生效、不取整(组件库 ignoreClick 特性 + 产品裁决);摸 thumb 零操作。 判定三条件:step=10、余数 ≥5 进位(:347-349),恰好 1 次真实 int 位移、且组件库 非 ignoreClick(:345-346);计数必须先于下发(:229-231 → :234), 刷新必须先 setValue(:357)再手动回调(:360)——修 bug 就是守这几句话。

10. 演进备选(记录,不实施)

备选方案 B:归零整套取整机制。若产品日后愿意把普通主题「点击必整十」妥协为 「点击定位 + 整数」,则可参照按键背光亮度(KeyBgLightLevelPrefController,05 篇 §9.3 对照组)的实现:删 3 处 setValueRoundUp(true)(BrakingStrengthPreferenceController.java:112 / CustomEnergyRecoveryStrengthController.kt:58 / MainCustomEnergyRecoveryStrengthController.kt:60)

  • setStepSize(1f)——计数器、==1 门、isIgnoreClick 门全部退役。

背光实现「零判定」的三点依据:

  1. 不开取整则 alignAdjustProgress/计数器整体休眠(线上 + 台架零问题实证, 05 篇 §9.3 对照组);
  2. 量化在组件库层,与手势判定解耦:setStepSize 由 SliderPreference.java:536 透传给组件库 Slider(setter :1022),吸附整数是库的有级调节链(syncValueInternal 的 mStepValue 取整 :997-1002),不经过本节任何计数/取整门;
  3. GT 下天然合规「只支持拖动」(组件库强制 ignoreClick,无需任何门控)。

代价 = 放弃 86420 整十特性(含「贴 thumb ±1 点击必取整」),需产品重新裁决,当前不执行。