BUTTON_FLOWS.md 46 KB

RobotSample 按钮交互与业务流程详解(开发者手册)

目标读者:拿到 RobotSample 想做二次开发的 Android 工程师。
本文不是单纯的“按钮 -> 接口”对照表,而是按页面拆解每一次点击背后的完整链路:UI 状态、API 调用参数、回调线程、错误分支、与 CoreService 的交互时序、以及常见踩坑点。

目录

  • 0 工程结构与启动链路
  • 1 主菜单 MainFragment
  • 2 基础运动 SportFragment
  • 3 视觉能力 VisionFragment
  • 4 地图与定位 LocationFragment
  • 5 导航 NavigationFragment
  • 6 充电 ChargeFragment
  • 7 语音能力 SpeechFragment
  • 8 音频录制 AudioFragment
  • 9 电动门控制 ElectricDoorActionControlFragment / ElectricDoorControlFragment
  • 10 人体跟随 BodyFollowFragment(已废弃)
  • 11 引领与巡航 LeadFragment
  • 12 失败页 FailedFragment
  • 13 公共控件:BaseFragment / BackView / ResultView / LogTools
  • 14 回调接口与线程模型
  • 15 错误码 / 状态码速查
  • 16 模拟器/真机调试与常见坑

0. 工程结构与启动链路

0.1 目录结构(关键部分)

app/src/main/java/com/ainirobot/robotos/
├── MainActivity.java              主入口,做 splash + 自检 + Fragment 容器
├── LogTools.java                  内部日志,会推到 ResultView 显示
├── application/
│   ├── RobotOSApplication.java    Application:连 CoreService、初始化语音
│   ├── ModuleCallback.java        ModuleCallbackApi 实现,处理底层 request
│   └── SpeechCallback.java        SkillCallback 实现,处理 ASR/TTS 回调
├── fragment/                      每个业务页面一个 Fragment
│   ├── BaseFragment.java          统一加 BackView/ResultView,子类填中间内容
│   ├── MainFragment.java          主菜单
│   ├── SportFragment.java         底盘运动 + 头部云台
│   ├── VisionFragment.java        人脸注册/识别
│   ├── LocationFragment.java      位置点 CRUD
│   ├── NavigationFragment.java    导航 + 闸机判断
│   ├── ChargeFragment.java        自动回充 + 脱桩
│   ├── SpeechFragment.java        TTS + Query
│   ├── AudioFragment.java         PCM 录音
│   ├── ElectricDoorActionControlFragment.java  电动门 ActionListener 版本
│   ├── ElectricDoorControlFragment.java        电动门 CommandListener + StatusListener 版本
│   ├── BodyFollowFragment.java    人体跟随(已废弃)
│   ├── LeadFragment.java          引领/巡航
│   ├── NavFragment.java           完整带地图的导航演示
│   └── FailedFragment.java        启动失败提示
├── view/                          自定义控件
│   ├── BackView.java              顶部返回主菜单按钮
│   ├── ResultView.java            底部日志输出区
│   └── MapView.java               地图渲染(pgm + 位置点)
├── audio/
│   ├── AudioManager.java          AudioRecord + 写 wav 文件
│   └── WavHeader.java             WAV 文件头构造
└── maputils/                      地图坐标转换、Pose、PlaceBean 等工具

0.2 启动链路(带时序图)

[Launcher 点击 App 图标]
         │
         ▼
RobotOSApplication.onCreate()
   ├── init()                           // 创建 SpeechCallback / ModuleCallback / HandlerThread("RobotOSDemo")
   └── initRobotApi()
         ├── RobotApi.connectServer(ctx, ApiListener)
         │       │
         │       ▼
         │   handleApiConnected()       // CoreService 跨进程绑定成功
         │       ├── RobotApi.setCallback(mModuleCallback)
         │       ├── RobotApi.setResponseThread(handlerThread)  // 把回调切到子线程
         │       └── initSkillApi()
         │             └── SkillApi.connectApi(ctx)
         │                     └── 成功 -> SkillApi.registerCallBack(mSkillCallback)
         │
MainActivity.onCreate()
   ├── 显示 splash_layout (1200ms)
   ├── 切到 activity_main(FrameLayout container_content)
   ├── checkInit()
   │     │ 每 300ms 重试,最多 10 次
   │     ├── isApiConnectedService() && isActive() = true  -> switchFragment(MainFragment)
   │     └── 超过 10 次仍未就绪              -> switchFragment(FailedFragment)
   └── (或)URL Scheme jerry://main 直接进 MainFragment

关键源码(MainActivity.checkInit):

private void checkInit() {
    checkTimes++;
    if (isEmulator()) {                 // 项目本地化补丁:模拟器跳过校验
        switchFragment(MainFragment.newInstance());
        return;
    }
    if (checkTimes > 10) {
        switchFragment(FailedFragment.newInstance());
    } else if (RobotApi.getInstance().isApiConnectedService()
            && RobotApi.getInstance().isActive()) {
        switchFragment(MainFragment.newInstance());
    } else {
        mContent.postDelayed(this::checkInit, 300);
    }
}

真机上必须从机器人 Launcher 启动,否则 isApiConnectedService() 永远 false,10 次后必进 FailedFragment

0.3 Fragment 切换约定

MainActivity.switchFragment() 直接 replace 容器,不入栈:

public void switchFragment(Fragment fragment) {
    getSupportFragmentManager().beginTransaction()
            .replace(R.id.container_content, fragment, fragment.getClass().getName())
            .commit();
}

后果:

  • 系统返回键不会回到上一页,而是直接 finish() Activity。
  • 业务里的“返回”全靠 BackView 把当前 Fragment 整体替换为 MainFragment

1. 主菜单 MainFragment

1.1 入口与 UI 组成

  • 类:com.ainirobot.robotos.fragment.MainFragment
  • 布局:fragment_main_layout.xml
  • 进入时主动隐藏公共控件:

    @Override
    public View onCreateView(Context context) {
    View root = mInflater.inflate(R.layout.fragment_main_layout, null, false);
    bindViews(root);
    hideBackView();
    hideResultView();
    return root;
    }
    

