汽车问答技术架构分享
飞书源:汽车问答技术架构分享(docx
IzdvdxDnto2zeTx0EVgcE6mhnlg)。本文原带大量截图(飞书空间图 403,已替换为> 📷 截图占位)。
相关文档
一、需求的由来和规划
1、需求由来
一句话总结由来:竞品都有这个功能。
计划整体达成人有我优,局部实现人无我有。
2、规划
主要有以下几种问题反馈设计:
2.1 VUI(语音交互):
用户:轮毂刮花包含在售后里吗?
VUI:人为损坏不在保修范围内哦
2.2 VUI + GUI
用户:怎么打开前备箱?
VUI + GUI: 可以通过蓝牙钥匙、手机app、语音等多种方式打开前备箱哦~

GUI: 图片、Gif、视频
2.3 打开车控页面 + 气泡标识
用户:能量回收强度在哪里?
GUI:打开驾驶偏好界面,滚动到【能量回收强度】,展示指示动画
🎬 视频:视频(video/mp4)
二、挑战和成果
1、挑战
-
涉及业务页面、控件和类型较多:
- 13个Tab页面 + 200多控件 + 多种不同类型控件;
-
业务逻辑复杂:
- 多车型、多配置兼容;
- 不同类型控件,解析和提示处理逻辑都有不同;
- 动态显示隐藏的控件(父子项…);
- 名称相同,需要支持优先级;
- 名称不规范,需要支持名称映射;
- 支持灵活自定义,指定不支持汽车问答的控件(包含分段选择器中的选项);
- 自动支持后续新增控件,避免硬编码;
简单列举下控件:




📷 截图






动态显示隐藏:


2、成果
- 所有Tab页中Preference自动支持汽车问答;
- 提供通用能力,已经获利的业务有:小爱(汽车问答 & 跳板卡)、SystemUI(状态栏)、快控、Iot中心;
三、架构
1、整体架构

💡 通过上面的整体架构图,能发现车控需要解决的几个问题:
- 怎么适配不同类型、样式的控件?
- 怎么扫描出需要上传的控件? (自动维护控件列表,减少后台维持的人力、避免硬编码)
- 控件怎么自动上传?
- 怎么寻找问答目标的控件?
扩展:
小爱云端,一个query,此处的意图判断有可能会经过多个垂域(不同的功能集:问答、导航、控制…),会根据匹配度来决定最后处理的垂域;
2、局部架构
汽车问答功能是车控与小爱App相互协作完成的,通过小爱App上传控件、问答埋点。带来的好处是:后续便于其他App支持汽车问答功能,埋点数据统一上报,更好排查。

应用分发中心:
小爱客户端跟其他应用跨进程通信的框架(IPC)
3、Settings 架构

