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