布局是个 ScrollView,内含纵向排列的 10 个按钮 + 右下角红色 EXIT

1.2 按钮明细

id 文案 跳转目标 备注
sport_scene BASIC MOTION SportFragment 底盘 + 头部云台
vision_scene VISUAL ABILITY VisionFragment 人脸注册/识别,按钮默认 disabled
location_scene MAP/POSITION LocationFragment 7 个位置点接口
navigation_scene NAVIGATION NavigationFragment 含闸机识别
charge_scene CHARGE ChargeFragment 4 个回充按钮
lead_scene LEAD LeadFragment XML 中 visibility="gone",主菜单默认看不到
speech_scene SPEECH ABILITY SpeechFragment TTS / Query
audio_scene AUDIO ABILITY AudioFragment 录音并写 WAV
electric_door_control ELECTRIC DOOR CONTROL ElectricDoorActionControlFragment 仓门控制
body_follow BODY FOLLOW BodyFollowFragment 已废弃
exit EXIT getActivity().onBackPressed(); getActivity().finish(); 退出 App

1.3 事件绑定方式

主菜单里同一段代码可以看到两种风格混用:

  • 老式 findViewById + setOnClickListener 命名了字段(如 mLead_scene)。
  • 新写法直接 root.findViewById(R.id.electric_door_control).setOnClickListener(...) 不存字段。

这只是历史代码风格差异,不影响功能。


2. 基础运动 SportFragment

2.1 UI 布局

  • 类:SportFragment
  • 布局:fragment_sport_layout.xml,分成两个 LinearLayout
    • 上半 “Body”:5 个按钮 Forward / Back / Left / Right / Stop
    • 下半 “Head”:4 个按钮 Up / Down / Left / Right

2.2 安全机制:移动遮罩 Dialog

任何前进/后退点击,都会立刻拉起一个全屏黑底 Dialog(moving_dialog_layout.xml),文字是 moving...tap to stop

private void showMovingDialog() {
    movingDialog = new Dialog(getContext(),
            android.R.style.Theme_Black_NoTitleBar_Fullscreen);
    movingDialog.setContentView(R.layout.moving_dialog_layout);
    movingDialog.setCancelable(false);
    movingDialog.show();
    // 任意触摸 -> stopMoving()
    // 3000ms 自动 -> stopMoving()
}

private void stopMoving() {
    if (movingDialog != null && movingDialog.isShowing()) movingDialog.dismiss();
    autoStopHandler.removeCallbacksAndMessages(null);
    RobotApi.getInstance().stopMove(0, mMotionListener);
}

含义:直行运动最多走 3 秒就会被强制停,并且用户随时可以点屏停下。这是一种最简单的“防止机器人撞死”的兜底逻辑,二次开发时可以根据实际场景调整 3000 这个超时时间。

2.3 底盘按钮

按钮 调用 入参语义 触发遮罩
go_forward RobotApi.goForward(0, 0.4f, mMotionListener) reqId=0distance=0.4m
go_back RobotApi.goBackward(0, 0.3f, mMotionListener) reqId=0distance=0.3m
turn_left RobotApi.turnLeft(0, 25f, mMotionListener) angle=25°
turn_right RobotApi.turnRight(0, 25f, mMotionListener) angle=25°
stop_move RobotApi.stopMove(0, mMotionListener)

注:

  • goForward / goBackward 是“走指定距离”的语义,不是“持续前进”。配合遮罩实现“限时移动 + 用户可中断”。
  • turnLeft / turnRight 是“旋转指定角度”,结束后自动停转,所以不需要遮罩。

2.4 头部云台按钮

接口:RobotApi.moveHead(reqId, "relative" | "absolute", "relative" | "absolute", deltaH, deltaV, listener)

  • 第二、三个参数分别是水平、垂直坐标系:relative 表示在当前角度上叠加;absolute 表示绝对坐标。
  • 每点一次 reqId++ 自增,避免重复请求被服务端去重。
按钮 水平 垂直 含义
head_up 0 -10 向上 10°(注意垂直方向负值代表抬头)
head_down 0 10 向下 10°
head_left -10 0 头部左转 10°
head_right 10 0 头部右转 10°

2.5 通用回调

private CommandListener mMotionListener = new CommandListener() {
    @Override
    public void onResult(int result, String message) {
        LogTools.info("result: " + result + " message:" + message);
    }
};

回调线程是 RobotOSApplication.mApiCallbackThread(HandlerThread),不在主线程;LogTools.info 内部用 mMainHandler.post 切回主线程更新 ResultView

2.6 时序图

用户按 [Forward]
   │
   ├── RobotApi.goForward(0, 0.4f, listener)            (UI 线程)
   │        └── CoreService 收到 -> 底盘开始前进
   │
   ├── showMovingDialog()                               (UI 线程)
   │       ├── 启动 3s 自动停止 Handler
   │       └── 等待用户触屏
   │
   ├── 3s 后 OR 用户触屏
   │       └── stopMoving()
   │             ├── dialog.dismiss()
   │             └── RobotApi.stopMove(0, listener)
   │
   └── CoreService 在子线程回调 onResult -> LogTools.info -> ResultView 文字更新

3. 视觉能力 VisionFragment

3.1 UI 与初始状态

  • 类:VisionFragment
  • 布局:fragment_vision_layout.xml
  • 两个按钮 Register / Find Face 在 XML 里 enabled="false",需要业务条件满足后再启用(例如机器人完成了视觉初始化)。
  • 底部一段 vision_warning:境外版本由于法律限制不提供注册接口。

3.2 状态机

action 字段记录当前用户意图:

private static String REGISTER  = "register";
private static String FIND_FACE = "findFace";
private String action = "";

两按钮共用一个监听 mListener,只是 action 不同时分支不同。

3.3 完整识别/注册时序

点击 Register/Find Face
  │ action = REGISTER 或 FIND_FACE
  ▼
PersonApi.registerPersonListener(mListener)              // 启动人脸订阅

(机器人摄像头持续推送)
  │
  ▼
