36 · 可见即可说语音链路
蓝牙页”可见即可说”:语音激活 → 列表亮序号角标 → 用户喊”连接第 3 个”/“连接小米手环” → 执行的端到端剖析。 行号基线:fuben_micarsettings dev HEAD 2994ee8ba(2026-09-10 fetch 实证)。 核心文件:
view/BluetoothMainEntrySettingsFragment.java、controller/Bluetooth{Bonded,Unbonded}DevicesPrefController.java、preference/NewBluetoothDevicePreference.java、base/settingsBaseLib/.../voiceassist/ViewDescriptionCompat.kt;框架侧mi.car.core.viewpage.*(平台 javalib jar 反编译实证)。
0. 一句话结论
语音不直接调蓝牙 API。 框架做的是「语音匹配语义标签 → 定位 View → 合成一次点击」,执行落回手指点击的同一条 OnPreferenceClickListener 路径。Settings 侧的全部职责 = 给每个条目 View 贴 MiCarViewDescription 标签(热词/序号/意图词)+ 维护序号角标。
1. 三方角色与分层
| 角色 | 位置 | 职责 |
|---|---|---|
| App(车设) | 本仓源码 | 贴标签、亮角标、提供正常点击回调 |
| 平台框架 | mi.car.core.viewpage(framework 内置,jar 反编译可见) | 遍历 View 树收集标签成 MiCarViewPageInfo;维护 per-window 回调通道;路由执行动作 |
| 语音服务(小爱) | 系统服务(本仓无源码) | 拉标签做 ASR/NLU 匹配;下发 performViewPageAction;播报结果 |
2. 进入/退出语音模式:notifyVoiceActive 链路
语音服务 setEnabled(true/false) ← IMiCarViewPageManagerClient.setEnabled(boolean,boolean)(AIDL)
→ 平台框架回调 View 上的 MiCarViewListener
→ BluetoothMainEntrySettingsFragment.onViewCreated 里挂的监听 (:110-118)
MiCarView.setViewListener(getListView(), listener)
→ setViewPageEnabled(viewPageEnabled)
→ notifyVoiceActive(viewPageEnabled) (:126-145)
页面销毁摘监听:onDestroyView():121-124 setViewListener(getListView(), null)——防框架异步回调打到死 Fragment。
notifyVoiceActive 本体三段:
- 守卫(:128-131):
getContext()==null || !isAdded()直接 abort——回调是框架异步打来的,与 onDestroyView 摘监听存在竞态,不挡则下面use()拿 PreferenceManager 会崩。 - 取两个 controller(:132-141):
use(Class, keyResId)(BaseXmlParserSettingsFragment.java:242)按 preference key 取已实例化 controller;判空(availability 过滤后可能不存在)。 - 分发:
bondedController.updateVoiceAssistActive(flag)+unBondedController.updateVoiceAssistActive(flag)。
3. Controller 侧:序号编排(连续编号的关键)
Bonded(BluetoothBondedDevicesPrefController.java):
updateVoiceAssistActive(:107-111):存mIsVoiceAssistActive→refreshVoiceAssistViewOrder。refreshVoiceAssistViewOrder(:117-140):遍历分组、跳过不可见项,逐设备setIsVoiceAssistActive+setVoiceAssistDeviceOrder(visiblePrefIndex)——序号从 0 起。
Unbonded(BluetoothUnbondedDevicesPrefController.java):
updateVoiceAssistActive(:568-572)同款。refreshVoiceAssistViewOrder(:574-598):先读已配对分组的条目数写入mDeviceIndexOffset(:576-580),可用设备序号 = 可见序号 + offset——未配对接着已配对连续编号(已配对 3 台 → 附近设备从 4 开始)。mDeviceIndexOffset用AtomicInteger(:71):回调线程/后台线程都可能读写。
快照刷新点(状态变化后不重刷则标签陈旧):
- bonded::397 / :415(设备状态变化后补刷);新建 preference 时 :149 补
setIsVoiceAssistActive。 - unbonded:
addPreference:494 对新条目补 flag。
4. Preference 侧:标签内容与角标(NewBluetoothDevicePreference)
setVoiceAssistDeviceOrder(index)(:252-263) 构造语音语义:
mViewDescription = new MiCarViewDescription();
mViewDescription.setTitle(设备名);
mViewDescription.getHotwords().add(设备名); // 热词 = 设备名 → 可直呼其名
mViewDescription.setIndex(index); // 序号 → "第 N 个"
mViewDescription.setTextIntent(isConnected() ? "断开" : "连接"); // 意图词,快照取值
notifyDataChanged(); // 触发 rebindrebind 时(onBindViewHolder :211-216):
ViewDescriptionCompat.setViewDescription(holder.itemView, mViewDescription)——把语义挂到 item View 供框架读取;updateViewIndexMarker(:222-243):mIsVoiceAssistActive && mViewDescription != null时显示左侧序号角标item_index_for_voice,显示值 = index+1(:233,用户念的从 1 开始);否则隐藏。角标自身setDescriptionHide(:242)防被语音误识别成可点元素。
ViewDescriptionCompat(base/settingsBaseLib/.../voiceassist/ViewDescriptionCompat.kt):
isSupportVoiceAssist()(:25-38):反射探测mi.car.core.viewpage.MiCarView#setViewDescription是否存在,不支持的平台所有调用打日志直通——老平台兼容设计。setDescriptionHide(vararg views)(:40-54):给 View 贴visibility=GONE的空描述,把装饰性元素(summary/角标)从语音树里摘出去。VoiceAssistConstant.DEBUG打开后有逐条 setViewDescription 日志(:78-80)。
5. 平台框架机制(反编译实证,xcd_javalib_1129.jar)
MiCarView 是纯 facade
// 反编译 MiCarView.setViewDescription:
view.getMiCarStub().setMiCarDescription(desc) // 每个 View 挂了 IMiCarViewStub(小米改的 framework View)IMiCarViewStub 方法面(摘):setMiCarDescription/getMiCarDescription、onViewClicked()、notifyViewShowIndex()、setViewListener(MiCarViewListener)、getViewPageEnabledStatus()、updateText/updateCheckStatus/updateCurrentProgress(开关/滑杆状态同步)、onDetachedFromWindowInternal()。
MiCarViewDescription = Parcelable 语义包
字段(反编译实证):title/subTitle、hotwords[]、index、textIntent/textIntents[]、matchType(FUZZY/COMPLETE/COMPLEX/NOT_MATCH/DEFAULT)、componentType(BUTTON/TEXT_LIST/ORDINAL/SWITCH/SLIDER/TAB…)、priority、isChecked/isSelected、pageType、categoryAction、AdjustMethod 等。
AIDL 五接口(语音服务 ↔ 管理器 ↔ App 窗口)
语音服务(小爱) IMiCarViewPageManager(系统侧) App 进程(framework View 层)
│ registerViewPageInfosUpdateListener / getViewPageInfos() │ updatePageInfo(MiCarViewPageInfo)
│ ────────────────────────► 管理器 ◄──────────────────────── │ (View 树遍历收集各 View 标签)
│ addClient → IMiCarViewPageManagerClient.setEnabled() ───► │ → MiCarViewListener.setViewPageEnabled
│ │ (=notifyVoiceActive 触发源)
│ performViewPageAction(actionId, targetId, index, Bundle) │
│ ────────────────────────► 按 IWindow 路由 ───────────────► │ IMiCarViewPageInteractionConnection
│ │ .performViewPageAction(int,int,Bundle)
│ onActionPerformResult / onActionPerformFailedReason ◄──── │ → 定位 View → 合成点击
│ (TTS 播报"好的/没找到") │ → onActionPerformResult 回传
窗口级通道:addViewPageInteractionConnection(IWindow, IMiCarViewPageInteractionConnection, pkg, type) / removeViewPageInteractionConnection(IWindow)——执行动作按窗口精确路由到 App 进程。
6. 用户喊话的匹配与执行
| 用户说 | 匹配字段 | 命中 |
|---|---|---|
| ”连接小米手环” | hotwords(=设备名),受 matchType 控制模糊/全匹配 | 该设备条目 |
| ”连接第 3 个” | index(componentType=ORDINAL,屏幕角标 index+1 就是可念的数字) | 序号 3 的条目 |
| ”连接” / “断开” | textIntent | 动作语义 |
执行链:语音服务 → performViewPageAction → 路由到前台窗口 → App 进程 framework 层对目标 View 合成点击(onViewClicked 标记)→ 落到该条目正常点击监听。
未配对设备条目即 BluetoothUnbondedDevicesPrefController.addPreference 里 setOnPreferenceClickListener(:496-545):mIsHandlePair 防抖 → 后台线程 stopDiscovery() + 注册配对回调 → MiBluetoothUtils.pair(...)——与手指点击逐行一致。
7. 排障推论(语音类问题的两条铁律)
- 语音能做的事 ≤ 手指能做的事。条目
setEnabled(false)、防抖拦截、守卫拦截,对语音指令同样生效。遇”语音说连接没反应”,先用手指点同一条目复现——手点失败则与语音链路无关。 - 标签是贴标签时刻的快照。
textIntent按当时isConnected()取一次(:259-261),设备连接状态变化后若无人调refreshVoiceAssistViewOrder,语音意图即陈旧(“连接”被理解成”断开”类单,先查 §3 刷新点覆盖)。同理设备名改名后 hotwords 也是旧值。