02 · 手势语义与取整机制(核心篇)
能量回收自定义滑块的全部「玄学」都在这一篇:拖到 67 松手就是 67,点一下轨道却跳到 70(Normal 主题),GT 主题点轨道则什么都不发生。三种手势三种语义,由
SliderPreference里一个计数器(mProgressChangeTimes)+ 组件库isIgnoreClick()共同判定。 修复历经 V1(→提测回归)→V2→V3 三轮,本篇描述 V3 最终态。代码基线:
fuben23_micarsettings/MiCarSettings@rls-xcd-u-mp-26-v7.0HEADe8b5a7ba5(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,setValueRoundUpjavadoc :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 不动 | 0 | count=1(同值回调计入),命中 → 67 被静默改写成 70,标签滞留 67(=N801PROB-132401 原案) | count=0,不命中 → 不取整 | 同 V1,不取整 ✅ |
| 点轨道 79(Normal) | 1 | count=1,命中 → 79→80,标签滞留 79(旧 bug 半面) | count=1,命中 → 80,标签立即跟随,但先泄漏一帧 79 | count=1,命中 → 先 setValue 再刷新,无泄漏帧,标签立即 80 ✅ |
| 点轨道(GT) | 0(手势不存在:库把 DOWN 平移到当前值,不派发) | 同「摸 thumb」→ 误取整 | 微抖跨 int 边界时 count=1 → 偶发吸附跳变(第三轮「点击抖动」) | isIgnoreClick() 门关闭取整,点按永不跳值 ✅ |
| 正常拖动(如 30→67,多值) | ≥2 | count>1,不命中 → 不取整 | 计数被下发覆盖锁死在 1,==1 恒真 → 松手必取整(V1 回归,05 篇 §9.2) | count>1,不命中 → 不取整 ✅ |
| 摸 thumb + 只拖 1 格(67→68) | 1 | count=2(同值计入),不命中 → 不取整 | count=1,命中 → 取整 68→70 | Normal:同 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 commite8b5a7ba5,基底 f63fe1cdc)累计四点: ① onValueChange 计数加mSliderValue != getIntSliderValue(value)条件且前置到下发之前 (:229-231);② 下发门>0→>1(:234,保持首个变值不下发);③ alignAdjustProgress 取整门<=1→==1且加!slider.isIgnoreClick()(:345-346),并改为先 setValue(:357) 再手动回调刷新(:360);④ MiCarIconSeekBarPreferenceupdateThumbText()→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? | 结果 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 33 | 30 | 3 | 否 | 30 | 65 | 60 | 5 | 是 | 70 | |
| 44 | 40 | 4 | 否 | 40 | 67 | 60 | 7 | 是 | 70 | |
| 45 | 50→40 | 5 | 是 | 50 | 79 | 70 | 9 | 是 | 80 | |
| 47 | 40 | 7 | 是 | 50 | 80 | 80 | 0 | 否 | 80(不动) | |
| 2 | 0 | 2 | 否 | 0(clamp 至 min=0) | 97 | 90 | 7 | 是 | 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 后的已知边角(诚实记录)
- 「摸 thumb + 拖 1 格」会取整(Normal 主题):1 次真实位移使 count==1 命中取整门
(67→拖一格 68→松手吸附 70)。2026-10-13 产品裁决已把这定性为需求:
「贴 thumb ±1 点击也必须取整,点击数值只能是整十」(见 §3 末);GT 主题由
isIgnoreClick门整体关闭取整,不适用。 - 取整模式拖动时,第一个变值不 SET(:234 的
>1门):第二个变值起 才跟手下发;松手时onSliderStopTrackingTouch的syncValueInternal(slider, true)(:397)force 兜底,终值必达——兜底同时负责「跟手下发时最后一次信号回弹 未松手导致回弹失败」的场景(:396 注释)。 - 摸 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)。 - 置灰保护两处:拖动中被信号置灰 → onValueChange 的
!isEnabled() && fromUser直接 return(:210-220);松手时onSliderStopTrackingTouchdisabled 直接 return (:385-387),不下发。 - 往返净零位移误判点击(理论级,未处理):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门全部退役。
背光实现「零判定」的三点依据:
- 不开取整则
alignAdjustProgress/计数器整体休眠(线上 + 台架零问题实证, 05 篇 §9.3 对照组); - 量化在组件库层,与手势判定解耦:
setStepSize由SliderPreference.java:536透传给组件库 Slider(setter :1022),吸附整数是库的有级调节链(syncValueInternal 的mStepValue取整 :997-1002),不经过本节任何计数/取整门; - GT 下天然合规「只支持拖动」(组件库强制 ignoreClick,无需任何门控)。
代价 = 放弃 86420 整十特性(含「贴 thumb ±1 点击必取整」),需产品重新裁决,当前不执行。