mListener.personChanged()
  │ allFaceList = PersonApi.getAllPersons()
  │ best = PersonUtils.getBestFace(allFaceList)
  │ best == null -> return
  │
  ├── PersonApi.unregisterPersonListener(mListener)      // 立即停止订阅,避免重入
  │
  └── RobotApi.getPictureById(reqID++, person.id, 1, cmdListener)
            │
            ▼
      onResult(JSON)
        ├── status == RESPONSE_OK 且 pictures[0] 非空
        │     └── RobotApi.getPersonInfoFromNet(reqID++, userId, [picPath], cmdListener2)
        │           │
        │           ▼
        │       onResult(JSON)
        │         ├── data.people.user_id 为空      => 未注册
        │         │       └── if action == REGISTER: registerPerson(person)
        │         └── data.people.user_id 非空      => 识别成功,打印 name/gender
        │
        └── 否则: LogTools.info("Can not found best face picture")

registerPerson(person)
  └── RobotApi.startRegister(reqID, "Person"+reqID, 20000, 5, 2, ActionListener)
        ├── REGISTER_REMOTE_SERVER_EXIST  -> "Register failed: user exists"
        └── REGISTER_REMOTE_SERVER_NEW    -> "Register success"

3.4 关键参数

startRegister(reqId, name, timeoutMs, faceCount, ?, listener)

  • name 用作存储 ID,业务里会被替换成真实姓名/UUID。
  • timeoutMs=20000 注册流程超时。
  • faceCount=5 底层要采集的人脸帧数。
  • 第 5 个参数 2 是 SDK 内部 mode,不同版本含义不同。

3.5 生命周期清理

@Override
public void onDestroyView() {
    super.onDestroyView();
    PersonApi.getInstance().unregisterPersonListener(mListener);
}

防止 Fragment 销毁后还收到回调引发空指针。

3.6 常见坑

  • mListener 不主动 unregister 会一直高频回调,导致 getPictureById 被反复触发并发问题。代码里在 personChanged() 一开始就 unregister,只处理一帧。
  • getPersonInfoFromNet 是带网络的,需要机器人能联外网。

4. 地图与定位 LocationFragment

4.1 UI 与字段

  • 类:LocationFragment
  • 布局:fragment_location_layout.xml,3 行 7 个按钮。
  • 字段 mCurrentX/Y/Theta:用作 Init Position 的输入,初始值为 0
  • 业务建议顺序:先 Get Point 把当前坐标缓存进字段,再点 Init Position

4.2 按钮 -> 接口对照(含入参)

按钮 id 调用 入参(关键) 返回中关注
is_in_location RobotApi.isRobotInlocations(0, json, listener) JSON_NAVI_TARGET_PLACE_NAME="接待点"JSON_NAVI_COORDINATE_DEVIATION=2.0 JSON_NAVI_IS_IN_LOCATION (boolean)
set_location RobotApi.setPoseEstimate(0, json, listener) mCurrentX/Y/Theta 构造 {x, y, theta} message=="succeed"
is_location RobotApi.isRobotEstimate(0, listener) message=="true" 表示已定位
set_reception_point RobotApi.setLocation(0, "接待点", listener) 直接命名当前位置点 message=="succeed"
get_location RobotApi.getPosition(0, listener) JSON 解析得到 X/Y/Theta,回写 mCurrentX/Y/Theta
remove_location RobotApi.removeLocation(0, "接待点", listener) 删除指定点 message=="succeed"
getname RobotApi.getMapName(0, listener) message 为地图名(代码里仅本地变量 name,未上屏)

4.3 set_location 防御逻辑

private void setPostEstimate() {
    if (mCurrentX == 0 || mCurrentY == 0) {
        LogTools.info("Estimate is empty, please set it before use");
        LogTools.info("坐标为空,请先获取当前坐标");
        return;
    }
    ...
}

如果你修改业务,建议把这个 0 比较改成显式的 boolean hasCurrentPose,因为坐标真有可能就是 (0, 0)。

4.4 典型业务回路

[Get Point]            -> 缓存 mCurrentX/Y/Theta
[Set reception point]  -> 当前位置命名为"接待点"
[Init Position]        -> 把 mCurrentX/Y/Theta 写入 setPoseEstimate
[Is estimate]          -> 检查机器人是否完成定位
[Is in reception?]     -> 在 2m 偏差内是否在接待点
[Del Point]            -> 移除"接待点"

5. 导航 NavigationFragment

5.1 UI 元素

  • 类:NavigationFragment
  • 布局:fragment_navigation_layout.xml
  • 控件:
    • et_navigation_point:目标点 EditText,hint=“会议间”。
    • start_navigation(绿)/stop_navigation(红)。
    • turn_direction:仅旋转到目标方位。
    • check_pass_gate:判断是否经过闸机;下方 check_pass_gate_status 文本及 start_pose_nameend_pose_name 默认 invisible

5.2 状态机:闸机导航三段式

currentStatus 跟踪闸机过点进度:

含义 UI 提示
0 还没开始/已重置
1 到达第一个闸机点 请打开闸机,之后导航到下一个闸机点位
2 到达第二个闸机点 请关闭闸机,之后导航至目标点位
3 抵达终点 闸机导航结束

每次导航成功 currentStatus++,UI 根据值刷新提示。

5.3 按钮调用细节

mStart_navigation.onClick   -> startNavigation("")        // 用 EditText
mStop_navigation.onClick    -> stopNavigation()
mTurn_direction.onClick     -> resumeSpecialPlaceTheta()
checkPassGate.onClick       -> checkoutPassGate()
start_pose_name.onClick     -> startNavigation(start_pose_name.text); end_pose_name.setEnabled(true)
end_pose_name.onClick       -> startNavigation(end_pose_name.text)

主要 API:

5.4 闸机识别完整流程