四、具体实现
1、数据准备(编译期)
- 标记页面 (注解)
@VoiceSearchProvider(priority = 1) // 添加注解
public class DrivingSettingsFragment extends AudioControlSettingsFragment implements
IPageRouteHandler {
...
}
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.TYPE})
public @interface VoiceSearchProvider {
// 优先级数值越大,优先级别越高
int priority() default 0;
}public class VoiceSearchProcessor extends AbstractProcessor {
...
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment env) {
...
1、收集注解 Element
2、处理 & 生成辅助类
}
...
}- 注解处理器生产的辅助类
/**
* Auto Generated Codes
*/
public final class VoiceSearchWidgetProvider {
// 只有一个方法,返回页面数据集合
public static List<VoiceSearchPageData> getSearchDataList() {
List<VoiceSearchPageData> list = new ArrayList();
try {
DrivingSettingsFragment fragment5 = new DrivingSettingsFragment();
list.add(new VoiceSearchPageData(fragment5.getPreferenceScreenResId(), fragment5.getPageAlias(), fragment5.getParentPageAlias(), 1, fragment5.getPreferenceKeyResIdsToRemove()));
} catch(Exception e) {
e.printStackTrace();
}
...
return list;
}
}- 页面数据类(VoiceSearchPageData):
public class VoiceSearchPageData {
private int mLayoutRes; // 页面布局
private String mPageTag; // 页面Tag,用于页面切换
private String mParentPageTag; // 父页面,中途有上过二级页面支持汽车问答
private String mPageName;
private int mPriority; // 优先级,按页面
// 不支持汽车问答的Key集合,主要用于适配不同车型、不同配置
private Set<Integer> mExcludeKeyIds;
}💡 mExcludeKeyIds 的赋值通过辅助类使用fragment5.getPreferenceKeyResIdsToRemove()进行赋值的,所以只要在设置这套框架内,汽车问答自动支持不同车型、不同配置;
- 字节码插桩,初始化自动获取生成的辅助类
自定义一个Plugin,注册Transform
public class VoiceSearchTransform extends Transform {
private static final String SUFFIX = "_VoiceSearchProvider";
private static final String SEARCH_PROVIDER_CLASS =
"com.android.car.settings.miauto.voiceassist.VoiceSearchManager";
@Override
public void transform(final TransformInvocation invocation)
throws TransformException, InterruptedException, IOException {
...
1、通过类名后缀获取所有处理器生成的辅助类;
2、通过全路径找到汽车问答管理类(VoiceSearchManager),字节码插桩,
在init()方法中注入初始化代码
...
}
}💡 可惜Setting是源码依赖,make 编译方式字节码插桩使用的是另外一种方式:AspectJ
2、控件解析 & 上传
2.1 控件解析
-
时机:
进程启动、小爱通知上传控件(子线程)
public class VoiceSearchManager {
// 页面数据
private List<VoiceSearchPageData> mOriginDataList;
// 支持手动添加控件
private static List<SearchResultInterceptor> mSearchResultInterceptors = new LinkedList<>();
// 解析后的结果, key: preferenceKey value: 控件具体信息
private Map<String, VoiceSearchResultData> mResultDataMap;
// 本地缓存的控件列表
private Map<String, VoiceSearchResultData> mLocalCacheResultDataMap;
}public class VoiceSearchResultData {
private String mTitle; // 控件名称
private int mPriority;
private String mWidgetKey; // 控件Key,
private String mParentWidgetKey; // 父控件Key,用于标识父子关系
private String mPageTag; // 所在页面的标识
private String mParentPageTag;
private String mPageName;
private int mTabPosition = DEFAULT_INVALID_POSITION; // NewTabPreference 中 tab 位置
private boolean mIsActionButton = false; // 是否是NewTabPreference 右边 Button
}- 进程启动时,先加载本地缓存:
SettingsApplication.onCreate() →
VoiceSearchManager.initSearchManager() →
VoiceSearchManager.loadCacheWidgets()
private synchronized void loadCacheWidgets() {
...
// VoiceSearchDBManager 控件信息数据库管理类,缓存上一次解析结果,
// 用于通过汽车问答启动进程时能快速处理
mLocalCacheResultDataMap = VoiceSearchDBManager.getAllWidgets();
...
}- 启动解析任务时机:HomePageActivityReal.onWindowFocusChanged()
等待页面完成加载后再启动,避免进程启动时开启太多线程,优化启动速度
VoiceSearchManager.initSearchManager():
private synchronized void parseWidgets(Context context) {
...
// 1、解析控件
parseTopFragmentPreferences(context);
// 2、处理手动添加的控件
dealWithResultDataInterceptor(mResultDataMap);
// 3、更新本地缓存
VoiceSearchDBManager.cacheWidgets(mResultDataMap);
...
}VoiceSearchManager.parseTopFragmentPreferences():
驾驶偏好页面布局文件:R.xml.miauto_driving_settings_fragment
private void parseTopFragmentPreferences(Context context) {
PreferenceManager preferenceManager = new PreferenceManager(appContext);
PreferenceExtraParseHelper parseHelper = new PreferenceExtraParseHelper();
for (VoiceSearchPageData item : mOriginDataList) {
// 1、通过系统的PreferenceManager加载对应页面的Preference树
PreferenceScreen preferenceScreen = preferenceManager.inflateFromResource(
appContext, item.getLayoutRes(), preferenceManager.getPreferenceScreen());
// 2、创建页面信息,支持在整个页面没有控件时把当前页面上传,因为汽车问答支持打开一级页
generatePageResultData(item);
// 3、通过自定义解析器,解析扩展属性
parseHelper.parsePreferenceScreenXml(appContext, item.getLayoutRes());
// 4、支持自定义别名(例:settings:relativeTitle)
Map<String, String> relativeTitleMap = parseHelper.getRelativeTitles();
// 5、支持自定义关闭控件上传(例:settings:supportVoiceAssist="false")
List<String> excludeKeys = parseHelper.getExcludeKeys();
// 6、获取对应的PreferenceController集合
Map<String, PreferenceController> controllerMap =
parsePreferenceControllers(appContext, item.getLayoutRes());
// 7、解析Preference
parsePreference(preferenceScreen, item, relativeTitleMap, controllerMap);
}
}VoiceSearchManager.parsePreference():
private void parsePreference(Preference preference, VoiceSearchPageData data,
Map<String, String> relativeTitleMap, Map<String, PreferenceController> controllerMap) {
// 处理PreferenceGroup (没接触过Preference的可以参考View和ViewGroup的关系)
if (preference instanceof PreferenceGroup) {
PreferenceGroup preferenceGroup = (PreferenceGroup) preference;
// Category整个隐藏
if (data.containExcludeKey(preferenceGroup.getKey())) {
return;
}
// 1、Category的标题也支持汽车问答
if (!TextUtils.isEmpty(preferenceGroup.getTitle())) {
VoiceSearchResultData resultData = createSearchResultData(data,
String.valueOf(preferenceGroup.getTitle()), preferenceGroup.getKey(), "");
mResultDataMap.put(resultData.getWidgetId(), resultData);
}
// 2、递归解析子Preference
int count = preferenceGroup.getPreferenceCount();
for (int i = 0; i < count; i++) {
parsePreference(preferenceGroup.getPreference(i), data, relativeTitleMap, controllerMap);
}
} else { // 处理Preference
String prefKey = preference.getKey();
// Preference没有指定key时,不会上传
if (TextUtils.isEmpty(prefKey)) {
return;
}
if (data.containExcludeKey(prefKey)) {
return;
}
// 1、指定的标题优先于自带的标题
String title = relativeTitleMap.getOrDefault(prefKey, null);
title = TextUtils.isEmpty(title) ? (String) preference.getTitle() : title;
PreferenceController controller = controllerMap.get(prefKey);
// 2、通过Controller获取父Preference key
String parentPrfKey = controller == null ? "" : controller.getDependKey();
if (TextUtils.isEmpty(title)) {
// 没有标题时,处理 NewTabPreference & ButtonTogglePreference
// 3、处理 Tab
CarUiTabLayout.Tab[] tabArr = getPreferenceTabArr(prefKey, controllerMap);
handleTabList(tabArr, data, prefKey, parentPrfKey);
// 4、处理Preference Action Button
CarUiTabLayout.Button buttonData = getPreferenceButton(prefKey, controllerMap);
if (buttonData != null && buttonData.isSupportVoiceSearch() &&
!TextUtils.isEmpty(buttonData.getText())) {
VoiceSearchResultData resultData = createSearchResultData(data,
String.valueOf(buttonData.getText()), prefKey, parentPrfKey);
resultData.setIsActionButton(true);
mResultDataMap.put(resultData.getWidgetId(), resultData);
}
} else {
mResultDataMap.put(prefKey,
createSearchResultData(data, title, prefKey, parentPrfKey));
}
}
}具体数据:
- 加载本地数据库中缓存耗费时间:120ms 左右,157个控件
- 解析所有页面控件耗费时间:250ms 左右, 13个页面
2.2 控件上传
- 跨进程通信:→ 通知上传 → 上传控件;
- 上传:分段上传,避免控件太多超过Binder传输数据大小限制;
💡 通过小爱App上传控件的优缺点:
优点:
- 便于后续其他应用接入(例如地图),避免各业务重复实现上传逻辑;
- 小爱离线模式下,也可以获取到控件信息;
- 通过小爱拉活相关业务进行控件上传,业务方可以方便获得解析时机;
缺点:
- 增加了两次跨进程通信的时间;
3、目标控件查找 & 提示
汽车问答跳转URI:
"uri":"intent:#Intent;action=android.settings.VEHICLE_PROPERTY_DELIVER;S.widget_id=pk_settings_driving_braking_strength_level;end"- DeeplinkActivity.onCreate() // 通过WidgetId(Preference Key) 去 VoiceSearchManager中查找控件控件信息 →
- … →
- BaseXmlParserSettingsFragment.handleWidgetKeyExtra()
protected void handleWidgetKeyExtra(String widgetKey, String widgetId) {
// 1
HighlightablePreferenceGroupAdapter adapter =
(HighlightablePreferenceGroupAdapter) getListView().getAdapter();
int position = adapter.getPreferenceAdapterPosition(widgetKey);
Preference preference = adapter.getItem(position);
// 对于汽车问答,widgetKey = widgetId,
// 抽离这个方法是为了支持复用汽车问答的逻辑,切换到目标页面 和 滚动到目标控件
if (!TextUtils.isEmpty(widgetId)) {
// 1、显示汽车问答通知View
showSearchNotifyView(widgetKey, widgetId, position, adapter);
}
// 2、将查询的组件移动到窗口中间,调整position
...
getListView().post(() -> { // 等待UI加载完成
getListView().smoothScrollToPosition(finalPosition);
});
}- VoiceSearchNotifyManager.showNotifyView(widgetId, viewHolder.itemView, getListView()) →
public class VoiceSearchNotifyManager {
static {
// 责任链,查找不同类型的View,最后如果没有找到,直接使用ItemVew
sTargetViewFindHelper = new TargetViewFindHelper();
// 处理 NewTabPreference 右侧 Button
sTargetViewFindHelper.addFinder(new RightButtonViewFinder());
// 处理 Tab
sTargetViewFindHelper.addFinder(new TabViewFinder());
// 处理 逻辑title
sTargetViewFindHelper.addFinder(new RelativeTitleViewFinder());
}
private interface ITargetViewFinder {
View findTargetView(String widgetId, View itemView);
}
}4、其他功能
4.1 父子项支持
子项会根据父项的状态动态显示隐藏
父子项通用能力具体使用:父子设置项收起与展示
- PreferenceVisibleStatusManager.isVisible(prefKey) →
- LightPreferenceVisibleStatusHelper.isVisible(prefKey)
五、需要大家帮忙遵守的规则
- Preference 的 key 很关键,通过其查找对应控件,所以声明后不能再更改;
- Preference 的 key 不能重复,否则会有相互覆盖的风险;
六、测试命令
-
能量回收强度:
- adb shell am start -a android.settings.VEHICLE_PROPERTY_DELIVER —es widget_id pk_settings_driving_braking_strength_level
-
陡坡缓降:
- adb shell am start -a android.settings.VEHICLE_PROPERTY_DELIVER —es widget_id pk_settings_driving_special_steering_mode_hdc
-
后雾灯:
- adb shell am start -a android.settings.VEHICLE_PROPERTY_DELIVER —es widget_id pk_settings_externallights_entry