汽车问答技术架构分享

飞书源:汽车问答技术架构分享(docx IzdvdxDnto2zeTx0EVgcE6mhnlg)。本文原带大量截图(飞书空间图 403,已替换为 > 📷 截图 占位)。

相关文档

【汽车语音】汽车问答-规划与实施

【汽车语音】汽车问答-通用问答-反馈设计-22/10

汽车问答技术方案评审

一、需求的由来和规划

1、需求由来

一句话总结由来:竞品都有这个功能。

计划整体达成人有我优,局部实现人无我有。

2、规划

主要有以下几种问题反馈设计:

2.1 VUI(语音交互):

用户:轮毂刮花包含在售后里吗?

VUI:人为损坏不在保修范围内哦

2.2 VUI + GUI

用户:怎么打开前备箱?

VUI + GUI: 可以通过蓝牙钥匙、手机app、语音等多种方式打开前备箱哦~

GUI: 图片、Gif、视频

2.3 打开车控页面 + 气泡标识

用户:能量回收强度在哪里?

GUI:打开驾驶偏好界面,滚动到【能量回收强度】,展示指示动画

🎬 视频:视频(video/mp4)

二、挑战和成果

1、挑战

  1. 涉及业务页面、控件和类型较多:

    1. 13个Tab页面 + 200多控件 + 多种不同类型控件;
  2. 业务逻辑复杂:

    1. 多车型、多配置兼容;
    2. 不同类型控件,解析和提示处理逻辑都有不同;
    3. 动态显示隐藏的控件(父子项…);
    4. 名称相同,需要支持优先级;
    5. 名称不规范,需要支持名称映射;
    6. 支持灵活自定义,指定不支持汽车问答的控件(包含分段选择器中的选项);
    7. 自动支持后续新增控件,避免硬编码;

简单列举下控件:

📷 截图

动态显示隐藏:

2、成果

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

三、架构

1、整体架构

💡 通过上面的整体架构图,能发现车控需要解决的几个问题:

  1. 怎么适配不同类型、样式的控件?
  2. 怎么扫描出需要上传的控件? (自动维护控件列表,减少后台维持的人力、避免硬编码)
  3. 控件怎么自动上传?
  4. 怎么寻找问答目标的控件?

扩展:

小爱云端,一个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

VoiceSearchTransform:

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 控件解析

  • 时机:

       进程启动、小爱通知上传控件(子线程)
    

VoiceSearchManager:

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;
}

VoiceSearchResultData:

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"
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);
    });
}
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 父子项支持

子项会根据父项的状态动态显示隐藏

父子项通用能力具体使用:父子设置项收起与展示

五、需要大家帮忙遵守的规则

  • 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