点击 [目标点位是否经过闸机]
   ├── currentStatus = 0
   ├── check_pass_gate_status.setVisibility(VISIBLE) "正在检查中..."
   └── RobotApi.getGatePassingRoute(2, point, cmdListener)
         │
         ▼
   onResult(result, message)
     ├── result==1 && poseList.size()==2
     │     ├── 计算 distance = getPlaceOrPoseDistance("闸机入口", poseList[0])
     │     ├── 计算 distance1 = getPlaceOrPoseDistance("闸机入口", poseList[1])
     │     ├── start_pose_name.text = (distance > distance1 ? "闸机出口" : "闸机入口")
     │     ├── end_pose_name.text   = (distance > distance1 ? "闸机入口" : "闸机出口")
     │     ├── start_pose_name/end_pose_name.setVisibility(VISIBLE)
     │     ├── end_pose_name.setEnabled(false)        // 必须先点 start
     │     └── status = "需要经过闸机,请先导航至第一个闸机点位"
     └── 否则 -> "点位小于两个,不需要经过闸机" / "获取失败,请重试"

点击 [闸机入口] (start_pose_name)
   ├── startNavigation(text)
   └── end_pose_name.setEnabled(true)                  // 解锁出口

[导航成功 -> ActionListener.onResult] currentStatus++
   └── currentStatus==1 -> "请打开闸机..."
   ...

注意 getPlaceOrPoseDistance 是同步方法,返回 double;其他都是异步。

5.5 ActionListener 错误码(导航专属)

接口 形参 用途
startNavigation(reqId, pointName, deviation, timeoutMs, ActionListener) 0, name, 1.5, 10_000 名称导航,1.5m 容差,10s 超时
startNavigation(reqId, Pose, deviation, timeoutMs, ActionListener) 直接用 Pose(注释里说明,可替换)
stopNavigation(reqId) 0 停止导航
resumeSpecialPlaceTheta(reqId, name, CommandListener) 0, name 仅旋转头部到目标方位,不实际移动
getGatePassingRoute(reqId, name, CommandListener) 2, name 查询是否有闸机;返回 List 长度 0/2
getPlaceOrPoseDistance(placeName, Pose) "闸机入口", pose 计算位置点和 Pose 的距离
错误码 含义
ERROR_NOT_ESTIMATE 当前未定位(先去 Location 页 Init Position)
ERROR_IN_DESTINATION 已经在目的地范围内
ERROR_DESTINATION_NOT_EXIST 目的地不存在(点位没建过)
ERROR_DESTINATION_CAN_NOT_ARRAIVE 避障超时(被障碍物挡死)
ACTION_RESPONSE_ALREADY_RUN 上一次还没结束,要先 stopNavigation
ACTION_RESPONSE_REQUEST_RES_ERROR 底盘被引领/巡航等占用

onStatusUpdate 里:

状态 含义
STATUS_NAVI_AVOID 路线被障碍物堵
STATUS_NAVI_AVOID_END 障碍物已移除

5.6 NavFragment 进阶(带地图)

NavFragment.java 是另一份未在主菜单暴露的实现,可作扩展参考:

  • 通过 RobotApi.getMapName 拿地图名。
  • ShareMemoryApi.getInstance().getMapPgmPFD(name) 拿到地图 pgm 的 ParcelFileDescriptor,用 MapppUtils.loadPFD2RoverMap(...) 解析为 RoverMap,渲染到自定义 MapView
  • getInternationalPlaceList 拿点位列表,过滤掉充电桩和导航辅助点后落到 MapView.setPoseBeans
  • 注册 STATUS_POSE_LISTEN 实时画机器人位置;注册 STATUS_POSE_ESTIMATE 监听是否定位丢失,丢失则隐藏 origin 标记。
  • mOnPlaceClickListener:点击地图上的位置点,弹 DialogConfirm 二次确认 -> startNavigation

如果想做“带地图的导航”,参考 NavFragment 的资源释放:

@Override
public void onStop() {
    super.onStop();
    RobotApi.getInstance().unregisterStatusListener(mStatusPoseListener);
    RobotApi.getInstance().unregisterStatusListener(mEstimateStateListen);
    ShareMemoryApi.getInstance().releaseGetMapPgmPFD();   // 释放共享内存
}

6. 充电 ChargeFragment

6.1 UI 与初始化

  • 类:ChargeFragment
  • 布局:fragment_charge_layout.xml,4 个按钮(Go Charge / Stop go charge / Disable Auto-Charge / Leaving Charge Dock)。
  • onCreateView 时打印当前电量:

    LogTools.info("Battery level:" +
        RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO) + "%");
    

6.2 按钮明细

按钮 id 调用 关键参数
start_auto_charge RobotApi.startNaviToAutoChargeAction(0, 3*MINUTE, mActionListener) 超时 180s
stop_auto_charge RobotApi.stopAutoChargeAction(0, true) 第二参数:是否真停
disable_auto_charge 切换:disableBattery() / enableBattery() UI 文案双向切换;autoChangeStatus 字段保存当前
charge_leave RobotApi.leaveChargingPile(0, 0.5f, 0.5f, CommandListener) 后两参为速度/距离

disable_auto_charge 关键代码:

if (autoChangeStatus) {
    RobotApi.disableBattery();
    mDisable_auto_charge.setText(R.string.charge_enable);
    autoChangeStatus = false;
} else {
    RobotApi.enableBattery();
    mDisable_auto_charge.setText(R.string.charge_disable);
    autoChangeStatus = true;
}

业务约束:要 Leaving Charge Dock(脱桩)前必须先 Disable Auto-Charge,否则系统会自动让它留在充电桩上。

6.3 ActionListener 关注的状态/错误

类别 取值 含义
onResult RESULT_OK 充电成功
onResult RESULT_FAILURE 充电失败
onStatusUpdate STATUS_NAVI_GLOBAL_PATH_FAILED 全局路径规划失败
onStatusUpdate STATUS_NAVI_OUT_MAP 充电点超出地图,可能是地图 / 位置点不匹配
onStatusUpdate STATUS_NAVI_AVOID 去往充电桩路径被堵
onStatusUpdate STATUS_NAVI_AVOID_END 障碍物已移除

7. 语音能力 SpeechFragment

7.1 关键依赖

mSkillApi = RobotOSApplication.getInstance().getSkillApi();

