版本: v2.0
日期: 2026-04-23
编写: 技术团队
状态: 待评审
| 版本 | 日期 | 修改人 | 修改内容 |
|---|---|---|---|
| v1.0 | 2026-04-23 | 技术团队 | 初稿完成 |
| v2.0 | 2026-04-23 | 技术团队 | 根据猎户星空厂商会议反馈更新:Java 开发语言、真实 SDK API、Android 9.0 适配 |
本项目面向领导视察场景,目标是在猎户星空豹小秘系列机器人上部署一套智慧医疗导诊系统。机器人基于 Android 9.0(API 28)+ RobotOS 定制系统,需在视觉上伪装为 HarmonyOS 4 风格,同时保留完整的导航带路能力。现有业务系统(Vue 前端 + Spring Boot 后端)已完成功能开发,现需构建原生安卓外壳以适配机器人硬件环境。
| 约束编号 | 约束内容 | 原因 |
|---|---|---|
| C1 | 不改机器人系统 | 无系统刷机权限,OTA 升级由厂商控制 |
| C2 | 必须使用猎户星空原生 SDK | 导航、TTS、电量等能力只有官方 SDK 能提供 |
| C3 | 2 人全栈团队,1 周交付,Java 开发 | 人力资源和时间窗口极为有限,方案必须极简,开发语言统一为 Java |
| C4 | 纯视觉伪装,不 claim 真鸿蒙 | 避免法律风险,仅 UI 层面模仿 HarmonyOS 4 风格 |
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
| 层级 | APK 数量 | 核心职责 | 技术栈 |
|---|---|---|---|
| 表层 Launcher | 1 | 替代系统桌面,提供仿鸿蒙 UI 壳 | Android Native(Java) |
| 中层业务 App | 1 | WebView 加载 H5,桥接原生能力 | Android Native + Vue H5 |
| 底层系统 | 0(不动) | 提供导航、语音、电量等硬件能力 | RobotOS(Android 定制版) |
action.orionstar.default.app)→ Launcher App 自启 → 显示仿鸿蒙桌面http://localhost:8080 → 用户与 AI 对话RobotBridge.navigate("内科门诊") → JSBridge → RobotApi.getInstance().startNavigation() → 机器人移动Launcher App 与业务 App 为两个独立 APK,通过标准 Android Intent 机制交互:
参考 HarmonyOS 4 横屏桌面视觉特征:
BlurMaskFilter / RenderScript 实现高斯模糊rgba(255,255,255,0.2)配色方案(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 | 微弱投影 |
AndroidManifest 声明 HOME:
<activity android:name=".LauncherActivity"
android:launchMode="singleTask"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
SDK 注册自启(主要方式)与 BOOT_COMPLETED 自启(备用方案):
主要方式:在 AndroidManifest.xml 中为 LauncherActivity 声明 SDK 注册的 intent-filter action.orionstar.default.app,以及 URL Scheme jerry://main:
<!-- 主要自启方式:SDK 注册 -->
<intent-filter>
<action android:name="action.orionstar.default.app" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- URL Scheme,用于被业务 App 唤回 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="jerry" android:host="main" />
</intent-filter>
备用方案:BOOT_COMPLETED 广播接收:
<!-- 权限声明(必须) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<receiver android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
注意:主要自启方式为猎户星空 SDK 的
action.orionstar.default.app注册机制,BOOT_COMPLETED作为备用/补充方案。若未声明RECEIVE_BOOT_COMPLETED权限,备用自启逻辑将失效。
下拉手势拦截:
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);
}
}
全屏沉浸式:
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
);
本节面向不熟悉 Android 开发的 Web 工程师(Java + Vue 背景),提供从零开始创建 Launcher 工程的完整步骤。
HarmonyLaunchercom.emoon.harmony.launcherapp/src/main/java/com/emoon/harmony/launcher/ 目录已生成给 Web 开发者的提示:Android Studio 的 Gradle Sync 类似于
npm install,会在首次打开项目时下载所有依赖。如果遇到网络问题,可在File → Settings → Build → Gradle中配置国内镜像源。
打开 app/build.gradle,替换为以下内容。每一行都加了中文注释,帮助理解其用途:
// 应用的 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 体积更小,编译更快。
打开 app/src/main/AndroidManifest.xml,替换为以下内容:
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 应用的入口配置文件(类比 Vue 的 index.html + 路由配置) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.emoon.harmony.launcher">
<!-- ==================== 权限声明 ==================== -->
<!-- 接收开机完成广播,实现开机自启动 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 系统级悬浮窗权限(用于控制中心悬浮显示,可选) -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- ==================== Application 配置 ==================== -->
<application
android:name=".LauncherApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HarmonyLauncher">
<!-- ==================== LauncherActivity:桌面主页 ==================== -->
<!-- 这是核心 Activity,声明为默认桌面 -->
<activity
android:name=".LauncherActivity"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:theme="@style/Theme.HarmonyLauncher.Fullscreen">
<!-- Intent 过滤器:声明此 Activity 为默认桌面入口 -->
<intent-filter>
<!-- MAIN action:标记这是应用的入口点 -->
<action android:name="android.intent.action.MAIN" />
<!-- HOME category:声明为桌面(Launcher) -->
<category android:name="android.intent.category.HOME" />
<!-- DEFAULT category:默认启动类别 -->
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- ==================== FakeSettingsActivity:假设置页 ==================== -->
<activity
android:name=".FakeSettingsActivity"
android:label="设置"
android:screenOrientation="landscape"
android:theme="@style/Theme.HarmonyLauncher.Fullscreen" />
<!-- ==================== BootReceiver:开机自启接收器 ==================== -->
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<!-- Intent 过滤器:接收系统开机完成广播 -->
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
关键说明:
HOME+DEFAULTcategory 的组合使此 Activity 能被系统识别为候选桌面screenOrientation="landscape"锁定横屏(机器人屏幕为横屏)excludeFromRecents="true"防止桌面出现在最近任务列表中- 主要自启方式为 SDK 注册(
action.orionstar.default.app),RECEIVE_BOOT_COMPLETED权限是 BootReceiver 备用自启方案生效的前提
创建所有文件后,完整目录结构如下:
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.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 等背景 |
这是 Launcher 的核心 Activity,负责桌面展示、图标网格、手势检测和时间更新。每一行都加了中文注释。
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<AppItem> 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();
}
}
这是桌面的主布局文件,对应 HarmonyOS 4 桌面的各个 UI 组件:
<?xml version="1.0" encoding="utf-8"?>
<!-- 桌面主页布局:仿 HarmonyOS 4 横屏桌面 -->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- ========== 第 1 层:壁纸背景(对应 HarmonyOS 桌面壁纸) ========== -->
<ImageView
android:id="@+id/iv_wallpaper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/bg_gradient"
android:contentDescription="桌面壁纸" />
<!-- ========== 第 2 层:主内容区(垂直排列) ========== -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="32dp">
<!-- ---------- 顶部区域:时间日期 Widget(对应 HarmonyOS 右上角时间) ---------- -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_marginTop="24dp"
android:gravity="end"
android:orientation="vertical">
<!-- 大号时间显示(如 14:30) -->
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="12:00"
android:textColor="@color/white"
android:textSize="@dimen/time_text_size"
android:textStyle="bold" />
<!-- 日期显示(如 4月23日 星期三) -->
<TextView
android:id="@+id/tv_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1月1日 星期一"
android:textColor="@color/date_text"
android:textSize="@dimen/date_text_size" />
</LinearLayout>
<!-- 中间留白,将图标网格推到底部 Dock 上方 -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- ---------- 中部区域:应用图标网格(对应 HarmonyOS 桌面图标区) ---------- -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_app_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:clipToPadding="false"
android:paddingHorizontal="16dp" />
<!-- ---------- 底部区域:Dock 栏(对应 HarmonyOS 底部 Dock) ---------- -->
<LinearLayout
android:id="@+id/dock_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/dock_height"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_dock"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="48dp">
<!-- Dock 栏内放置 4 个高频应用图标 -->
<ImageView
android:layout_width="@dimen/dock_icon_size"
android:layout_height="@dimen/dock_icon_size"
android:layout_marginHorizontal="16dp"
android:src="@drawable/ic_medical"
android:contentDescription="智慧医疗" />
<ImageView
android:layout_width="@dimen/dock_icon_size"
android:layout_height="@dimen/dock_icon_size"
android:layout_marginHorizontal="16dp"
android:src="@drawable/ic_settings"
android:contentDescription="设置" />
<ImageView
android:layout_width="@dimen/dock_icon_size"
android:layout_height="@dimen/dock_icon_size"
android:layout_marginHorizontal="16dp"
android:src="@drawable/ic_camera"
android:contentDescription="相机" />
<ImageView
android:layout_width="@dimen/dock_icon_size"
android:layout_height="@dimen/dock_icon_size"
android:layout_marginHorizontal="16dp"
android:src="@drawable/ic_files"
android:contentDescription="文件管理" />
</LinearLayout>
</LinearLayout>
<!-- ========== 第 3 层:下拉控制中心(初始隐藏,对应 HarmonyOS 控制中心) ========== -->
<com.emoon.harmony.launcher.ControlCenterView
android:id="@+id/control_center_container"
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_gravity="top"
android:visibility="gone" />
</FrameLayout>
桌面应用的数据模型,类比 Vue 中的 data() 返回的对象结构:
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 // 装饰图标(点击提示"即将推出")
}
}
RecyclerView 的适配器实现,负责将应用数据渲染为图标卡片。类比 Vue 中 v-for 循环渲染列表组件:
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<AppGridAdapter.AppViewHolder> {
private final List<AppItem> appList; // 应用列表数据源
private final OnItemClickListener onItemClick; // 点击回调接口
public interface OnItemClickListener {
void onItemClick(AppItem item);
}
public AppGridAdapter(List<AppItem> 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();
}
}
这是每个应用图标的布局文件,使用 CardView 实现 HarmonyOS 风格的圆角卡片:
<?xml version="1.0" encoding="utf-8"?>
<!-- 单个应用图标卡片布局(仿 HarmonyOS 大圆角图标) -->
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/card_app_icon"
android:layout_width="@dimen/icon_card_size"
android:layout_height="@dimen/icon_card_size"
android:layout_margin="@dimen/icon_grid_spacing"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
app:cardBackgroundColor="@color/icon_card_normal"
app:cardCornerRadius="@dimen/icon_card_radius"
app:cardElevation="@dimen/card_elevation">
<!-- 垂直布局:图标在上,文字在下 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<!-- 应用图标图片 -->
<ImageView
android:id="@+id/iv_app_icon"
android:layout_width="@dimen/icon_image_size"
android:layout_height="@dimen/icon_image_size"
android:scaleType="fitCenter"
android:contentDescription="应用图标" />
<!-- 应用名称文字 -->
<TextView
android:id="@+id/tv_app_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="@dimen/icon_label_size" />
</LinearLayout>
</androidx.cardview.widget.CardView>
使用自定义 View 实现下拉控制中心(不使用 Fragment,避免复杂生命周期管理)。包含快捷开关和滑块,所有交互仅切换 UI 状态,不做真实系统调用。
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<String, Boolean> switchStates = new HashMap<String, Boolean>() {{
put("wifi", true); // WiFi:默认开启
put("bluetooth", false); // 蓝牙:默认关闭
put("mobile", true); // 移动数据:默认开启
put("airplane", false); // 飞行模式:默认关闭
put("location", true); // 位置服务:默认开启
}};
// 开关视图映射表(键为开关名称,值为对应的 ImageView)
private final Map<String, ImageView> 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();
}
}
});
}
}
控制中心的布局文件 res/layout/control_center.xml:
<?xml version="1.0" encoding="utf-8"?>
<!-- 仿 HarmonyOS 4 下拉控制中心布局 -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_control_center"
android:orientation="vertical"
android:padding="24dp">
<!-- 顶部拖动手柄(视觉提示) -->
<View
android:layout_width="48dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:background="@drawable/handle_bar" />
<!-- 快捷开关网格(2 行 x 3 列) -->
<GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:columnCount="3"
android:rowCount="2"
android:useDefaultMargins="true">
<!-- WiFi 开关 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/iv_wifi"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_wifi"
android:contentDescription="WiFi" />
<TextView
android:id="@+id/tv_wifi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="WLAN"
android:textSize="12sp" />
</LinearLayout>
<!-- 蓝牙开关 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/iv_bluetooth"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_bluetooth"
android:contentDescription="蓝牙" />
<TextView
android:id="@+id/tv_bluetooth"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="蓝牙"
android:textSize="12sp" />
</LinearLayout>
<!-- 移动数据开关 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/iv_mobile"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_mobile"
android:contentDescription="移动数据" />
<TextView
android:id="@+id/tv_mobile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="移动数据"
android:textSize="12sp" />
</LinearLayout>
<!-- 飞行模式开关 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/iv_airplane"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_airplane"
android:contentDescription="飞行模式" />
<TextView
android:id="@+id/tv_airplane"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="飞行模式"
android:textSize="12sp" />
</LinearLayout>
<!-- 位置服务开关 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/iv_location"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_location"
android:contentDescription="位置服务" />
<TextView
android:id="@+id/tv_location"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="位置服务"
android:textSize="12sp" />
</LinearLayout>
<!-- 关闭按钮 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/btn_close_control"
android:layout_width="@dimen/control_switch_size"
android:layout_height="@dimen/control_switch_size"
android:padding="16dp"
android:src="@drawable/ic_close"
android:background="@drawable/bg_switch_off"
android:contentDescription="关闭" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="关闭"
android:textSize="12sp" />
</LinearLayout>
</GridLayout>
<!-- 亮度滑块区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_brightness"
android:contentDescription="亮度" />
<SeekBar
android:id="@+id/seekbar_brightness"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="8dp" />
<TextView
android:id="@+id/tv_brightness_value"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:text="70%"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
<!-- 音量滑块区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_volume"
android:contentDescription="音量" />
<SeekBar
android:id="@+id/seekbar_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="8dp" />
<TextView
android:id="@+id/tv_volume_value"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:text="50%"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
完整的假设置页实现,包含列表式布局和"关于本机"页面:
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<SettingItem> 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<SettingsAdapter.SettingViewHolder> {
private final List<SettingItem> items;
private final OnItemClickListener onClick;
public interface OnItemClickListener {
void onItemClick(SettingItem item);
}
public SettingsAdapter(List<SettingItem> 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();
}
}
}
activity_fake_settings.xml(设置页主布局):
<?xml version="1.0" encoding="utf-8"?>
<!-- 假设置页主布局(仿 HarmonyOS 设置页风格) -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/settings_background"
android:orientation="vertical">
<!-- 顶部标题栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="@color/white"
android:gravity="center_vertical"
android:paddingHorizontal="16dp">
<ImageView
android:id="@+id/btn_back"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_back"
android:contentDescription="返回" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="设置"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 设置列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_settings"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp" />
</LinearLayout>
item_setting.xml(单个设置项布局):
<?xml version="1.0" encoding="utf-8"?>
<!-- 单个设置列表项(仿 HarmonyOS 设置列表) -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/setting_item_height"
android:background="@color/white"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground">
<!-- 左侧图标 -->
<ImageView
android:id="@+id/iv_setting_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="设置图标" />
<!-- 中间文字区域 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_setting_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="16sp" />
<TextView
android:id="@+id/tv_setting_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray_text"
android:textSize="14sp" />
</LinearLayout>
<!-- 右侧箭头 -->
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_arrow_right"
android:contentDescription="进入" />
</LinearLayout>
完整的开机自启广播接收器实现(BOOT_COMPLETED 备用自启方案):
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作为备用自启方案,生效需要同时满足三个条件:
AndroidManifest.xml中声明RECEIVE_BOOT_COMPLETED权限BootReceiver在 Manifest 中正确注册并声明BOOT_COMPLETED过滤器- 应用至少被用户手动打开过一次(Android 3.1+ 的安全限制)
- 主要自启方式仍为 SDK 注册机制(
action.orionstar.default.app)
安装 Launcher APK 后,需要将其设为系统默认桌面。以下是详细步骤:
方法一:首次按 Home 键选择(推荐)
adb install HarmonyLauncher.apk方法二:adb 命令直接设置(自动化部署时使用)
如果设备没有弹出选择器(某些定制 Android 系统会屏蔽),可通过 adb 命令强制设置:
# 查看当前默认桌面组件名
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
方法三:通过设置应用手动切换
验证方法:
所有 HarmonyOS 风格配色的 XML 定义:
<?xml version="1.0" encoding="utf-8"?>
<!-- 颜色资源文件:定义 HarmonyOS 风格配色方案 -->
<resources>
<!-- ===== 基础色 ===== -->
<!-- 纯白 -->
<color name="white">#FFFFFF</color>
<!-- 纯黑 -->
<color name="black">#000000</color>
<!-- 鸿蒙蓝(开关开启态、强调色) -->
<color name="harmony_blue">#007DFF</color>
<!-- 灰色文字 -->
<color name="gray_text">#999999</color>
<!-- ===== 桌面配色 ===== -->
<!-- 图标卡片常态背景:白色 15% 透明度(毛玻璃效果) -->
<color name="icon_card_normal">#26FFFFFF</color>
<!-- 图标卡片按下态背景:白色 25% 透明度 -->
<color name="icon_card_pressed">#40FFFFFF</color>
<!-- Dock 栏背景:黑色 30% 透明度 -->
<color name="dock_background">#4D000000</color>
<!-- 控制中心背景:深蓝黑 95% 透明度 -->
<color name="control_center_background">#F21A1A2E</color>
<!-- 开关开启态背景:鸿蒙蓝 -->
<color name="switch_on_background">#007DFF</color>
<!-- 开关关闭态背景:深灰 -->
<color name="switch_off_background">#404040</color>
<!-- 日期文字颜色:白色 70% 透明度 -->
<color name="date_text">#B3FFFFFF</color>
<!-- ===== 设置页配色 ===== -->
<!-- 设置页背景:浅灰白 -->
<color name="settings_background">#F1F3F5</color>
<!-- 设置页卡片:纯白 -->
<color name="settings_card">#FFFFFF</color>
<!-- 设置页标题:纯黑 -->
<color name="settings_title">#000000</color>
<!-- 设置页副文字:灰色 -->
<color name="settings_subtitle">#999999</color>
<!-- ===== 渐变配色 ===== -->
<!-- 桌面背景渐变起始色:深蓝黑 -->
<color name="gradient_start">#1A1A2E</color>
<!-- 桌面背景渐变结束色:靛蓝 -->
<color name="gradient_end">#16213E</color>
</resources>
所有尺寸值的集中定义(便于统一修改和维护):
<?xml version="1.0" encoding="utf-8"?>
<!-- 尺寸资源文件:基于 10 寸横屏 1280x800 的设计规范 -->
<resources>
<!-- ===== 图标尺寸 ===== -->
<!-- 桌面图标卡片整体尺寸:80dp x 80dp -->
<dimen name="icon_card_size">80dp</dimen>
<!-- 图标内实际图片大小:56dp x 56dp -->
<dimen name="icon_image_size">56dp</dimen>
<!-- 图标卡片圆角:20dp(HarmonyOS 标志性大圆角) -->
<dimen name="icon_card_radius">20dp</dimen>
<!-- 图标标签文字大小:12sp -->
<dimen name="icon_label_size">12sp</dimen>
<!-- 图标网格间距:24dp -->
<dimen name="icon_grid_spacing">24dp</dimen>
<!-- 卡片投影高度:4dp -->
<dimen name="card_elevation">4dp</dimen>
<!-- ===== Dock 栏尺寸 ===== -->
<!-- Dock 栏高度:64dp -->
<dimen name="dock_height">64dp</dimen>
<!-- Dock 栏圆角:24dp -->
<dimen name="dock_radius">24dp</dimen>
<!-- Dock 栏内图标大小:48dp x 48dp -->
<dimen name="dock_icon_size">48dp</dimen>
<!-- ===== 控制中心尺寸 ===== -->
<!-- 控制中心卡片圆角:24dp -->
<dimen name="control_center_radius">24dp</dimen>
<!-- 控制中心开关尺寸:64dp x 64dp -->
<dimen name="control_switch_size">64dp</dimen>
<!-- ===== 时间日期尺寸 ===== -->
<!-- 时间文字大小:48sp -->
<dimen name="time_text_size">48sp</dimen>
<!-- 日期文字大小:14sp -->
<dimen name="date_text_size">14sp</dimen>
<!-- ===== 设置页尺寸 ===== -->
<!-- 设置列表项高度:56dp -->
<dimen name="setting_item_height">56dp</dimen>
</resources>
全屏主题和样式定义:
<?xml version="1.0" encoding="utf-8"?>
<!-- 主题样式文件:定义应用的全局外观 -->
<resources>
<!-- ===== 应用基础主题 ===== -->
<!-- 继承自 Material 的深色主题(适合深色桌面背景) -->
<style name="Theme.HarmonyLauncher" parent="Theme.MaterialComponents.Dark.NoActionBar">
<!-- 主色调:鸿蒙蓝 -->
<item name="colorPrimary">@color/harmony_blue</item>
<!-- 主色调变体 -->
<item name="colorPrimaryVariant">#005BB5</item>
<!-- 强调色 -->
<item name="colorAccent">@color/harmony_blue</item>
<!-- 窗口背景:默认深蓝黑 -->
<item name="android:windowBackground">@color/gradient_start</item>
</style>
<!-- ===== 全屏无标题栏主题(用于 Launcher 和设置页) ===== -->
<style name="Theme.HarmonyLauncher.Fullscreen" parent="Theme.HarmonyLauncher">
<!-- 隐藏 ActionBar(标题栏) -->
<item name="android:windowActionBar">false</item>
<!-- 无标题 -->
<item name="android:windowNoTitle">true</item>
<!-- 全屏显示 -->
<item name="android:windowFullscreen">true</item>
<!-- 透明状态栏背景 -->
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- 透明导航栏背景 -->
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
以下是桌面核心视觉元素的 Drawable 定义:
bg_gradient.xml(桌面背景渐变):
<?xml version="1.0" encoding="utf-8"?>
<!-- 桌面背景渐变:深蓝黑到靛蓝,营造 HarmonyOS 科技感 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="135"
android:endColor="@color/gradient_end"
android:startColor="@color/gradient_start"
android:type="linear" />
</shape>
bg_icon_card.xml(图标卡片常态背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- 图标卡片常态背景:半透明圆角矩形(毛玻璃效果基底) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/icon_card_normal" />
<corners android:radius="@dimen/icon_card_radius" />
</shape>
bg_icon_card_pressed.xml(图标卡片按下态背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- 图标卡片按下态背景:更高透明度,提供视觉反馈 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/icon_card_pressed" />
<corners android:radius="@dimen/icon_card_radius" />
</shape>
bg_dock.xml(Dock 栏背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- Dock 栏背景:半透明黑色圆角矩形(上方圆角) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/dock_background" />
<!-- 只设置上方圆角,下方保持直角贴合屏幕底部 -->
<corners
android:topLeftRadius="@dimen/dock_radius"
android:topRightRadius="@dimen/dock_radius" />
</shape>
bg_control_center.xml(控制中心背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- 控制中心背景:近不透明的深色圆角卡片 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/control_center_background" />
<!-- 只设置下方圆角,上方拉出时与屏幕边缘自然过渡 -->
<corners
android:bottomLeftRadius="@dimen/control_center_radius"
android:bottomRightRadius="@dimen/control_center_radius" />
</shape>
bg_switch_on.xml(开关开启态背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- 控制中心开关开启态:鸿蒙蓝圆形背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/switch_on_background" />
</shape>
bg_switch_off.xml(开关关闭态背景):
<?xml version="1.0" encoding="utf-8"?>
<!-- 控制中心开关关闭态:深灰圆形背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/switch_off_background" />
</shape>
handle_bar.xml(控制中心拖动手柄):
<?xml version="1.0" encoding="utf-8"?>
<!-- 控制中心顶部拖动手柄:细横条,提示用户可拖动 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#66FFFFFF" />
<corners android:radius="2dp" />
</shape>
图标说明:上述布局中引用的
ic_*.xml图标(如ic_wifi、ic_bluetooth等)建议使用 Android Studio 内置的 Vector Asset Studio 导入 Material Design 图标,或从 Material Icons 下载 SVG 后转为 Vector Drawable。对于迎检演示,使用简单的白色线形图标即可达到 HarmonyOS 风格效果。
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 配置工具
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)可接受。若后续部署到公网或多方接入环境,需收紧安全配置。
| 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 实现:
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<Pose> 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 播报)。
robotservice.jar(约 1.1MB),放入 app/libs/ 目录build.gradle 配置:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}
AndroidManifest 声明权限:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="com.orionstar.robot.permission.ROBOT_CONTROL" />
Application 中初始化(连接流程:Application.onCreate() → RobotApi.getInstance().connectServer(context, ApiListener) → ApiListener.handleApiConnected() → SkillApi.getInstance().connectApi(context)):
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);
}
});
}
}
导航状态监听(SDK 提供三种回调接口:ActionListener 用于长操作如导航,含 onResult/onError/onStatusUpdate 三个方法;CommandListener 用于单次命令如获取位置,含 onResult 方法;TextListener 用于 TTS 播报,含 onStart/onStop/onError/onComplete 四个方法):
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);
}
});
| 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 |
本节专为从未接触过 Android 开发的 Web 全栈工程师编写,每一步都配有详细说明和截图指引对应操作。
下载安装 Android Studio
.dmg 文件后,将 Android Studio 拖入 Applications 文件夹.exe 安装程序,按向导完成安装SDK Manager 配置
Android 10.0 (API 29) —— 编译目标版本(对应 compileSdkVersion)Android 4.4 (API 19) —— 最低支持版本(对应 minSdkVersion)Android SDK Build-Tools 34 —— 构建工具Android SDK Platform-Tools —— 包含 adb、fastboot 等调试工具Android SDK Command-line Tools (latest) —— 命令行工具JDK 配置
/Applications/Android Studio.app/Contents/jbr/Contents/Home)ADB 环境变量配置
macOS 配置:
# 打开终端,编辑 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)中运行:
adb version
应输出类似 Android Debug Bridge version 1.0.xxx 的版本信息
MedicalRobotApp(应用名称)com.emoon.medical.robot(应用包名,全局唯一标识)~/Projects/MedicalRobotApp)Java(猎户星空 SDK Demo 项目使用 Java,团队现有代码库均为 Java)API 19: Android 4.4 (KitKat)(猎户星空机器人系统兼容 Android 4.4+,建议 minSdkVersion 设为 19)Groovy (DSL)(使用传统 Groovy 脚本配置 Gradle,与猎户星空 Demo 项目一致)以下文件位于
MedicalRobotApp/app/build.gradle,是应用模块的构建配置(使用 Groovy DSL)。每一行都带有中文注释,说明其作用。
/**
* 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()获取单例。
以下文件位于
MedicalRobotApp/app/src/main/AndroidManifest.xml,是 Android 应用的配置文件。声明了应用组件、权限、主题等核心信息。每行都有中文注释。
<?xml version="1.0" encoding="utf-8"?>
<!-- AndroidManifest.xml:Android 应用的入口配置文件 -->
<!-- 作用:声明应用包名、组件(Activity/Service/BroadcastReceiver)、权限、主题等 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- ===== 权限声明 ===== -->
<!-- 权限分为普通权限(安装时自动授予)和危险权限(运行时需动态申请) -->
<!-- INTERNET:访问网络的权限,普通权限
用途:WebView 加载 H5 页面、调用后端 API、与猎户星空云服务通信 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- ACCESS_NETWORK_STATE:获取网络连接状态,普通权限
用途:判断当前是否有网络连接,用于离线提示和重试逻辑 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- RECORD_AUDIO:录制音频,危险权限(Android 6.0+ 需运行时动态申请)
用途:语音对话功能,采集用户语音并发送给语音识别服务 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- CAMERA:访问摄像头,危险权限(运行时动态申请)
用途:可选,用于身份证 OCR 识别、舌诊拍照等功能
如果 H5 页面通过 WebView 调用相机,也需要此权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- WRITE_EXTERNAL_STORAGE:写入外部存储,危险权限
用途:保存图片、日志文件等
Android 10+ 推荐使用 Scoped Storage(分区存储)替代 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- READ_EXTERNAL_STORAGE:读取外部存储,危险权限
用途:读取已保存的文件、相册图片等 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- FOREGROUND_SERVICE:前台服务权限(Android 9.0+ 需要)
用途:如果 SDK 需要在后台保持连接,可能用到前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- WAKE_LOCK:保持屏幕唤醒,普通权限
用途:迎检演示时防止屏幕自动熄灭 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ===== 应用配置 ===== -->
<application
<!-- 应用名称:显示在系统设置中的应用列表中 -->
android:label="@string/app_name"
<!-- 应用图标:显示在桌面和任务列表中 -->
android:icon="@mipmap/ic_launcher"
<!-- 应用简介:长文本描述 -->
android:description="@string/app_description"
<!-- 自定义 Application 类:用于全局初始化和 SDK 连接管理
必须在 java/com/emoon/medical/robot/ 目录下存在同名类文件 -->
android:name=".MedicalRobotApplication"
<!-- 应用主题:全屏无标题栏主题,适合机器人 kiosk 模式
在 res/values/themes.xml 中定义 -->
android:theme="@style/Theme.MedicalRobotApp.Fullscreen"
<!-- 允许明文 HTTP 通信:true 表示允许访问 HTTP(非 HTTPS)URL
迎检环境为内网,后端可能未配置 SSL,因此需要开启
生产环境部署到公网时,应关闭此选项并强制使用 HTTPS -->
android:usesCleartextTraffic="true"
<!-- 网络安全配置文件:自定义网络安全策略
用于配置证书信任、明文通信域名白名单等 -->
android:networkSecurityConfig="@xml/network_security_config"
<!-- 支持从其他应用打开此应用的数据文件(如 content:// URI) -->
android:allowBackup="false"
<!-- 是否支持提取原生库(so 文件)到文件系统 -->
android:extractNativeLibs="true"
<!-- 请求的最大内存:WebView 渲染复杂 H5 页面需要较大内存 -->
android:largeHeap="true"
<!-- 工具命名空间:用于覆盖库中的属性 -->
tools:targetApi="34">
<!-- ===== Activity 声明 ===== -->
<!-- Activity 是 Android 的界面容器,每个可见页面都是一个 Activity -->
<!-- MainActivity:主界面,WebView 容器
这是用户打开应用后看到的第一个页面 -->
<activity
<!-- Activity 的完整类名(相对于包名) -->
android:name=".MainActivity"
<!-- 是否导出:false 表示不允许其他应用直接启动此 Activity
提高安全性,防止恶意应用劫持 -->
android:exported="false"
<!-- 屏幕方向:landscape 表示强制横屏
机器人屏幕为横屏,固定方向可避免布局错乱 -->
android:screenOrientation="landscape"
<!-- 启动模式:singleTask 确保只有一个实例存在
从 Launcher 返回时不会创建新实例 -->
android:launchMode="singleTask"
<!-- 配置变更处理:当屏幕方向或键盘状态改变时,不重建 Activity
避免 WebView 页面重新加载导致状态丢失 -->
android:configChanges="orientation|screenSize|keyboardHidden">
<!-- Intent Filter:定义此 Activity 如何被启动 -->
<intent-filter>
<!-- MAIN:标记此 Activity 为应用入口 -->
<action android:name="android.intent.action.MAIN" />
<!-- LAUNCHER:在应用启动器中显示图标,用户点击后启动此 Activity -->
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
安全提示:
android:usesCleartextTraffic="true"仅适用于内网迎检环境。若后续部署到公网,应移除此属性或配合network_security_config.xml配置域名白名单,强制使用 HTTPS 通信。
以下是创建完成后的完整工程目录结构,帮助 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或应用入口配置
Application 类是 Android 应用的全局入口,在应用启动时第一个被初始化。这里负责初始化猎户星空 SDK 和建立与机器人系统服务的连接。SDK 初始化流程:
onCreate()→RobotApi.getInstance().connectServer(context, ApiListener)→ 在handleApiConnected()回调中调用SkillApi.getInstance().connectApi(context)。
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 的 <application> 标签中通过 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方法。
SDK 管理封装类:统一封装猎户星空 SDK 的三个核心 API 类(
RobotApi导航/位置/运动控制、SkillApi语音 TTS/ASR、RobotSettingApi设备信息/电量),对外提供简洁的接口,内部处理连接管理、错误处理和 Mock 模式切换。回调接口统一使用 SDK 原生的ActionListener(长操作)、CommandListener(单次命令)、TextListener(TTS 播报)。
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<Pose> 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 模拟器上运行即可看到完整的业务流程。
MainActivity 是应用的唯一直接可见界面,本质上是一个全屏的 WebView 容器,负责加载 H5 页面并桥接 Native 能力。以下代码完整可用,每行都有中文注释。
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 表示已处理此弹窗
}
/**
* 处理文件选择(如 <input type="file">)
* 用于 H5 页面上传图片、拍照等功能
*/
@Override
public boolean onShowFileChooser(WebView view, android.webkit.ValueCallback<android.net.Uri[]> 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()。
以下文件位于
MedicalRobotApp/app/src/main/res/layout/activity_main.xml。布局非常简单:全屏 WebView + 居中的加载进度条。
<?xml version="1.0" encoding="utf-8"?>
<!-- activity_main.xml:MainActivity 的界面布局文件 -->
<!-- 作用:定义 WebView 容器和加载动画的位置与样式 -->
<!-- ConstraintLayout:灵活的约束布局容器
xmlns:android:Android 命名空间,所有 Android 属性都需要此前缀
xmlns:app:应用级自定义属性命名空间(ConstraintLayout 的约束属性用此前缀)
android:layout_width/height="match_parent":宽高填满父容器(即整个屏幕)
android:background:背景颜色,使用主题中定义的背景色 -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<!-- WebView:网页渲染组件
android:id="@+id/webview":定义视图 ID,在 Java 代码中通过 R.id.webview 引用
android:layout_width="0dp":ConstraintLayout 中 0dp 表示由约束决定尺寸
app:layout_constraintStart_toStartOf="parent":左边缘与父容器左边缘对齐
app:layout_constraintEnd_toEndOf="parent":右边缘与父容器右边缘对齐
app:layout_constraintTop_toTopOf="parent":上边缘与父容器上边缘对齐
app:layout_constraintBottom_toBottomOf="parent":下边缘与父容器下边缘对齐
以上四个约束的组合效果:WebView 填满整个 ConstraintLayout(即全屏) -->
<WebView
android:id="@+id/webview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<!-- ProgressBar:圆形进度指示器(转圈圈)
style="?android:attr/progressBarStyleLarge":使用系统大号的进度圈样式
android:visibility="gone":初始状态不可见,页面开始加载时通过代码设为 visible
app:layout_constraintStart/End/Top/Bottom_toStart/End/Top/BottomOf="parent":在父容器中居中
注意:同时设置 top 和 bottom 约束到 parent,配合 0dp 高度可实现垂直居中
同时设置 start 和 end 约束到 parent,配合 0dp 宽度可实现水平居中 -->
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Web 开发者提示:
ConstraintLayout类似于 CSS Flexbox + Absolute Positioning 的结合体。match_parent类似于width: 100%,wrap_content类似于width: auto(由内容决定)。0dp在 ConstraintLayout 中表示由约束决定尺寸,类似于 CSS 中同时设置left: 0; right: 0;。
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()。
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 侧调用时需按以下方式封装:
> // 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
adb install app/build/outputs/apk/debug/app-debug.apk
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
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
adb shell dumpsys activity top
adb shell pm list packages | grep emoon
adb shell getprop ro.build.version.release
adb shell ps | grep medical
adb shell am force-stop com.emoon.medical.robot
adb reboot
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 <?xml version="1.0" encoding="utf-8"?>
<!-- base-config:基础安全配置,应用于所有域名 -->
<!-- cleartextTrafficPermitted="true":允许明文 HTTP 通信 -->
<!-- 默认情况下 Android 9.0+ 禁止明文 HTTP,必须配置此选项才能访问 HTTP URL -->
<base-config cleartextTrafficPermitted="true">
<!-- trust-anchors:定义信任的证书颁发机构 -->
<trust-anchors>
<!-- 信任系统预装的 CA 证书(大多数 HTTPS 网站使用) -->
<certificates src="system" />
<!-- 如需信任用户安装的自定义证书(如内网自签名证书),添加以下行: -->
<!-- <certificates src="user" /> -->
</trust-anchors>
</base-config>
<!-- 如需针对特定域名配置(比全局配置更安全),使用 domain-config: -->
<!--
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.100</domain>
<domain includeSubdomains="true">localhost</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
-->
> **安全警告**:`<base-config cleartextTrafficPermitted="true">` 会允许所有域名的明文 HTTP 通信。在生产环境中,建议改用注释中的 `<domain-config>` 方式,仅允许特定内网 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 /**
// ========== 全局回调池管理 ========== window.robotCallbacks = window.robotCallbacks || {} let _callbackCounter = 0
/**
${prefix}_${++_callbackCounter}_${Date.now()}
}/**
/**
@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.RobotBridgemethod
} 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')
// ========== 辅助工具 ==========
/**
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 部分修改
在 `<script>` 顶部新增 import:
javascript import { ref, computed } from 'vue' import { isRobotEnv, navigateTo } from '@/api/robot' // 新增:引入机器人桥接
在 `setup()` 函数内新增 `isRobot` 状态和 `goToDepartment` 方法:
javascript setup(props, { emit }) {
const selectedDepartment = ref('')
const isRobot = ref(isRobotEnv()) // 新增:机器人环境检测状态
// ... 原有 computed 和 methods 不变 ...
// 新增:机器人导航方法
const goToDepartment = async () => {
const deptName = selectedDepartmentInfo.value?.name
if (!deptName) return
try {
await navigateTo(deptName)
} catch (e) {
console.error('导航调用失败:', e)
alert('导航启动失败:' + (e?.message || '未知错误'))
}
}
return {
selectedDepartment,
departments,
selectedDepartmentInfo,
handleDepartmentChange,
confirmSelection,
isRobot, // 新增:暴露到模板
goToDepartment // 新增:暴露到模板
}
}
#### 2)template 部分修改
在确认按钮下方新增"带我去"按钮(仅机器人环境显示,且需先选择科室):
vue
<button
class="confirm-button"
:disabled="!selectedDepartment"
@click="confirmSelection"
>
确认选择
</button>
<!-- 新增:机器人导航按钮 -->
<button
v-if="isRobot && selectedDepartment"
class="navigate-button"
@click="goToDepartment"
>
<span class="nav-icon">🤖</span>
带我去
</button>
#### 3)style 部分新增
在 `<style scoped>` 末尾追加以下样式:
css /* 带我去按钮 — 大尺寸、触屏友好、医疗蓝配色 / .navigate-button { width: 100%; padding: 16px; margin-top: 12px; background: #e6f7ff; color: #1a5f9e; border: 2px solid #1a5f9e; border-radius: 12px; font-size: 17px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 52px; / 触屏最小触控高度 52px */ }
.navigate-button:hover { background: #1a5f9e; color: white; transform: translateY(-2px); box-shadow: 0 8px 20px rgba(26, 95, 158, 0.4); }
.nav-icon { font-size: 20px; }
**兼容性说明:**
- 现有组件使用 `setup()` 而非 `methods:`,因此 `isRobotEnv` 不作为方法直接使用,而是在 setup 内调用并赋值给 `isRobot` ref
- `v-if="isRobot && selectedDepartment"` 同时控制两个条件:必须在机器人环境且已选择科室才显示导航按钮
- 按钮最小高度 **52px**,超过 iOS 人机界面指南推荐的 44px 最小触控区域,适合机器人触摸屏操作
---
### 5.3 ChatInterface.vue 横屏适配改造
> 当前 `ChatInterface.vue` 采用竖屏手机风格设计(`max-width: 480px`),在机器人横屏(通常为 1280×800 或类似分辨率)上会导致两侧大量留白、内容区域过小。本节给出完整的横屏适配方案。
#### 当前需要修改的硬编码竖屏尺寸
| 选择器 | 当前竖屏值 | 问题 |
|--------|-----------|------|
| `.chat-container` | `max-width: 480px; max-height: 900px` | 横屏时容器宽度被限制,两侧留白 |
| `.chat-header` | `padding: 12px 15px` | 横屏时头部过矮,视觉拥挤 |
| `.header-avatar` | `width: 36px; height: 36px` | 机器人屏幕观看距离远,头像过小 |
| `.header-title` | `font-size: 14px` | 字体偏小,远处看不清 |
| `.header-subtitle` | `font-size: 9px` | 几乎不可读 |
| `.message-avatar` | `width: 36px; height: 36px` | 头像尺寸不足 |
| `.message-text` | `font-size: 14px; padding: 12px 16px` | 消息文字偏小 |
| `.voice-button` | `width: 40px; height: 40px` | 接近 44px 下限,横屏需更大 |
| `.send-button` | `width: 40px; height: 40px` | 同上 |
| `.quick-action-btn` | `padding: 7px 12px; font-size: 12px` | 快捷按钮文字偏小 |
| `.input-hint` | `font-size: 12px` | 提示文字偏小 |
#### 横屏适配完整 CSS
将以下 `@media` 规则追加到 `ChatInterface.vue` 的 `<style scoped>` 末尾(放在所有现有样式之后):
css @media screen and (orientation: landscape) { /* 容器:取消 480px 宽度限制,占满屏幕 */ .chat-container {
max-width: 100%;
max-height: 100vh;
height: 100vh;
border-radius: 0;
}
/* 头部:增大内边距和元素尺寸 */ .chat-header {
padding: 16px 24px;
}
.header-avatar {
width: 48px;
height: 48px;
}
.header-title {
font-size: 18px;
}
.header-subtitle {
font-size: 12px;
}
/* 消息列表:增大间距 */ .chat-messages {
padding: 20px 24px;
}
.message {
gap: 14px;
margin-bottom: 20px;
}
/* 消息头像:增大至 44px(满足最小触控区域) */ .message-avatar {
width: 44px;
height: 44px;
}
.message-avatar svg {
width: 24px;
height: 24px;
}
/* 消息内容:放宽宽度限制 */ .message-content {
max-width: 80%;
}
/* 消息气泡:增大字体和内边距 */ .message-text {
padding: 14px 20px;
font-size: 16px;
}
/* 输入区域:整体放大 */ .chat-input-container {
padding: 14px 24px 18px;
}
.chat-input-wrapper {
gap: 12px;
padding: 6px 6px 6px 16px;
}
/* 语音按钮:增大至 48px */ .voice-button {
width: 48px;
height: 48px;
min-width: 48px;
}
.voice-icon {
width: 26px;
height: 26px;
}
.voice-label {
font-size: 13px;
}
/* 输入框:增大字体 */ .chat-input {
font-size: 17px;
padding: 10px 0;
}
/* 发送按钮:增大至 48px */ .send-button {
width: 48px;
height: 48px;
}
.send-button svg {
width: 24px;
height: 24px;
}
/* 提示文字 */ .input-hint {
font-size: 14px;
margin: 10px 0 0 6px;
}
.input-hint svg {
width: 16px;
height: 16px;
}
/* 快捷按钮:增大触控区域和字体 */ .quick-actions {
gap: 12px;
padding: 16px 24px 12px;
}
.quick-action-btn {
padding: 10px 16px;
font-size: 14px;
gap: 6px;
}
.quick-action-btn svg {
width: 16px;
height: 16px;
}
/* 加载动画尺寸同步放大 */ .loading-message .message-content {
padding: 20px 24px;
}
.typing-indicator span {
width: 10px;
height: 10px;
} }
**横屏适配关键数值总结:**
| 项目 | 竖屏值 | 横屏值 | 设计理由 |
|------|--------|--------|---------|
| 容器 max-width | 480px | 100% | 占满机器人屏幕,消除两侧留白 |
| 容器 max-height | 900px | 100vh | 占满高度 |
| 头像尺寸 | 36px | 48px | 观看距离远,需要更大图标 |
| 消息字体 | 14px | 16px | 远处可读 |
| 标题字体 | 14px | 18px | 远处可读 |
| 输入框字体 | 15px | 17px | 远处可读 |
| 按钮尺寸 | 40px | 48px | 超过 44px 最小触控区域 |
| 快捷按钮字体 | 12px | 14px | 远处可读 |
| 消息内边距 | 12px 16px | 14px 20px | 视觉呼吸感 |
---
### 5.4 vue.config.js 的 WebView 兼容配置
> 当前 `vue.config.js` 已有基础的 `outputDir` 和 `devServer.proxy` 配置。以下展示需要**新增或修改**的项。
#### 修改后的完整 vue.config.js
javascript const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({ transpileDependencies: true,
// 构建输出目录 outputDir: 'dist',
// publicPath 策略: // - 远程服务器加载模式(开发/测试阶段):使用 '/',WebView 加载 http://192.168.x.x:8080 // - 本地 assets 加载模式(APK 内嵌):使用 './',确保所有资源使用相对路径 publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
devServer: {
port: 8080,
host: '0.0.0.0', // 允许局域网内其他设备(包括机器人)访问
allowedHosts: 'all', // WebView 请求不会被 webpack-dev-server 拒绝
proxy: {
'/api': {
target: 'http://localhost:3380',
changeOrigin: true
}
}
},
chainWebpack: config => {
config.plugin('html').tap(args => {
args[0].title = '甘肃省中医院'
return args
})
} })
#### 配置项说明
| 配置项 | 修改前 | 修改后 | 原因 |
|--------|--------|--------|------|
| `devServer.host` | 未设置(默认 localhost) | `'0.0.0.0'` | 让局域网设备(机器人 WebView)可通过 IP 访问开发服务器 |
| `devServer.allowedHosts` | 未设置 | `'all'` | webpack-dev-server 5.x 默认只允许 localhost,WebView 通过 IP 访问会被 403 拒绝 |
| `publicPath` | `'/'` | `process.env.NODE_ENV === 'production' ? './' : '/'` | 生产构建使用相对路径,确保 APK 内嵌 assets 加载时资源路径正确 |
| `outputDir` | `'dist'` | 保持 `'dist'` | 构建产物默认输出到 `dist/`;如需直接打包到 Android 工程,可改为 `'../../android-app/app/src/main/assets/web'`(需根据实际 Android 工程路径调整) |
---
### 5.5 H5 页面在 WebView 中的常见坑和解决方案
以下表格汇总了机器人 WebView 环境下最常见的问题,建议在前端联调阶段逐项排查:
| 问题 | 表现 | 原因 | 解决方案 |
|------|------|------|---------|
| 页面空白 | WebView 加载后白屏 | JS 执行错误被 WebView 静默吞掉 | Chrome DevTools 远程调试查看 Console |
| 跨域请求失败 | API 调用报 CORS 错误 | WebView 对 CORS 策略更严格 | 后端配置 CORS 允许所有来源(迎检环境) |
| 文件上传无反应 | 点击拍照/上传按钮无响应 | WebView 需要 `WebChromeClient.onShowFileChooser` | 确认 MainActivity 中已实现 |
| 键盘遮挡输入框 | 弹出键盘时输入框被遮挡 | WebView 的 softInputMode 配置 | AndroidManifest 中设置 `adjustResize` |
| localStorage 丢失 | 刷新后数据丢失 | WebView DOM Storage 未启用 | `webView.settings.domStorageEnabled = true` |
| 页面缩放异常 | 页面自动缩放导致布局错乱 | 未设置 viewport meta | 确认 `index.html` 中有正确的 viewport meta 标签 |
| HTTPS 证书错误 | 加载 HTTPS 页面失败 | 自签名证书不受信任 | `WebViewClient.onReceivedSslError` 中处理(仅限内网) |
| 视频/音频无法播放 | 语音功能不工作 | 需要 `mediaPlaybackRequiresUserGesture = false` | WebView settings 中配置 |
| CSS 动画卡顿 | 页面滚动和动画不流畅 | 未启用硬件加速 | WebView 启用 `setLayerType(LAYER_TYPE_HARDWARE)` |
**联调检查清单:**
1. 确认 `index.html` 中的 viewport:`<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">`
2. 确认 AndroidManifest 中 Activity 的 `android:windowSoftInputMode="adjustResize"`
3. 确认 WebView 初始化代码包含:
java WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
4. 使用 Chrome `chrome://inspect/#devices` 连接机器人 WebView 查看 Console 错误
---
### 5.6 前端打包部署到机器人的完整流程
#### 部署方式 A:远程服务器加载(推荐开发阶段)
1. 后端 Spring Boot 启动在服务器 `192.168.1.100:8080`
2. 前端 `npm run serve` 启动开发服务器(或 `npm run build` 后由 Spring Boot 提供静态资源)
3. 机器人上的业务 App WebView 加载 `http://192.168.1.100:8080`
4. **优点**:修改前端代码后刷新即可生效,无需重新打包 APK
5. **缺点**:依赖网络,离线无法使用
#### 部署方式 B:打包到 APK 本地 assets(推荐迎检)
1. `cd medical-card-demo/frontend`
2. `npm run build`
3. 将 `dist/` 目录下所有文件复制到 Android 工程的 `app/src/main/assets/web/`
4. 修改 `MainActivity.java` 中的加载地址为 `file:///android_asset/web/index.html`
5. 重新构建 APK 并安装
6. **优点**:离线可用,不依赖网络
7. **缺点**:每次前端修改都需要重新打包 APK
#### 部署方式 C:混合模式(推荐正式使用)
1. 默认加载远程服务器地址
2. 如果远程加载失败(网络不可用),自动降级到本地 assets 资源
3. 在 `MainActivity.java` 的 `onReceivedError` 中实现降级逻辑:
java // MainActivity.java 中的 WebViewClient 实现降级加载 public class MedicalWebViewClient extends WebViewClient {
private boolean hasError = false;
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);
// 仅处理主框架错误,忽略子资源(图片、CSS)加载失败
if (request.isForMainFrame() && !hasError) {
hasError = true;
Log.w("MainActivity", "远程页面加载失败,降级到本地 assets: " + error.getDescription());
view.loadUrl("file:///android_asset/web/index.html");
}
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// 本地 assets 加载成功时重置错误标志
if (url != null && url.startsWith("file:///android_asset")) {
hasError = false;
}
}
}
**三种部署方式对比:**
| 维度 | 方式 A(远程) | 方式 B(本地 assets) | 方式 C(混合) |
|------|--------------|---------------------|--------------|
| 网络依赖 | 必须 | 不需要 | 远程失败自动降级 |
| 前端更新成本 | 低(刷新即可) | 高(需重打 APK) | 中 |
| 离线可用 | 否 | 是 | 是 |
| 适用阶段 | 开发调试 | 迎检演示 | 正式运行 |
---
### 5.7 main.js 中的机器人环境初始化
在 Vue 项目的 `main.js` 中,加入机器人环境检测和开发模式 Mock 的初始化逻辑:
javascript // medical-card-demo/frontend/src/main.js import { createApp } from 'vue' import App from './App.vue' import { enableDevMock, isRobotEnv } from '@/api/robot' // 新增:引入机器人桥接
const app = createApp(App)
// 开发模式下启用 Mock(可选,方便浏览器中调试导航流程) if (process.env.NODE_ENV === 'development') { enableDevMock() }
// 全局注册机器人环境检测 app.config.globalProperties.$isRobot = isRobotEnv()
app.mount('#app')
**说明:**
- `enableDevMock()` 仅在开发环境(`npm run serve`)下生效,生产构建(`npm run build`)不会执行
- `app.config.globalProperties.$isRobot` 让所有组件可通过 `this.$isRobot` 访问环境状态(在 Options API 中)或通过 `getCurrentInstance()` 访问(在 Composition API 中)
- 如果项目使用 Pinia,也可以将 `isRobotEnv()` 的结果存入全局 Store,供任意组件订阅
---
## 六、核心交互流程
### 6.1 完整交互链路
+-----------+ +----------------+ +------------------+ | 开机 | --> | SDK 注册自启 | --> | Launcher 自启 | +-----------+ +----------------+ +------------------+
|
v
+-----------+ +----------------+ +------------------+ | 显示桌面 | <-- | 仿鸿蒙 UI | <-- | 桌面主页加载 | +-----------+ +----------------+ +------------------+
|
点击"智慧医疗"图标 v
+-----------+ +----------------+ +------------------+ | 业务 App | <-- | Intent | <-- | Launcher 启动 | +-----------+ +----------------+ +------------------+
|
v
+-----------+ +----------------+ +------------------+ | WebView | <-- | 加载 localhost | <-- | H5 页面渲染 | +-----------+ +----------------+ +------------------+
|
用户语音/文字对话 v
+-----------+ +----------------+ +------------------+ | 推荐科室 | <-- | Spring Boot | <-- | AI 对话交互 | +-----------+ +----------------+ +------------------+
|
点击"带我去"按钮 v
+-----------+ +----------------+ +------------------+ | JSBridge | --> | RobotBridge. | --> | 原生 SDK 调用 | | 调用 | | navigate() | | | +-----------+ +----------------+ +------------------+
|
v
+-----------+ +----------------+ +------------------+ | 到达科室 | --> | 导航完成回调 | --> | 机器人停止移动 | +-----------+ +----------------+ +------------------+
|
按 Home 键 v
+-----------+ +----------------+ +------------------+ | 回到桌面 | <-- | HOME 按键 | <-- | Launcher 前台 | +-----------+ +----------------+ +------------------+ ```
| 步骤 | 操作人 | 动作 | 解说词/预期效果 |
|---|---|---|---|
| 1 | 演示员 | 开机/唤醒机器人 | "这是我们定制了 HarmonyOS 风格界面的豹小秘 Pro 机器人" |
| 2 | 演示员 | 展示桌面 | "大家可以看到全新的桌面风格,操作流畅" |
| 3 | 演示员 | 点击"智慧医疗" | "我们现在进入智慧医疗导诊系统" |
| 4 | 领导/演示员 | 语音/触屏输入"我最近头疼" | AI 回复并推荐科室 |
| 5 | 演示员 | 展示推荐结果 | "系统根据症状推荐了神经内科" |
| 6 | 演示员 | 点击"带我去" | "我现在让机器人带路" |
| 7 | 机器人 | 自动移动 | 机器人播报"请跟我来"并向前移动 |
| 8 | 演示员 | 跟随机器人到达 | "顺利到达目标科室" |
| 9 | 演示员 | 按 Home 键 | "按 Home 键即可回到桌面" |
| 天数 | 开发者 A 任务 | 开发者 B 任务 | 交付物 |
|---|---|---|---|
| Day 1 | Launcher 框架 + 桌面 UI 布局 | 业务 App 工程搭建 + WebView 容器 | Launcher 可看桌面,业务 App 可加载 H5 |
| Day 2 | 下拉控制中心 + 假设置页 | SDK 集成 + JSBridge 桥接层 | Launcher 完整可演示,导航 SDK 可调用 |
| Day 3 | Launcher 细节打磨(动画、壁纸、图标) | H5 前端适配 + 导航对接 | 两个 App 独立可用 |
| Day 4 | 整机联调(Launcher → 业务 App 跳转) | 导航全流程联调 | 端到端流程跑通 |
| Day 5 | Bug 修复 + 演示彩排 | Bug 修复 + 演示彩排 | 交付迎检 |
| 风险编号 | 风险描述 | 可能性 | 影响 | 应对策略 |
|---|---|---|---|---|
| R1 | Launcher 替换可能被 RobotOS 限制,无法设为默认桌面 | 低 | 高 | ✅ 厂商确认无法修改系统 Launcher,但我方方案为设为默认 Launcher(非修改系统),仍可行;备选方案:点击自启动 + 禁用返回键模拟全屏桌面 |
| R2 | SDK 在第三方 APK 中运行时权限不足,导航/TTS 调用失败 | 中 | 高 | Day 2 必须完成 SDK 集成验证,发现问题立即联系厂商技术支持 |
| R3 | 横屏适配导致 H5 页面布局异常(卡片变形、文字截断) | 高 | 中 | Day 3 专门预留 UI 适配时间,使用 Chrome DevTools 横屏模拟器预检 |
| R4 | 导航过程中系统弹窗(电量低、网络提示)覆盖业务界面 | 中 | 中 | 提前将设备充满电、关闭不必要的系统通知,必要时联系厂商关闭系统弹窗 |
| R5 | 网络环境导致 H5 加载失败(后端服务未启动/断网) | 中 | 高 | 使用本地 loopback 访问,Spring Boot 设为开机自启;准备离线兜底静态页 |
| R6 | 迎检现场突发状况(机器人定位丢失、地图异常) | 低 | 高 | 提前到场测试,准备地图重定位操作手册;演示脚本预留"重启恢复"备案 |
| R7 | Home 键回到原厂桌面 | 已确认 | 中 | 已知风险,Android Home 键会回到原厂桌面;迎检时可通过物理遮挡 Home 键或提前演练规避,演示脚本中已包含按 Home 键回桌面的操作 |
以下问题用于与猎户星空厂商会议沟通,每个问题标注优先级(P0 必问 / P1 重要 / P2 可选),并附"为什么要问"说明。厂商会议已召开,确认结果已填入"确认结果"列。
以下为与猎户星空厂商会议的关键确认结果汇总,供快速参考。
| 序号 | 确认事项 | 确认结果 | 备注 |
|---|---|---|---|
| 1 | 机器人操作系统 | ✅ Android 9.0 + RobotOS | 开发语言为 Java |
| 2 | SDK 形式与获取 | ✅ JAR 包(robotservice.jar),从 GitHub Release 获取 | 非 AAR 包 |
| 3 | 三方 APK 开机自启 | ✅ 支持,通过 SDK 注册方式(Action: action.orionstar.default.app,URL Scheme: jerry://main) |
非 BOOT_COMPLETED 广播 |
| 4 | 修改系统 Launcher | ⚠️ 厂商说不可修改系统 Launcher | 我方方案为设为默认 Launcher(非修改系统),仍可行 |
| 5 | WebView 可行性 | ✅ 已确认可行 | 之前会议纪要有误,已纠正 |
| 6 | 麦克风访问 | ✅ 需通过 SDK 获取 | 不能直接使用 Android 标准 API |
| 7 | 自定义 APK 独立性 | ✅ 与原厂应用完全独立 | 无白名单限制 |
| 8 | Home 键行为 | ⚠️ 已知:Home 键会回到原厂桌面 | 迎检时可通过物理遮挡或提前演练规避 |
| 9 | Demo 项目 | ✅ 已提供 RobotSample-main | 可作为集成参考 |
| 10 | SDK 核心类 | ✅ RobotApi / SkillApi / RobotSettingApi / PersonApi | 导航用 RobotApi,TTS 用 SkillApi,设备信息用 RobotSettingApi |
| 编号 | 问题 | 优先级 | 为什么要问 | 确认结果 |
|---|---|---|---|---|
| Q1 | RobotOS SDK AAR 包如何获取?最新版本号是多少?是否有更新日志? | P0 | 决定我们集成的基础依赖版本和获取渠道 | ✅ 已确认为 JAR 包(非 AAR),从 GitHub Release 获取 |
| Q2 | 是否提供模拟器或仿真环境用于本地开发调试? | P1 | 2 人团队时间紧,若必须真机调试会大幅增加联调成本 | 待确认 |
| Q3 | SDK 初始化是否需要授权码或 License?授权绑定设备还是企业? | P0 | 若需授权,需提前申请避免现场无法运行 | 待确认 |
| Q4 | 目标设备的 Android API Level 是多少?最低兼容版本? | P1 | 决定业务 App 的 compileSdk 和 minSdk 配置 |
✅ 已确认 Android 9.0 + RobotOS |
| Q5 | APK 开发与 OPK 开发,对于本场景(WebView + 导航)更推荐哪种? | P1 | 影响技术选型,OPK 可能限制更多但集成更快 | ✅ 确认采用 APK 方式(WebView + 导航场景) |
| Q6 | 是否提供 Demo APK 源码可供参考? | P2 | 有参考代码可大幅缩短集成时间 | ✅ 已提供 RobotSample-main Demo 项目 |
| Q7 | SDK 是否有混淆规则(ProGuard)要求? | P2 | 若开启代码混淆需要额外配置 | 待确认 |
| Q8 | SDK 版本与 RobotOS 固件版本是否存在绑定关系? | P1 | 确保 SDK 版本与设备固件匹配 | 待确认 |
| 编号 | 问题 | 优先级 | 为什么要问 | 确认结果 |
|---|---|---|---|---|
| Q9 | RobotOS 是否允许第三方应用声明 HOME category 作为默认桌面? |
P0 | 这是表层 Launcher 方案的核心前提 | ⚠️ 厂商说不可修改系统 Launcher,但我方方案为设为默认 Launcher(非修改系统),仍可行 |
| Q10 | 系统是否会拦截或覆盖自定义 Launcher 的 HOME intent? | P0 | 若被拦截,需准备备选方案(如定时拉起) | ⚠️ 设为默认桌面方式可行,Home 键会回到原厂桌面(已知风险,可接受) |
| Q11 | 系统状态栏和虚拟导航栏是否可以完全隐藏?推荐方式是什么? | P1 | 影响全屏沉浸式体验的实现 | 待确认 |
| Q12 | 开机启动动画/LOGO 是否可以替换或跳过? | P2 | 影响开机到桌面的完整视觉链路 | 待确认 |
| Q13 | 是否存在应用白名单限制,第三方 APK 需要额外申请权限? | P0 | 若 APK 无法安装或运行,整个方案失效 | ✅ 已确认无白名单限制,第三方 APK 与原厂应用完全独立 |
| Q14 | 系统 OTA 升级后,默认桌面设置是否会重置? | P2 | 影响长期维护,迎检时若被重置需手动恢复 | 待确认 |
| 编号 | 问题 | 优先级 | 为什么要问 | 确认结果 |
|---|---|---|---|---|
| Q15 | RobotApi.startNavigation() 在第三方 APK 中调用是否受限制? |
P0 | 迎检核心功能,必须确认可用 | 待确认(SDK API 可调用,具体限制待真机验证) |
| Q16 | 导航过程中,前台 Activity 是否会被系统强制切换回系统桌面? | P1 | 若被强制切走,用户看不到导航状态 | 待确认 |
| Q17 | 地图位置点数据当前是否已录入?如何查看和验证? | P0 | 导航目标点必须预先存在才能调用成功 | 待确认 |
| Q18 | TTS 和 ASR API 在第三方 APK 中是否可正常调用? | P1 | 语音播报是导航体验的重要组成部分 | ✅ TTS 可通过 SkillApi.getInstance().playText() 调用;麦克风需通过 SDK 获取 |
| Q19 | 多个 APK 同时运行(Launcher + 业务 App)时,SDK 连接是否会冲突? | P1 | 涉及架构设计,避免资源争用 | 待确认 |
| Q20 | 导航状态回调(避障、堵死、到达)的实时性和可靠性如何? | P1 | 影响前端状态展示和异常处理 | 待确认 |
| Q21 | 是否需要预先进行机器人定位(Estimate)才能启动导航? | P0 | 若需预定位,演示前必须完成此步骤 | 待确认 |
| Q22 | 导航到达后是否支持自动返回充电桩或原位置? | P2 | 影响演示结束后的恢复流程 | 待确认 |
| 编号 | 问题 | 优先级 | 为什么要问 | 确认结果 |
|---|---|---|---|---|
| Q23 | 具体屏幕分辨率、DPI、物理尺寸是多少? | P0 | UI 设计必须基于真实尺寸 | 待确认 |
| Q24 | 屏幕是否支持横屏锁定?系统是否有强制方向策略? | P1 | 影响 Launcher 和业务 App 的方向配置 | 待确认 |
| Q25 | 触屏是否支持多点触控?触控采样率如何? | P2 | 影响手势交互体验(如下拉控制中心) | 待确认 |
| Q26 | 系统是否有强制全屏/非全屏的策略?WebView 全屏是否受限? | P1 | 影响 H5 页面的显示效果 | ✅ WebView 方案已确认可行(会议纪要有误,已纠正) |
| Q27 | 屏幕是否存在异形区域(刘海、挖孔)需要适配? | P2 | 影响顶部 UI 布局 | 待确认 |
| 功能分类 | API | 说明 |
|---|---|---|
| 导航 | RobotApi.startNavigation(destination) |
导航到指定位置点 |
| 导航 | RobotApi.stopMove() |
停止运动 |
| 地图 | RobotApi.getPlaceList() |
获取所有位置点列表 |
| 地图 | RobotApi.getPosition() |
获取当前坐标 {x, y, theta} |
| 地图 | RobotApi.isRobotEstimate() |
是否已定位 |
| 语音 | speechApi.playText(text) |
TTS 播报文本 |
| 语音 | speechApi.stopTTS() |
停止 TTS |
| 语音 | speechApi.setRecognizable(boolean) |
设置语音识别开关 |
| 电量 | RobotApi.getBatteryLevel() |
获取电量百分比 |
| 系统 | SystemInfo.getDeviceSn() |
获取设备 SN |
导航状态码速查:
| 状态码 | 说明 |
|---|---|
| 32730001 | 开始导航 |
| 32730004 | 避障中 |
| 32730011 | 堵死 |
| 32730009 | 定位丢失 |
| 32610007 | 到达目的地 |
| -32620001 | 未定位 |
| -32620009 | 路径规划失败 |
#4A90E2 → #7B68EE),辅助色为纯白和浅灰cornerRadius: 24dp),尺寸统一 72x72dp,纯色背景 + 白色线形图标fontWeight: 600)rgba(255,255,255,0.15) 带毛玻璃模糊| 术语 | 说明 |
|---|---|
| RobotOS | 猎户星空基于 Android 深度定制的机器人操作系统 |
| APK | Android 应用安装包 |
| OPK | 猎户星空插件包(基于 React Native) |
| AAR | Android Archive,Android 库文件格式 |
| SDK | Software Development Kit,软件开发工具包 |
| JSBridge | WebView 中 JavaScript 与 Native 代码通信的桥梁 |
| TTS | Text to Speech,文本转语音 |
| ASR | Automatic Speech Recognition,自动语音识别 |
| Intent | Android 组件间通信的机制 |
| HOME | Android 系统中返回桌面的标准 Intent Category |
| Estimate | 机器人定位,确认自身在地图中的坐标位置 |
本文档基于猎户星空机器人 API 参考手册及现有 medical-card-demo 业务系统设计,具体 API 参数以官方最新文档为准。