# 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`): ```java 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` 容器,不入栈: ```java 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` - 进入时主动隐藏公共控件: ```java @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`: ```java 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=0`,`distance=0.4m` | 是 | | `go_back` | `RobotApi.goBackward(0, 0.3f, mMotionListener)` | `reqId=0`,`distance=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 通用回调 ```java 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` 字段记录当前用户意图: ```java 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 生命周期清理 ```java @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 防御逻辑 ```java 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_name`、`end_pose_name` 默认 `invisible`。 ### 5.2 状态机:闸机导航三段式 `currentStatus` 跟踪闸机过点进度: | 值 | 含义 | UI 提示 | | --- | --- | --- | | 0 | 还没开始/已重置 | — | | 1 | 到达第一个闸机点 | `请打开闸机,之后导航到下一个闸机点位` | | 2 | 到达第二个闸机点 | `请关闭闸机,之后导航至目标点位` | | 3 | 抵达终点 | `闸机导航结束` | 每次导航成功 `currentStatus++`,UI 根据值刷新提示。 ### 5.3 按钮调用细节 ```java 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: | 接口 | 形参 | 用途 | | --- | --- | --- | | `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 的距离 | ### 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 错误码(导航专属) | 错误码 | 含义 | | --- | --- | | `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` 的资源释放: ```java @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` 时打印当前电量: ```java 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` 关键代码: ```java 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 关键依赖 ```java 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)` | 把文本作为一次语音指令交给技能引擎 | `mTextListener`(`TextListener`): | 回调 | 触发时机 | | --- | --- | | `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`。 - 进入时申请权限: ```java requestPermissions(new String[]{ Manifest.permission.RECORD_AUDIO, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, }, 1); ``` ### 8.2 录制实现 `AudioManager` 单例(`Singleton`),关键参数: | 项 | 值 | | --- | --- | | 采样率 `AUDIO_RATE` | `48000` | | 声道 | 默认传入 `CHANNEL_IN_MONO`,也支持 `CHANNEL_IN_STEREO` | | 编码 | `ENCODING_PCM_16BIT` | | 缓冲区 | `recBufSize = AudioRecord.getMinBufferSize(...) * 2` | | 输出文件 | `/sdcard/audio_.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)`;`callback` 在 `LogTools.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`。 - 进入页面时注册门状态订阅: ```java 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`(开时被堵) | 按钮调用: ```java RobotApi.setElectricDoorCtrl(0, doorCmd, new CommandListener() { ... }); ``` `get_door_status` -> `RobotApi.getElectricDoorStatus(0, listener)` 主动拉一次状态。 ### 9.3 业务约束 > 在执行开/关命令前先检查门是否在 `RUNNING` 状态,运动中再下指令会失败或被忽略。 伪代码: ```java if (door.isRunning()) return; RobotApi.startControlElectricDoor(...); ``` ### 9.4 销毁清理 ```java @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()` ```java List route = RobotApi.getInstance().getPlaceList(); route.remove(0); route.remove(1); // 跳过前两个点,业务自定 int startPoint = 0; List 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()`(参考实现) ```java LeadingParams params = new LeadingParams(); List 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_MAP`、`STATUS_NAVI_AVOID`、`STATUS_NAVI_AVOID_END`、`STATUS_GUEST_FARAWAY`、`STATUS_DEST_NEAR`、`STATUS_LEAD_NORMAL`。 ### 11.6 停止 `stopLead` ```java 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` 按钮带左箭头图标。 - 点击: ```java 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`: ```java LogTools.addLogListener(data -> mTv_result.setText(data)); ``` ### 13.4 `LogTools` - 静态全局 `StringBuilder mBuilder` + 静态 `List 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` 里: ```java mApiCallbackThread = new HandlerThread("RobotOSDemo"); mApiCallbackThread.start(); RobotApi.getInstance().setResponseThread(mApiCallbackThread); ``` 意味着 `RobotApi` 注册的 `CommandListener / ActionListener / StatusListener` 都在 `RobotOSDemo` 这个子线程回调。 **直接更新 UI 会崩**。本项目的解法是通过 `LogTools.info -> mMainHandler.post(...)` 切回主线程,业务里更新 UI 必须自己切: ```java 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_LISTEN`(`Constant.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 在子线程**:直接调 `setText` 或 `setVisibility` 会抛 `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` 上的 `Lead`、`Vision` 按钮默认禁用/隐藏**:业务接通后需手动启用。 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 | `action`、`reqID` | 当前是注册还是识别 | | 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.xml` 把 `body_follow` 节点 `android:visibility="gone"` | | 调整移动遮罩超时 | `SportFragment.showMovingDialog` 的 `3000` 毫秒 | | 修改前进距离 | `SportFragment.mGo_forward` 处 `goForward(0, 0.4f, ...)` 第二参数 | | 切换语音 sid | `SpeechFragment.playText` 的 `TTSEntity` 第一个参数 | | 改录音文件路径 | `AudioManager.TEST_FILE_NAME` 与 `startRecord` 里的赋值 | | 改自检超时次数 | `MainActivity.checkInit()` 的 `checkTimes > 10` | | 屏蔽模拟器演示模式 | `MainActivity.isEmulator()` 直接 `return false` |