getSkillApi() 内部判断 isApiConnectedService(),未连接返回 null
因此本页 3 个按钮在每个调用前都做 null 防御

7.2 按钮明细

按钮 调用 行为
play_btn mSkillApi.playText(new TTSEntity("sid-1234567890", text), mTextListener) 让机器人 TTS 念出 EditText 内容(空则用 hint),同时 clearFocus + hideKeyboard
stop_btn mSkillApi.stopTTS() 停止当前播放
query_btn mSkillApi.queryByText(text) 把文本作为一次语音指令交给技能引擎

mTextListenerTextListener):

回调 触发时机
onStart() 开始合成
onStop() 主动停止
onComplete() 完成播放
onError() 失败

均通过 LogTools.info(...) 写到日志区。

7.3 TTSEntity 的 sid

TTSEntity("sid-1234567890", text) 第一个参数是会话 ID,业务上一般用 UUID.randomUUID().toString(),这里写死方便演示。

7.4 注意

  • SpeechFragment 之前 RobotOSApplication 必须完成 initSkillApi(),否则 mSkillApi 为 null,所有按钮都不响应(不会崩)。
  • queryByText 触发的语音回调(识别结果、ASR 内容)会到 SpeechCallback 全局回调里,不在本页直接处理。

8. 音频录制 AudioFragment

8.1 UI 与权限

  • 类:AudioFragment
  • 布局:fragment_audio_layout.xml,2 按钮 start / stop
  • 进入时申请权限:

    requestPermissions(new String[]{
        Manifest.permission.RECORD_AUDIO,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.READ_EXTERNAL_STORAGE,
    }, 1);
    

8.2 录制实现 AudioManager

单例(Singleton<AudioManager>),关键参数:

采样率 AUDIO_RATE 48000
声道 默认传入 CHANNEL_IN_MONO,也支持 CHANNEL_IN_STEREO
编码 ENCODING_PCM_16BIT
缓冲区 recBufSize = AudioRecord.getMinBufferSize(...) * 2
输出文件 /sdcard/audio_<timestamp>.wav

启动流程(startRecord(channelConfig, bufferSize, callback)):

  ├── 已在录音 -> 直接 return
  ├── 重置 mAudioRecorder(先 release)
  ├── new AudioRecord(MIC, 48000, channelConfig, ENCODING_PCM_16BIT, recBufSize)
  ├── audioRecorder.startRecording()
  ├── 检查 RECORDSTATE_RECORDING;不是则失败返回 -1
  ├── 启动后台线程 mRunnableRecorder(THREAD_PRIORITY_URGENT_AUDIO)
  │     循环:
  │     ├── audioRecorder.read(tempBufRec, 0, mBufferSize)
  │     ├── 立体声: deinterleaveData -> bfOutLeft / bfOutRight
  │     ├── 单声道: 直接 arraycopy 到 bfOutLeft
  │     ├── callback.onFrameDataIn(bfOutLeft)
  │     └── saveData(bfOutLeft) -> 追加写入 wav 文件
  └── return 0

停止流程(stopRecord()):

  ├── mThreadRun = false        让循环退出
  ├── audioRecorder.stop() + release()
  ├── mProducerThread.join(50)  最多等 50ms
  └── reWriteWavHeader()        以 RandomAccessFile 重写 RIFF/data 长度,得到合法 WAV

WavHeader.getHeader() 拼接的字段:RIFF chunk -> WAVE -> fmt -> data。重写时把 riffChunkSize 改成实际文件长度,让其他播放器能正确读时长。

8.3 按钮 -> 行为对照

按钮 行为
start_btn AudioManager.startRecord(CHANNEL_IN_MONO, 48000, callback)callbackLogTools.info("开始录音 size:" + data.length)
stop_btn AudioManager.stopRecord();并 LogTools.info("结束录音 文件已保存到:" + AudioManager.TEST_FILE_NAME)

8.4 易踩的坑

  • bufferSize=48000 是直接写死的(对应约 0.5s 单声道 16bit 样本),实际项目里建议直接用 getMinBufferSize 的返回值。
  • /sdcard/ 在 Android 10+ 受限,真机上可能因为 requestLegacyExternalStorage="true"(在 manifest 里)才能正常写入。
  • 关掉 Fragment 没有自动 stopRecord,如果业务需要请在 onDestroyView 里补。

9. 电动门控制

仓内有两份实现,建议先看 ElectricDoorActionControlFragment(主菜单按钮跳转的就是它),再看 ElectricDoorControlFragment 作为对照。

9.1 ElectricDoorActionControlFragment(ActionListener 版)

  • 布局:fragment_electric_door_action_control_layout.xml,6 个按钮。
  • 接口:RobotApi.startControlElectricDoor(reqId, doorCmd, ActionListener)
按钮 id 命令常量 含义
open_first_door CAN_DOOR_DOOR1_DOOR2_OPEN 上仓门开(door1+door2 一起)
close_first_door CAN_DOOR_DOOR1_DOOR2_CLOSE 上仓门关
open_second_door CAN_DOOR_DOOR3_DOOR4_OPEN 下仓门开
close_second_door CAN_DOOR_DOOR3_DOOR4_CLOSE 下仓门关
open_all_door CAN_DOOR_ALL_OPEN 全开
close_all_door CAN_DOOR_ALL_CLOSE 全关

ActionListener 关心:

类别 取值 含义
onResult RESULT_OK 成功
onError ERROR_ELECTRIC_DOOR_BLOCK 被卡住
onError ERROR_ELECTRIC_DOOR_UPPER_BLOCK 上层卡住
onError ERROR_ELECTRIC_DOOR_LOWER_BLOCK 下层卡住
onError ERROR_ELECTRIC_DOOR_TIMEOUT 超时
onStatusUpdate STATUS_ELECTRIC_DOOR_BLOCK / UPPER_BLOCK / LOWER_BLOCK 阻塞状态

9.2 ElectricDoorControlFragment(CommandListener + StatusListener 版)

  • 布局:fragment_electric_door_control_layout.xml,多了一个 get_door_status
  • 进入页面时注册门状态订阅:

    RobotApi.registerStatusListener(STATUS_CAN_ELECTRIC_DOOR_CTRL, statusListener);
    

