# 仿鸿蒙机器人系统技术方案
> **版本**: v2.0
> **日期**: 2026-04-23
> **编写**: 技术团队
> **状态**: 待评审
---
## 修改记录
| 版本 | 日期 | 修改人 | 修改内容 |
|------|------|--------|---------|
| v1.0 | 2026-04-23 | 技术团队 | 初稿完成 |
| v2.0 | 2026-04-23 | 技术团队 | 根据猎户星空厂商会议反馈更新:Java 开发语言、真实 SDK API、Android 9.0 适配 |
---
## 一、项目概述与目标
### 1.1 迎检需求背景
本项目面向领导视察场景,目标是在猎户星空豹小秘系列机器人上部署一套**智慧医疗导诊系统**。机器人基于 **Android 9.0(API 28)+ RobotOS** 定制系统,需在视觉上伪装为 HarmonyOS 4 风格,同时保留完整的导航带路能力。现有业务系统(Vue 前端 + Spring Boot 后端)已完成功能开发,现需构建原生安卓外壳以适配机器人硬件环境。
### 1.2 四条硬性约束
| 约束编号 | 约束内容 | 原因 |
|----------|---------|------|
| C1 | **不改机器人系统** | 无系统刷机权限,OTA 升级由厂商控制 |
| C2 | **必须使用猎户星空原生 SDK** | 导航、TTS、电量等能力只有官方 SDK 能提供 |
| C3 | **2 人全栈团队,1 周交付,Java 开发** | 人力资源和时间窗口极为有限,方案必须极简,开发语言统一为 Java |
| C4 | **纯视觉伪装,不 claim 真鸿蒙** | 避免法律风险,仅 UI 层面模仿 HarmonyOS 4 风格 |
### 1.3 迎检成功标准
1. 开机后进入仿鸿蒙桌面,视觉风格以假乱真
2. 点击"智慧医疗"图标正常进入业务系统
3. 用户对话后推荐科室,点击"带我去"机器人实际移动带路
4. 全程无系统弹窗、无崩溃、无白屏
5. 对外口径统一为"仿鸿蒙风格 UI 定制",不对外宣称已部署正式 HarmonyOS 系统,避免合规风险
---
## 二、整体架构设计
### 2.1 三层架构图
```mermaid
graph TB
subgraph 表层["表层:仿 HarmonyOS 4 Launcher App(APK #1)"]
L1[桌面主页]
L2[下拉控制中心]
L3[假设置页]
end
subgraph 中层["中层:原生业务 App(APK #2)"]
W[WebView 容器]
JB[JSBridge 桥接层]
SDK[猎户星空 SDK]
end
subgraph 底层["底层:RobotOS 原生系统"]
OS[Android 系统]
NAV[导航服务]
TTS[TTS 引擎]
end
L1 -->|Intent 启动| W
W -->|JS 调用| JB
JB -->|SDK API| SDK
SDK -->|RobotOS Service| NAV
SDK -->|RobotOS Service| TTS
OS -->|系统服务| NAV
OS -->|系统服务| TTS
```
### 2.2 各层职责边界
| 层级 | APK 数量 | 核心职责 | 技术栈 |
|------|---------|---------|--------|
| 表层 Launcher | 1 | 替代系统桌面,提供仿鸿蒙 UI 壳 | Android Native(Java) |
| 中层业务 App | 1 | WebView 加载 H5,桥接原生能力 | Android Native + Vue H5 |
| 底层系统 | 0(不动) | 提供导航、语音、电量等硬件能力 | RobotOS(Android 定制版) |
### 2.3 数据流与调用链路
1. **开机启动**:SDK 注册自启(Action: `action.orionstar.default.app`)→ Launcher App 自启 → 显示仿鸿蒙桌面
2. **进入业务**:用户点击"智慧医疗"图标 → Launcher 发送 Intent 启动业务 App
3. **业务交互**:WebView 加载 `http://localhost:8080` → 用户与 AI 对话
4. **触发导航**:H5 调用 `RobotBridge.navigate("内科门诊")` → JSBridge → `RobotApi.getInstance().startNavigation()` → 机器人移动
5. **返回桌面**:用户按 Home 键 → 业务 App 退后台 → Launcher 回到前台
### 2.4 两个 APK 的关系
Launcher App 与业务 App 为**两个独立 APK**,通过标准 Android Intent 机制交互:
- Launcher 不直接包含业务逻辑,仅作为入口和视觉伪装层
- 业务 App 包内嵌 WebView,加载本地或局域网 H5 页面
- 业务 App 不处理 HOME 键,按 Home 时系统回调 Launcher(因 Launcher 是默认桌面)
- **已知风险**:按 Home 键会回到原厂桌面而非自定义 Launcher(需通过设为默认桌面规避)
---
## 三、仿鸿蒙 Launcher App 详细设计
### 3.1 UI 设计规范
参考 HarmonyOS 4 横屏桌面视觉特征:
- **圆角大图标卡片**:应用图标采用 24dp 圆角矩形,尺寸 72x72dp,带轻量投影
- **毛玻璃效果**:控制中心、文件夹背景使用 `BlurMaskFilter` / `RenderScript` 实现高斯模糊
- **底部 Dock 栏**:横屏下 Dock 位于底部,固定 4-5 个高频应用,背景为半透明白色 `rgba(255,255,255,0.2)`
- **时间日期 Widget**:桌面右上角显示大号时间(HH:mm)和日期(MM月dd日 星期X),字体使用鸿蒙风格无衬线体
- **壁纸风格**:蓝紫渐变 / 淡雅风景图,与系统默认壁纸拉开差异以显"新系统"感
#### 3.1.1 精确设计参数
**配色方案(HarmonyOS 4 风格)**
| 用途 | 颜色值 | 说明 |
|------|--------|------|
| 桌面背景渐变起始 | #1A1A2E | 深蓝黑 |
| 桌面背景渐变结束 | #16213E | 靛蓝 |
| 图标卡片背景 | #FFFFFF 15% opacity | 毛玻璃白 |
| 图标卡片按下态 | #FFFFFF 25% opacity | 按下反馈 |
| 图标标签文字 | #FFFFFF | 纯白 |
| Dock 栏背景 | #000000 30% opacity | 半透明黑 |
| 控制中心背景 | #1A1A2E 95% opacity | 近不透明深色 |
| 控制中心开关-开启 | #007DFF | 鸿蒙蓝 |
| 控制中心开关-关闭 | #404040 | 深灰 |
| 时间文字 | #FFFFFF | 纯白 |
| 日期文字 | #FFFFFF 70% opacity | 半透明白 |
| 设置页背景 | #F1F3F5 | 浅灰白 |
| 设置页卡片 | #FFFFFF | 纯白 |
| 设置页标题文字 | #000000 | 纯黑 |
| 设置页副文字 | #999999 | 灰色 |
**尺寸规范(基于 10 寸横屏 1280x800 分辨率)**
| 元素 | 尺寸 | 说明 |
|------|------|------|
| 桌面图标卡片 | 80x80dp | 含内边距的整体触控区域 |
| 图标内图像 | 56x56dp | 实际图标大小 |
| 图标卡片圆角 | 20dp | HarmonyOS 标志性大圆角 |
| 图标标签字号 | 12sp | 图标下方文字 |
| 图标网格间距 | 24dp | 图标之间的间距 |
| Dock 栏高度 | 64dp | 底部固定栏 |
| Dock 栏圆角 | 24dp | 上方圆角 |
| Dock 栏内图标 | 48x48dp | 稍小于桌面图标 |
| 控制中心圆角 | 24dp | 卡片圆角 |
| 控制中心开关 | 64x64dp | 单个开关触控区域 |
| 时间字号 | 48sp | 桌面时钟 |
| 日期字号 | 14sp | 日期文字 |
| 设置页列表项高度 | 56dp | 单行设置项 |
| 毛玻璃模糊半径 | 25px(RenderScript) | 背景模糊效果 |
| 卡片投影 | elevation 4dp, color #00000020 | 微弱投影 |
### 3.2 核心页面
#### 页面 1:桌面主页(Landscape)
- 横屏网格布局:2 行 x 4 列应用图标
- 应用列表:智慧医疗(真入口)、设置、相机、文件管理、日历、时钟、计算器、相册
- 仅"智慧医疗"可点击,其余为装饰性图标(点击可弹出"功能开发中"Toast)
- 右上角放置时间日期 Widget
#### 页面 2:下拉控制中心
- 触发方式:单指从屏幕顶部 50px 区域下拉
- 布局:圆角卡片(16dp 圆角),分为快捷开关区 + 滑块区
- 快捷开关(假交互):WiFi、蓝牙、移动数据、飞行模式、手电筒、截图
- 滑块:亮度、音量(仅 UI 滑动效果,不修改系统值)
- 所有开关点击仅切换本地 UI 状态(图标变色),不做真实系统调用
#### 页面 3:假设置页面
- 入口:桌面点击"设置"图标进入
- 内容:
- 设备名称:豹小秘 Pro
- 系统主题:HarmonyOS 风格 4.0
- 处理器:Kirin 9000S(假数据)
- 运行内存:8 GB
- 存储空间:128 GB
- 序列号:模拟 SN 号
### 3.3 技术实现要点
**AndroidManifest 声明 HOME**:
```xml
```
**SDK 注册自启(主要方式)与 BOOT_COMPLETED 自启(备用方案)**:
主要方式:在 `AndroidManifest.xml` 中为 `LauncherActivity` 声明 SDK 注册的 intent-filter `action.orionstar.default.app`,以及 URL Scheme `jerry://main`:
```xml
```
备用方案:`BOOT_COMPLETED` 广播接收:
```xml
```
> **注意**:主要自启方式为猎户星空 SDK 的 `action.orionstar.default.app` 注册机制,`BOOT_COMPLETED` 作为备用/补充方案。若未声明 `RECEIVE_BOOT_COMPLETED` 权限,备用自启逻辑将失效。
**下拉手势拦截**:
```java
public class LauncherActivity extends Activity {
private GestureDetector gestureDetector;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float vx, float vy) {
if (e1 != null && e1.getY() < 100 && vy > 200) {
showControlCenter();
return true;
}
return false;
}
});
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
}
```
**全屏沉浸式**:
```java
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
);
```
### 3.4 Launcher 工程搭建指南(面向 Web 开发者)
本节面向不熟悉 Android 开发的 Web 工程师(Java + Vue 背景),提供从零开始创建 Launcher 工程的完整步骤。
#### 3.4.1 Android Studio 创建项目
1. 打开 Android Studio → File → New → New Project
2. 选择 **"Empty Views Activity"**(**不要选 Compose**,因为 Web 开发者更熟悉 XML 布局方式)
3. 在配置向导中填写:
- **Name**: `HarmonyLauncher`
- **Package name**: `com.emoon.harmony.launcher`
- **Language**: **Java**(Web 开发者更熟悉的语言,与 Vue 后端开发语言一致)
- **Minimum SDK**: **API 19 (Android 4.4)**(兼容更多老旧设备)
- **Build configuration language**: **Groovy DSL (build.gradle)**(传统 Android 构建配置方式)
4. 点击 **Finish**,等待 Gradle Sync 完成(首次可能需要下载依赖,约 5-10 分钟)
5. Sync 完成后,在左侧 Project 面板确认 `app/src/main/java/com/emoon/harmony/launcher/` 目录已生成
> **给 Web 开发者的提示**:Android Studio 的 Gradle Sync 类似于 `npm install`,会在首次打开项目时下载所有依赖。如果遇到网络问题,可在 `File → Settings → Build → Gradle` 中配置国内镜像源。
#### 3.4.2 完整 build.gradle(Module: app)
打开 `app/build.gradle`,替换为以下内容。每一行都加了中文注释,帮助理解其用途:
```groovy
// 应用的 Gradle 构建脚本(类比 Vue 项目的 package.json + vite.config.js)
apply plugin: 'com.android.application'
android {
// 编译使用的 SDK 版本(API 29 = Android 10)
compileSdkVersion 29
// 默认配置块(类比 package.json 中的字段)
defaultConfig {
// 应用包名,全局唯一标识
applicationId "com.emoon.harmony.launcher"
// 最低支持 Android 版本(API 19 = Android 4.4)
minSdkVersion 19
// 目标 Android 版本(API 28 = Android 9.0)
targetSdkVersion 28
// 应用版本号(内部数字版本)
versionCode 1
// 应用版本名称(对外显示)
versionName "1.0.0"
}
// 构建类型配置
buildTypes {
// 发布构建(类比 npm run build --production)
release {
// 开启代码混淆(保护源码,减小包体积)
minifyEnabled false
// 混淆规则文件
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// 编译选项
compileOptions {
// 源代码兼容 Java 8
sourceCompatibility JavaVersion.VERSION_1_8
// 目标字节码兼容 Java 8
targetCompatibility JavaVersion.VERSION_1_8
}
// 构建特性开关
buildFeatures {
// 启用 ViewBinding(类似 Vue 的模板绑定,自动生成视图引用)
viewBinding true
}
}
// 依赖声明(类比 package.json 中的 dependencies)
dependencies {
// Android 核心支持库(AppCompat 兼容旧版本)
implementation 'androidx.appcompat:appcompat:1.2.0'
// Material Design 组件库(提供 CardView 等组件)
implementation 'com.google.android.material:material:1.3.0'
// RecyclerView 列表组件(高性能列表,类比 Vue 的 v-for)
implementation 'androidx.recyclerview:recyclerview:1.1.0'
// ConstraintLayout 约束布局(灵活布局,类比 CSS Flexbox)
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
// CardView 卡片组件(圆角卡片容器)
implementation 'androidx.cardview:cardview:1.0.0'
}
```
> **说明**:这是一个纯 Launcher 工程,**不需要集成猎户星空 SDK**(SDK 集成在独立的业务 App 中)。这样 Launcher APK 体积更小,编译更快。
#### 3.4.3 完整 AndroidManifest.xml
打开 `app/src/main/AndroidManifest.xml`,替换为以下内容:
```xml
```
> **关键说明**:
> - `HOME` + `DEFAULT` category 的组合使此 Activity 能被系统识别为候选桌面
> - `screenOrientation="landscape"` 锁定横屏(机器人屏幕为横屏)
> - `excludeFromRecents="true"` 防止桌面出现在最近任务列表中
> - 主要自启方式为 SDK 注册(`action.orionstar.default.app`),`RECEIVE_BOOT_COMPLETED` 权限是 BootReceiver 备用自启方案生效的前提
#### 3.4.4 工程目录结构
创建所有文件后,完整目录结构如下:
```
HarmonyLauncher/
├── app/
│ ├── src/
│ │ └── main/
│ │ ├── java/com/emoon/harmony/launcher/
│ │ │ ├── LauncherActivity.java # 桌面主页 Activity
│ │ │ ├── FakeSettingsActivity.java # 假设置页 Activity
│ │ │ ├── LauncherApplication.java # 自定义 Application(可选)
│ │ │ ├── AppGridAdapter.java # 图标网格适配器
│ │ │ ├── AppItem.java # 应用数据类
│ │ │ ├── ControlCenterView.java # 下拉控制中心自定义 View
│ │ │ └── BootReceiver.java # 开机自启广播接收器(备用方案)
│ │ ├── res/
│ │ │ ├── layout/
│ │ │ │ ├── activity_launcher.xml # 桌面主布局
│ │ │ │ ├── activity_fake_settings.xml # 设置页布局
│ │ │ │ ├── item_app_icon.xml # 单个图标卡片布局
│ │ │ │ └── item_setting.xml # 设置列表单项布局
│ │ │ ├── drawable/
│ │ │ │ ├── bg_gradient.xml # 桌面背景渐变
│ │ │ │ ├── bg_icon_card.xml # 图标卡片背景
│ │ │ │ ├── bg_icon_card_pressed.xml # 图标卡片按下态
│ │ │ │ ├── bg_dock.xml # Dock 栏背景
│ │ │ │ ├── bg_control_center.xml # 控制中心背景
│ │ │ │ ├── bg_switch_on.xml # 开关开启态
│ │ │ │ ├── bg_switch_off.xml # 开关关闭态
│ │ │ │ ├── ic_medical.xml # 智慧医疗图标
│ │ │ │ ├── ic_settings.xml # 设置图标
│ │ │ │ ├── ic_camera.xml # 相机图标
│ │ │ │ ├── ic_files.xml # 文件管理图标
│ │ │ │ ├── ic_calendar.xml # 日历图标
│ │ │ │ ├── ic_clock.xml # 时钟图标
│ │ │ │ ├── ic_calculator.xml # 计算器图标
│ │ │ │ ├── ic_weather.xml # 天气图标
│ │ │ │ ├── ic_music.xml # 音乐图标
│ │ │ │ └── ic_gallery.xml # 图库图标
│ │ │ ├── values/
│ │ │ │ ├── colors.xml # 颜色定义
│ │ │ │ ├── dimens.xml # 尺寸定义
│ │ │ │ ├── strings.xml # 字符串资源
│ │ │ │ └── themes.xml # 主题样式
│ │ │ └── mipmap-xxxhdpi/
│ │ │ ├── ic_launcher.png # 应用图标
│ │ │ └── ic_launcher_round.png # 圆形应用图标
│ │ └── AndroidManifest.xml # 应用清单文件
│ └── build.gradle # 模块构建配置
├── build.gradle # 项目级构建配置
├── settings.gradle # 项目设置
└── gradle.properties # Gradle 属性配置
```
> **给 Web 开发者的对照说明**:
> - `res/layout/` ≈ Vue 的 `template/` 目录(XML 声明 UI 结构)
> - `res/drawable/` ≈ CSS / SVG(定义颜色、形状、图标)
> - `res/values/` ≈ CSS 变量 / 主题配置
> - `AndroidManifest.xml` ≈ `index.html` + 路由配置 + 权限声明
### 3.5 Launcher 核心代码完整实现
以下是小节编号与源码文件的对照索引:
| 小节 | 文件 | 说明 |
|------|------|------|
| 3.5.1 | `LauncherActivity.java` | 桌面主页 Activity,全屏沉浸、手势检测、图标网格 |
| 3.5.2 | `activity_launcher.xml` | 桌面主布局 XML |
| 3.5.3 | `AppItem.java` | 应用数据类 |
| 3.5.4 | `AppGridAdapter.java` | 图标网格适配器 |
| 3.5.5 | `item_app_icon.xml` | 单个图标卡片布局 |
| 3.5.6 | `ControlCenterView.java` | 下拉控制中心自定义 View |
| 3.5.7 | `control_center` 相关 XML | 控制中心布局 |
| 3.5.8 | `FakeSettingsActivity.java` | 假设置页 Activity |
| 3.5.9 | 设置页布局 XML | `activity_fake_settings.xml` + `item_setting.xml` |
| 3.5.10 | `BootReceiver.java` | 开机自启广播接收器(备用方案) |
| 3.5.11 | 默认桌面设置方法 | 详细步骤 + adb 备选方案 |
| 3.5.12 | `colors.xml` | 配色资源 |
| 3.5.13 | `dimens.xml` | 尺寸资源 |
| 3.5.14 | `themes.xml` | 主题样式 |
| 3.5.15 | `drawable/` 背景 XML | 渐变、卡片、Dock 等背景 |
#### 3.5.1 LauncherActivity.java 完整代码
这是 Launcher 的核心 Activity,负责桌面展示、图标网格、手势检测和时间更新。每一行都加了中文注释。
```java
package com.emoon.harmony.launcher;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;
import android.widget.Toast;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
/**
* 桌面主页 Activity
* 这是用户开机后看到的第一个页面,负责展示仿鸿蒙桌面 UI
*/
public class LauncherActivity extends Activity {
// 桌面图标网格 RecyclerView(类比 Vue 的列表渲染)
private RecyclerView recyclerView;
// 下拉控制中心容器视图
private View controlCenterView;
// 手势检测器(用于识别下拉手势)
private GestureDetector gestureDetector;
/**
* Activity 创建时调用(类比 Vue 的 mounted 生命周期)
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 第一步:设置全屏沉浸式,隐藏状态栏和导航栏
hideSystemUI();
// 第二步:加载 XML 布局文件
setContentView(R.layout.activity_launcher);
// 第三步:初始化桌面图标网格
setupAppGrid();
// 第四步:初始化下拉手势检测
setupGestureDetector();
// 第五步:启动时间显示更新
updateTimeDisplay();
}
/**
* 隐藏系统状态栏和导航栏,实现全屏沉浸式体验
* 这是仿鸿蒙桌面的关键:必须完全隐藏系统 UI,否则会暴露 Android 原生界面
*/
private void hideSystemUI() {
// 设置窗口为全屏模式(隐藏顶部状态栏)
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
);
// 设置系统 UI 可见性标志(沉浸式模式)
window.getDecorView().setSystemUiVisibility(
// 粘性沉浸模式:用户滑动边缘时系统 UI 暂时出现,几秒后自动隐藏
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
// 隐藏导航栏(底部虚拟按键)
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
// 隐藏状态栏(顶部时间、电量等)
| View.SYSTEM_UI_FLAG_FULLSCREEN
// 保持布局稳定,防止系统栏显示/隐藏时布局跳动
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
// 允许布局延伸到全屏区域
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// 允许布局延伸到导航栏区域
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
);
}
/**
* 初始化桌面图标网格 RecyclerView
* 使用 5 列网格布局,展示所有应用图标
*/
private void setupAppGrid() {
recyclerView = findViewById(R.id.rv_app_grid);
// 设置网格布局管理器:5 列(横屏下每行显示 5 个图标)
recyclerView.setLayoutManager(new GridLayoutManager(this, 5));
// 创建预定义的应用列表(10 个应用图标)
List appList = new ArrayList<>(Arrays.asList(
new AppItem("智慧医疗", R.drawable.ic_medical, AppItem.Type.BUSINESS),
new AppItem("设置", R.drawable.ic_settings, AppItem.Type.SETTINGS),
new AppItem("相机", R.drawable.ic_camera, AppItem.Type.FAKE),
new AppItem("文件管理", R.drawable.ic_files, AppItem.Type.FAKE),
new AppItem("日历", R.drawable.ic_calendar, AppItem.Type.FAKE),
new AppItem("时钟", R.drawable.ic_clock, AppItem.Type.FAKE),
new AppItem("计算器", R.drawable.ic_calculator, AppItem.Type.FAKE),
new AppItem("天气", R.drawable.ic_weather, AppItem.Type.FAKE),
new AppItem("音乐", R.drawable.ic_music, AppItem.Type.FAKE),
new AppItem("图库", R.drawable.ic_gallery, AppItem.Type.FAKE)
));
// 设置适配器,传入应用列表和点击回调
recyclerView.setAdapter(new AppGridAdapter(appList, new AppGridAdapter.OnItemClickListener() {
@Override
public void onItemClick(AppItem item) {
onAppClick(item);
}
}));
}
/**
* 处理应用图标点击事件
* 根据应用类型执行不同操作:跳转业务 App、打开设置页、或显示提示
*/
private void onAppClick(AppItem item) {
switch (item.getType()) {
case BUSINESS:
// 业务应用:通过包名跳转到业务 App
Intent intent = getPackageManager().getLaunchIntentForPackage(
"com.emoon.harmony.robot" // 业务 App 的包名(需与业务 App 保持一致)
);
if (intent != null) {
startActivity(intent);
} else {
// 开发阶段友好提示:业务 App 未安装
Toast.makeText(this, "业务应用未安装", Toast.LENGTH_SHORT).show();
}
break;
case SETTINGS:
// 设置应用:打开假设置页面
startActivity(new Intent(this, FakeSettingsActivity.class));
break;
case FAKE:
// 装饰图标:点击后显示"即将推出"提示,保持桌面完整性
Toast.makeText(this, item.getName() + " 即将推出", Toast.LENGTH_SHORT).show();
break;
}
}
/**
* 初始化下拉手势检测器
* 从屏幕顶部向下滑动时显示控制中心(仿 HarmonyOS 操作逻辑)
*/
private void setupGestureDetector() {
controlCenterView = findViewById(R.id.control_center_container);
gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
/**
* 检测快速滑动手势(Fling)
* @param e1 手势起点(按下位置)
* @param e2 手势终点(抬起位置)
* @param velocityX X 方向滑动速度
* @param velocityY Y 方向滑动速度(正值表示向下)
*/
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (e1 == null) return false;
// 判断条件:从屏幕顶部 100px 内开始(e1.y < 100)
// 且向下滑动速度大于 500(velocityY > 500)
// 且滑动距离大于 100px(e2.y - e1.y > 100)
if (e1.getY() < 100 && velocityY > 500 && e2.getY() - e1.getY() > 100) {
showControlCenter();
return true; // 返回 true 表示已消费此手势
}
return false;
}
});
}
/**
* 显示仿鸿蒙控制中心
* 带动画效果:从屏幕上方滑入
*/
private void showControlCenter() {
controlCenterView.setVisibility(View.VISIBLE);
// 初始位置:在屏幕上方(Y 轴负方向偏移控件高度)
controlCenterView.setTranslationY(-controlCenterView.getHeight());
// 执行滑入动画:300ms 内从上方滑到正常位置
controlCenterView.animate()
.translationY(0f)
.setDuration(300)
.start();
}
/**
* 隐藏控制中心
* 带动画效果:向上滑出屏幕
*/
public void hideControlCenter() {
controlCenterView.animate()
.translationY(-controlCenterView.getHeight())
.setDuration(300)
.withEndAction(new Runnable() {
@Override
public void run() {
// 动画结束后将视图设为不可见(节省渲染资源)
controlCenterView.setVisibility(View.GONE);
}
})
.start();
}
/**
* 更新桌面时间日期显示
* 使用 Handler 每分钟刷新一次
*/
private void updateTimeDisplay() {
final Handler handler = new Handler(getMainLooper());
final TextView timeView = findViewById(R.id.tv_time);
final TextView dateView = findViewById(R.id.tv_date);
// 创建定时任务 Runnable
final Runnable runnable = new Runnable() {
@Override
public void run() {
Calendar now = Calendar.getInstance();
// 更新时间:HH:mm 格式(如 14:30)
timeView.setText(String.format(
"%02d:%02d",
now.get(Calendar.HOUR_OF_DAY),
now.get(Calendar.MINUTE)
));
// 星期数组(周日开始)
String[] weekDays = {"日", "一", "二", "三", "四", "五", "六"};
// 更新日期:MM月dd日 星期X 格式
dateView.setText(String.format(
"%d月%d日 星期%s",
now.get(Calendar.MONTH) + 1, // 月份从 0 开始,需 +1
now.get(Calendar.DAY_OF_MONTH),
weekDays[now.get(Calendar.DAY_OF_WEEK) - 1]
));
// 60 秒后再次执行(60000 毫秒 = 1 分钟)
handler.postDelayed(this, 60000);
}
};
// 立即执行第一次(避免等待 1 分钟才显示时间)
handler.post(runnable);
}
/**
* 拦截触摸事件,传递给手势检测器
* 必须重写此方法,否则 GestureDetector 无法接收到触摸事件
*/
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
gestureDetector.onTouchEvent(ev);
return super.dispatchTouchEvent(ev);
}
/**
* 拦截返回键
* 桌面作为系统入口,不允许通过返回键退出(否则回到系统桌面)
*/
@Override
public void onBackPressed() {
// 如果控制中心正在显示,优先关闭控制中心
if (controlCenterView.getVisibility() == View.VISIBLE) {
hideControlCenter();
}
// 否则不做任何操作(桌面不响应返回键退出)
}
/**
* 窗口焦点变化时重新隐藏系统 UI
* 当从其他 Activity 返回桌面时,确保系统栏保持隐藏
*/
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideSystemUI();
}
}
```
#### 3.5.2 activity_launcher.xml 完整布局
这是桌面的主布局文件,对应 HarmonyOS 4 桌面的各个 UI 组件:
```xml
```
#### 3.5.3 AppItem.java 数据类
桌面应用的数据模型,类比 Vue 中的 `data()` 返回的对象结构:
```java
package com.emoon.harmony.launcher;
/**
* 桌面应用项数据类
* 用于存储每个应用图标的名称、图标资源和类型
*/
public class AppItem {
private final String name; // 应用显示名称(如"智慧医疗")
private final int iconResId; // 图标资源 ID(指向 drawable 中的图标)
private final Type type; // 应用类型,决定点击后的行为
public AppItem(String name, int iconResId, Type type) {
this.name = name;
this.iconResId = iconResId;
this.type = type;
}
public String getName() { return name; }
public int getIconResId() { return iconResId; }
public Type getType() { return type; }
/**
* 应用类型枚举
* BUSINESS: 真实业务应用(点击跳转)
* SETTINGS: 设置入口(点击打开假设置页)
* FAKE: 装饰性图标(点击显示提示)
*/
public enum Type {
BUSINESS, // 业务应用(点击跳转到业务 App)
SETTINGS, // 设置(点击打开假设置页)
FAKE // 装饰图标(点击提示"即将推出")
}
}
```
#### 3.5.4 AppGridAdapter.java 图标网格适配器
RecyclerView 的适配器实现,负责将应用数据渲染为图标卡片。类比 Vue 中 `v-for` 循环渲染列表组件:
```java
package com.emoon.harmony.launcher;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.cardview.widget.CardView;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
/**
* 桌面图标网格适配器
* 负责将 AppItem 数据列表渲染为桌面上的图标卡片
*/
public class AppGridAdapter extends RecyclerView.Adapter {
private final List appList; // 应用列表数据源
private final OnItemClickListener onItemClick; // 点击回调接口
public interface OnItemClickListener {
void onItemClick(AppItem item);
}
public AppGridAdapter(List appList, OnItemClickListener onItemClick) {
this.appList = appList;
this.onItemClick = onItemClick;
}
/**
* ViewHolder:缓存每个列表项的视图引用
* 避免每次滚动时都调用 findViewById,提升性能(类比 Vue 的虚拟 DOM 复用)
*/
public static class AppViewHolder extends RecyclerView.ViewHolder {
// 图标卡片容器(CardView,实现圆角和阴影)
public final CardView cardView;
// 应用图标图片
public final ImageView iconImage;
// 应用名称文字
public final TextView nameText;
public AppViewHolder(View itemView) {
super(itemView);
cardView = itemView.findViewById(R.id.card_app_icon);
iconImage = itemView.findViewById(R.id.iv_app_icon);
nameText = itemView.findViewById(R.id.tv_app_name);
}
}
/**
* 创建新的 ViewHolder(当列表需要展示新的项时调用)
* @param parent 父视图容器
* @param viewType 视图类型(多类型列表时使用,此处只有一种类型)
*/
@Override
public AppViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
// 加载 item_app_icon.xml 布局文件
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_app_icon, parent, false);
return new AppViewHolder(view);
}
/**
* 绑定数据到 ViewHolder(将数据填充到视图中)
* @param holder 要绑定的 ViewHolder
* @param position 数据在列表中的索引位置
*/
@Override
public void onBindViewHolder(final AppViewHolder holder, int position) {
final AppItem item = appList.get(position);
// 设置应用图标图片
holder.iconImage.setImageResource(item.getIconResId());
// 设置应用名称文字
holder.nameText.setText(item.getName());
// 设置点击事件监听器
holder.cardView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onItemClick.onItemClick(item); // 调用外部传入的点击回调
}
});
// 设置按下效果:点击时临时改变背景色(视觉反馈)
holder.cardView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
// 手指按下:切换到按下态背景
case MotionEvent.ACTION_DOWN:
holder.cardView.setCardBackgroundColor(
v.getContext().getColor(R.color.icon_card_pressed)
);
break;
// 手指抬起或取消:恢复常态背景
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
holder.cardView.setCardBackgroundColor(
v.getContext().getColor(R.color.icon_card_normal)
);
break;
}
return false; // 返回 false 表示不拦截触摸事件,继续传递
}
});
}
/**
* 返回列表项总数
* RecyclerView 通过此方法知道需要渲染多少个列表项
*/
@Override
public int getItemCount() {
return appList.size();
}
}
```
#### 3.5.5 item_app_icon.xml 单个图标卡片布局
这是每个应用图标的布局文件,使用 CardView 实现 HarmonyOS 风格的圆角卡片:
```xml
```
#### 3.5.6 ControlCenterView.java 下拉控制中心
使用自定义 View 实现下拉控制中心(不使用 Fragment,避免复杂生命周期管理)。包含快捷开关和滑块,所有交互仅切换 UI 状态,**不做真实系统调用**。
```java
package com.emoon.harmony.launcher;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
import java.util.HashMap;
import java.util.Map;
/**
* 仿 HarmonyOS 下拉控制中心自定义 View
* 从屏幕顶部下滑触发,包含快捷开关和亮度/音量滑块
*/
public class ControlCenterView extends FrameLayout {
// 开关状态存储表(HashMap,键为开关名称,值为开启/关闭状态)
private final Map switchStates = new HashMap() {{
put("wifi", true); // WiFi:默认开启
put("bluetooth", false); // 蓝牙:默认关闭
put("mobile", true); // 移动数据:默认开启
put("airplane", false); // 飞行模式:默认关闭
put("location", true); // 位置服务:默认开启
}};
// 开关视图映射表(键为开关名称,值为对应的 ImageView)
private final Map switchViews = new HashMap<>();
public ControlCenterView(Context context) {
this(context, null, 0);
}
public ControlCenterView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ControlCenterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 加载控制中心的布局 XML
LayoutInflater.from(context).inflate(R.layout.control_center, this, true);
// 初始化所有开关
initSwitches();
// 初始化亮度滑块
initBrightnessSlider();
// 初始化音量滑块
initVolumeSlider();
// 初始化关闭按钮
initCloseButton();
}
/**
* 初始化快捷开关
* 每个开关点击时仅切换本地 UI 状态,不调用真实系统 API
*/
private void initSwitches() {
// 定义开关配置:(开关名称,图标 View 的 ID,标签 View 的 ID)
class SwitchConfig {
final String name;
final int iconId;
final int labelId;
SwitchConfig(String name, int iconId, int labelId) {
this.name = name;
this.iconId = iconId;
this.labelId = labelId;
}
}
SwitchConfig[] switchConfigs = new SwitchConfig[] {
new SwitchConfig("wifi", R.id.iv_wifi, R.id.tv_wifi),
new SwitchConfig("bluetooth", R.id.iv_bluetooth, R.id.tv_bluetooth),
new SwitchConfig("mobile", R.id.iv_mobile, R.id.tv_mobile),
new SwitchConfig("airplane", R.id.iv_airplane, R.id.tv_airplane),
new SwitchConfig("location", R.id.iv_location, R.id.tv_location)
};
for (final SwitchConfig config : switchConfigs) {
final ImageView iconView = findViewById(config.iconId);
final TextView labelView = findViewById(config.labelId);
switchViews.put(config.name, iconView);
// 设置初始状态
updateSwitchUI(config.name, iconView, labelView);
// 设置点击监听器
iconView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// 切换开关状态(取反当前状态)
boolean newState = !(switchStates.get(config.name) != null ? switchStates.get(config.name) : false);
switchStates.put(config.name, newState);
// 更新 UI 显示
updateSwitchUI(config.name, iconView, labelView);
}
});
}
}
/**
* 更新开关的 UI 显示
* @param name 开关名称
* @param iconView 开关图标视图
* @param labelView 开关标签视图
*/
private void updateSwitchUI(String name, ImageView iconView, TextView labelView) {
boolean isOn = switchStates.get(name) != null ? switchStates.get(name) : false;
if (isOn) {
// 开启态:鸿蒙蓝色背景 + 白色图标
iconView.setBackgroundResource(R.drawable.bg_switch_on);
iconView.setColorFilter(getContext().getColor(android.R.color.white));
labelView.setTextColor(getContext().getColor(R.color.harmony_blue));
} else {
// 关闭态:深灰背景 + 浅灰图标
iconView.setBackgroundResource(R.drawable.bg_switch_off);
iconView.setColorFilter(getContext().getColor(R.color.gray_text));
labelView.setTextColor(getContext().getColor(R.color.gray_text));
}
}
/**
* 初始化亮度滑块
* 仅改变滑块位置,不修改系统实际亮度
*/
private void initBrightnessSlider() {
SeekBar seekBar = findViewById(R.id.seekbar_brightness);
final TextView valueText = findViewById(R.id.tv_brightness_value);
// 设置初始值 70%
seekBar.setProgress(70);
valueText.setText("70%");
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
// 滑块拖动时实时更新显示
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
valueText.setText(progress + "%");
}
// 开始拖动(空实现,但接口要求覆写)
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
// 结束拖动(空实现,但接口要求覆写)
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
});
}
/**
* 初始化音量滑块
* 仅改变滑块位置,不修改系统实际音量
*/
private void initVolumeSlider() {
SeekBar seekBar = findViewById(R.id.seekbar_volume);
final TextView valueText = findViewById(R.id.tv_volume_value);
// 设置初始值 50%
seekBar.setProgress(50);
valueText.setText("50%");
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
valueText.setText(progress + "%");
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
});
}
/**
* 初始化关闭按钮
* 点击后隐藏控制中心
*/
private void initCloseButton() {
findViewById(R.id.btn_close_control).setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// 获取父 Activity 并调用其 hideControlCenter 方法
if (getContext() instanceof LauncherActivity) {
((LauncherActivity) getContext()).hideControlCenter();
}
}
});
}
}
```
#### 3.5.7 控制中心布局 XML
控制中心的布局文件 `res/layout/control_center.xml`:
```xml
```
#### 3.5.8 FakeSettingsActivity.java 假设置页
完整的假设置页实现,包含列表式布局和"关于本机"页面:
```java
package com.emoon.harmony.launcher;
import android.app.Activity;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 假设置页面 Activity
* 仿 HarmonyOS 设置页风格,展示假设备信息
*/
public class FakeSettingsActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fake_settings);
// 初始化设置列表
setupSettingsList();
}
/**
* 初始化设置列表 RecyclerView
*/
private void setupSettingsList() {
RecyclerView recyclerView = findViewById(R.id.rv_settings);
// 垂直线性布局(类似 Vue 的垂直列表)
recyclerView.setLayoutManager(new LinearLayoutManager(this));
// 定义设置项数据列表
List settingsItems = new ArrayList<>(Arrays.asList(
new SettingItem("WLAN", "已连接", R.drawable.ic_wifi),
new SettingItem("蓝牙", "已开启", R.drawable.ic_bluetooth),
new SettingItem("显示和亮度", "", R.drawable.ic_brightness),
new SettingItem("声音和振动", "", R.drawable.ic_volume),
new SettingItem("关于本机", "", R.drawable.ic_info)
));
// 设置适配器
recyclerView.setAdapter(new SettingsAdapter(settingsItems, new SettingsAdapter.OnItemClickListener() {
@Override
public void onItemClick(SettingItem item) {
onSettingClick(item);
}
}));
}
/**
* 处理设置项点击事件
*/
private void onSettingClick(SettingItem item) {
if ("关于本机".equals(item.getTitle())) {
showAboutDialog();
} else {
Toast.makeText(this, item.getTitle() + " 功能即将推出", Toast.LENGTH_SHORT).show();
}
}
/**
* 显示"关于本机"对话框
* 展示仿造的设备信息,营造 HarmonyOS 系统的视觉假象
*/
private void showAboutDialog() {
// 使用 AlertDialog 展示关于信息
View aboutView = LayoutInflater.from(this).inflate(R.layout.dialog_about, null);
// 填充设备信息数据
((TextView) aboutView.findViewById(R.id.tv_device_name)).setText("设备名称:豹小秘 Pro");
((TextView) aboutView.findViewById(R.id.tv_system_theme)).setText("系统主题:HarmonyOS 风格 4.0");
((TextView) aboutView.findViewById(R.id.tv_processor)).setText("处理器:Kirin 9000S");
((TextView) aboutView.findViewById(R.id.tv_ram)).setText("运行内存:4 GB");
((TextView) aboutView.findViewById(R.id.tv_storage)).setText("存储空间:64 GB");
((TextView) aboutView.findViewById(R.id.tv_resolution)).setText("分辨率:1280 × 800");
new android.app.AlertDialog.Builder(this)
.setView(aboutView)
.setPositiveButton("确定", null)
.show();
}
/**
* 设置项数据类
*/
public static class SettingItem {
private final String title; // 设置项标题
private final String subtitle; // 副标题(如"已连接")
private final int iconResId; // 左侧图标资源 ID
public SettingItem(String title, String subtitle, int iconResId) {
this.title = title;
this.subtitle = subtitle;
this.iconResId = iconResId;
}
public String getTitle() { return title; }
public String getSubtitle() { return subtitle; }
public int getIconResId() { return iconResId; }
}
/**
* 设置列表适配器
*/
public static class SettingsAdapter extends RecyclerView.Adapter {
private final List items;
private final OnItemClickListener onClick;
public interface OnItemClickListener {
void onItemClick(SettingItem item);
}
public SettingsAdapter(List items, OnItemClickListener onClick) {
this.items = items;
this.onClick = onClick;
}
public static class SettingViewHolder extends RecyclerView.ViewHolder {
public final ImageView iconView;
public final TextView titleView;
public final TextView subtitleView;
public SettingViewHolder(View itemView) {
super(itemView);
iconView = itemView.findViewById(R.id.iv_setting_icon);
titleView = itemView.findViewById(R.id.tv_setting_title);
subtitleView = itemView.findViewById(R.id.tv_setting_subtitle);
}
}
@Override
public SettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_setting, parent, false);
return new SettingViewHolder(view);
}
@Override
public void onBindViewHolder(SettingViewHolder holder, int position) {
final SettingItem item = items.get(position);
holder.iconView.setImageResource(item.getIconResId());
holder.titleView.setText(item.getTitle());
holder.subtitleView.setText(item.getSubtitle());
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onClick.onItemClick(item);
}
});
}
@Override
public int getItemCount() {
return items.size();
}
}
}
```
#### 3.5.9 设置页布局 XML
**activity_fake_settings.xml**(设置页主布局):
```xml
```
**item_setting.xml**(单个设置项布局):
```xml
```
#### 3.5.10 BootReceiver.java 开机自启接收器(备用方案)
完整的开机自启广播接收器实现(`BOOT_COMPLETED` 备用自启方案):
```java
package com.emoon.harmony.launcher;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* 开机自启广播接收器
* 接收系统开机完成广播(BOOT_COMPLETED),自动启动 LauncherActivity
*/
public class BootReceiver extends BroadcastReceiver {
/**
* 当收到广播时调用
* @param context 应用上下文
* @param intent 收到的广播 Intent
*/
@Override
public void onReceive(Context context, Intent intent) {
// 判断广播动作是否为开机完成
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
// 创建启动 LauncherActivity 的 Intent
Intent launchIntent = new Intent(context, LauncherActivity.class);
// FLAG_ACTIVITY_NEW_TASK 是必须的:从非 Activity 上下文启动 Activity 需要此标志
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 启动桌面 Activity
context.startActivity(launchIntent);
}
}
}
```
> **关键提示**:`BootReceiver` 作为备用自启方案,生效需要同时满足三个条件:
> 1. `AndroidManifest.xml` 中声明 `RECEIVE_BOOT_COMPLETED` 权限
> 2. `BootReceiver` 在 Manifest 中正确注册并声明 `BOOT_COMPLETED` 过滤器
> 3. 应用至少被用户手动打开过一次(Android 3.1+ 的安全限制)
> 4. 主要自启方式仍为 SDK 注册机制(`action.orionstar.default.app`)
#### 3.5.11 设置为默认桌面的方法
安装 Launcher APK 后,需要将其设为系统默认桌面。以下是详细步骤:
**方法一:首次按 Home 键选择(推荐)**
1. 通过 adb 安装 Launcher APK:`adb install HarmonyLauncher.apk`
2. 在机器人设备上按 **Home 键**(或点击桌面的 Home 图标)
3. 系统会弹出选择器,询问"要使用哪个应用?"
4. 选择 **HarmonyLauncher** → 勾选 **"设为默认应用"** → 点击 **"始终"**
5. 此后每次按 Home 键都会直接进入仿鸿蒙桌面
**方法二:adb 命令直接设置(自动化部署时使用)**
如果设备没有弹出选择器(某些定制 Android 系统会屏蔽),可通过 adb 命令强制设置:
```bash
# 查看当前默认桌面组件名
adb shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME
# 设置 HarmonyLauncher 为默认桌面(将下面命令中的包名替换为实际值)
adb shell cmd package set-home-activity com.emoon.harmony.launcher/.LauncherActivity
# 验证设置是否成功
adb shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME
```
**方法三:通过设置应用手动切换**
1. 进入系统"设置" → "应用"
2. 找到当前的默认桌面应用(如"Launcher3")
3. 点击"默认打开" → "清除默认操作"
4. 再次按 Home 键,重新选择 HarmonyLauncher
**验证方法**:
- 按 Home 键,确认进入仿鸿蒙桌面(显示蓝紫渐变背景 + 圆角图标网格)
- 重启设备,确认开机后自动进入仿鸿蒙桌面(验证 SDK 注册自启生效,BootReceiver 作为备用方案)
#### 3.5.12 res/values/colors.xml
所有 HarmonyOS 风格配色的 XML 定义:
```xml
#FFFFFF
#000000
#007DFF
#999999
#26FFFFFF
#40FFFFFF
#4D000000
#F21A1A2E
#007DFF
#404040
#B3FFFFFF
#F1F3F5
#FFFFFF
#000000
#999999
#1A1A2E
#16213E
```
#### 3.5.13 res/values/dimens.xml
所有尺寸值的集中定义(便于统一修改和维护):
```xml
80dp
56dp
20dp
12sp
24dp
4dp
64dp
24dp
48dp
24dp
64dp
48sp
14sp
56dp
```
#### 3.5.14 res/values/themes.xml
全屏主题和样式定义:
```xml
```
#### 3.5.15 res/drawable/ 关键背景 XML
以下是桌面核心视觉元素的 Drawable 定义:
**bg_gradient.xml**(桌面背景渐变):
```xml
```
**bg_icon_card.xml**(图标卡片常态背景):
```xml
```
**bg_icon_card_pressed.xml**(图标卡片按下态背景):
```xml
```
**bg_dock.xml**(Dock 栏背景):
```xml
```
**bg_control_center.xml**(控制中心背景):
```xml
```
**bg_switch_on.xml**(开关开启态背景):
```xml
```
**bg_switch_off.xml**(开关关闭态背景):
```xml
```
**handle_bar.xml**(控制中心拖动手柄):
```xml
```
> **图标说明**:上述布局中引用的 `ic_*.xml` 图标(如 `ic_wifi`、`ic_bluetooth` 等)建议使用 Android Studio 内置的 **Vector Asset Studio** 导入 Material Design 图标,或从 [Material Icons](https://fonts.google.com/icons) 下载 SVG 后转为 Vector Drawable。对于迎检演示,使用简单的白色线形图标即可达到 HarmonyOS 风格效果。
---
## 四、原生业务 App 详细设计
### 4.1 Android Studio 工程结构
```
com.medical.robotapp/
├── MainActivity.java # WebView 容器 Activity
├── bridge/
│ └── RobotBridge.java # JSBridge 接口实现
├── sdk/
│ └── RobotSDKManager.java # 猎户星空 SDK 封装(RobotApi/SkillApi/RobotSettingApi)
├── service/
│ └── NavigationCallback.java # 导航状态监听(ActionListener 回调)
└── util/
└── WebViewUtil.java # WebView 配置工具
```
### 4.2 WebView 容器配置
```java
WebView webView = findViewById(R.id.webView);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowUniversalAccessFromFileURLs(true);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setSupportZoom(false);
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
webView.setWebChromeClient(new WebChromeClient());
webView.addJavascriptInterface(new RobotBridge(this, webView), "RobotBridge");
// 调试模式:联调阶段开启,支持 Chrome DevTools 远程调试
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}
// 加载本地 H5(Spring Boot 后端部署在 localhost:8080)
webView.loadUrl("http://localhost:8080");
```
> **安全提示**:迎检环境为内网受控设备,上述 WebView 配置(allowFileAccess、allowUniversalAccessFromFileURLs)可接受。若后续部署到公网或多方接入环境,需收紧安全配置。
### 4.3 JSBridge 通信协议设计
| Bridge 方法名 | 参数 | 返回值(通过回调) | 说明 | 底层 SDK 调用 | 本期实现 |
|---|---|---|---|---|---|
| `RobotBridge.navigate(destination, callbackId)` | destination: String, callbackId: String | {code, msg} | 导航到指定位置 | `RobotApi.getInstance().startNavigation(reqId, place, coordinate, timeout, ActionListener)` | ✅ |
| `RobotBridge.stopNavigation(callbackId)` | callbackId: String | {code, msg} | 停止导航 | `RobotApi.getInstance().stopNavigation(reqId)` | 预留 |
| `RobotBridge.getPlaceList(callbackId)` | callbackId: String | JSON 数组 | 获取所有定位点 | `RobotApi.getInstance().getPlaceList(reqId, CommandListener)` | ✅ |
| `RobotBridge.getPosition(callbackId)` | callbackId: String | {x,y,theta} | 获取当前坐标 | `RobotApi.getInstance().getPosition(reqId, CommandListener)` | 预留 |
| `RobotBridge.playTTS(text, callbackId)` | text: String, callbackId: String | {code, msg} | TTS 语音播报 | `SkillApi.getInstance().playText(TTSEntity, TextListener)` | ✅ |
| `RobotBridge.stopTTS(callbackId)` | callbackId: String | {code, msg} | 停止播报 | `SkillApi.getInstance().stopTTS()` | 预留 |
| `RobotBridge.getBattery(callbackId)` | callbackId: String | {level} | 获取电量 | `RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO)` | ✅ |
**通信机制说明**:Android WebView 的 `@JavascriptInterface` 只支持基本类型参数(String/int/boolean),无法直接传递 JS 函数对象。因此采用 "callbackId + 全局回调池" 模式:H5 侧生成唯一 callbackId 并注册回调函数到 `window.__robotCallbacks`,将 callbackId 字符串传给 Native;Native 处理完成后通过 `webView.evaluateJavascript()` 执行 `window.__robotCallbacks[callbackId](result)` 回推结果。
**Android 端 Bridge 实现**:
```java
public class RobotBridge {
private final Context context;
private final WebView webView;
public RobotBridge(Context context, WebView webView) {
this.context = context;
this.webView = webView;
}
@JavascriptInterface
public void navigate(final String destination, final String callbackId) {
new Thread(new Runnable() {
@Override
public void run() {
RobotApi.getInstance().startNavigation(0, destination, 1.5, 10 * 1000, new ActionListener() {
@Override
public void onResult(int status, String responseString) {
final String result = "{\"code\":0,\"msg\":\"navigation_started\",\"destination\":\"" + destination + "\"}";
webView.post(new Runnable() {
@Override
public void run() {
webView.evaluateJavascript(
"window.__robotCallbacks && window.__robotCallbacks['" + callbackId + "'] && window.__robotCallbacks['" + callbackId + "'](" + result + ")",
null
);
}
});
}
@Override
public void onError(int errorCode, String errorString) {}
@Override
public void onStatusUpdate(int status, String data) {}
});
}
}).start();
}
@JavascriptInterface
public void getPlaceList(final String callbackId) {
new Thread(new Runnable() {
@Override
public void run() {
List places = RobotApi.getInstance().getPlaceList();
final String result = new JSONArray(places).toString();
webView.post(new Runnable() {
@Override
public void run() {
webView.evaluateJavascript(
"window.__robotCallbacks && window.__robotCallbacks['" + callbackId + "'] && window.__robotCallbacks['" + callbackId + "'](" + result + ")",
null
);
}
});
}
}).start();
}
@JavascriptInterface
public void playTTS(final String text, final String callbackId) {
new Thread(new Runnable() {
@Override
public void run() {
SkillApi skillApi = RobotOSApplication.getInstance().getSkillApi();
if (skillApi != null) {
skillApi.playText(new TTSEntity("sid-" + System.currentTimeMillis(), text), new TextListener() {
@Override public void onStart() {}
@Override public void onStop() {}
@Override public void onComplete() {}
@Override public void onError() {}
});
}
final String result = "{\"code\":0,\"msg\":\"tts_started\"}";
webView.post(new Runnable() {
@Override
public void run() {
webView.evaluateJavascript(
"window.__robotCallbacks && window.__robotCallbacks['" + callbackId + "'] && window.__robotCallbacks['" + callbackId + "'](" + result + ")",
null
);
}
});
}
}).start();
}
@JavascriptInterface
public void getBattery(final String callbackId) {
new Thread(new Runnable() {
@Override
public void run() {
String levelStr = RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO);
final String result = "{\"code\":0,\"level\":" + levelStr + "}";
webView.post(new Runnable() {
@Override
public void run() {
webView.evaluateJavascript(
"window.__robotCallbacks && window.__robotCallbacks['" + callbackId + "'] && window.__robotCallbacks['" + callbackId + "'](" + result + ")",
null
);
}
});
}
}).start();
}
}
```
> 以上仅示例本期必须实现的核心接口,预留接口(stopNavigation、getPosition、stopTTS)遵循相同的 callbackId 协议模式,按需在后续迭代中实现。底层通过 `RobotApi`、`SkillApi`、`RobotSettingApi` 三个核心 SDK 类分别调用导航、语音、设备信息等能力,回调接口统一使用 `ActionListener`(长操作)、`CommandListener`(单次命令)、`TextListener`(TTS 播报)。
### 4.4 猎户星空 SDK 集成步骤
1. **获取 SDK JAR 包**:从猎户星空获取 `robotservice.jar`(约 1.1MB),放入 `app/libs/` 目录
2. **build.gradle 配置**:
```gradle
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}
```
3. **AndroidManifest 声明权限**:
```xml
```
4. **Application 中初始化**(连接流程:`Application.onCreate()` → `RobotApi.getInstance().connectServer(context, ApiListener)` → `ApiListener.handleApiConnected()` → `SkillApi.getInstance().connectApi(context)`):
```java
public class RobotApp extends Application {
@Override
public void onCreate() {
super.onCreate();
RobotApi.getInstance().connectServer(this, new ApiListener() {
@Override
public void handleApiConnected() {
Log.d("RobotSDK", "Server 连接成功");
SkillApi skillApi = new SkillApi();
skillApi.connectApi(RobotApp.this);
}
});
}
}
```
5. **导航状态监听**(SDK 提供三种回调接口:`ActionListener` 用于长操作如导航,含 `onResult`/`onError`/`onStatusUpdate` 三个方法;`CommandListener` 用于单次命令如获取位置,含 `onResult` 方法;`TextListener` 用于 TTS 播报,含 `onStart`/`onStop`/`onError`/`onComplete` 四个方法):
```java
RobotApi.getInstance().startNavigation(0, "目标点位", 1.5, 10 * 1000, new ActionListener() {
@Override
public void onStatusUpdate(int status, String data) {
switch (status) {
case 32730001:
Log.d("NAV", "开始导航");
break;
case 32730004:
Log.d("NAV", "避障中");
break;
case 32730011:
Log.d("NAV", "堵死");
break;
case 32730009:
Log.d("NAV", "定位丢失");
break;
}
}
@Override
public void onResult(int status, String responseString) {
switch (status) {
case 32610007:
Log.d("NAV", "到达目的地");
break;
case -32620001:
Log.d("NAV", "未定位");
break;
case -32620009:
Log.d("NAV", "路径规划失败");
break;
}
}
@Override
public void onError(int errorCode, String errorString) {
Log.e("NAV", "导航出错: " + errorCode + ", " + errorString);
}
});
```
### 4.5 参考的关键 API
| API | 功能 | 所属 SDK 类 |
|-----|------|------------|
| `RobotApi.getInstance().startNavigation(reqId, place, coordinate, timeout, ActionListener)` | 启动导航到指定位置点 | RobotApi(导航/位置/运动控制) |
| `RobotApi.getInstance().stopNavigation(reqId)` | 停止当前导航 | RobotApi |
| `RobotApi.getInstance().getPlaceList(reqId, CommandListener)` | 获取地图中所有位置点列表 | RobotApi |
| `RobotApi.getInstance().getPosition(reqId, CommandListener)` | 获取当前坐标 `{x, y, theta}` | RobotApi |
| `RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO)` | 获取电量信息 | RobotSettingApi(设备信息/电量) |
| `SkillApi.getInstance().playText(TTSEntity, TextListener)` | TTS 语音播报(需先构建 TTSEntity) | SkillApi(语音 TTS/ASR) |
| `SkillApi.getInstance().stopTTS()` | 停止 TTS 播放 | SkillApi |
| `PersonApi.getInstance()` | 人脸识别能力 | PersonApi(人脸识别) |
| `RobotApi.getInstance().connectServer(context, ApiListener)` | 连接 SDK 服务,初始化入口 | RobotApi |
| `SkillApi.getInstance().connectApi(context)` | 连接语音服务(在 ApiListener.handleApiConnected 回调中调用) | SkillApi |
### 4.6 补充:Android Studio 环境准备和业务 App 工程搭建(面向 Web 开发者)
> 本节专为从未接触过 Android 开发的 Web 全栈工程师编写,每一步都配有详细说明和截图指引对应操作。
#### 4.6.1 Android Studio 环境安装与配置
1. **下载安装 Android Studio**
- 访问 https://developer.android.com/studio 下载最新稳定版(推荐 Android Studio Hedgehog 或更新版本)
- macOS 用户:下载 `.dmg` 文件后,将 Android Studio 拖入 Applications 文件夹
- Windows 用户:运行 `.exe` 安装程序,按向导完成安装
- 首次启动会提示导入设置,选择 "Do not import settings"
- 接着会下载 Android SDK,选择 **"Standard"** 安装即可(包含常用 SDK 和模拟器)
2. **SDK Manager 配置**
- 打开 Android Studio → 顶部菜单 **Android Studio → Preferences**(macOS)或 **File → Settings**(Windows)
- 左侧导航选择 **Appearance & Behavior → System Settings → Android SDK**
- 在 **SDK Platforms** 标签页中勾选以下版本:
- `Android 10.0 (API 29)` —— 编译目标版本(对应 compileSdkVersion)
- `Android 4.4 (API 19)` —— 最低支持版本(对应 minSdkVersion)
- 在 **SDK Tools** 标签页中勾选:
- `Android SDK Build-Tools 34` —— 构建工具
- `Android SDK Platform-Tools` —— 包含 adb、fastboot 等调试工具
- `Android SDK Command-line Tools (latest)` —— 命令行工具
- 点击右下角 **Apply** 按钮,等待下载完成(根据网络情况可能需要 5-20 分钟)
3. **JDK 配置**
- Android Studio 自带 JDK 17(路径通常在 `/Applications/Android Studio.app/Contents/jbr/Contents/Home`)
- 一般无需额外安装 JDK
- 验证路径:点击 **File → Project Structure → SDK Location**,确认 **JDK Location** 字段有值且路径存在
- 如需手动指定:点击右侧文件夹图标,选择本地 JDK 17 安装目录
4. **ADB 环境变量配置**
- ADB(Android Debug Bridge)是连接开发电脑与机器人设备的核心工具
- **macOS 配置**:
```bash
# 打开终端,编辑 zsh 配置文件
open ~/.zshrc
# 在文件末尾添加以下行(根据实际 SDK 路径调整)
export PATH="$PATH:$HOME/Library/Android/sdk/platform-tools"
# 保存后执行
source ~/.zshrc
```
- **Windows 配置**:
- 右键 "此电脑" → 属性 → 高级系统设置 → 环境变量
- 在 "系统变量" 中找到 `Path`,点击编辑
- 点击新建,添加路径:`C:\Users\<你的用户名>\AppData\Local\Android\Sdk\platform-tools`
- 点击确定保存
- **验证配置**:在终端(macOS 的 Terminal 或 Windows 的 CMD/PowerShell)中运行:
```bash
adb version
```
应输出类似 `Android Debug Bridge version 1.0.xxx` 的版本信息
#### 4.6.2 创建业务 App 工程
1. 打开 Android Studio,点击 **File → New → New Project**
2. 在项目模板选择界面,选择 **"Empty Views Activity"**(使用传统 View 系统的空 Activity,适合 Web 开发者理解)
- 注意:不要选择 "Empty Activity"(那是 Jetpack Compose 版本,学习成本更高)
3. 在配置页面填写以下信息:
- **Name**: `MedicalRobotApp`(应用名称)
- **Package name**: `com.emoon.medical.robot`(应用包名,全局唯一标识)
- **Save location**: 选择本地目录(如 `~/Projects/MedicalRobotApp`)
- **Language**: `Java`(猎户星空 SDK Demo 项目使用 Java,团队现有代码库均为 Java)
- **Minimum SDK**: `API 19: Android 4.4 (KitKat)`(猎户星空机器人系统兼容 Android 4.4+,建议 minSdkVersion 设为 19)
- **Build configuration language**: `Groovy (DSL)`(使用传统 Groovy 脚本配置 Gradle,与猎户星空 Demo 项目一致)
4. 点击 **Finish**,等待 Gradle 首次同步完成(首次可能需要下载依赖,耗时 5-15 分钟)
5. 同步完成后,左侧 Project 面板应显示工程结构,顶部工具栏出现绿色运行按钮,表示工程创建成功
---
### 4.7 补充:完整的 build.gradle(Module: app)
> 以下文件位于 `MedicalRobotApp/app/build.gradle`,是应用模块的构建配置(使用 Groovy DSL)。每一行都带有中文注释,说明其作用。
```groovy
/**
* app 模块构建配置文件
* 作用:定义编译版本、依赖库、构建变体等
* 位置:MedicalRobotApp/app/build.gradle
*/
// 插件声明:应用 Android 应用程序插件
apply plugin: 'com.android.application'
// Android 构建设置
android {
// 编译 SDK 版本:使用 API 29(Android 10)进行编译
// 决定了可以使用的最新 Android API
compileSdkVersion 29
// 默认配置:所有构建变体共享的基础配置
defaultConfig {
// 应用包名,设备上唯一标识此应用
applicationId "com.emoon.medical.robot"
// 最低支持的 Android 版本:API 19(Android 4.4)
// 低于此版本的设备无法安装此应用
minSdkVersion 19
// 目标 SDK 版本:API 28(Android 9.0)
// 表示应用已在此版本上充分测试,系统会启用该版本的行为特性
targetSdkVersion 28
// 版本号:内部版本标识,每次发布必须递增
versionCode 1
// 版本名称:对外展示的用户友好版本号
versionName "1.0.0"
// 测试运行器:使用 AndroidJUnit4 进行单元测试
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// 构建类型配置
buildTypes {
// 发布(Release)构建配置
release {
// 是否启用代码压缩和混淆(发布时建议开启以减小体积)
minifyEnabled false
// 混淆规则文件:proguard-rules.pro 中定义了保留哪些类不被混淆
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
// 调试(Debug)构建配置
debug {
// Debug 模式不启用混淆,方便调试和查看堆栈
minifyEnabled false
// 开启 Debug 签名,无需手动配置签名密钥
debuggable true
}
}
// 编译选项
compileOptions {
// 源码兼容性:Java 8
sourceCompatibility JavaVersion.VERSION_1_8
// 目标兼容性:Java 8
targetCompatibility JavaVersion.VERSION_1_8
}
// 构建特性开关
buildFeatures {
// 启用 ViewBinding:自动生成绑定类,替代 findViewById,类型安全且无需额外依赖
viewBinding true
}
// 打包选项
packagingOptions {
// 排除重复的资源文件,避免打包冲突
exclude '/META-INF/{AL2.0,LGPL2.1}'
}
}
// 依赖声明:项目所需的外部库
dependencies {
// ===== AndroidX 核心库 =====
// AppCompat 库:提供向后兼容的 ActionBar 和主题支持
implementation 'androidx.appcompat:appcompat:1.2.0'
// Material Design 组件库:提供按钮、卡片、对话框等符合 Material 规范的 UI 组件
implementation 'com.google.android.material:material:1.3.0'
// ConstraintLayout:灵活高效的布局容器,适合复杂界面
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
// WebKit 库:提供增强型 WebView 支持
implementation 'androidx.webkit:webkit:1.4.0'
// ===== 猎户星空 RobotOS SDK =====
// 通过本地 JAR 文件引入(Demo 工程使用此方式)
implementation fileTree(dir: 'libs', include: ['*.jar'])
// ===== JSON 处理库 =====
// Gson:Google 的 JSON 序列化/反序列化库
// 用于 JSBridge 中 Native 与 H5 之间的 JSON 数据转换
implementation 'com.google.code.gson:gson:2.8.6'
// ===== 测试库(开发阶段使用) =====
// JUnit 4:单元测试框架
testImplementation 'junit:junit:4.13.2'
// AndroidX Test:Android 测试扩展库
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
// Espresso:UI 自动化测试框架
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
```
> **说明**:`libs/robotservice.jar` 文件需要从猎户星空获取。将 JAR 文件放入 `app/libs/` 目录后,Gradle 通过 `implementation fileTree(dir: 'libs', include: ['*.jar'])` 自动识别。SDK 核心类包括 `RobotApi`(导航/位置/运动控制)、`SkillApi`(语音 TTS/ASR)、`RobotSettingApi`(设备信息/电量)、`PersonApi`(人脸识别),均通过 `XXXApi.getInstance()` 获取单例。
---
### 4.8 补充:完整的 AndroidManifest.xml
> 以下文件位于 `MedicalRobotApp/app/src/main/AndroidManifest.xml`,是 Android 应用的配置文件。声明了应用组件、权限、主题等核心信息。每行都有中文注释。
```xml
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:description="@string/app_description"
android:name=".MedicalRobotApplication"
android:theme="@style/Theme.MedicalRobotApp.Fullscreen"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false"
android:extractNativeLibs="true"
android:largeHeap="true"
tools:targetApi="34">
android:name=".MainActivity"
android:exported="false"
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden">
```
> **安全提示**:`android:usesCleartextTraffic="true"` 仅适用于内网迎检环境。若后续部署到公网,应移除此属性或配合 `network_security_config.xml` 配置域名白名单,强制使用 HTTPS 通信。
### 4.9 补充:完整的工程目录结构
> 以下是创建完成后的完整工程目录结构,帮助 Web 开发者快速理解 Android 项目的组织方式。
```
MedicalRobotApp/ // 项目根目录
├── app/ // 应用模块(核心代码都在这里)
│ ├── src/main/ // 主源码目录(还有 test/ 和 androidTest/ 用于测试)
│ │ ├── java/com/emoon/medical/robot/ // Java 源码根目录,按包名层级组织
│ │ │ ├── MedicalRobotApplication.java // Application 类:全局初始化、SDK 连接
│ │ │ ├── MainActivity.java // 主 Activity:WebView 容器,唯一可见界面
│ │ │ ├── bridge/ // JSBridge 相关代码
│ │ │ │ └── RobotBridge.java // JSBridge 实现:Native 与 H5 的通信桥梁
│ │ │ └── sdk/ // SDK 封装层
│ │ │ └── RobotSDKManager.java // SDK 管理器:封装 RobotApi/SkillApi/RobotSettingApi
│ │ ├── res/ // 资源文件目录
│ │ │ ├── layout/ // 布局文件:XML 描述的界面结构
│ │ │ │ └── activity_main.xml // MainActivity 的布局:全屏 WebView
│ │ │ ├── values/ // 值资源:颜色、字符串、主题、尺寸等
│ │ │ │ ├── colors.xml // 颜色定义
│ │ │ │ ├── strings.xml // 字符串定义(应用名称、提示文案等)
│ │ │ │ └── themes.xml // 主题和样式定义
│ │ │ └── xml/ // XML 配置文件
│ │ │ └── network_security_config.xml // 网络安全配置(允许明文 HTTP)
│ │ ├── assets/ // 静态资源目录(不参与编译,原样打包)
│ │ │ └── web/ // H5 打包文件存放位置(离线模式使用)
│ │ │ └── index.html // 本地 H5 入口页面(可选)
│ │ └── AndroidManifest.xml // 应用配置文件(权限、组件声明)
│ ├── libs/ // 本地依赖库目录
│ │ └── robotservice.jar // 猎户星空 SDK(从官方获取,约 1.1MB)
│ └── build.gradle // 模块级构建配置(Groovy DSL,依赖、编译选项)
├── build.gradle // 项目级构建配置(Gradle 插件版本)
├── settings.gradle // Gradle 项目设置(包含的模块列表)
├── gradle.properties // Gradle 属性配置(JVM 参数、代理等)
└── gradle/ // Gradle Wrapper 目录
└── wrapper/
├── gradle-wrapper.jar // Gradle Wrapper 可执行文件
└── gradle-wrapper.properties // Wrapper 配置(Gradle 版本号)
```
> **与 Web 项目的对比**:
> - `src/main/java/` 相当于 Web 后端的 `src/main/java/`(源码目录)
> - `src/main/res/` 相当于前端项目的 `public/` 或 `assets/`(静态资源)
> - `build.gradle` 相当于 `package.json` + `webpack.config.js`(依赖和构建配置,使用 Groovy DSL)
> - `AndroidManifest.xml` 相当于 `web.xml` 或应用入口配置
---
### 4.10 补充:MedicalRobotApplication.java 完整代码
> Application 类是 Android 应用的全局入口,在应用启动时第一个被初始化。这里负责初始化猎户星空 SDK 和建立与机器人系统服务的连接。SDK 初始化流程:`onCreate()` → `RobotApi.getInstance().connectServer(context, ApiListener)` → 在 `handleApiConnected()` 回调中调用 `SkillApi.getInstance().connectApi(context)`。
```java
package com.emoon.medical.robot;
import android.app.Application;
import android.util.Log;
/**
* 应用全局初始化类
* 职责:
* 1. 初始化猎户星空 RobotOS SDK
* 2. 建立与机器人系统服务的连接
* 3. 管理全局状态(SDK 连接状态、Mock 模式开关等)
*
* 生命周期:
* - 应用进程启动时,系统首先创建此类的实例并调用 onCreate()
* - 在应用运行期间保持单例,直到进程被杀死
* - 任何 Activity、Service 都可以通过 (MedicalRobotApplication) getApplication() 访问
*
* 注意:必须在 AndroidManifest.xml 的 标签中通过 android:name 属性声明此类,
* 否则系统不会调用它。
*/
public class MedicalRobotApplication extends Application {
// 日志标签:所有此类相关的日志都使用此标签,方便在 logcat 中过滤
public static final String TAG = "MedicalRobot";
// 全局 SDK 管理器实例
private static RobotSDKManager sdkManager;
// Mock 模式开关:true 表示使用模拟数据(无需真机即可开发调试)
// false 表示调用真实 SDK(需要部署到机器人真机)
// 开发阶段建议设为 true,联调阶段设为 false
public static boolean useMockMode = true;
public static RobotSDKManager getSdkManager() {
return sdkManager;
}
/**
* 应用创建时的初始化方法
* 系统回调:应用进程启动后第一个被调用的方法
* 注意:此方法执行时间过长会阻塞应用启动,因此只应做轻量级初始化
*/
@Override
public void onCreate() {
// 调用父类实现,确保框架级初始化正常完成
super.onCreate();
// 输出应用启动日志,方便在 logcat 中确认初始化流程
Log.i(TAG, "========================================");
Log.i(TAG, "应用启动,开始初始化...");
Log.i(TAG, "Mock 模式: " + useMockMode);
Log.i(TAG, "========================================");
// 初始化 SDK 管理器,传入 Application 上下文
// 上下文(Context)是 Android 中访问系统资源和服务的关键对象
sdkManager = new RobotSDKManager(this);
// 执行 SDK 初始化(连接机器人系统服务)
// 如果是 Mock 模式,初始化会快速完成并返回模拟的连接成功状态
sdkManager.initialize();
Log.i(TAG, "应用初始化完成");
}
/**
* 应用终止时的清理方法
* 系统回调:应用进程即将被杀死时调用(不保证一定被调用)
* 用于释放资源、断开连接等清理操作
*/
@Override
public void onTerminate() {
Log.i(TAG, "应用终止,执行清理...");
// 断开 SDK 连接,释放资源
sdkManager.release();
// 调用父类实现
super.onTerminate();
}
/**
* 内存不足时的回调
* 系统回调:系统内存紧张时调用
* 应在此释放不必要的缓存和资源
*/
@Override
public void onLowMemory() {
Log.w(TAG, "系统内存不足,释放资源...");
super.onLowMemory();
}
}
```
> **Web 开发者提示**:`Application` 类类似于 Spring Boot 的 `@SpringBootApplication` 主类,是全局配置的入口。`onCreate()` 类似于 Spring 的 `CommandLineRunner` 或 `@PostConstruct` 方法。
---
### 4.11 补充:RobotSDKManager.java 完整代码
> SDK 管理封装类:统一封装猎户星空 SDK 的三个核心 API 类(`RobotApi` 导航/位置/运动控制、`SkillApi` 语音 TTS/ASR、`RobotSettingApi` 设备信息/电量),对外提供简洁的接口,内部处理连接管理、错误处理和 Mock 模式切换。回调接口统一使用 SDK 原生的 `ActionListener`(长操作)、`CommandListener`(单次命令)、`TextListener`(TTS 播报)。
```java
package com.emoon.medical.robot;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.ainirobot.coreservice.client.Definition;
import com.ainirobot.coreservice.client.RobotApi;
import com.ainirobot.coreservice.client.actionbean.Pose;
import com.ainirobot.coreservice.client.listener.ActionListener;
import com.ainirobot.coreservice.client.robotsetting.RobotSettingApi;
import com.ainirobot.coreservice.client.speech.SkillApi;
import com.ainirobot.coreservice.client.speech.entity.TTSEntity;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* 猎户星空 RobotOS SDK 管理封装类
* 职责:
* 1. 封装 SDK 的初始化和连接管理
* 2. 提供简洁的业务 API(导航、TTS、位置查询、电量等)
* 3. 统一管理回调接口,将 SDK 的异步结果转换为业务友好的回调
* 4. 支持 Mock 模式,在没有真机时返回模拟数据
*
* 设计模式:外观模式(Facade Pattern),对外隐藏 SDK 的复杂调用细节
*/
public class RobotSDKManager {
// 日志标签
private final String tag = "RobotSDKManager";
private final Context context;
// ===== Mock 模式标志 =====
// 通过 MedicalRobotApplication.useMockMode 统一控制
private boolean isMock() {
return MedicalRobotApplication.useMockMode;
}
// ===== 连接状态 =====
// 记录 SDK 与机器人系统服务的连接状态
private boolean isConnected = false;
// ===== 回调接口定义 =====
// 使用接口(Interface)定义回调规范,调用方实现此接口接收结果
/**
* 通用操作回调接口
* 适用于导航、TTS 等只需要知道成功/失败的操作
*/
public interface OperationCallback {
/**
* 操作成功时调用
* @param data 可选的返回数据(JSON 格式字符串)
*/
void onSuccess(String data);
/**
* 操作失败时调用
* @param code 错误码
* @param message 错误描述
*/
void onError(int code, String message);
}
/**
* 导航状态监听接口
* 用于接收导航过程中的实时状态更新(开始导航、避障、堵死、到达等)
*/
public interface NavigationListener {
/**
* 导航状态变化时调用
* @param statusCode 状态码(如 32730001 表示开始导航)
* @param data 附加数据
*/
void onStatus(int statusCode, String data);
/**
* 导航结果回调
* @param resultCode 结果码(如 32610007 表示到达目的地)
* @param data 附加数据
*/
void onResult(int resultCode, String data);
}
/**
* 位置信息数据类
* 封装机器人的当前坐标和定位状态
*/
public static class Position {
public final double x; // X 坐标(地图坐标系,单位:米)
public final double y; // Y 坐标
public final double theta; // 朝向角度(弧度,0 表示正东方向)
public final boolean isEstimated; // 是否已完成定位
public Position(double x, double y, double theta, boolean isEstimated) {
this.x = x;
this.y = y;
this.theta = theta;
this.isEstimated = isEstimated;
}
}
/**
* 位置点数据类
* 封装地图中预设的导航目标点信息
*/
public static class Place {
public final String name; // 位置点名称(如 "导诊台"、"神经内科")
public final double x; // X 坐标
public final double y; // Y 坐标
public final double theta; // 到达后的朝向角度
public Place(String name, double x, double y, double theta) {
this.name = name;
this.x = x;
this.y = y;
this.theta = theta;
}
}
public RobotSDKManager(Context context) {
this.context = context;
}
// ===== 初始化与连接管理 =====
/**
* 初始化 SDK 并建立与机器人系统服务的连接
* 应在 Application.onCreate() 中调用
*/
public void initialize() {
if (isMock()) {
// Mock 模式:模拟初始化成功,无需连接真实服务
Log.i(tag, "[Mock] SDK 初始化成功(模拟模式)");
isConnected = true;
return;
}
// 真实 SDK 初始化路径
try {
Log.i(tag, "开始初始化猎户星空 SDK...");
RobotApi.getInstance().connectServer(context, new com.ainirobot.coreservice.client.ApiListener() {
@Override
public void handleApiConnected() {
Log.i(tag, "RobotApi 连接成功");
isConnected = true;
}
});
Log.i(tag, "SDK 初始化完成,等待连接...");
} catch (Exception e) {
Log.e(tag, "SDK 初始化失败: " + e.getMessage(), e);
isConnected = false;
}
}
/**
* 释放 SDK 资源,断开连接
* 应在 Application.onTerminate() 或 Activity.onDestroy() 中调用
*/
public void release() {
if (isMock()) {
Log.i(tag, "[Mock] SDK 资源已释放");
isConnected = false;
return;
}
try {
RobotApi.getInstance().disconnectServer();
isConnected = false;
Log.i(tag, "SDK 连接已断开");
} catch (Exception e) {
Log.e(tag, "释放 SDK 资源失败: " + e.getMessage(), e);
}
}
/**
* 获取当前 SDK 连接状态
* @return true 表示已连接,false 表示未连接
*/
public boolean isConnected() {
return isConnected;
}
// ===== 导航 API =====
/**
* 启动导航到指定位置点
* @param destination 目标位置点名称(如 "导诊台"、"神经内科")
* @param callback 操作结果回调
*/
public void startNavigation(final String destination, final OperationCallback callback) {
Log.i(tag, "开始导航到: " + destination);
if (isMock()) {
// Mock 模式:模拟导航成功,延迟 500ms 后回调
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
Log.i(tag, "[Mock] 导航到 [" + destination + "] 成功");
try {
JSONObject json = new JSONObject();
json.put("destination", destination);
json.put("mock", true);
callback.onSuccess(json.toString());
} catch (JSONException e) {
callback.onSuccess("{}");
}
}
}, 500);
return;
}
// 真实 SDK 调用路径
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
RobotApi.getInstance().startNavigation(0, destination, 1.5, 10 * 1000, new ActionListener() {
@Override
public void onResult(int status, String responseString) {
if (status == 32610007) {
callback.onSuccess("{}"); // 到达目的地
} else {
callback.onError(status, "导航结果: " + responseString);
}
}
@Override
public void onError(int errorCode, String errorString) {
callback.onError(errorCode, "导航失败: " + errorString);
}
@Override
public void onStatusUpdate(int status, String data) {
Log.i(tag, "导航状态更新: " + status + ", " + data);
}
});
} catch (Exception e) {
Log.e(tag, "导航调用异常: " + e.getMessage(), e);
callback.onError(-2, "导航异常: " + e.getMessage());
}
}
/**
* 停止当前导航
* @param callback 操作结果回调
*/
public void stopNavigation(final OperationCallback callback) {
Log.i(tag, "停止导航");
if (isMock()) {
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
Log.i(tag, "[Mock] 导航已停止");
callback.onSuccess("{}");
}
}, 200);
return;
}
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
RobotApi.getInstance().stopNavigation(0);
callback.onSuccess("{}");
} catch (Exception e) {
Log.e(tag, "停止导航异常: " + e.getMessage(), e);
callback.onError(-2, "停止导航异常: " + e.getMessage());
}
}
// ===== 位置与地图 API =====
/**
* 获取地图中所有预设位置点列表
* @param callback 结果回调,返回位置点列表的 JSON 字符串
*/
public void getPlaceList(final OperationCallback callback) {
Log.i(tag, "获取位置点列表");
if (isMock()) {
// Mock 模式:返回模拟的医院位置点数据
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
JSONArray mockPlaces = new JSONArray();
try {
JSONObject p1 = new JSONObject();
p1.put("name", "导诊台");
p1.put("x", 1.5);
p1.put("y", 2.0);
p1.put("theta", 1.57);
mockPlaces.put(p1);
JSONObject p2 = new JSONObject();
p2.put("name", "神经内科");
p2.put("x", 5.2);
p2.put("y", 3.8);
p2.put("theta", 0.0);
mockPlaces.put(p2);
JSONObject p3 = new JSONObject();
p3.put("name", "心血管内科");
p3.put("x", 5.2);
p3.put("y", 6.5);
p3.put("theta", 0.0);
mockPlaces.put(p3);
JSONObject p4 = new JSONObject();
p4.put("name", "检验科");
p4.put("x", 8.0);
p4.put("y", 2.0);
p4.put("theta", -1.57);
mockPlaces.put(p4);
JSONObject p5 = new JSONObject();
p5.put("name", "药房");
p5.put("x", 10.5);
p5.put("y", 5.0);
p5.put("theta", 3.14);
mockPlaces.put(p5);
} catch (JSONException e) {
Log.e(tag, "Mock 数据构造失败", e);
}
Log.i(tag, "[Mock] 返回 " + mockPlaces.length() + " 个位置点");
callback.onSuccess(mockPlaces.toString());
}
}, 300);
return;
}
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
List places = RobotApi.getInstance().getPlaceList();
callback.onSuccess(new JSONArray(places).toString());
} catch (Exception e) {
Log.e(tag, "获取位置点列表异常: " + e.getMessage(), e);
callback.onError(-2, "获取位置点列表异常: " + e.getMessage());
}
}
/**
* 获取机器人当前位置坐标
* @param callback 结果回调,返回坐标 JSON 字符串
*/
public void getPosition(final OperationCallback callback) {
Log.i(tag, "获取当前位置");
if (isMock()) {
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
try {
JSONObject mockPosition = new JSONObject();
mockPosition.put("x", 3.5);
mockPosition.put("y", 4.2);
mockPosition.put("theta", 0.78);
mockPosition.put("isEstimated", true);
Log.i(tag, "[Mock] 当前位置: (3.5, 4.2)");
callback.onSuccess(mockPosition.toString());
} catch (JSONException e) {
callback.onSuccess("{}");
}
}
}, 200);
return;
}
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
// 真实 SDK 调用路径(按需实现)
callback.onSuccess("{}");
} catch (Exception e) {
Log.e(tag, "获取位置异常: " + e.getMessage(), e);
callback.onError(-2, "获取位置异常: " + e.getMessage());
}
}
/**
* 检查机器人是否已完成定位
* @param callback 结果回调,返回 JSON 字符串 {"isEstimated": true/false}
*/
public void isRobotEstimate(OperationCallback callback) {
if (isMock()) {
callback.onSuccess("{\"isEstimated\": true}");
return;
}
try {
boolean result = RobotApi.getInstance().isRobotEstimate();
callback.onSuccess("{\"isEstimated\": " + result + "}");
} catch (Exception e) {
callback.onError(-2, "检查定位状态异常: " + e.getMessage());
}
}
// ===== TTS 语音 API =====
/**
* 播放 TTS 语音播报
* @param text 要播报的文本内容
* @param callback 操作结果回调
*/
public void playTTS(final String text, final OperationCallback callback) {
Log.i(tag, "TTS 播报: " + text);
if (isMock()) {
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
Log.i(tag, "[Mock] TTS 播报完成: " + text);
try {
JSONObject json = new JSONObject();
json.put("text", text);
json.put("mock", true);
callback.onSuccess(json.toString());
} catch (JSONException e) {
callback.onSuccess("{}");
}
}
}, text.length() * 200L); // 模拟朗读时间:每个字 200ms
return;
}
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
SkillApi skillApi = ((MedicalRobotApplication) context.getApplicationContext()).getSkillApi();
if (skillApi != null) {
skillApi.playText(new TTSEntity("sid-" + System.currentTimeMillis(), text), new com.ainirobot.coreservice.client.listener.TextListener() {
@Override public void onStart() {}
@Override public void onStop() {}
@Override public void onComplete() {
callback.onSuccess("{}");
}
@Override public void onError() {
callback.onError(-3, "TTS 播放错误");
}
});
} else {
callback.onError(-1, "SkillApi 未连接");
}
} catch (Exception e) {
Log.e(tag, "TTS 播报异常: " + e.getMessage(), e);
callback.onError(-2, "TTS 播报异常: " + e.getMessage());
}
}
/**
* 停止 TTS 语音播报
* @param callback 操作结果回调
*/
public void stopTTS(OperationCallback callback) {
Log.i(tag, "停止 TTS");
if (isMock()) {
callback.onSuccess("{}");
return;
}
try {
SkillApi skillApi = ((MedicalRobotApplication) context.getApplicationContext()).getSkillApi();
if (skillApi != null) {
skillApi.stopTTS();
}
callback.onSuccess("{}");
} catch (Exception e) {
Log.e(tag, "停止 TTS 异常: " + e.getMessage(), e);
callback.onError(-2, "停止 TTS 异常: " + e.getMessage());
}
}
// ===== 电量 API =====
/**
* 获取机器人当前电量
* @param callback 结果回调,返回 JSON 字符串 {"level": 85}
*/
public void getBatteryLevel(final OperationCallback callback) {
Log.i(tag, "获取电量");
if (isMock()) {
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
int mockLevel = 85; // 模拟电量 85%
Log.i(tag, "[Mock] 当前电量: " + mockLevel + "%");
callback.onSuccess("{\"level\": " + mockLevel + "}");
}
}, 100);
return;
}
try {
if (!isConnected) {
callback.onError(-1, "SDK 未连接");
return;
}
String levelStr = RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO);
callback.onSuccess("{\"level\": " + levelStr + "}");
} catch (Exception e) {
Log.e(tag, "获取电量异常: " + e.getMessage(), e);
callback.onError(-2, "获取电量异常: " + e.getMessage());
}
}
}
```
> **Mock 数据说明**:`getPlaceList()` 返回 5 个模拟医院位置点(导诊台、神经内科、心血管内科、检验科、药房);`getPosition()` 返回固定坐标 (3.5, 4.2);`getBatteryLevel()` 返回固定电量 85%;`playTTS()` 按字数模拟延迟。Mock 模式下绕过 `RobotApi`/`SkillApi`/`RobotSettingApi` 的真实调用,直接返回模拟数据。开发者在 PC 模拟器上运行即可看到完整的业务流程。
### 4.12 补充:MainActivity.java 完整代码(WebView 容器)
> MainActivity 是应用的唯一直接可见界面,本质上是一个全屏的 WebView 容器,负责加载 H5 页面并桥接 Native 能力。以下代码完整可用,每行都有中文注释。
```java
package com.emoon.medical.robot;
import android.annotation.SuppressLint;
import android.content.pm.ActivityInfo;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
/**
* 主界面 Activity:WebView 容器
* 职责:
* 1. 提供全屏沉浸式的 WebView 环境( kiosk 模式,类似银行的自助终端)
* 2. 加载 H5 页面(远程服务器或本地 assets)
* 3. 注入 JSBridge,使 H5 页面能够调用机器人的导航、语音等能力
* 4. 管理 WebView 生命周期和系统 UI 状态
*
* 生命周期:
* onCreate → onStart → onResume → [运行中] → onPause → onStop → onDestroy
* 对应 Web 概念:页面创建 → 可见 → 可交互 → 后台 → 销毁
*/
public class MainActivity extends AppCompatActivity {
// WebView:Android 内置浏览器组件,用于加载和显示 H5 页面
private WebView webView;
// ProgressBar:页面加载时的进度指示器(转圈动画)
private ProgressBar progressBar;
// RobotBridge:JSBridge 实例,负责 Native 与 H5 之间的通信
private RobotBridge robotBridge;
// 页面加载失败标志:用于记录当前是否处于错误状态
private boolean hasLoadError = false;
/**
* Activity 创建时调用(系统回调)
* 这是设置布局、初始化组件的核心方法
*/
@SuppressLint("SetJavaScriptEnabled") // 抑制 "启用 JavaScript 可能有安全风险" 的编译器警告
@Override
protected void onCreate(Bundle savedInstanceState) {
// 调用父类实现,确保框架级初始化完成
super.onCreate(savedInstanceState);
// ===== 第 1 步:全屏沉浸式设置 =====
// 隐藏状态栏和导航栏,提供 kiosk 模式体验
hideSystemUI();
// 强制横屏:机器人屏幕为横屏,锁定方向防止旋转
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
// ===== 第 2 步:设置布局 =====
// 将 activity_main.xml 中定义的视图结构加载到 Activity 中
setContentView(R.layout.activity_main);
// ===== 第 3 步:查找视图组件 =====
// 通过视图 ID 获取布局文件中定义的组件实例
webView = findViewById(R.id.webview);
progressBar = findViewById(R.id.progress_bar);
// ===== 第 4 步:初始化 WebView =====
setupWebView();
// ===== 第 5 步:注入 JSBridge =====
// 将 RobotBridge 对象注入到 WebView 的 JavaScript 环境中
// H5 页面可以通过 window.RobotBridge 访问 Native 方法
injectJSBridge();
// ===== 第 6 步:加载页面 =====
loadPage();
}
/**
* 隐藏系统 UI(状态栏和导航栏),实现全屏沉浸式体验
* 原理:设置窗口的 systemUiVisibility 标志,告诉系统不要显示状态栏和导航栏
*/
private void hideSystemUI() {
// 如果 Android 版本 >= 11(API 30),使用新的 WindowInsetsController API
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// 设置窗口为全屏布局模式,内容延伸到状态栏和导航栏下方
getWindow().setDecorFitsSystemWindows(false);
// 获取窗口的 Insets 控制器,控制系统栏的显示/隐藏
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
// 隐藏状态栏(显示时间、电量等系统信息的顶部栏)
controller.hide(WindowInsets.Type.statusBars());
// 隐藏导航栏(底部的返回/主页/多任务键)
controller.hide(WindowInsets.Type.navigationBars());
// 设置系统栏行为:用户交互时自动隐藏(防止用户滑动调出导航栏)
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
// Android 10 及以下版本使用传统的 systemUiVisibility 标志
getWindow().getDecorView().setSystemUiVisibility(
// 全屏模式:内容延伸到状态栏后面
View.SYSTEM_UI_FLAG_FULLSCREEN
// 隐藏导航栏
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
// 沉浸式模式:用户交互后仍保持隐藏
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
// 内容延伸到导航栏后面
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
// 内容延伸到状态栏后面
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// 保持布局稳定,防止系统栏显示/隐藏时内容跳动
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
}
// 保持屏幕常亮:迎检演示时防止屏幕自动熄灭
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
/**
* 配置 WebView 的各项设置
* WebView 是 Android 内置的浏览器引擎(基于 Chromium),用于渲染 H5 页面
*/
@SuppressLint("SetJavaScriptEnabled")
private void setupWebView() {
WebSettings settings = webView.getSettings();
// ===== JavaScript 支持 =====
// 启用 JavaScript:Vue 等现代前端框架需要 JS 才能运行,必须开启
settings.setJavaScriptEnabled(true);
// 启用 DOM Storage(Web Storage API):Vuex/Pinia 等状态管理库依赖此特性
settings.setDomStorageEnabled(true);
// 启用数据库存储:部分 H5 应用使用 Web SQL 或 IndexedDB
settings.setDatabaseEnabled(true);
// 允许文件访问:WebView 可以加载本地文件(如 assets 中的资源)
settings.setAllowFileAccess(true);
// 允许内容访问:WebView 可以访问 ContentProvider 提供的内容
settings.setAllowContentAccess(true);
// 允许从文件 URL 访问其他文件 URL:本地 H5 页面可能需要加载本地其他资源
settings.setAllowFileAccessFromFileURLs(true);
// 允许从文件 URL 访问任意来源:本地页面可能需要访问网络资源
settings.setAllowUniversalAccessFromFileURLs(true);
// 允许自动播放媒体(音频/视频):语音播报功能需要自动播放
settings.setMediaPlaybackRequiresUserGesture(false);
// 允许混合内容(HTTP + HTTPS):内网环境可能同时存在两种协议
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
// 使用宽视口:H5 页面可以设置自己的 viewport,WebView 按页面要求渲染
settings.setUseWideViewPort(true);
// 以概览模式加载页面:页面宽度适配屏幕宽度
settings.setLoadWithOverviewMode(true);
// 禁用缩放按钮:kiosk 模式下不允许用户手动缩放
settings.setSupportZoom(false);
settings.setBuiltInZoomControls(false);
settings.setDisplayZoomControls(false);
// 设置缓存策略:优先使用缓存,加快页面加载速度
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
// 设置 User-Agent:追加自定义标识,H5 侧可通过此判断是否在机器人环境中
// H5 代码示例:if (navigator.userAgent.includes('MedicalRobot')) { ... }
settings.setUserAgentString(settings.getUserAgentString() + " MedicalRobot/1.0");
// 启用硬件加速:利用 GPU 渲染页面,提升动画和滚动性能
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
// ===== 调试模式配置 =====
// BuildConfig.DEBUG 在 Debug 构建时为 true,Release 构建时为 false
if (BuildConfig.DEBUG) {
// 启用 WebView 远程调试:允许 Chrome DevTools 连接到此 WebView
// 配合 Chrome 浏览器的 chrome://inspect 页面使用
WebView.setWebContentsDebuggingEnabled(true);
}
// ===== WebViewClient:处理页面加载事件 =====
// WebViewClient 控制 WebView 如何处理 URL 加载和页面事件
webView.setWebViewClient(new WebViewClient() {
/**
* 页面开始加载时调用
* @param view WebView 实例
* @param url 正在加载的 URL
*/
@Override
public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) {
super.onPageStarted(view, url, favicon);
// 显示加载动画,提示用户页面正在加载
progressBar.setVisibility(View.VISIBLE);
hasLoadError = false;
}
/**
* 页面加载完成时调用
* @param view WebView 实例
* @param url 已加载完成的 URL
*/
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// 隐藏加载动画
progressBar.setVisibility(View.GONE);
// 如果之前加载失败,现在成功了,清除错误标志
if (hasLoadError) {
hasLoadError = false;
}
}
/**
* 页面加载出错时调用
* @param view WebView 实例
* @param request 失败的请求信息
* @param error 错误详情
*/
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);
// 标记加载失败状态
hasLoadError = true;
progressBar.setVisibility(View.GONE);
// 在主线程显示错误提示
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(
MainActivity.this,
"页面加载失败,请检查网络连接",
Toast.LENGTH_LONG
).show();
}
});
// 可选:加载本地离线提示页
// webView.loadUrl("file:///android_asset/error.html");
}
/**
* 拦截 URL 加载请求
* 返回 true 表示由应用处理此 URL,返回 false 表示由 WebView 继续加载
* @param view WebView 实例
* @param request 加载请求
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// 示例:拦截特定的自定义协议(如 robot://settings)
// if (url.startsWith("robot://")) {
// handleRobotProtocol(url);
// return true;
// }
// 默认行为:由 WebView 继续加载
return false;
}
});
// ===== WebChromeClient:处理 JS 弹窗和高级功能 =====
// WebChromeClient 处理需要与 UI 交互的 Web 功能
webView.setWebChromeClient(new WebChromeClient() {
/**
* 处理 JavaScript 的 alert() 弹窗
* 默认行为是弹出系统对话框,这里使用原生 AlertDialog 替代
*/
@Override
public boolean onJsAlert(WebView view, String url, String message, final android.webkit.JsResult result) {
// 创建 AlertDialog 替代默认弹窗
new AlertDialog.Builder(MainActivity.this)
.setTitle("提示") // 对话框标题
.setMessage(message) // 显示 JS 传来的消息内容
.setPositiveButton("确定", new android.content.DialogInterface.OnClickListener() {
@Override
public void onClick(android.content.DialogInterface dialog, int which) {
// 用户点击确定后,通知 JS 弹窗已确认
result.confirm();
}
})
.setCancelable(false) // 禁止点击外部取消,确保 JS 流程继续
.show();
return true; // 返回 true 表示已处理此弹窗
}
/**
* 处理文件选择(如 )
* 用于 H5 页面上传图片、拍照等功能
*/
@Override
public boolean onShowFileChooser(WebView view, android.webkit.ValueCallback filePathCallback, FileChooserParams fileChooserParams) {
// 实际实现需要启动相机或文件选择器
// 简化示例:返回取消,H5 侧可降级处理
filePathCallback.onReceiveValue(null);
return true;
}
});
}
/**
* 注入 JSBridge 到 WebView
* 通过 @JavascriptInterface 注解,将 Java 方法暴露给 JavaScript 调用
*/
private void injectJSBridge() {
// 创建 RobotBridge 实例,传入 WebView 和 SDK 管理器
robotBridge = new RobotBridge(webView, MedicalRobotApplication.getSdkManager());
// 将 RobotBridge 对象注入到 WebView 的 JavaScript 环境中
// 第二个参数 "RobotBridge" 是 JS 侧访问此对象时使用的名称
// JS 调用方式:window.RobotBridge.navigate("导诊台", "cb_001")
webView.addJavascriptInterface(robotBridge, "RobotBridge");
}
/**
* 加载 H5 页面
* 支持两种方式:远程服务器(开发推荐)或本地 assets(离线模式)
*/
private void loadPage() {
// ===== 方式 A:加载远程服务器页面(推荐,开发阶段使用) =====
// 优势:修改 H5 代码后无需重新打包 APK,刷新即可生效
// 替换为实际的后端服务器 IP 地址和端口
webView.loadUrl("http://192.168.1.100:8080");
// ===== 方式 B:加载本地 assets 中的打包文件(离线模式) =====
// 优势:无需网络,适合演示环境或网络不稳定场景
// 使用方法:将 Vue 打包后的 dist 目录内容复制到 app/src/main/assets/web/ 下
// 取消下面一行的注释即可切换到本地模式
// webView.loadUrl("file:///android_asset/web/index.html");
}
/**
* 处理返回键事件
* 系统回调:用户按下物理返回键时调用
*/
@Override
public void onBackPressed() {
// 检查 WebView 是否有历史记录可以后退
if (webView.canGoBack()) {
// WebView 可以后退(如从报告详情页返回到首页)
webView.goBack();
} else {
// WebView 已无历史记录,返回到 Launcher(不退出应用进程)
// moveTaskToBack 将当前任务移到后台,类似点击 Home 键
// true 表示即使当前 Activity 是根 Activity 也执行
moveTaskToBack(true);
}
}
/**
* 窗口焦点变化时调用
* 系统回调:Activity 获得或失去焦点时调用
* 用于在弹窗关闭后重新隐藏系统 UI
*/
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
// 当 Activity 重新获得焦点时,再次隐藏系统 UI
// 防止用户通过滑动调出导航栏后,焦点变化导致系统 UI 保持显示
if (hasFocus) {
hideSystemUI();
}
}
/**
* 系统内存不足时调用
* 系统回调:系统内存紧张时调用,应释放不必要的资源
*/
@Override
public void onLowMemory() {
super.onLowMemory();
// 清理 WebView 缓存,释放内存
webView.clearCache(true);
// 可选:清理历史记录、Cookie 等
// webView.clearHistory();
}
/**
* Activity 销毁时调用
* 系统回调:Activity 被销毁前调用,用于清理资源
*/
@Override
protected void onDestroy() {
// 释放 WebView 资源,防止内存泄漏
// WebView 持有大量 native 资源,必须显式释放
webView.stopLoading(); // 停止正在进行的加载
webView.loadUrl("about:blank"); // 加载空白页,释放当前页面资源
webView.clearHistory(); // 清除历史记录
webView.removeAllViews(); // 移除所有子视图
webView.destroy(); // 销毁 WebView 实例,释放 native 内存
// 调用父类实现
super.onDestroy();
}
}
```
> **Web 开发者提示**:`AppCompatActivity` 类似于 Web 框架的页面控制器;`onCreate()` 类似于页面挂载时的 `mounted()` 钩子;`onDestroy()` 类似于 `beforeUnmount()` 钩子。`findViewById` 类似于 `document.getElementById()`。
---
### 4.13 补充:activity_main.xml 完整布局
> 以下文件位于 `MedicalRobotApp/app/src/main/res/layout/activity_main.xml`。布局非常简单:全屏 WebView + 居中的加载进度条。
```xml
```
> **Web 开发者提示**:`ConstraintLayout` 类似于 CSS Flexbox + Absolute Positioning 的结合体。`match_parent` 类似于 `width: 100%`,`wrap_content` 类似于 `width: auto`(由内容决定)。`0dp` 在 ConstraintLayout 中表示由约束决定尺寸,类似于 CSS 中同时设置 `left: 0; right: 0;`。
### 4.14 补充:RobotBridge.java 完整 JSBridge 实现
> RobotBridge 是 Native 与 H5 之间的通信桥梁。H5 页面通过 `window.RobotBridge.xxx()` 调用 Native 方法,Native 通过 `webView.evaluateJavascript()` 将结果回传给 H5。以下代码包含全部 7 个接口的完整实现,支持 Mock 模式。底层 SDK 调用方式:导航通过 `RobotApi.getInstance().startNavigation()`,TTS 通过 `SkillApi.getInstance().playText(TTSEntity, TextListener)`,电量通过 `RobotSettingApi.getInstance().getRobotString()`,位置通过 `RobotApi.getInstance().getPosition()`。
```java
package com.emoon.medical.robot;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import org.json.JSONException;
import org.json.JSONObject;
/**
* JSBridge 桥接类:连接 H5 JavaScript 与 Android Native 代码
* 职责:
* 1. 接收 H5 通过 window.RobotBridge 发起的调用请求
* 2. 转发请求到 RobotSDKManager 执行实际的 SDK 操作
* 3. 将 SDK 执行结果通过 evaluateJavascript 回传给 H5
* 4. 提供 Mock 模式,支持在 PC 模拟器上完整调试
*
* 通信协议(callbackId 模式):
* 1. H5 生成唯一 callbackId,将回调函数注册到 window.__robotCallbacks[callbackId]
* 2. H5 调用 window.RobotBridge.methodName(arg1, arg2, ..., callbackId)
* 3. Native 的 @JavascriptInterface 方法被触发,在子线程执行 SDK 调用
* 4. SDK 返回结果后,Native 通过 webView.evaluateJavascript 执行:
* window.__robotCallbacks[callbackId](resultJson)
* 5. H5 的回调函数被执行,处理返回数据
*/
public class RobotBridge {
private final WebView webView;
private final RobotSDKManager sdkManager;
// 日志标签
private final String tag = "RobotBridge";
// 主线程 Handler:用于从子线程切换回主线程操作 WebView
// WebView 的所有操作必须在主线程执行
private final Handler mainHandler = new Handler(Looper.getMainLooper());
// Mock 模式标志:从 Application 全局配置读取
private boolean isMock() {
return MedicalRobotApplication.useMockMode;
}
public RobotBridge(WebView webView, RobotSDKManager sdkManager) {
this.webView = webView;
this.sdkManager = sdkManager;
}
/**
* 统一回调方法:将结果 JSON 字符串回传给 H5
* @param callbackId H5 传入的回调标识
* @param resultJson 结果数据的 JSON 字符串
*/
private void callbackToH5(final String callbackId, final String resultJson) {
// 构造要执行的 JavaScript 代码
// 先检查 __robotCallbacks 和指定 callbackId 是否存在,避免空指针
final String jsCode =
"(function() {" +
" var cb = window.__robotCallbacks && window.__robotCallbacks['" + callbackId + "'];" +
" if (typeof cb === 'function') {" +
" cb(" + resultJson + ");" +
" delete window.__robotCallbacks['" + callbackId + "'];" +
" return 'callback_executed';" +
" } else {" +
" return 'callback_not_found';" +
" }" +
"})()";
// 切换到主线程执行(WebView 必须在主线程操作)
mainHandler.post(new Runnable() {
@Override
public void run() {
// evaluateJavascript:在 WebView 中执行 JavaScript 代码
// 第二个参数是结果回调(此处不需要,传 null)
webView.evaluateJavascript(jsCode, null);
}
});
}
/**
* 构造标准成功响应 JSON
* @return JSON 字符串 {"code": 0, "msg": "success"}
*/
private String successJson() {
return "{\"code\":0,\"msg\":\"success\"}";
}
/**
* 构造标准成功响应 JSON(带数据)
* @param data 业务数据 JSON 字符串
* @return JSON 字符串
*/
private String successJson(String data) {
return "{\"code\":0,\"msg\":\"success\",\"data\":" + data + "}";
}
/**
* 构造标准错误响应 JSON
* @param code 错误码
* @param message 错误描述
* @return JSON 字符串 {"code": code, "msg": "message"}
*/
private String errorJson(int code, String message) {
return "{\"code\":" + code + ",\"msg\":\"" + message + "\"}";
}
// ===== 接口 1:导航到指定位置 =====
/**
* 导航到指定位置点
* @JavascriptInterface 注解:将此方法暴露给 JavaScript 调用
* @param destination 目标位置点名称(如 "导诊台"、"神经内科")
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void navigate(final String destination, final String callbackId) {
Log.i(tag, "JSBridge 收到导航请求: destination=" + destination + ", callbackId=" + callbackId);
// 在子线程中执行 SDK 调用,避免阻塞 WebView 的 JS 线程
new Thread(new Runnable() {
@Override
public void run() {
// 调用 SDK 管理器的导航方法
sdkManager.startNavigation(destination, new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
// 导航成功,构造成功响应并回传
String result = successJson(data);
Log.i(tag, "导航成功,回传结果: " + result);
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
// 导航失败,构造错误响应并回传
String result = errorJson(code, message);
Log.e(tag, "导航失败,回传结果: " + result);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 2:停止导航 =====
/**
* 停止当前导航
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void stopNavigation(final String callbackId) {
Log.i(tag, "JSBridge 收到停止导航请求: callbackId=" + callbackId);
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.stopNavigation(new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
String result = successJson();
Log.i(tag, "停止导航成功");
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "停止导航失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 3:获取位置点列表 =====
/**
* 获取地图中所有预设位置点列表
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void getPlaceList(final String callbackId) {
Log.i(tag, "JSBridge 收到获取位置点列表请求: callbackId=" + callbackId);
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.getPlaceList(new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
// data 是 JSON 数组字符串,直接放入响应中
String result = successJson(data);
Log.i(tag, "获取位置点列表成功");
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "获取位置点列表失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 4:获取当前位置 =====
/**
* 获取机器人当前坐标位置
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void getPosition(final String callbackId) {
Log.i(tag, "JSBridge 收到获取位置请求: callbackId=" + callbackId);
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.getPosition(new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
String result = successJson(data);
Log.i(tag, "获取位置成功: " + data);
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "获取位置失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 5:TTS 语音播报 =====
/**
* 播放 TTS 语音播报
* @param text 要播报的文本内容
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void playTTS(final String text, final String callbackId) {
Log.i(tag, "JSBridge 收到 TTS 请求: text=" + text + ", callbackId=" + callbackId);
// 输入校验:文本不能为空
if (text == null || text.trim().isEmpty()) {
String result = errorJson(-3, "TTS 文本不能为空");
callbackToH5(callbackId, result);
return;
}
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.playTTS(text, new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
String result = successJson();
Log.i(tag, "TTS 播报成功: " + text);
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "TTS 播报失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 6:停止 TTS =====
/**
* 停止 TTS 语音播报
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void stopTTS(final String callbackId) {
Log.i(tag, "JSBridge 收到停止 TTS 请求: callbackId=" + callbackId);
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.stopTTS(new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
String result = successJson();
Log.i(tag, "停止 TTS 成功");
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "停止 TTS 失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
// ===== 接口 7:获取电量 =====
/**
* 获取机器人当前电量
* @param callbackId H5 生成的回调标识
*/
@JavascriptInterface
public void getBattery(final String callbackId) {
Log.i(tag, "JSBridge 收到获取电量请求: callbackId=" + callbackId);
new Thread(new Runnable() {
@Override
public void run() {
sdkManager.getBatteryLevel(new RobotSDKManager.OperationCallback() {
@Override
public void onSuccess(String data) {
// data 是 JSON 字符串 {"level": 85}
String result = successJson(data);
Log.i(tag, "获取电量成功: " + data);
callbackToH5(callbackId, result);
}
@Override
public void onError(int code, String message) {
String result = errorJson(code, message);
Log.e(tag, "获取电量失败: " + message);
callbackToH5(callbackId, result);
}
});
}
}).start();
}
}
```
> **回调处理说明**:所有接口都采用统一的回调协议。H5 侧调用时需按以下方式封装:
> ```javascript
> // H5 侧调用示例
> const callbackId = 'nav_' + Date.now();
> window.__robotCallbacks[callbackId] = (result) => {
> if (result.code === 0) {
> console.log('成功:', result);
> } else {
> console.error('失败:', result.msg);
> }
> };
> window.RobotBridge.navigate('神经内科', callbackId);
> ```
---
### 4.15 补充:WebView 与原生交互原理图解
> 以下图解帮助 Web 开发者理解 H5 页面如何与 Android Native 代码通信。
**通信流程(以导航为例):**
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 调用流程(H5 → Native) │
└─────────────────────────────────────────────────────────────────────────────┘
H5 页面 (JavaScript)
│
├── 用户点击 "带我去神经内科" 按钮
│
├── H5 生成唯一 callbackId: "cb_001"
│ └── window.__robotCallbacks["cb_001"] = function(result) { ... }
│
├── 调用 Native 方法
│ └── window.RobotBridge.navigate("神经内科", "cb_001")
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ @JavascriptInterface │
│ │ WebView 拦截并调用 Android 方法 │
│ │ RobotBridge.navigate(...) │
│ └─────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ 切换到子线程执行 │
│ │ Thread { sdkManager.startNavigation(...) }
│ └─────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ 调用猎户星空 SDK API │
│ │ RobotApi.getInstance() │
│ │ .startNavigation(...) │
│ └─────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ 机器人硬件执行导航 │
│ │ 底盘运动、避障、到达目标点 │
│ └─────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────────────────┐
│ 回调流程(Native → H5) │
└─────────────────────────────────────────────────────────────────────────────┘
机器人硬件
│
├── 导航完成(到达目标点或出错)
│
├── SDK 回调结果到 RobotSDKManager
│
├── RobotSDKManager 回调到 RobotBridge
│
├── RobotBridge 构造 JSON 结果
│ └── { "code": 0, "msg": "success", "destination": "神经内科" }
│
├── 切换到主线程(WebView 必须在主线程操作)
│ └── mainHandler.post { ... }
│
├── 通过 evaluateJavascript 执行 JS 回调
│ └── webView.evaluateJavascript(
│ "window.__robotCallbacks['cb_001']({...})")
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ H5 的回调函数被执行 │
│ │ window.__robotCallbacks["cb_001"](result)
│ │ └── 更新 UI:显示 "已到达神经内科" │
│ └─────────────────────────────────────┘
│
└── 清理:delete window.__robotCallbacks["cb_001"]
```
**Mermaid 序列图:**
```mermaid
sequenceDiagram
participant H5 as H5 页面 (JavaScript)
participant WV as WebView
participant RB as RobotBridge
participant SM as RobotSDKManager
participant SDK as 猎户星空 SDK
participant HW as 机器人硬件
H5->>H5: 生成 callbackId = "cb_001"
H5->>H5: window.__robotCallbacks["cb_001"] = callbackFn
H5->>WV: window.RobotBridge.navigate("神经内科", "cb_001")
WV->>RB: @JavascriptInterface navigate()
RB->>SM: startNavigation()
SM->>SDK: RobotApi.getInstance().startNavigation()
SDK->>HW: 执行导航
HW-->>SDK: 导航结果
SDK-->>SM: 回调结果
SM-->>RB: onSuccess/onError
RB->>RB: 构造 JSON 响应
RB->>WV: evaluateJavascript("window.__robotCallbacks['cb_001'](result)")
WV->>H5: 执行 JS 回调函数
H5->>H5: 更新 UI / 提示用户
H5->>H5: delete window.__robotCallbacks["cb_001"]
```
> **关键设计决策**:
> 1. **callbackId 模式**:因为 `@JavascriptInterface` 只支持基本类型参数,无法传递 JS 函数对象,所以用字符串 ID 关联回调
> 2. **子线程执行**:SDK 调用可能耗时(如网络请求、硬件操作),必须在子线程执行避免阻塞 WebView
> 3. **主线程回调**:`evaluateJavascript` 必须在主线程调用,因此使用 `Handler(Looper.getMainLooper())` 切换线程
> 4. **SDK 回调接口**:导航等长操作使用 `ActionListener`(含 `onResult`/`onError`/`onStatusUpdate`),获取位置等单次命令使用 `CommandListener`(含 `onResult`),TTS 播报使用 `TextListener`(含 `onStart`/`onStop`/`onError`/`onComplete`)
---
### 4.16 补充:ADB 调试完整指南
> ADB(Android Debug Bridge)是 Android 开发的必备调试工具。本节面向从未使用过 ADB 的 Web 开发者,涵盖从连接到排错的完整流程。
#### 4.16.1 连接机器人设备
**方式一:USB 连接(有线连接)**
- 使用 USB 数据线将开发电脑连接到机器人主板的 USB 接口
- 在机器人系统设置中启用 "USB 调试"(通常在 设置 → 开发者选项 → USB 调试)
- 首次连接时,机器人屏幕会弹出 "允许 USB 调试吗?" 的授权对话框,点击 "确定"
**方式二:WiFi 连接(无线连接,推荐)**
- 确保开发电脑和机器人在同一局域网内
- 先用 USB 连接一次,执行以下命令启用网络调试:
```bash
adb tcpip 5555
```
- 断开 USB 线,然后通过 WiFi 连接:
```bash
# 将 192.168.1.xxx 替换为机器人的实际 IP 地址
adb connect 192.168.1.xxx:5555
```
- 后续无需再插 USB 线,直接通过 WiFi 调试
#### 4.16.2 验证连接状态
```bash
# 查看已连接的设备列表
adb devices
```
正常输出示例:
```
List of devices attached
192.168.1.100:5555 device
```
- `device` 表示连接正常,可以进行调试
- `unauthorized` 表示未授权,需要在机器人屏幕上确认 USB 调试授权
- `offline` 表示设备离线,检查网络或重新连接
#### 4.16.3 安装 APK
```bash
# 安装 APK 到机器人设备
adb install app/build/outputs/apk/debug/app-debug.apk
# -r 参数:覆盖安装(保留应用数据,升级时使用)
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 如果安装失败,先卸载再安装
adb uninstall com.emoon.medical.robot
adb install app/build/outputs/apk/debug/app-debug.apk
```
#### 4.16.4 查看实时日志
```bash
# 查看所有日志(信息量大,建议配合过滤使用)
adb logcat
# 只查看 MedicalRobot 标签的日志(推荐)
adb logcat -s MedicalRobot:V
# 同时查看多个标签的日志
adb logcat -s MedicalRobot:V RobotSDKManager:V RobotBridge:V
# 查看日志并保存到文件(方便后续分析)
adb logcat -s MedicalRobot:V > robot_log.txt
# 清除旧日志后查看(避免历史日志干扰)
adb logcat -c && adb logcat -s MedicalRobot:V
```
> **日志级别说明**:`V` = Verbose(所有级别),`D` = Debug,`I` = Info,`W` = Warn,`E` = Error。`-s MedicalRobot:V` 表示显示 MedicalRobot 标签的所有级别日志。
#### 4.16.5 远程调试 WebView
这是调试 H5 页面最强大的方式,可以直接使用 Chrome DevTools:
1. **确保代码中启用了 WebView 调试**(已在 MainActivity.java 中配置):
```java
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}
```
2. **在开发电脑的 Chrome 浏览器中访问**:
```
chrome://inspect
```
3. **在 "Remote Target" 区域找到你的 WebView**:
- 显示设备名称 + WebView 加载的 URL
- 例如:`ORIONSTAR-001 - http://192.168.1.100:8080`
4. **点击 "inspect" 按钮**:
- 会打开独立的 Chrome DevTools 窗口
- 功能与桌面端完全一致:Elements、Console、Network、Sources、Application 等
- 可以在 Console 中直接执行 JS 代码测试 RobotBridge 接口
#### 4.16.6 截屏
```bash
# 截取当前屏幕并保存到电脑
adb exec-out screencap -p > screenshot.png
# 先保存到设备,再拉取到电脑
adb shell screencap -p /sdcard/screen.png
adb pull /sdcard/screen.png ./screen.png
```
#### 4.16.7 卸载应用
```bash
# 按包名卸载应用
adb uninstall com.emoon.medical.robot
# 卸载但保留数据(应用数据不会被删除)
adb shell pm uninstall -k com.emoon.medical.robot
```
#### 4.16.8 常用排错命令
```bash
# 只看错误级别的日志(快速定位崩溃原因)
adb logcat *:E
# 查看当前前台 Activity(确认应用是否在运行)
adb shell dumpsys activity top
# 查看已安装的应用包列表(确认应用是否安装成功)
adb shell pm list packages | grep emoon
# 查看设备信息(Android 版本、SDK 版本等)
adb shell getprop ro.build.version.release
# 查看应用进程是否在运行
adb shell ps | grep medical
# 强制停止应用(相当于系统设置中的 "强行停止")
adb shell am force-stop com.emoon.medical.robot
# 重启设备
adb reboot
# 进入设备的 shell 环境(可以执行 Linux 命令)
adb shell
# 从电脑推送文件到设备
adb push local_file.txt /sdcard/remote_file.txt
# 从设备拉取文件到电脑
adb pull /sdcard/remote_file.txt ./local_file.txt
```
> **排错建议**:如果应用启动后白屏,首先检查 `adb logcat *:E` 查看是否有崩溃信息;如果 WebView 页面加载失败,使用 `chrome://inspect` 检查 Network 面板查看请求状态。
---
### 4.17 补充:network_security_config.xml
> 以下文件位于 `MedicalRobotApp/app/src/main/res/xml/network_security_config.xml`。用于配置网络安全策略,允许应用访问明文 HTTP 通信(内网环境必需)。
```xml
```
> **安全警告**:`` 会允许所有域名的明文 HTTP 通信。在生产环境中,建议改用注释中的 `` 方式,仅允许特定内网 IP 或域名的明文通信,其他请求仍强制使用 HTTPS。
---
## 五、H5 前端适配改造
> 本章面向对移动端开发不熟悉的全栈 Web 工程师(Java + Vue 背景),提供可直接复制使用的完整代码。当前前端基于 Vue 3 + Composition API,构建工具为 `@vue/cli-service`。
>
> **✅ WebView 方案已确认可行**:经与猎户星空厂商会议确认,第三方 APK 内嵌 WebView 加载 H5 页面的方案完全可行,无系统限制。
### 5.1 新增 robot.js — 机器人原生能力桥接层
在 `medical-card-demo/frontend/src/api/` 目录下新建 `robot.js`,完整代码如下(可直接复制使用):
```javascript
/**
* robot.js - 机器人原生能力桥接层
*
* 职责:
* 1. 检测当前是否在机器人 WebView 环境中
* 2. 封装所有 JSBridge 调用为 Promise
* 3. 管理全局回调池
* 4. 非机器人环境下提供优雅降级
*
* 文件位置:/src/api/robot.js
*/
// ========== 全局回调池管理 ==========
window.__robotCallbacks = window.__robotCallbacks || {}
let _callbackCounter = 0
/**
* 生成唯一回调 ID
*/
function generateCallbackId(prefix) {
return `${prefix}_${++_callbackCounter}_${Date.now()}`
}
/**
* 检测当前是否在机器人 WebView 环境中
* 判断依据:原生 App 会注入 window.RobotBridge 对象
* 同时检查 UserAgent 中是否包含 MedicalRobot 标识
*/
export function isRobotEnv() {
return !!(window.RobotBridge) || navigator.userAgent.includes('MedicalRobot')
}
/**
* 通用原生方法调用封装
* @param {string} method - RobotBridge 上的方法名
* @param {Array} args - 方法参数(不含 callbackId)
* @param {string} prefix - 回调 ID 前缀
* @param {number} timeout - 超时时间(毫秒),默认 30 秒
* @returns {Promise}
*/
function callNative(method, args = [], prefix = 'cb', timeout = 30000) {
return new Promise((resolve, reject) => {
// 非机器人环境降级处理
if (!isRobotEnv()) {
reject(new Error(`非机器人环境,无法调用 ${method}`))
return
}
// 检查方法是否存在
if (typeof window.RobotBridge[method] !== 'function') {
reject(new Error(`RobotBridge.${method} 方法不存在`))
return
}
const cbId = generateCallbackId(prefix)
// 超时处理
const timer = setTimeout(() => {
delete window.__robotCallbacks[cbId]
reject(new Error(`${method} 调用超时(${timeout}ms)`))
}, timeout)
// 注册回调
window.__robotCallbacks[cbId] = (result) => {
clearTimeout(timer)
delete window.__robotCallbacks[cbId]
if (result && result.code === 0) {
resolve(result)
} else {
reject(result || { code: -1, msg: '未知错误' })
}
}
// 执行原生调用
try {
window.RobotBridge[method](...args, cbId)
} catch (e) {
clearTimeout(timer)
delete window.__robotCallbacks[cbId]
reject(new Error(`调用 ${method} 异常: ${e.message}`))
}
})
}
// ========== 导航相关 API ==========
/** 导航到指定位置(机器人带路) */
export const navigateTo = (destination) =>
callNative('navigate', [destination], 'nav', 60000) // 导航超时 60 秒
/** 停止导航 */
export const stopNavigation = () =>
callNative('stopNavigation', [], 'stopnav')
/** 获取所有位置点列表 */
export const getPlaceList = () =>
callNative('getPlaceList', [], 'places')
/** 获取当前坐标 {x, y, theta} */
export const getPosition = () =>
callNative('getPosition', [], 'pos')
// ========== 语音相关 API ==========
/** TTS 语音播报 */
export const playTTS = (text) =>
callNative('playTTS', [text], 'tts')
/** 停止 TTS */
export const stopTTS = () =>
callNative('stopTTS', [], 'stoptts')
// ========== 设备信息 API ==========
/** 获取电量百分比 */
export const getBattery = () =>
callNative('getBattery', [], 'bat')
// ========== 辅助工具 ==========
/**
* 在非机器人环境下模拟 RobotBridge(开发调试用)
* 在 main.js 中调用此方法可以在浏览器中模拟机器人环境
*/
export function enableDevMock() {
if (isRobotEnv()) return // 真实环境不覆盖
console.warn('[robot.js] 开发模式:启用 Mock RobotBridge')
window.RobotBridge = {
navigate(dest, cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_navigation_started', destination: dest })
}, 500)
},
stopNavigation(cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_stopped' })
}, 200)
},
getPlaceList(cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({
code: 0,
data: [
{ name: '门诊大厅', x: 0, y: 0 },
{ name: '内科诊室', x: 10.5, y: 3.2 },
{ name: '外科诊室', x: 15.0, y: -2.1 },
{ name: '药房', x: 5.3, y: 8.7 },
{ name: '检验科', x: 20.0, y: 0.5 }
]
})
}, 300)
},
getPosition(cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, data: { x: 0.0, y: 0.0, theta: 0.0 } })
}, 100)
},
playTTS(text, cbId) {
console.log(`[Mock TTS] 播报: ${text}`)
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_tts_done' })
}, text.length * 100)
},
stopTTS(cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_tts_stopped' })
}, 100)
},
getBattery(cbId) {
setTimeout(() => {
window.__robotCallbacks[cbId]?.({ code: 0, data: { level: 85 } })
}, 100)
}
}
}
```
**代码要点说明:**
| 要点 | 说明 |
|------|------|
| `isRobotEnv()` | 同时检测 `window.RobotBridge` 和 UserAgent,防止误判 |
| `callNative()` | 所有原生调用统一走此函数,自带超时、异常处理、回调清理 |
| callbackId 模式 | 每个调用生成唯一 ID,通过全局 `__robotCallbacks` 池管理,避免回调地狱 |
| `enableDevMock()` | 浏览器开发时模拟机器人环境,无需真机即可调试导航逻辑 |
---
### 5.2 DepartmentSelectionCard.vue 改造 — 增加"带我去"导航按钮
> 现有 `DepartmentSelectionCard.vue` 已使用 Vue 3 Composition API(`setup()`)。以下仅展示需要**新增或修改**的代码片段,直接合并到现有文件中即可。
#### 1)script 部分修改
在 `