目标读者:拿到
RobotSample想做二次开发的 Android 工程师。
本文不是单纯的“按钮 -> 接口”对照表,而是按页面拆解每一次点击背后的完整链路:UI 状态、API 调用参数、回调线程、错误分支、与CoreService的交互时序、以及常见踩坑点。
MainFragmentSportFragmentVisionFragmentLocationFragmentNavigationFragmentChargeFragmentSpeechFragmentAudioFragmentElectricDoorActionControlFragment / ElectricDoorControlFragmentBodyFollowFragment(已废弃)LeadFragmentFailedFragmentBaseFragment / BackView / ResultView / LogToolsapp/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 等工具
[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。
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。MainFragmentcom.ainirobot.robotos.fragment.MainFragmentfragment_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。
| 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 |
主菜单里同一段代码可以看到两种风格混用:
findViewById + setOnClickListener 命名了字段(如 mLead_scene)。root.findViewById(R.id.electric_door_control).setOnClickListener(...) 不存字段。这只是历史代码风格差异,不影响功能。
SportFragmentSportFragmentfragment_sport_layout.xml,分成两个 LinearLayout:
Forward / Back / Left / Right / Stop。Up / Down / Left / Right。任何前进/后退点击,都会立刻拉起一个全屏黑底 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 这个超时时间。
| 按钮 | 调用 | 入参语义 | 触发遮罩 |
|---|---|---|---|
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 是“旋转指定角度”,结束后自动停转,所以不需要遮罩。接口: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° |
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。
用户按 [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 文字更新
VisionFragmentVisionFragmentfragment_vision_layout.xmlRegister / Find Face 在 XML 里 enabled="false",需要业务条件满足后再启用(例如机器人完成了视觉初始化)。vision_warning:境外版本由于法律限制不提供注册接口。action 字段记录当前用户意图:
private static String REGISTER = "register";
private static String FIND_FACE = "findFace";
private String action = "";
两按钮共用一个监听 mListener,只是 action 不同时分支不同。
点击 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"
startRegister(reqId, name, timeoutMs, faceCount, ?, listener):
name 用作存储 ID,业务里会被替换成真实姓名/UUID。timeoutMs=20000 注册流程超时。faceCount=5 底层要采集的人脸帧数。2 是 SDK 内部 mode,不同版本含义不同。@Override
public void onDestroyView() {
super.onDestroyView();
PersonApi.getInstance().unregisterPersonListener(mListener);
}
防止 Fragment 销毁后还收到回调引发空指针。
mListener 不主动 unregister 会一直高频回调,导致 getPictureById 被反复触发并发问题。代码里在 personChanged() 一开始就 unregister,只处理一帧。getPersonInfoFromNet 是带网络的,需要机器人能联外网。LocationFragmentLocationFragmentfragment_location_layout.xml,3 行 7 个按钮。mCurrentX/Y/Theta:用作 Init Position 的输入,初始值为 0。Get Point 把当前坐标缓存进字段,再点 Init Position。| 按钮 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,未上屏) |
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)。
[Get Point] -> 缓存 mCurrentX/Y/Theta
[Set reception point] -> 当前位置命名为"接待点"
[Init Position] -> 把 mCurrentX/Y/Theta 写入 setPoseEstimate
[Is estimate] -> 检查机器人是否完成定位
[Is in reception?] -> 在 2m 偏差内是否在接待点
[Del Point] -> 移除"接待点"
NavigationFragmentNavigationFragmentfragment_navigation_layout.xmlet_navigation_point:目标点 EditText,hint=“会议间”。start_navigation(绿)/stop_navigation(红)。turn_direction:仅旋转到目标方位。check_pass_gate:判断是否经过闸机;下方 check_pass_gate_status 文本及 start_pose_name、end_pose_name 默认 invisible。currentStatus 跟踪闸机过点进度:
| 值 | 含义 | UI 提示 |
|---|---|---|
| 0 | 还没开始/已重置 | — |
| 1 | 到达第一个闸机点 | 请打开闸机,之后导航到下一个闸机点位 |
| 2 | 到达第二个闸机点 | 请关闭闸机,之后导航至目标点位 |
| 3 | 抵达终点 | 闸机导航结束 |
每次导航成功 currentStatus++,UI 根据值刷新提示。
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 的距离 |
| 错误码 | 含义 |
|---|---|
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 |
障碍物已移除 |
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(); // 释放共享内存
}
ChargeFragmentChargeFragmentfragment_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) + "%");
| 按钮 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,否则系统会自动让它留在充电桩上。
| 类别 | 取值 | 含义 |
|---|---|---|
| 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 |
障碍物已移除 |
SpeechFragmentmSkillApi = RobotOSApplication.getInstance().getSkillApi();
getSkillApi() 内部判断 isApiConnectedService(),未连接返回 null。
因此本页 3 个按钮在每个调用前都做 null 防御。
| 按钮 | 调用 | 行为 |
|---|---|---|
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(...) 写到日志区。
TTSEntity("sid-1234567890", text) 第一个参数是会话 ID,业务上一般用 UUID.randomUUID().toString(),这里写死方便演示。
SpeechFragment 之前 RobotOSApplication 必须完成 initSkillApi(),否则 mSkillApi 为 null,所有按钮都不响应(不会崩)。queryByText 触发的语音回调(识别结果、ASR 内容)会到 SpeechCallback 全局回调里,不在本页直接处理。AudioFragmentAudioFragmentfragment_audio_layout.xml,2 按钮 start / stop。进入时申请权限:
requestPermissions(new String[]{
Manifest.permission.RECORD_AUDIO,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
}, 1);
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 改成实际文件长度,让其他播放器能正确读时长。
| 按钮 | 行为 |
|---|---|
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) |
bufferSize=48000 是直接写死的(对应约 0.5s 单声道 16bit 样本),实际项目里建议直接用 getMinBufferSize 的返回值。/sdcard/ 在 Android 10+ 受限,真机上可能因为 requestLegacyExternalStorage="true"(在 manifest 里)才能正常写入。stopRecord,如果业务需要请在 onDestroyView 里补。仓内有两份实现,建议先看 ElectricDoorActionControlFragment(主菜单按钮跳转的就是它),再看 ElectricDoorControlFragment 作为对照。
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 |
阻塞状态 |
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) 主动拉一次状态。
在执行开/关命令前先检查门是否在
RUNNING状态,运动中再下指令会失败或被忽略。
伪代码:
if (door.isRunning()) return;
RobotApi.startControlElectricDoor(...);
@Override
public void onDestroyView() {
super.onDestroyView();
RobotApi.unregisterStatusListener(statusListener);
}
BodyFollowFragment(Deprecated)@Deprecated,注释“老版本 7.9 可以支持,新版本不再支持”。fragment_body_follow_layout.xml,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)
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 |
障碍消失 |
onDestroyView 同时 unregisterPersonListener + stopBodyFollowAction,不留尾巴。
LeadFragment主菜单的 lead_scene 按钮 XML 上 visibility="gone",在 release 形态下默认看不到。
要打开,把 XML 该属性删掉,或在 MainFragment.bindViews 里写 mLead_scene.setVisibility(View.VISIBLE)。
LeadFragmentfragment_lead_layout.xml:1 个 EditText + 2 按钮 + 红色提示文本。代码里 start_lead_btn 实际调用的是 startCruise()(巡航),不是 startLead()。
也就是说当前两个按钮分别是“开始巡航”和“停止引领”,行为不完全对称。startLead() 仍保留作为完整引领示例,可被业务自行调用。
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 |
底盘被占用 |
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_MAP、STATUS_NAVI_AVOID、STATUS_NAVI_AVOID_END、STATUS_GUEST_FARAWAY、STATUS_DEST_NEAR、STATUS_LEAD_NORMAL。
stopLeadRobotApi.stopLead(0, true);
第二参数 isResetHW:是否在停止时把摄像头切回前置(引领期间会切到后置)。
true 表示恢复,false 保持当前状态。
FailedFragmentFailedFragmentfragment_failed_layout.xmlMainActivity.checkInit() 10 次自检失败。@string/connect_failedYour sdk init failed
Make sure launch this app from Home Launcher!
按钮:
| 按钮 | 行为 |
|---|---|
exit |
System.exit(0) 直接结束进程 |
注意
System.exit(0)比较粗暴,业务里可以换成getActivity().finishAndRemoveTask()。
BaseFragmentonCreateView(LayoutInflater, ViewGroup, Bundle) 加载 fragment_basic_layout 三段式(BackView + rl_content + ResultView),调用子类 onCreateView(Context) 把业务视图塞进 rl_content。showBackView() / hideBackView() / showResultView() / hideResultView()。switchFragment(Fragment) 转发给 MainActivity。onStop() 时统一 LogTools.clearHistory(),避免页面切换累积日志。BackViewlayout_back_view.xmlBack 按钮带左箭头图标。点击:
MainActivity.getInstance().switchFragment(MainFragment.newInstance());
直接回主菜单,不走 FragmentManager 的回退栈。
ResultViewlayout_result_view.xmlScrollView+TextView 显示日志,加两个按钮:
clear_result(Clear):LogTools.clearHistory() + 清空 textView。recovery_result(RCVY):LogTools.getHistoryText() 重新填回。在 init 时注册到 LogTools:
LogTools.addLogListener(data -> mTv_result.setText(data));
LogToolsStringBuilder mBuilder + 静态 List<OnLogListener> mAllListener。info(text):写 logcat + 累加到 mBuilder + 主线程通知所有监听器。clearHistory() / getHistoryText()。BaseFragment.onStop() 才主动清空。| 接口 | 用途 | 主要方法 | 出现页 |
|---|---|---|---|
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) |
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 中就是这样切回主线程更新闸机状态。
每个 API 都有 reqId,作用是匹配回调和请求。本项目里大多写死为 0,因为没有并发场景;但只要业务里同一接口可能并发触发(比如多 Fragment 同时移动头部),就应该自增防去重。SportFragment 的头部接口就用了 static int reqId = 0; reqId++。
来源:com.ainirobot.coreservice.client.Definition(在 app/libs/robotservice.jar 里)。下表摘录代码用到的常量。
| 常量 | 含义 |
|---|---|
RESULT_OK |
成功 |
RESULT_FAILURE |
失败 |
RESPONSE_OK |
(字符串状态)正常 |
SUCCEED |
字符串 "succeed" |
| 常量 | 含义 |
|---|---|
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 |
主动停止成功 |
| 常量 | 含义 |
|---|---|
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 |
障碍消失 |
| 常量 | 含义 |
|---|---|
ERROR_SET_TRACK_FAILED |
设置跟踪失败 |
ERROR_TARGET_NOT_FOUND |
目标找不到 |
ERROR_FOLLOW_TIME_OUT |
跟丢超时 |
ERROR_HEAD |
引领中操作头部失败 |
| 常量 | 含义 |
|---|---|
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 |
状态推送 |
| 常量 | 含义 |
|---|---|
REGISTER_REMOTE_TYPE |
字段名 |
REGISTER_REMOTE_NAME |
字段名 |
REGISTER_REMOTE_SERVER_EXIST |
用户已存在 |
REGISTER_REMOTE_SERVER_NEW |
新注册成功 |
| 常量 | 含义 |
|---|---|
JSON_NAVI_TARGET_PLACE_NAME |
目标点名 |
JSON_NAVI_COORDINATE_DEVIATION |
坐标容差 |
JSON_NAVI_IS_IN_LOCATION |
是否在指定位置 |
JSON_NAVI_POSITION_X / Y / THETA |
坐标三维 |
| 常量 | 含义 |
|---|---|
MINUTE |
60_000 毫秒 |
ROBOT_SETTINGS_BATTERY_INFO |
电量字段名 |
STATUS_POSE_ESTIMATE |
位姿是否定位状态推送 |
STATUS_POSE_LISTEN(Constant.CoreDef.POSE_LISTEN) |
位姿实时推送 |
按 README:
直接编译运行需要 JDK 8(本仓库已升级为 JDK 11/17 + AGP 8 也能跑)。必须从机器人 Launcher 点击启动,否则
CoreService不会授权 -> 自检失败页。
本仓库已加入“模拟器演示模式”:
RobotOSApplication.initRobotApi() 用 try/catch 包了 RobotApi.connectServer,连接不上不会崩。MainActivity.isEmulator() 检查 Build.FINGERPRINT/MODEL/HARDWARE,命中模拟器特征则直接进 MainFragment。含义:模拟器上能进主菜单、能看页面布局,但点功能按钮底层调用都会失败(RemoteException 或日志报错),UI 不会崩,可以单独用来验证导航、错误码处理、UI 状态切换。
setText 或 setVisibility 会抛 CalledFromWrongThreadException。请用 getActivity().runOnUiThread(...)。PersonApi.registerPersonListener / RobotApi.registerStatusListener 注册后必须在 onDestroyView 反注册,否则页面销毁后还在收回调,可能 NPE。reqId 冲突:本项目里大多写死 0,二次开发并发场景请改成自增。LeadFragment 巡航中点 NavigationFragment.startNavigation 会触发 ACTION_RESPONSE_REQUEST_RES_ERROR,必须先停止前一个动作。NavigationFragment.checkoutPassGate 没有在重新点击时把 start_pose_name/end_pose_name 隐藏,可能上一次的按钮还显示着。需要二次开发时加 setVisibility(INVISIBLE)。/sdcard/audio_*.wav 在 Android 11+ 上需要 manifest 里的 requestLegacyExternalStorage 才能用旧 API 访问。MainFragment 上的 Lead、Vision 按钮默认禁用/隐藏:业务接通后需手动启用。switchFragment 直接 replace,没有 addToBackStack,所以只能通过 BackView 回主菜单,系统返回键直接退出 Activity。SkillApi 可能为 null:要先确认 RobotOSApplication.getSkillApi() 不是 null,再调 playText / queryByText。stopMove 也要传 reqId:和具体动作的 reqId 不一定要一致,但建议在同一会话里递增。| 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 |
录音线程开关 / 文件名 |
| 想做的事 | 改哪里 |
|---|---|
| 新增一个业务页面 | 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 |