每次门状态变化(包括下指令引发的变化)都会调用 handlerElectricResult(data),把 JSON 解成 CanElectricDoorBean

字段 含义
door1/door2 上层两扇门状态:OPEN / CLOSE / RUNNING
door3/door4 下层两扇门状态
upStatus/downStatus 整体堵转状态:BLOCK_AND_BOUNCE(关时被堵)/ BLOCKING_STOP(开时被堵)

按钮调用:

RobotApi.setElectricDoorCtrl(0, doorCmd, new CommandListener() { ... });

get_door_status -> RobotApi.getElectricDoorStatus(0, listener) 主动拉一次状态。

9.3 业务约束

在执行开/关命令前先检查门是否在 RUNNING 状态,运动中再下指令会失败或被忽略。

伪代码:

if (door.isRunning()) return;
RobotApi.startControlElectricDoor(...);

9.4 销毁清理

@Override
public void onDestroyView() {
    super.onDestroyView();
    RobotApi.unregisterStatusListener(statusListener);
}

10. 人体跟随 BodyFollowFragment(Deprecated)

10.1 状态

  • 类上有 @Deprecated,注释“老版本 7.9 可以支持,新版本不再支持”。
  • 布局:fragment_body_follow_layout.xml,2 个按钮。

10.2 流程

[开始人体跟随]
  ├── isBodyFollowing = false
  └── PersonApi.registerPersonListener(mPersonListener)

mPersonListener.personChanged()
  ├── 拿 faceList(getCompleteFaceList,或 mIsNeedInCompleteFace 时用 getAllFaceList)
  ├── PersonUtils.getBestFace(faceList, maxDistance=3, maxFaceAngleX=60)
  ├── 没找到脸 + mIsNeedBody  -> PersonUtils.getBestBody(allBodyList, 3)
  ├── isBodyFollowing == true -> return(防重入)
  └── bestPerson 不空 ->
        ├── isBodyFollowing = true
        └── RobotApi.startBodyFollowAction(0, person.id, ActionListener)

[停止人体跟随]
  ├── PersonApi.unregisterPersonListener(mPersonListener)
  └── RobotApi.stopBodyFollowAction(0)

10.3 ActionListener 关注

类别 取值 含义
onError ERROR_SET_TRACK_FAILED 设置跟踪失败
onError ERROR_TARGET_NOT_FOUND 目标找不到
onError ERROR_FOLLOW_TIME_OUT 跟丢超时
onStatusUpdate STATUS_TRACK_TARGET_SUCCEED 跟踪成功
onStatusUpdate STATUS_GUEST_NEAR 目标靠近
onStatusUpdate STATUS_NAVI_OBSTACLES_AVOID 1 米内有障碍,暂停
onStatusUpdate STATUS_NAVI_OBSTACLES_DISAPPEAR 障碍消失

10.4 销毁清理

onDestroyView 同时 unregisterPersonListener + stopBodyFollowAction,不留尾巴。


11. 引领与巡航 LeadFragment

11.1 默认隐藏

主菜单的 lead_scene 按钮 XML 上 visibility="gone",在 release 形态下默认看不到。
要打开,把 XML 该属性删掉,或在 MainFragment.bindViews 里写 mLead_scene.setVisibility(View.VISIBLE)

11.2 UI

  • 类:LeadFragment
  • 布局:fragment_lead_layout.xml:1 个 EditText + 2 按钮 + 红色提示文本。

11.3 实际按钮 != 函数名

代码里 start_lead_btn 实际调用的是 startCruise()(巡航),不是 startLead()
也就是说当前两个按钮分别是“开始巡航”和“停止引领”,行为不完全对称。startLead() 仍保留作为完整引领示例,可被业务自行调用。

11.4 巡航 startCruise()

List<Pose> route = RobotApi.getInstance().getPlaceList();
route.remove(0);
route.remove(1);                            // 跳过前两个点,业务自定
int startPoint = 0;
List<Integer> dockingPoints = new ArrayList<>();
dockingPoints.add(1);                       // 在路线第 1 个点停留
RobotApi.startCruise(reqId, route, startPoint, dockingPoints, cruiseListener);

cruiseListener 关心:

类别 取值 含义
onStatusUpdate STATUS_START_CRUISE 开始巡航
onStatusUpdate STATUS_CRUISE_REACH_POINT 到达第 N 个点(data 是下标)
onStatusUpdate STATUS_NAVI_AVOID/AVOID_END 路径堵/恢复
onStatusUpdate STATUS_NAVI_OUT_MAP 巡航点不在地图
onResult RESULT_OK 巡航完成
onResult ACTION_RESPONSE_STOP_SUCCESS 主动 stopCruise 成功
onError ACTION_RESPONSE_ALREADY_RUN 巡航已在执行
onError ERROR_NOT_ESTIMATE 未定位
onError ERROR_NAVIGATION_FAILED 巡航点导航失败
onError ACTION_RESPONSE_REQUEST_RES_ERROR 底盘被占用

11.5 引领 startLead()(参考实现)

LeadingParams params = new LeadingParams();
List<Person> personList = PersonApi.getAllBodyList();
Person person = PersonUtils.getBestBody(personList, 3);
params.setPersonId(person.getId());
params.setDestinationName(getLeadPoint());
params.setLostTimer(10 * 1000);             // 10s 跟丢
params.setDetectDelay(5 * 1000);            // 5s 重试
params.setMaxDistance(3);                   // 最大跟随距离 3m
RobotApi.startLead(reqId, params, ActionListener);

错误分支覆盖很全:未定位、目标找不到、已在目的地、避障超时、目的地不存在、引领中操作头部失败、引领已在进行、底盘被占用。
状态分支:STATUS_NAVI_OUT_MAPSTATUS_NAVI_AVOIDSTATUS_NAVI_AVOID_ENDSTATUS_GUEST_FARAWAYSTATUS_DEST_NEARSTATUS_LEAD_NORMAL

11.6 停止 stopLead

RobotApi.stopLead(0, true);

第二参数 isResetHW:是否在停止时把摄像头切回前置(引领期间会切到后置)。
true 表示恢复,false 保持当前状态。


12. 失败页 FailedFragment

  • 类:FailedFragment
  • 布局:fragment_failed_layout.xml
  • 触发:MainActivity.checkInit() 10 次自检失败。
  • 文案:@string/connect_failed

Your sdk init failed
Make sure launch this app from Home Launcher!

按钮:

按钮 行为
exit System.exit(0) 直接结束进程

注意 System.exit(0) 比较粗暴,业务里可以换成 getActivity().finishAndRemoveTask()


13. 公共控件

13.1 BaseFragment

  • 模板方法:onCreateView(LayoutInflater, ViewGroup, Bundle) 加载 fragment_basic_layout 三段式(BackView + rl_content + ResultView),调用子类 onCreateView(Context) 把业务视图塞进 rl_content
  • 提供:showBackView() / hideBackView() / showResultView() / hideResultView()
  • switchFragment(Fragment) 转发给 MainActivity
  • onStop() 时统一 LogTools.clearHistory(),避免页面切换累积日志。

13.2 BackView

  • 布局:layout_back_view.xml
  • 一个绿色的 Back 按钮带左箭头图标。
  • 点击:

    MainActivity.getInstance().switchFragment(MainFragment.newInstance());
    

直接回主菜单,不走 FragmentManager 的回退栈。

13.3 ResultView

  • 布局:layout_result_view.xml
  • 一个 ScrollView+TextView 显示日志,加两个按钮:
    • clear_result(Clear):LogTools.clearHistory() + 清空 textView。
    • recovery_result(RCVY):LogTools.getHistoryText() 重新填回。
  • init 时注册到 LogTools

    LogTools.addLogListener(data -> mTv_result.setText(data));
    

13.4 LogTools

  • 静态全局 StringBuilder mBuilder + 静态 List<OnLogListener> mAllListener
  • info(text):写 logcat + 累加到 mBuilder + 主线程通知所有监听器。
  • clearHistory() / getHistoryText()
  • 由于全局静态,多 Fragment 都会共享同一个累积区BaseFragment.onStop() 才主动清空。

14. 回调接口与线程模型

14.1 主要回调接口

接口 用途 主要方法 出现页
CommandListener 一次性命令结果 onResult(int, String[, String]) Sport / Location / ChargeLeave / DoorCtrl
ActionListener 长任务多阶段事件 onResult / onStatusUpdate / onError Navigation / Charge / DoorAction / Lead / BodyFollow
PersonListener 人脸/人体变化推送 personChanged() Vision / BodyFollow
StatusListener 订阅型状态推送 onStatusUpdate(type, data) DoorControl / NavFragment
TextListener TTS 进度 onStart/onStop/onComplete/onError Speech
SkillCallback 语音技能事件(ASR、音量) 多个 全局(Application)
ModuleCallbackApi 底层 request、HW 异常、控制权变化 onSendRequest/onHWReport/onSuspend/onRecovery 全局(Application)

14.2 回调线程

RobotOSApplication 里:

mApiCallbackThread = new HandlerThread("RobotOSDemo");
mApiCallbackThread.start();
RobotApi.getInstance().setResponseThread(mApiCallbackThread);

意味着 RobotApi 注册的 CommandListener / ActionListener / StatusListener 都在 RobotOSDemo 这个子线程回调。
直接更新 UI 会崩。本项目的解法是通过 LogTools.info -> mMainHandler.post(...) 切回主线程,业务里更新 UI 必须自己切:

getActivity().runOnUiThread(() -> {
    mTextView.setText("...");
});

例如 NavigationFragment.checkoutPassGate 中就是这样切回主线程更新闸机状态。

14.3 reqId 设计

每个 API 都有 reqId,作用是匹配回调和请求。本项目里大多写死为 0,因为没有并发场景;但只要业务里同一接口可能并发触发(比如多 Fragment 同时移动头部),就应该自增防去重。SportFragment 的头部接口就用了 static int reqId = 0; reqId++


15. 错误码 / 状态码速查

来源:com.ainirobot.coreservice.client.Definition(在 app/libs/robotservice.jar 里)。下表摘录代码用到的常量。

15.1 通用

常量 含义
RESULT_OK 成功
RESULT_FAILURE 失败
RESPONSE_OK (字符串状态)正常
SUCCEED 字符串 "succeed"

15.2 导航类错误

常量 含义
ERROR_NOT_ESTIMATE 未定位
ERROR_IN_DESTINATION 已在目的地
ERROR_DESTINATION_NOT_EXIST 目的地不存在
ERROR_DESTINATION_CAN_NOT_ARRAIVE 避障超时不可达
ERROR_NAVIGATION_FAILED 导航失败
ACTION_RESPONSE_ALREADY_RUN 上一个还在执行
ACTION_RESPONSE_REQUEST_RES_ERROR 底盘被占用
ACTION_RESPONSE_STOP_SUCCESS 主动停止成功

15.3 导航类状态

常量 含义
STATUS_NAVI_AVOID 路径被堵
STATUS_NAVI_AVOID_END 障碍解除
STATUS_NAVI_OUT_MAP 目标点超出地图
STATUS_NAVI_GLOBAL_PATH_FAILED 全局路径规划失败
STATUS_START_CRUISE 巡航开始
STATUS_CRUISE_REACH_POINT 到达第 N 个巡航点
STATUS_LEAD_NORMAL 引领开始
STATUS_DEST_NEAR 引领目标接近目的地
STATUS_GUEST_FARAWAY 引领目标距离过远
STATUS_GUEST_NEAR 跟随目标靠近
STATUS_TRACK_TARGET_SUCCEED 跟踪成功
STATUS_NAVI_OBSTACLES_AVOID 1m 内有障碍
STATUS_NAVI_OBSTACLES_DISAPPEAR 障碍消失

15.4 跟随类错误

常量 含义
ERROR_SET_TRACK_FAILED 设置跟踪失败
ERROR_TARGET_NOT_FOUND 目标找不到
ERROR_FOLLOW_TIME_OUT 跟丢超时
ERROR_HEAD 引领中操作头部失败

15.5 电动门

常量 含义
CAN_DOOR_DOOR1_DOOR2_OPEN/CLOSE 上仓门开/关
CAN_DOOR_DOOR3_DOOR4_OPEN/CLOSE 下仓门开/关
CAN_DOOR_ALL_OPEN/CLOSE 全开/全关
CAN_DOOR_STATUS_OPEN/CLOSE/RUNNING 单门状态
CAN_DOOR_STATUS_BLOCK_AND_BOUNCE 关门时被堵反弹
CAN_DOOR_STATUS_BLOCKING_STOP 开门时被堵停止
STATUS_CAN_ELECTRIC_DOOR_CTRL 状态订阅 type
ERROR_ELECTRIC_DOOR_BLOCK / UPPER_BLOCK / LOWER_BLOCK / TIMEOUT 错误码
STATUS_ELECTRIC_DOOR_BLOCK / UPPER_BLOCK / LOWER_BLOCK 状态推送

15.6 注册

常量 含义
REGISTER_REMOTE_TYPE 字段名
REGISTER_REMOTE_NAME 字段名
REGISTER_REMOTE_SERVER_EXIST 用户已存在
REGISTER_REMOTE_SERVER_NEW 新注册成功

15.7 位置点 JSON 字段

常量 含义
JSON_NAVI_TARGET_PLACE_NAME 目标点名
JSON_NAVI_COORDINATE_DEVIATION 坐标容差
JSON_NAVI_IS_IN_LOCATION 是否在指定位置
JSON_NAVI_POSITION_X / Y / THETA 坐标三维

15.8 杂项

常量 含义
MINUTE 60_000 毫秒
ROBOT_SETTINGS_BATTERY_INFO 电量字段名
STATUS_POSE_ESTIMATE 位姿是否定位状态推送
STATUS_POSE_LISTENConstant.CoreDef.POSE_LISTEN 位姿实时推送

16. 模拟器/真机调试与常见坑

16.1 真机

按 README:

直接编译运行需要 JDK 8(本仓库已升级为 JDK 11/17 + AGP 8 也能跑)。必须从机器人 Launcher 点击启动,否则 CoreService 不会授权 -> 自检失败页。

16.2 模拟器

本仓库已加入“模拟器演示模式”:

  • RobotOSApplication.initRobotApi()try/catch 包了 RobotApi.connectServer,连接不上不会崩。
  • MainActivity.isEmulator() 检查 Build.FINGERPRINT/MODEL/HARDWARE,命中模拟器特征则直接进 MainFragment

含义:模拟器上能进主菜单、能看页面布局,但点功能按钮底层调用都会失败(RemoteException 或日志报错),UI 不会崩,可以单独用来验证导航、错误码处理、UI 状态切换。

16.3 常见坑

  1. 更新 UI 在子线程:直接调 setTextsetVisibility 会抛 CalledFromWrongThreadException。请用 getActivity().runOnUiThread(...)
  2. listener 未反注册PersonApi.registerPersonListener / RobotApi.registerStatusListener 注册后必须在 onDestroyView 反注册,否则页面销毁后还在收回调,可能 NPE。
  3. reqId 冲突:本项目里大多写死 0,二次开发并发场景请改成自增。
  4. 底盘资源被占用:当 LeadFragment 巡航中点 NavigationFragment.startNavigation 会触发 ACTION_RESPONSE_REQUEST_RES_ERROR,必须先停止前一个动作。
  5. 闸机识别 UI 状态未重置NavigationFragment.checkoutPassGate 没有在重新点击时把 start_pose_name/end_pose_name 隐藏,可能上一次的按钮还显示着。需要二次开发时加 setVisibility(INVISIBLE)
  6. 录音文件路径/sdcard/audio_*.wav 在 Android 11+ 上需要 manifest 里的 requestLegacyExternalStorage 才能用旧 API 访问。
  7. MainFragment 上的 LeadVision 按钮默认禁用/隐藏:业务接通后需手动启用。
  8. 页面切换不入栈switchFragment 直接 replace,没有 addToBackStack,所以只能通过 BackView 回主菜单,系统返回键直接退出 Activity。
  9. SkillApi 可能为 null:要先确认 RobotOSApplication.getSkillApi() 不是 null,再调 playText / queryByText
  10. stopMove 也要传 reqId:和具体动作的 reqId 不一定要一致,但建议在同一会话里递增。

附录 A:每个 Fragment 的状态字段表(业务保留状态)

Fragment 字段 用途
MainActivity mInstance / checkTimes 单例引用 + 自检计数
SportFragment reqId(static) 头部云台请求自增
SportFragment movingDialog / autoStopHandler 全屏移动遮罩
VisionFragment actionreqID 当前是注册还是识别
LocationFragment mCurrentX/Y/Theta 上次 getPosition 缓存
NavigationFragment currentStatus 闸机三段式进度
ChargeFragment autoChangeStatus 自动回充开关态
BodyFollowFragment isBodyFollowing 防重入 + 跟随阈值
AudioManager mThreadRun / TEST_FILE_NAME 录音线程开关 / 文件名

附录 B:典型扩展任务速查

想做的事 改哪里
新增一个业务页面 1) 新建 XxxFragment extends BaseFragment;2) 新建 fragment_xxx_layout.xml;3) MainFragment 加按钮 + switchFragment(XxxFragment.newInstance())
隐藏 Body Follow fragment_main_layout.xmlbody_follow 节点 android:visibility="gone"
调整移动遮罩超时 SportFragment.showMovingDialog3000 毫秒
修改前进距离 SportFragment.mGo_forwardgoForward(0, 0.4f, ...) 第二参数
切换语音 sid SpeechFragment.playTextTTSEntity 第一个参数
改录音文件路径 AudioManager.TEST_FILE_NAMEstartRecord 里的赋值
改自检超时次数 MainActivity.checkInit()checkTimes > 10
屏蔽模拟器演示模式 MainActivity.isEmulator() 直接 return false