|
|
@@ -0,0 +1,5215 @@
|
|
|
+# 仿鸿蒙机器人系统技术方案
|
|
|
+
|
|
|
+> **版本**: v1.0
|
|
|
+> **日期**: 2026-04-23
|
|
|
+> **编写**: 技术团队
|
|
|
+> **状态**: 待评审
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 修改记录
|
|
|
+
|
|
|
+| 版本 | 日期 | 修改人 | 修改内容 |
|
|
|
+|------|------|--------|---------|
|
|
|
+| v1.0 | 2026-04-23 | 技术团队 | 初稿完成 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、项目概述与目标
|
|
|
+
|
|
|
+### 1.1 迎检需求背景
|
|
|
+
|
|
|
+本项目面向领导视察场景,目标是在猎户星空豹小秘系列机器人上部署一套**智慧医疗导诊系统**。机器人需在视觉上伪装为 HarmonyOS 4 风格,同时保留完整的导航带路能力。现有业务系统(Vue 前端 + Spring Boot 后端)已完成功能开发,现需构建原生安卓外壳以适配机器人硬件环境。
|
|
|
+
|
|
|
+### 1.2 四条硬性约束
|
|
|
+
|
|
|
+| 约束编号 | 约束内容 | 原因 |
|
|
|
+|----------|---------|------|
|
|
|
+| C1 | **不改机器人系统** | 无系统刷机权限,OTA 升级由厂商控制 |
|
|
|
+| C2 | **必须使用猎户星空原生 SDK** | 导航、TTS、电量等能力只有官方 SDK 能提供 |
|
|
|
+| C3 | **2 人全栈团队,1 周交付** | 人力资源和时间窗口极为有限,方案必须极简 |
|
|
|
+| 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 -->|JNI/AIDL| SDK
|
|
|
+ SDK -->|AIDL| NAV
|
|
|
+ SDK -->|AIDL| TTS
|
|
|
+ OS -->|系统服务| NAV
|
|
|
+ OS -->|系统服务| TTS
|
|
|
+```
|
|
|
+
|
|
|
+### 2.2 各层职责边界
|
|
|
+
|
|
|
+| 层级 | APK 数量 | 核心职责 | 技术栈 |
|
|
|
+|------|---------|---------|--------|
|
|
|
+| 表层 Launcher | 1 | 替代系统桌面,提供仿鸿蒙 UI 壳 | Android Native(Java/Kotlin) |
|
|
|
+| 中层业务 App | 1 | WebView 加载 H5,桥接原生能力 | Android Native + Vue H5 |
|
|
|
+| 底层系统 | 0(不动) | 提供导航、语音、电量等硬件能力 | RobotOS(Android 定制版) |
|
|
|
+
|
|
|
+### 2.3 数据流与调用链路
|
|
|
+
|
|
|
+1. **开机启动**:`BOOT_COMPLETED` 广播 → Launcher App 自启 → 显示仿鸿蒙桌面
|
|
|
+2. **进入业务**:用户点击"智慧医疗"图标 → Launcher 发送 Intent 启动业务 App
|
|
|
+3. **业务交互**:WebView 加载 `http://localhost:8080` → 用户与 AI 对话
|
|
|
+4. **触发导航**:H5 调用 `RobotBridge.navigate("内科门诊")` → JSBridge → `RobotApi.startNavigation()` → 机器人移动
|
|
|
+5. **返回桌面**:用户按 Home 键 → 业务 App 退后台 → Launcher 回到前台
|
|
|
+
|
|
|
+### 2.4 两个 APK 的关系
|
|
|
+
|
|
|
+Launcher App 与业务 App 为**两个独立 APK**,通过标准 Android Intent 机制交互:
|
|
|
+
|
|
|
+- Launcher 不直接包含业务逻辑,仅作为入口和视觉伪装层
|
|
|
+- 业务 App 包内嵌 WebView,加载本地或局域网 H5 页面
|
|
|
+- 业务 App 不处理 HOME 键,按 Home 时系统回调 Launcher(因 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
|
|
|
+<activity android:name=".LauncherActivity"
|
|
|
+ android:launchMode="singleTask"
|
|
|
+ android:excludeFromRecents="true">
|
|
|
+ <intent-filter>
|
|
|
+ <action android:name="android.intent.action.MAIN" />
|
|
|
+ <category android:name="android.intent.category.HOME" />
|
|
|
+ <category android:name="android.intent.category.DEFAULT" />
|
|
|
+ </intent-filter>
|
|
|
+</activity>
|
|
|
+```
|
|
|
+
|
|
|
+**BOOT_COMPLETED 自启**:
|
|
|
+
|
|
|
+```xml
|
|
|
+<!-- 权限声明(必须) -->
|
|
|
+<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
|
+
|
|
|
+<receiver android:name=".BootReceiver"
|
|
|
+ android:exported="true">
|
|
|
+ <intent-filter>
|
|
|
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
|
+ </intent-filter>
|
|
|
+</receiver>
|
|
|
+```
|
|
|
+
|
|
|
+> **注意**:若未声明 `RECEIVE_BOOT_COMPLETED` 权限,`BootReceiver` 将无法收到系统开机完成广播,自启逻辑完全失效。
|
|
|
+
|
|
|
+**下拉手势拦截**:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+class LauncherActivity : Activity() {
|
|
|
+ private val gestureDetector by lazy {
|
|
|
+ GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
|
|
+ override fun onFling(e1: MotionEvent?, e2: MotionEvent?, vx: Float, vy: Float): Boolean {
|
|
|
+ if (e1 != null && e1.y < 100 && vy > 200) {
|
|
|
+ showControlCenter()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
|
+ return gestureDetector.onTouchEvent(event)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**全屏沉浸式**:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+window.decorView.systemUiVisibility = (
|
|
|
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
|
|
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
|
+ or 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**: **Kotlin**(现代 Android 开发首选语言,语法比 Java 更简洁)
|
|
|
+ - **Minimum SDK**: **API 24 (Android 7.0)**(豹小秘系列最低兼容 Android 7)
|
|
|
+ - **Build configuration language**: **Kotlin DSL (build.gradle.kts)**(比 Groovy 语法更严格、更易读)
|
|
|
+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.kts(Module: app)
|
|
|
+
|
|
|
+打开 `app/build.gradle.kts`,替换为以下内容。每一行都加了中文注释,帮助理解其用途:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+// 应用的 Gradle 构建脚本(类比 Vue 项目的 package.json + vite.config.js)
|
|
|
+plugins {
|
|
|
+ // Android 应用插件,必选
|
|
|
+ id("com.android.application")
|
|
|
+ // Kotlin Android 插件,支持 Kotlin 语法
|
|
|
+ id("org.jetbrains.kotlin.android")
|
|
|
+}
|
|
|
+
|
|
|
+android {
|
|
|
+ // 编译使用的 SDK 版本(API 34 = Android 14)
|
|
|
+ compileSdk = 34
|
|
|
+
|
|
|
+ // 默认配置块(类比 package.json 中的字段)
|
|
|
+ defaultConfig {
|
|
|
+ // 应用包名,全局唯一标识
|
|
|
+ applicationId = "com.emoon.harmony.launcher"
|
|
|
+ // 最低支持 Android 版本(API 24 = Android 7.0)
|
|
|
+ minSdk = 24
|
|
|
+ // 目标 Android 版本(API 34 = Android 14)
|
|
|
+ targetSdk = 34
|
|
|
+ // 应用版本号(内部数字版本)
|
|
|
+ versionCode = 1
|
|
|
+ // 应用版本名称(对外显示)
|
|
|
+ versionName = "1.0.0"
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建类型配置
|
|
|
+ buildTypes {
|
|
|
+ // 发布构建(类比 npm run build --production)
|
|
|
+ release {
|
|
|
+ // 开启代码混淆(保护源码,减小包体积)
|
|
|
+ isMinifyEnabled = 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
|
|
|
+ }
|
|
|
+
|
|
|
+ // Kotlin 编译选项
|
|
|
+ kotlinOptions {
|
|
|
+ // Kotlin JVM 目标版本为 Java 8
|
|
|
+ jvmTarget = "1.8"
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建特性开关
|
|
|
+ buildFeatures {
|
|
|
+ // 启用 ViewBinding(类似 Vue 的模板绑定,自动生成视图引用)
|
|
|
+ viewBinding = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 依赖声明(类比 package.json 中的 dependencies)
|
|
|
+dependencies {
|
|
|
+ // Android 核心支持库(AppCompat 兼容旧版本)
|
|
|
+ implementation("androidx.appcompat:appcompat:1.6.1")
|
|
|
+ // Material Design 组件库(提供 CardView 等组件)
|
|
|
+ implementation("com.google.android.material:material:1.11.0")
|
|
|
+ // RecyclerView 列表组件(高性能列表,类比 Vue 的 v-for)
|
|
|
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
|
|
|
+ // ConstraintLayout 约束布局(灵活布局,类比 CSS Flexbox)
|
|
|
+ implementation("androidx.constraintlayout:constraintlayout:2.1.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
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- Android 应用的入口配置文件(类比 Vue 的 index.html + 路由配置) -->
|
|
|
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ package="com.emoon.harmony.launcher">
|
|
|
+
|
|
|
+ <!-- ==================== 权限声明 ==================== -->
|
|
|
+ <!-- 接收开机完成广播,实现开机自启动 -->
|
|
|
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
|
+ <!-- 系统级悬浮窗权限(用于控制中心悬浮显示,可选) -->
|
|
|
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
|
|
+
|
|
|
+ <!-- ==================== Application 配置 ==================== -->
|
|
|
+ <application
|
|
|
+ android:name=".LauncherApplication"
|
|
|
+ android:allowBackup="false"
|
|
|
+ android:icon="@mipmap/ic_launcher"
|
|
|
+ android:label="@string/app_name"
|
|
|
+ android:roundIcon="@mipmap/ic_launcher_round"
|
|
|
+ android:supportsRtl="true"
|
|
|
+ android:theme="@style/Theme.HarmonyLauncher">
|
|
|
+
|
|
|
+ <!-- ==================== LauncherActivity:桌面主页 ==================== -->
|
|
|
+ <!-- 这是核心 Activity,声明为默认桌面 -->
|
|
|
+ <activity
|
|
|
+ android:name=".LauncherActivity"
|
|
|
+ android:excludeFromRecents="true"
|
|
|
+ android:launchMode="singleTask"
|
|
|
+ android:screenOrientation="landscape"
|
|
|
+ android:theme="@style/Theme.HarmonyLauncher.Fullscreen">
|
|
|
+ <!-- Intent 过滤器:声明此 Activity 为默认桌面入口 -->
|
|
|
+ <intent-filter>
|
|
|
+ <!-- MAIN action:标记这是应用的入口点 -->
|
|
|
+ <action android:name="android.intent.action.MAIN" />
|
|
|
+ <!-- HOME category:声明为桌面(Launcher) -->
|
|
|
+ <category android:name="android.intent.category.HOME" />
|
|
|
+ <!-- DEFAULT category:默认启动类别 -->
|
|
|
+ <category android:name="android.intent.category.DEFAULT" />
|
|
|
+ </intent-filter>
|
|
|
+ </activity>
|
|
|
+
|
|
|
+ <!-- ==================== FakeSettingsActivity:假设置页 ==================== -->
|
|
|
+ <activity
|
|
|
+ android:name=".FakeSettingsActivity"
|
|
|
+ android:label="设置"
|
|
|
+ android:screenOrientation="landscape"
|
|
|
+ android:theme="@style/Theme.HarmonyLauncher.Fullscreen" />
|
|
|
+
|
|
|
+ <!-- ==================== BootReceiver:开机自启接收器 ==================== -->
|
|
|
+ <receiver
|
|
|
+ android:name=".BootReceiver"
|
|
|
+ android:enabled="true"
|
|
|
+ android:exported="true">
|
|
|
+ <!-- Intent 过滤器:接收系统开机完成广播 -->
|
|
|
+ <intent-filter>
|
|
|
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
|
+ </intent-filter>
|
|
|
+ </receiver>
|
|
|
+
|
|
|
+ </application>
|
|
|
+
|
|
|
+</manifest>
|
|
|
+```
|
|
|
+
|
|
|
+> **关键说明**:
|
|
|
+> - `HOME` + `DEFAULT` category 的组合使此 Activity 能被系统识别为候选桌面
|
|
|
+> - `screenOrientation="landscape"` 锁定横屏(机器人屏幕为横屏)
|
|
|
+> - `excludeFromRecents="true"` 防止桌面出现在最近任务列表中
|
|
|
+> - `RECEIVE_BOOT_COMPLETED` 权限是 BootReceiver 生效的前提,**缺少此权限则开机自启完全失效**
|
|
|
+
|
|
|
+#### 3.4.4 工程目录结构
|
|
|
+
|
|
|
+创建所有文件后,完整目录结构如下:
|
|
|
+
|
|
|
+```
|
|
|
+HarmonyLauncher/
|
|
|
+├── app/
|
|
|
+│ ├── src/
|
|
|
+│ │ └── main/
|
|
|
+│ │ ├── java/com/emoon/harmony/launcher/
|
|
|
+│ │ │ ├── LauncherActivity.kt # 桌面主页 Activity
|
|
|
+│ │ │ ├── FakeSettingsActivity.kt # 假设置页 Activity
|
|
|
+│ │ │ ├── LauncherApplication.kt # 自定义 Application(可选)
|
|
|
+│ │ │ ├── AppGridAdapter.kt # 图标网格适配器
|
|
|
+│ │ │ ├── AppItem.kt # 应用数据类
|
|
|
+│ │ │ ├── ControlCenterView.kt # 下拉控制中心自定义 View
|
|
|
+│ │ │ └── BootReceiver.kt # 开机自启广播接收器
|
|
|
+│ │ ├── 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.kts # 模块构建配置
|
|
|
+├── build.gradle.kts # 项目级构建配置
|
|
|
+├── settings.gradle.kts # 项目设置
|
|
|
+└── 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.kt` | 桌面主页 Activity,全屏沉浸、手势检测、图标网格 |
|
|
|
+| 3.5.2 | `activity_launcher.xml` | 桌面主布局 XML |
|
|
|
+| 3.5.3 | `AppItem.kt` | 应用数据类 |
|
|
|
+| 3.5.4 | `AppGridAdapter.kt` | 图标网格适配器 |
|
|
|
+| 3.5.5 | `item_app_icon.xml` | 单个图标卡片布局 |
|
|
|
+| 3.5.6 | `ControlCenterView.kt` | 下拉控制中心自定义 View |
|
|
|
+| 3.5.7 | `control_center` 相关 XML | 控制中心布局 |
|
|
|
+| 3.5.8 | `FakeSettingsActivity.kt` | 假设置页 Activity |
|
|
|
+| 3.5.9 | 设置页布局 XML | `activity_fake_settings.xml` + `item_setting.xml` |
|
|
|
+| 3.5.10 | `BootReceiver.kt` | 开机自启广播接收器 |
|
|
|
+| 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.kt 完整代码
|
|
|
+
|
|
|
+这是 Launcher 的核心 Activity,负责桌面展示、图标网格、手势检测和时间更新。每一行都加了中文注释。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.harmony.launcher
|
|
|
+
|
|
|
+import android.app.Activity
|
|
|
+import android.content.Intent
|
|
|
+import android.os.Bundle
|
|
|
+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.Calendar
|
|
|
+
|
|
|
+/**
|
|
|
+ * 桌面主页 Activity
|
|
|
+ * 这是用户开机后看到的第一个页面,负责展示仿鸿蒙桌面 UI
|
|
|
+ */
|
|
|
+class LauncherActivity : Activity() {
|
|
|
+
|
|
|
+ // 桌面图标网格 RecyclerView(类比 Vue 的列表渲染)
|
|
|
+ private lateinit var recyclerView: RecyclerView
|
|
|
+ // 下拉控制中心容器视图
|
|
|
+ private lateinit var controlCenterView: View
|
|
|
+ // 手势检测器(用于识别下拉手势)
|
|
|
+ private lateinit var gestureDetector: GestureDetector
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Activity 创建时调用(类比 Vue 的 mounted 生命周期)
|
|
|
+ */
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
+ // 第一步:设置全屏沉浸式,隐藏状态栏和导航栏
|
|
|
+ hideSystemUI()
|
|
|
+ // 第二步:加载 XML 布局文件
|
|
|
+ setContentView(R.layout.activity_launcher)
|
|
|
+ // 第三步:初始化桌面图标网格
|
|
|
+ setupAppGrid()
|
|
|
+ // 第四步:初始化下拉手势检测
|
|
|
+ setupGestureDetector()
|
|
|
+ // 第五步:启动时间显示更新
|
|
|
+ updateTimeDisplay()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 隐藏系统状态栏和导航栏,实现全屏沉浸式体验
|
|
|
+ * 这是仿鸿蒙桌面的关键:必须完全隐藏系统 UI,否则会暴露 Android 原生界面
|
|
|
+ */
|
|
|
+ private fun hideSystemUI() {
|
|
|
+ // 设置窗口为全屏模式(隐藏顶部状态栏)
|
|
|
+ window.setFlags(
|
|
|
+ WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
|
|
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
|
|
|
+ )
|
|
|
+ // 设置系统 UI 可见性标志(沉浸式模式)
|
|
|
+ window.decorView.systemUiVisibility = (
|
|
|
+ // 粘性沉浸模式:用户滑动边缘时系统 UI 暂时出现,几秒后自动隐藏
|
|
|
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
|
|
+ // 隐藏导航栏(底部虚拟按键)
|
|
|
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
|
+ // 隐藏状态栏(顶部时间、电量等)
|
|
|
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
|
|
|
+ // 保持布局稳定,防止系统栏显示/隐藏时布局跳动
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
|
+ // 允许布局延伸到全屏区域
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
|
+ // 允许布局延伸到导航栏区域
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化桌面图标网格 RecyclerView
|
|
|
+ * 使用 5 列网格布局,展示所有应用图标
|
|
|
+ */
|
|
|
+ private fun setupAppGrid() {
|
|
|
+ recyclerView = findViewById(R.id.rv_app_grid)
|
|
|
+ // 设置网格布局管理器:5 列(横屏下每行显示 5 个图标)
|
|
|
+ recyclerView.layoutManager = GridLayoutManager(this, 5)
|
|
|
+ // 创建预定义的应用列表(10 个应用图标)
|
|
|
+ val appList = listOf(
|
|
|
+ AppItem("智慧医疗", R.drawable.ic_medical, AppItem.Type.BUSINESS),
|
|
|
+ AppItem("设置", R.drawable.ic_settings, AppItem.Type.SETTINGS),
|
|
|
+ AppItem("相机", R.drawable.ic_camera, AppItem.Type.FAKE),
|
|
|
+ AppItem("文件管理", R.drawable.ic_files, AppItem.Type.FAKE),
|
|
|
+ AppItem("日历", R.drawable.ic_calendar, AppItem.Type.FAKE),
|
|
|
+ AppItem("时钟", R.drawable.ic_clock, AppItem.Type.FAKE),
|
|
|
+ AppItem("计算器", R.drawable.ic_calculator, AppItem.Type.FAKE),
|
|
|
+ AppItem("天气", R.drawable.ic_weather, AppItem.Type.FAKE),
|
|
|
+ AppItem("音乐", R.drawable.ic_music, AppItem.Type.FAKE),
|
|
|
+ AppItem("图库", R.drawable.ic_gallery, AppItem.Type.FAKE)
|
|
|
+ )
|
|
|
+ // 设置适配器,传入应用列表和点击回调
|
|
|
+ recyclerView.adapter = AppGridAdapter(appList) { item ->
|
|
|
+ onAppClick(item)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理应用图标点击事件
|
|
|
+ * 根据应用类型执行不同操作:跳转业务 App、打开设置页、或显示提示
|
|
|
+ */
|
|
|
+ private fun onAppClick(item: AppItem) {
|
|
|
+ when (item.type) {
|
|
|
+ AppItem.Type.BUSINESS -> {
|
|
|
+ // 业务应用:通过包名跳转到业务 App
|
|
|
+ val intent = packageManager.getLaunchIntentForPackage(
|
|
|
+ "com.emoon.harmony.robot" // 业务 App 的包名(需与业务 App 保持一致)
|
|
|
+ )
|
|
|
+ if (intent != null) {
|
|
|
+ startActivity(intent)
|
|
|
+ } else {
|
|
|
+ // 开发阶段友好提示:业务 App 未安装
|
|
|
+ Toast.makeText(this, "业务应用未安装", Toast.LENGTH_SHORT).show()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ AppItem.Type.SETTINGS -> {
|
|
|
+ // 设置应用:打开假设置页面
|
|
|
+ startActivity(Intent(this, FakeSettingsActivity::class.java))
|
|
|
+ }
|
|
|
+ AppItem.Type.FAKE -> {
|
|
|
+ // 装饰图标:点击后显示"即将推出"提示,保持桌面完整性
|
|
|
+ Toast.makeText(this, "${item.name} 即将推出", Toast.LENGTH_SHORT).show()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化下拉手势检测器
|
|
|
+ * 从屏幕顶部向下滑动时显示控制中心(仿 HarmonyOS 操作逻辑)
|
|
|
+ */
|
|
|
+ private fun setupGestureDetector() {
|
|
|
+ controlCenterView = findViewById(R.id.control_center_container)
|
|
|
+ gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
|
|
+ /**
|
|
|
+ * 检测快速滑动手势(Fling)
|
|
|
+ * @param e1 手势起点(按下位置)
|
|
|
+ * @param e2 手势终点(抬起位置)
|
|
|
+ * @param velocityX X 方向滑动速度
|
|
|
+ * @param velocityY Y 方向滑动速度(正值表示向下)
|
|
|
+ */
|
|
|
+ override fun onFling(
|
|
|
+ e1: MotionEvent?,
|
|
|
+ e2: MotionEvent,
|
|
|
+ velocityX: Float,
|
|
|
+ velocityY: Float
|
|
|
+ ): Boolean {
|
|
|
+ if (e1 == null) return false
|
|
|
+ // 判断条件:从屏幕顶部 100px 内开始(e1.y < 100)
|
|
|
+ // 且向下滑动速度大于 500(velocityY > 500)
|
|
|
+ // 且滑动距离大于 100px(e2.y - e1.y > 100)
|
|
|
+ if (e1.y < 100 && velocityY > 500 && e2.y - e1.y > 100) {
|
|
|
+ showControlCenter()
|
|
|
+ return true // 返回 true 表示已消费此手势
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 显示仿鸿蒙控制中心
|
|
|
+ * 带动画效果:从屏幕上方滑入
|
|
|
+ */
|
|
|
+ private fun showControlCenter() {
|
|
|
+ controlCenterView.visibility = View.VISIBLE
|
|
|
+ // 初始位置:在屏幕上方(Y 轴负方向偏移控件高度)
|
|
|
+ controlCenterView.translationY = -controlCenterView.height.toFloat()
|
|
|
+ // 执行滑入动画:300ms 内从上方滑到正常位置
|
|
|
+ controlCenterView.animate()
|
|
|
+ .translationY(0f)
|
|
|
+ .setDuration(300)
|
|
|
+ .start()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 隐藏控制中心
|
|
|
+ * 带动画效果:向上滑出屏幕
|
|
|
+ */
|
|
|
+ fun hideControlCenter() {
|
|
|
+ controlCenterView.animate()
|
|
|
+ .translationY(-controlCenterView.height.toFloat())
|
|
|
+ .setDuration(300)
|
|
|
+ .withEndAction {
|
|
|
+ // 动画结束后将视图设为不可见(节省渲染资源)
|
|
|
+ controlCenterView.visibility = View.GONE
|
|
|
+ }
|
|
|
+ .start()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新桌面时间日期显示
|
|
|
+ * 使用 Handler 每分钟刷新一次
|
|
|
+ */
|
|
|
+ private fun updateTimeDisplay() {
|
|
|
+ val handler = android.os.Handler(mainLooper)
|
|
|
+ val timeView = findViewById<TextView>(R.id.tv_time)
|
|
|
+ val dateView = findViewById<TextView>(R.id.tv_date)
|
|
|
+ // 创建定时任务 Runnable
|
|
|
+ val runnable = object : Runnable {
|
|
|
+ override fun run() {
|
|
|
+ val now = Calendar.getInstance()
|
|
|
+ // 更新时间:HH:mm 格式(如 14:30)
|
|
|
+ timeView.text = String.format(
|
|
|
+ "%02d:%02d",
|
|
|
+ now.get(Calendar.HOUR_OF_DAY),
|
|
|
+ now.get(Calendar.MINUTE)
|
|
|
+ )
|
|
|
+ // 星期数组(周日开始)
|
|
|
+ val weekDays = arrayOf("日", "一", "二", "三", "四", "五", "六")
|
|
|
+ // 更新日期:MM月dd日 星期X 格式
|
|
|
+ dateView.text = 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 fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
|
|
+ gestureDetector.onTouchEvent(ev)
|
|
|
+ return super.dispatchTouchEvent(ev)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拦截返回键
|
|
|
+ * 桌面作为系统入口,不允许通过返回键退出(否则回到系统桌面)
|
|
|
+ */
|
|
|
+ override fun onBackPressed() {
|
|
|
+ // 如果控制中心正在显示,优先关闭控制中心
|
|
|
+ if (controlCenterView.visibility == View.VISIBLE) {
|
|
|
+ hideControlCenter()
|
|
|
+ }
|
|
|
+ // 否则不做任何操作(桌面不响应返回键退出)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 窗口焦点变化时重新隐藏系统 UI
|
|
|
+ * 当从其他 Activity 返回桌面时,确保系统栏保持隐藏
|
|
|
+ */
|
|
|
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
|
|
|
+ super.onWindowFocusChanged(hasFocus)
|
|
|
+ if (hasFocus) hideSystemUI()
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.2 activity_launcher.xml 完整布局
|
|
|
+
|
|
|
+这是桌面的主布局文件,对应 HarmonyOS 4 桌面的各个 UI 组件:
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 桌面主页布局:仿 HarmonyOS 4 横屏桌面 -->
|
|
|
+<FrameLayout
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent">
|
|
|
+
|
|
|
+ <!-- ========== 第 1 层:壁纸背景(对应 HarmonyOS 桌面壁纸) ========== -->
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_wallpaper"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:scaleType="centerCrop"
|
|
|
+ android:src="@drawable/bg_gradient"
|
|
|
+ android:contentDescription="桌面壁纸" />
|
|
|
+
|
|
|
+ <!-- ========== 第 2 层:主内容区(垂直排列) ========== -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:paddingHorizontal="32dp">
|
|
|
+
|
|
|
+ <!-- ---------- 顶部区域:时间日期 Widget(对应 HarmonyOS 右上角时间) ---------- -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_gravity="end|top"
|
|
|
+ android:layout_marginTop="24dp"
|
|
|
+ android:gravity="end"
|
|
|
+ android:orientation="vertical">
|
|
|
+
|
|
|
+ <!-- 大号时间显示(如 14:30) -->
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_time"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:text="12:00"
|
|
|
+ android:textColor="@color/white"
|
|
|
+ android:textSize="@dimen/time_text_size"
|
|
|
+ android:textStyle="bold" />
|
|
|
+
|
|
|
+ <!-- 日期显示(如 4月23日 星期三) -->
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_date"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:text="1月1日 星期一"
|
|
|
+ android:textColor="@color/date_text"
|
|
|
+ android:textSize="@dimen/date_text_size" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 中间留白,将图标网格推到底部 Dock 上方 -->
|
|
|
+ <View
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="0dp"
|
|
|
+ android:layout_weight="1" />
|
|
|
+
|
|
|
+ <!-- ---------- 中部区域:应用图标网格(对应 HarmonyOS 桌面图标区) ---------- -->
|
|
|
+ <androidx.recyclerview.widget.RecyclerView
|
|
|
+ android:id="@+id/rv_app_grid"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginBottom="32dp"
|
|
|
+ android:clipToPadding="false"
|
|
|
+ android:paddingHorizontal="16dp" />
|
|
|
+
|
|
|
+ <!-- ---------- 底部区域:Dock 栏(对应 HarmonyOS 底部 Dock) ---------- -->
|
|
|
+ <LinearLayout
|
|
|
+ android:id="@+id/dock_bar"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="@dimen/dock_height"
|
|
|
+ android:layout_marginBottom="16dp"
|
|
|
+ android:background="@drawable/bg_dock"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="horizontal"
|
|
|
+ android:paddingHorizontal="48dp">
|
|
|
+
|
|
|
+ <!-- Dock 栏内放置 4 个高频应用图标 -->
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="@dimen/dock_icon_size"
|
|
|
+ android:layout_height="@dimen/dock_icon_size"
|
|
|
+ android:layout_marginHorizontal="16dp"
|
|
|
+ android:src="@drawable/ic_medical"
|
|
|
+ android:contentDescription="智慧医疗" />
|
|
|
+
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="@dimen/dock_icon_size"
|
|
|
+ android:layout_height="@dimen/dock_icon_size"
|
|
|
+ android:layout_marginHorizontal="16dp"
|
|
|
+ android:src="@drawable/ic_settings"
|
|
|
+ android:contentDescription="设置" />
|
|
|
+
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="@dimen/dock_icon_size"
|
|
|
+ android:layout_height="@dimen/dock_icon_size"
|
|
|
+ android:layout_marginHorizontal="16dp"
|
|
|
+ android:src="@drawable/ic_camera"
|
|
|
+ android:contentDescription="相机" />
|
|
|
+
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="@dimen/dock_icon_size"
|
|
|
+ android:layout_height="@dimen/dock_icon_size"
|
|
|
+ android:layout_marginHorizontal="16dp"
|
|
|
+ android:src="@drawable/ic_files"
|
|
|
+ android:contentDescription="文件管理" />
|
|
|
+ </LinearLayout>
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- ========== 第 3 层:下拉控制中心(初始隐藏,对应 HarmonyOS 控制中心) ========== -->
|
|
|
+ <com.emoon.harmony.launcher.ControlCenterView
|
|
|
+ android:id="@+id/control_center_container"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="400dp"
|
|
|
+ android:layout_gravity="top"
|
|
|
+ android:visibility="gone" />
|
|
|
+
|
|
|
+</FrameLayout>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.3 AppItem.kt 数据类
|
|
|
+
|
|
|
+桌面应用的数据模型,类比 Vue 中的 `data()` 返回的对象结构:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.harmony.launcher
|
|
|
+
|
|
|
+/**
|
|
|
+ * 桌面应用项数据类
|
|
|
+ * 用于存储每个应用图标的名称、图标资源和类型
|
|
|
+ */
|
|
|
+data class AppItem(
|
|
|
+ val name: String, // 应用显示名称(如"智慧医疗")
|
|
|
+ val iconResId: Int, // 图标资源 ID(指向 drawable 中的图标)
|
|
|
+ val type: Type // 应用类型,决定点击后的行为
|
|
|
+) {
|
|
|
+ /**
|
|
|
+ * 应用类型枚举
|
|
|
+ * BUSINESS: 真实业务应用(点击跳转)
|
|
|
+ * SETTINGS: 设置入口(点击打开假设置页)
|
|
|
+ * FAKE: 装饰性图标(点击显示提示)
|
|
|
+ */
|
|
|
+ enum class Type {
|
|
|
+ BUSINESS, // 业务应用(点击跳转到业务 App)
|
|
|
+ SETTINGS, // 设置(点击打开假设置页)
|
|
|
+ FAKE // 装饰图标(点击提示"即将推出")
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.4 AppGridAdapter.kt 图标网格适配器
|
|
|
+
|
|
|
+RecyclerView 的适配器实现,负责将应用数据渲染为图标卡片。类比 Vue 中 `v-for` 循环渲染列表组件:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.harmony.launcher
|
|
|
+
|
|
|
+import android.view.LayoutInflater
|
|
|
+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
|
|
|
+
|
|
|
+/**
|
|
|
+ * 桌面图标网格适配器
|
|
|
+ * 负责将 AppItem 数据列表渲染为桌面上的图标卡片
|
|
|
+ */
|
|
|
+class AppGridAdapter(
|
|
|
+ private val appList: List<AppItem>, // 应用列表数据源
|
|
|
+ private val onItemClick: (AppItem) -> Unit // 点击回调函数(lambda)
|
|
|
+) : RecyclerView.Adapter<AppGridAdapter.AppViewHolder>() {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ViewHolder:缓存每个列表项的视图引用
|
|
|
+ * 避免每次滚动时都调用 findViewById,提升性能(类比 Vue 的虚拟 DOM 复用)
|
|
|
+ */
|
|
|
+ class AppViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
|
|
+ // 图标卡片容器(CardView,实现圆角和阴影)
|
|
|
+ val cardView: CardView = itemView.findViewById(R.id.card_app_icon)
|
|
|
+ // 应用图标图片
|
|
|
+ val iconImage: ImageView = itemView.findViewById(R.id.iv_app_icon)
|
|
|
+ // 应用名称文字
|
|
|
+ val nameText: TextView = itemView.findViewById(R.id.tv_app_name)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建新的 ViewHolder(当列表需要展示新的项时调用)
|
|
|
+ * @param parent 父视图容器
|
|
|
+ * @param viewType 视图类型(多类型列表时使用,此处只有一种类型)
|
|
|
+ */
|
|
|
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
|
|
|
+ // 加载 item_app_icon.xml 布局文件
|
|
|
+ val view = LayoutInflater.from(parent.context)
|
|
|
+ .inflate(R.layout.item_app_icon, parent, false)
|
|
|
+ return AppViewHolder(view)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 绑定数据到 ViewHolder(将数据填充到视图中)
|
|
|
+ * @param holder 要绑定的 ViewHolder
|
|
|
+ * @param position 数据在列表中的索引位置
|
|
|
+ */
|
|
|
+ override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
|
|
|
+ val item = appList[position]
|
|
|
+ // 设置应用图标图片
|
|
|
+ holder.iconImage.setImageResource(item.iconResId)
|
|
|
+ // 设置应用名称文字
|
|
|
+ holder.nameText.text = item.name
|
|
|
+ // 设置点击事件监听器
|
|
|
+ holder.cardView.setOnClickListener {
|
|
|
+ onItemClick(item) // 调用外部传入的点击回调
|
|
|
+ }
|
|
|
+ // 设置按下效果:点击时临时改变背景色(视觉反馈)
|
|
|
+ holder.cardView.setOnTouchListener { v, event ->
|
|
|
+ when (event.action) {
|
|
|
+ // 手指按下:切换到按下态背景
|
|
|
+ android.view.MotionEvent.ACTION_DOWN -> {
|
|
|
+ holder.cardView.setCardBackgroundColor(
|
|
|
+ v.context.getColor(R.color.icon_card_pressed)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ // 手指抬起或取消:恢复常态背景
|
|
|
+ android.view.MotionEvent.ACTION_UP,
|
|
|
+ android.view.MotionEvent.ACTION_CANCEL -> {
|
|
|
+ holder.cardView.setCardBackgroundColor(
|
|
|
+ v.context.getColor(R.color.icon_card_normal)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ false // 返回 false 表示不拦截触摸事件,继续传递
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 返回列表项总数
|
|
|
+ * RecyclerView 通过此方法知道需要渲染多少个列表项
|
|
|
+ */
|
|
|
+ override fun getItemCount(): Int = appList.size
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.5 item_app_icon.xml 单个图标卡片布局
|
|
|
+
|
|
|
+这是每个应用图标的布局文件,使用 CardView 实现 HarmonyOS 风格的圆角卡片:
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 单个应用图标卡片布局(仿 HarmonyOS 大圆角图标) -->
|
|
|
+<androidx.cardview.widget.CardView
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
|
+ android:id="@+id/card_app_icon"
|
|
|
+ android:layout_width="@dimen/icon_card_size"
|
|
|
+ android:layout_height="@dimen/icon_card_size"
|
|
|
+ android:layout_margin="@dimen/icon_grid_spacing"
|
|
|
+ android:clickable="true"
|
|
|
+ android:focusable="true"
|
|
|
+ android:foreground="?android:attr/selectableItemBackground"
|
|
|
+ app:cardBackgroundColor="@color/icon_card_normal"
|
|
|
+ app:cardCornerRadius="@dimen/icon_card_radius"
|
|
|
+ app:cardElevation="@dimen/card_elevation">
|
|
|
+
|
|
|
+ <!-- 垂直布局:图标在上,文字在下 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+
|
|
|
+ <!-- 应用图标图片 -->
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_app_icon"
|
|
|
+ android:layout_width="@dimen/icon_image_size"
|
|
|
+ android:layout_height="@dimen/icon_image_size"
|
|
|
+ android:scaleType="fitCenter"
|
|
|
+ android:contentDescription="应用图标" />
|
|
|
+
|
|
|
+ <!-- 应用名称文字 -->
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_app_name"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:ellipsize="end"
|
|
|
+ android:maxLines="1"
|
|
|
+ android:textColor="@color/white"
|
|
|
+ android:textSize="@dimen/icon_label_size" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+</androidx.cardview.widget.CardView>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.6 ControlCenterView.kt 下拉控制中心
|
|
|
+
|
|
|
+使用自定义 View 实现下拉控制中心(不使用 Fragment,避免复杂生命周期管理)。包含快捷开关和滑块,所有交互仅切换 UI 状态,**不做真实系统调用**。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+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
|
|
|
+
|
|
|
+/**
|
|
|
+ * 仿 HarmonyOS 下拉控制中心自定义 View
|
|
|
+ * 从屏幕顶部下滑触发,包含快捷开关和亮度/音量滑块
|
|
|
+ */
|
|
|
+class ControlCenterView @JvmOverloads constructor(
|
|
|
+ context: Context,
|
|
|
+ attrs: AttributeSet? = null,
|
|
|
+ defStyleAttr: Int = 0
|
|
|
+) : FrameLayout(context, attrs, defStyleAttr) {
|
|
|
+
|
|
|
+ // 开关状态存储表(HashMap,键为开关名称,值为开启/关闭状态)
|
|
|
+ private val switchStates = HashMap<String, Boolean>().apply {
|
|
|
+ put("wifi", true) // WiFi:默认开启
|
|
|
+ put("bluetooth", false) // 蓝牙:默认关闭
|
|
|
+ put("mobile", true) // 移动数据:默认开启
|
|
|
+ put("airplane", false) // 飞行模式:默认关闭
|
|
|
+ put("location", true) // 位置服务:默认开启
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开关视图映射表(键为开关名称,值为对应的 ImageView)
|
|
|
+ private val switchViews = HashMap<String, ImageView>()
|
|
|
+
|
|
|
+ init {
|
|
|
+ // 加载控制中心的布局 XML
|
|
|
+ LayoutInflater.from(context).inflate(R.layout.control_center, this, true)
|
|
|
+ // 初始化所有开关
|
|
|
+ initSwitches()
|
|
|
+ // 初始化亮度滑块
|
|
|
+ initBrightnessSlider()
|
|
|
+ // 初始化音量滑块
|
|
|
+ initVolumeSlider()
|
|
|
+ // 初始化关闭按钮
|
|
|
+ initCloseButton()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化快捷开关
|
|
|
+ * 每个开关点击时仅切换本地 UI 状态,不调用真实系统 API
|
|
|
+ */
|
|
|
+ private fun initSwitches() {
|
|
|
+ // 定义开关配置:(开关名称,图标 View 的 ID,标签 View 的 ID)
|
|
|
+ val switchConfigs = listOf(
|
|
|
+ Triple("wifi", R.id.iv_wifi, R.id.tv_wifi),
|
|
|
+ Triple("bluetooth", R.id.iv_bluetooth, R.id.tv_bluetooth),
|
|
|
+ Triple("mobile", R.id.iv_mobile, R.id.tv_mobile),
|
|
|
+ Triple("airplane", R.id.iv_airplane, R.id.tv_airplane),
|
|
|
+ Triple("location", R.id.iv_location, R.id.tv_location)
|
|
|
+ )
|
|
|
+
|
|
|
+ switchConfigs.forEach { (name, iconId, labelId) ->
|
|
|
+ val iconView = findViewById<ImageView>(iconId)
|
|
|
+ val labelView = findViewById<TextView>(labelId)
|
|
|
+ switchViews[name] = iconView
|
|
|
+
|
|
|
+ // 设置初始状态
|
|
|
+ updateSwitchUI(name, iconView, labelView)
|
|
|
+
|
|
|
+ // 设置点击监听器
|
|
|
+ iconView.setOnClickListener {
|
|
|
+ // 切换开关状态(取反当前状态)
|
|
|
+ val newState = !(switchStates[name] ?: false)
|
|
|
+ switchStates[name] = newState
|
|
|
+ // 更新 UI 显示
|
|
|
+ updateSwitchUI(name, iconView, labelView)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新开关的 UI 显示
|
|
|
+ * @param name 开关名称
|
|
|
+ * @param iconView 开关图标视图
|
|
|
+ * @param labelView 开关标签视图
|
|
|
+ */
|
|
|
+ private fun updateSwitchUI(name: String, iconView: ImageView, labelView: TextView) {
|
|
|
+ val isOn = switchStates[name] ?: false
|
|
|
+ if (isOn) {
|
|
|
+ // 开启态:鸿蒙蓝色背景 + 白色图标
|
|
|
+ iconView.setBackgroundResource(R.drawable.bg_switch_on)
|
|
|
+ iconView.setColorFilter(context.getColor(android.R.color.white))
|
|
|
+ labelView.setTextColor(context.getColor(R.color.harmony_blue))
|
|
|
+ } else {
|
|
|
+ // 关闭态:深灰背景 + 浅灰图标
|
|
|
+ iconView.setBackgroundResource(R.drawable.bg_switch_off)
|
|
|
+ iconView.setColorFilter(context.getColor(R.color.gray_text))
|
|
|
+ labelView.setTextColor(context.getColor(R.color.gray_text))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化亮度滑块
|
|
|
+ * 仅改变滑块位置,不修改系统实际亮度
|
|
|
+ */
|
|
|
+ private fun initBrightnessSlider() {
|
|
|
+ val seekBar = findViewById<SeekBar>(R.id.seekbar_brightness)
|
|
|
+ val valueText = findViewById<TextView>(R.id.tv_brightness_value)
|
|
|
+ // 设置初始值 70%
|
|
|
+ seekBar.progress = 70
|
|
|
+ valueText.text = "70%"
|
|
|
+
|
|
|
+ seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
|
|
+ // 滑块拖动时实时更新显示
|
|
|
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
|
|
+ valueText.text = "${progress}%"
|
|
|
+ }
|
|
|
+ // 开始拖动(空实现,但接口要求覆写)
|
|
|
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
|
|
+ // 结束拖动(空实现,但接口要求覆写)
|
|
|
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化音量滑块
|
|
|
+ * 仅改变滑块位置,不修改系统实际音量
|
|
|
+ */
|
|
|
+ private fun initVolumeSlider() {
|
|
|
+ val seekBar = findViewById<SeekBar>(R.id.seekbar_volume)
|
|
|
+ val valueText = findViewById<TextView>(R.id.tv_volume_value)
|
|
|
+ // 设置初始值 50%
|
|
|
+ seekBar.progress = 50
|
|
|
+ valueText.text = "50%"
|
|
|
+
|
|
|
+ seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
|
|
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
|
|
+ valueText.text = "${progress}%"
|
|
|
+ }
|
|
|
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
|
|
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化关闭按钮
|
|
|
+ * 点击后隐藏控制中心
|
|
|
+ */
|
|
|
+ private fun initCloseButton() {
|
|
|
+ findViewById<View>(R.id.btn_close_control).setOnClickListener {
|
|
|
+ // 获取父 Activity 并调用其 hideControlCenter 方法
|
|
|
+ (context as? LauncherActivity)?.hideControlCenter()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.7 控制中心布局 XML
|
|
|
+
|
|
|
+控制中心的布局文件 `res/layout/control_center.xml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 仿 HarmonyOS 4 下拉控制中心布局 -->
|
|
|
+<LinearLayout
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:background="@drawable/bg_control_center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="24dp">
|
|
|
+
|
|
|
+ <!-- 顶部拖动手柄(视觉提示) -->
|
|
|
+ <View
|
|
|
+ android:layout_width="48dp"
|
|
|
+ android:layout_height="4dp"
|
|
|
+ android:layout_gravity="center_horizontal"
|
|
|
+ android:layout_marginBottom="16dp"
|
|
|
+ android:background="@drawable/handle_bar" />
|
|
|
+
|
|
|
+ <!-- 快捷开关网格(2 行 x 3 列) -->
|
|
|
+ <GridLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:columnCount="3"
|
|
|
+ android:rowCount="2"
|
|
|
+ android:useDefaultMargins="true">
|
|
|
+
|
|
|
+ <!-- WiFi 开关 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_wifi"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_wifi"
|
|
|
+ android:contentDescription="WiFi" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_wifi"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="WLAN"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 蓝牙开关 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_bluetooth"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_bluetooth"
|
|
|
+ android:contentDescription="蓝牙" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_bluetooth"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="蓝牙"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 移动数据开关 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_mobile"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_mobile"
|
|
|
+ android:contentDescription="移动数据" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_mobile"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="移动数据"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 飞行模式开关 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_airplane"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_airplane"
|
|
|
+ android:contentDescription="飞行模式" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_airplane"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="飞行模式"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 位置服务开关 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_location"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_location"
|
|
|
+ android:contentDescription="位置服务" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_location"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="位置服务"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 关闭按钮 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:gravity="center"
|
|
|
+ android:orientation="vertical"
|
|
|
+ android:padding="8dp">
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/btn_close_control"
|
|
|
+ android:layout_width="@dimen/control_switch_size"
|
|
|
+ android:layout_height="@dimen/control_switch_size"
|
|
|
+ android:padding="16dp"
|
|
|
+ android:src="@drawable/ic_close"
|
|
|
+ android:background="@drawable/bg_switch_off"
|
|
|
+ android:contentDescription="关闭" />
|
|
|
+ <TextView
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="4dp"
|
|
|
+ android:text="关闭"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+ </GridLayout>
|
|
|
+
|
|
|
+ <!-- 亮度滑块区域 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="16dp"
|
|
|
+ android:gravity="center_vertical"
|
|
|
+ android:orientation="horizontal">
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="24dp"
|
|
|
+ android:layout_height="24dp"
|
|
|
+ android:src="@drawable/ic_brightness"
|
|
|
+ android:contentDescription="亮度" />
|
|
|
+ <SeekBar
|
|
|
+ android:id="@+id/seekbar_brightness"
|
|
|
+ android:layout_width="0dp"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_weight="1"
|
|
|
+ android:layout_marginHorizontal="8dp" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_brightness_value"
|
|
|
+ android:layout_width="40dp"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:text="70%"
|
|
|
+ android:textColor="@color/white"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 音量滑块区域 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginTop="12dp"
|
|
|
+ android:gravity="center_vertical"
|
|
|
+ android:orientation="horizontal">
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="24dp"
|
|
|
+ android:layout_height="24dp"
|
|
|
+ android:src="@drawable/ic_volume"
|
|
|
+ android:contentDescription="音量" />
|
|
|
+ <SeekBar
|
|
|
+ android:id="@+id/seekbar_volume"
|
|
|
+ android:layout_width="0dp"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_weight="1"
|
|
|
+ android:layout_marginHorizontal="8dp" />
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_volume_value"
|
|
|
+ android:layout_width="40dp"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:text="50%"
|
|
|
+ android:textColor="@color/white"
|
|
|
+ android:textSize="12sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+</LinearLayout>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.8 FakeSettingsActivity.kt 假设置页
|
|
|
+
|
|
|
+完整的假设置页实现,包含列表式布局和"关于本机"页面:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+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
|
|
|
+
|
|
|
+/**
|
|
|
+ * 假设置页面 Activity
|
|
|
+ * 仿 HarmonyOS 设置页风格,展示假设备信息
|
|
|
+ */
|
|
|
+class FakeSettingsActivity : Activity() {
|
|
|
+
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
+ setContentView(R.layout.activity_fake_settings)
|
|
|
+ // 初始化设置列表
|
|
|
+ setupSettingsList()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化设置列表 RecyclerView
|
|
|
+ */
|
|
|
+ private fun setupSettingsList() {
|
|
|
+ val recyclerView = findViewById<RecyclerView>(R.id.rv_settings)
|
|
|
+ // 垂直线性布局(类似 Vue 的垂直列表)
|
|
|
+ recyclerView.layoutManager = LinearLayoutManager(this)
|
|
|
+
|
|
|
+ // 定义设置项数据列表
|
|
|
+ val settingsItems = listOf(
|
|
|
+ SettingItem("WLAN", "已连接", R.drawable.ic_wifi),
|
|
|
+ SettingItem("蓝牙", "已开启", R.drawable.ic_bluetooth),
|
|
|
+ SettingItem("显示和亮度", "", R.drawable.ic_brightness),
|
|
|
+ SettingItem("声音和振动", "", R.drawable.ic_volume),
|
|
|
+ SettingItem("关于本机", "", R.drawable.ic_info)
|
|
|
+ )
|
|
|
+
|
|
|
+ // 设置适配器
|
|
|
+ recyclerView.adapter = SettingsAdapter(settingsItems) { item ->
|
|
|
+ onSettingClick(item)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理设置项点击事件
|
|
|
+ */
|
|
|
+ private fun onSettingClick(item: SettingItem) {
|
|
|
+ when (item.title) {
|
|
|
+ "关于本机" -> showAboutDialog()
|
|
|
+ else -> Toast.makeText(this, "${item.title} 功能即将推出", Toast.LENGTH_SHORT).show()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 显示"关于本机"对话框
|
|
|
+ * 展示仿造的设备信息,营造 HarmonyOS 系统的视觉假象
|
|
|
+ */
|
|
|
+ private fun showAboutDialog() {
|
|
|
+ // 使用 AlertDialog 展示关于信息
|
|
|
+ val aboutView = LayoutInflater.from(this).inflate(R.layout.dialog_about, null)
|
|
|
+ // 填充设备信息数据
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_device_name).text = "设备名称:豹小秘 Pro"
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_system_theme).text = "系统主题:HarmonyOS 风格 4.0"
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_processor).text = "处理器:Kirin 9000S"
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_ram).text = "运行内存:4 GB"
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_storage).text = "存储空间:64 GB"
|
|
|
+ aboutView.findViewById<TextView>(R.id.tv_resolution).text = "分辨率:1280 × 800"
|
|
|
+
|
|
|
+ android.app.AlertDialog.Builder(this)
|
|
|
+ .setView(aboutView)
|
|
|
+ .setPositiveButton("确定", null)
|
|
|
+ .show()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 设置项数据类
|
|
|
+ */
|
|
|
+ data class SettingItem(
|
|
|
+ val title: String, // 设置项标题
|
|
|
+ val subtitle: String, // 副标题(如"已连接")
|
|
|
+ val iconResId: Int // 左侧图标资源 ID
|
|
|
+ )
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 设置列表适配器
|
|
|
+ */
|
|
|
+ class SettingsAdapter(
|
|
|
+ private val items: List<SettingItem>,
|
|
|
+ private val onClick: (SettingItem) -> Unit
|
|
|
+ ) : RecyclerView.Adapter<SettingsAdapter.SettingViewHolder>() {
|
|
|
+
|
|
|
+ class SettingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
|
|
+ val iconView: ImageView = itemView.findViewById(R.id.iv_setting_icon)
|
|
|
+ val titleView: TextView = itemView.findViewById(R.id.tv_setting_title)
|
|
|
+ val subtitleView: TextView = itemView.findViewById(R.id.tv_setting_subtitle)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
|
|
|
+ val view = LayoutInflater.from(parent.context)
|
|
|
+ .inflate(R.layout.item_setting, parent, false)
|
|
|
+ return SettingViewHolder(view)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
|
|
|
+ val item = items[position]
|
|
|
+ holder.iconView.setImageResource(item.iconResId)
|
|
|
+ holder.titleView.text = item.title
|
|
|
+ holder.subtitleView.text = item.subtitle
|
|
|
+ holder.itemView.setOnClickListener { onClick(item) }
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun getItemCount(): Int = items.size
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.9 设置页布局 XML
|
|
|
+
|
|
|
+**activity_fake_settings.xml**(设置页主布局):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 假设置页主布局(仿 HarmonyOS 设置页风格) -->
|
|
|
+<LinearLayout
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:background="@color/settings_background"
|
|
|
+ android:orientation="vertical">
|
|
|
+
|
|
|
+ <!-- 顶部标题栏 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="56dp"
|
|
|
+ android:background="@color/white"
|
|
|
+ android:gravity="center_vertical"
|
|
|
+ android:paddingHorizontal="16dp">
|
|
|
+
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/btn_back"
|
|
|
+ android:layout_width="24dp"
|
|
|
+ android:layout_height="24dp"
|
|
|
+ android:src="@drawable/ic_back"
|
|
|
+ android:contentDescription="返回" />
|
|
|
+
|
|
|
+ <TextView
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_marginStart="16dp"
|
|
|
+ android:text="设置"
|
|
|
+ android:textColor="@color/black"
|
|
|
+ android:textSize="20sp"
|
|
|
+ android:textStyle="bold" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 设置列表 -->
|
|
|
+ <androidx.recyclerview.widget.RecyclerView
|
|
|
+ android:id="@+id/rv_settings"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:layout_marginTop="8dp" />
|
|
|
+
|
|
|
+</LinearLayout>
|
|
|
+```
|
|
|
+
|
|
|
+**item_setting.xml**(单个设置项布局):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 单个设置列表项(仿 HarmonyOS 设置列表) -->
|
|
|
+<LinearLayout
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="@dimen/setting_item_height"
|
|
|
+ android:background="@color/white"
|
|
|
+ android:gravity="center_vertical"
|
|
|
+ android:orientation="horizontal"
|
|
|
+ android:paddingHorizontal="16dp"
|
|
|
+ android:clickable="true"
|
|
|
+ android:focusable="true"
|
|
|
+ android:foreground="?android:attr/selectableItemBackground">
|
|
|
+
|
|
|
+ <!-- 左侧图标 -->
|
|
|
+ <ImageView
|
|
|
+ android:id="@+id/iv_setting_icon"
|
|
|
+ android:layout_width="24dp"
|
|
|
+ android:layout_height="24dp"
|
|
|
+ android:contentDescription="设置图标" />
|
|
|
+
|
|
|
+ <!-- 中间文字区域 -->
|
|
|
+ <LinearLayout
|
|
|
+ android:layout_width="0dp"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:layout_weight="1"
|
|
|
+ android:layout_marginStart="16dp"
|
|
|
+ android:orientation="vertical">
|
|
|
+
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_setting_title"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:textColor="@color/black"
|
|
|
+ android:textSize="16sp" />
|
|
|
+
|
|
|
+ <TextView
|
|
|
+ android:id="@+id/tv_setting_subtitle"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:textColor="@color/gray_text"
|
|
|
+ android:textSize="14sp" />
|
|
|
+ </LinearLayout>
|
|
|
+
|
|
|
+ <!-- 右侧箭头 -->
|
|
|
+ <ImageView
|
|
|
+ android:layout_width="16dp"
|
|
|
+ android:layout_height="16dp"
|
|
|
+ android:src="@drawable/ic_arrow_right"
|
|
|
+ android:contentDescription="进入" />
|
|
|
+
|
|
|
+</LinearLayout>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.10 BootReceiver.kt 开机自启接收器
|
|
|
+
|
|
|
+完整的开机自启广播接收器实现:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.harmony.launcher
|
|
|
+
|
|
|
+import android.content.BroadcastReceiver
|
|
|
+import android.content.Context
|
|
|
+import android.content.Intent
|
|
|
+
|
|
|
+/**
|
|
|
+ * 开机自启广播接收器
|
|
|
+ * 接收系统开机完成广播(BOOT_COMPLETED),自动启动 LauncherActivity
|
|
|
+ */
|
|
|
+class BootReceiver : BroadcastReceiver() {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当收到广播时调用
|
|
|
+ * @param context 应用上下文
|
|
|
+ * @param intent 收到的广播 Intent
|
|
|
+ */
|
|
|
+ override fun onReceive(context: Context, intent: Intent) {
|
|
|
+ // 判断广播动作是否为开机完成
|
|
|
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
|
|
+ // 创建启动 LauncherActivity 的 Intent
|
|
|
+ val launchIntent = Intent(context, LauncherActivity::class.java).apply {
|
|
|
+ // FLAG_ACTIVITY_NEW_TASK 是必须的:从非 Activity 上下文启动 Activity 需要此标志
|
|
|
+ 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+ 的安全限制)
|
|
|
+
|
|
|
+#### 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 键,确认进入仿鸿蒙桌面(显示蓝紫渐变背景 + 圆角图标网格)
|
|
|
+- 重启设备,确认开机后自动进入仿鸿蒙桌面(验证 BootReceiver 生效)
|
|
|
+
|
|
|
+#### 3.5.12 res/values/colors.xml
|
|
|
+
|
|
|
+所有 HarmonyOS 风格配色的 XML 定义:
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 颜色资源文件:定义 HarmonyOS 风格配色方案 -->
|
|
|
+<resources>
|
|
|
+ <!-- ===== 基础色 ===== -->
|
|
|
+ <!-- 纯白 -->
|
|
|
+ <color name="white">#FFFFFF</color>
|
|
|
+ <!-- 纯黑 -->
|
|
|
+ <color name="black">#000000</color>
|
|
|
+ <!-- 鸿蒙蓝(开关开启态、强调色) -->
|
|
|
+ <color name="harmony_blue">#007DFF</color>
|
|
|
+ <!-- 灰色文字 -->
|
|
|
+ <color name="gray_text">#999999</color>
|
|
|
+
|
|
|
+ <!-- ===== 桌面配色 ===== -->
|
|
|
+ <!-- 图标卡片常态背景:白色 15% 透明度(毛玻璃效果) -->
|
|
|
+ <color name="icon_card_normal">#26FFFFFF</color>
|
|
|
+ <!-- 图标卡片按下态背景:白色 25% 透明度 -->
|
|
|
+ <color name="icon_card_pressed">#40FFFFFF</color>
|
|
|
+ <!-- Dock 栏背景:黑色 30% 透明度 -->
|
|
|
+ <color name="dock_background">#4D000000</color>
|
|
|
+ <!-- 控制中心背景:深蓝黑 95% 透明度 -->
|
|
|
+ <color name="control_center_background">#F21A1A2E</color>
|
|
|
+ <!-- 开关开启态背景:鸿蒙蓝 -->
|
|
|
+ <color name="switch_on_background">#007DFF</color>
|
|
|
+ <!-- 开关关闭态背景:深灰 -->
|
|
|
+ <color name="switch_off_background">#404040</color>
|
|
|
+ <!-- 日期文字颜色:白色 70% 透明度 -->
|
|
|
+ <color name="date_text">#B3FFFFFF</color>
|
|
|
+
|
|
|
+ <!-- ===== 设置页配色 ===== -->
|
|
|
+ <!-- 设置页背景:浅灰白 -->
|
|
|
+ <color name="settings_background">#F1F3F5</color>
|
|
|
+ <!-- 设置页卡片:纯白 -->
|
|
|
+ <color name="settings_card">#FFFFFF</color>
|
|
|
+ <!-- 设置页标题:纯黑 -->
|
|
|
+ <color name="settings_title">#000000</color>
|
|
|
+ <!-- 设置页副文字:灰色 -->
|
|
|
+ <color name="settings_subtitle">#999999</color>
|
|
|
+
|
|
|
+ <!-- ===== 渐变配色 ===== -->
|
|
|
+ <!-- 桌面背景渐变起始色:深蓝黑 -->
|
|
|
+ <color name="gradient_start">#1A1A2E</color>
|
|
|
+ <!-- 桌面背景渐变结束色:靛蓝 -->
|
|
|
+ <color name="gradient_end">#16213E</color>
|
|
|
+</resources>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.13 res/values/dimens.xml
|
|
|
+
|
|
|
+所有尺寸值的集中定义(便于统一修改和维护):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 尺寸资源文件:基于 10 寸横屏 1280x800 的设计规范 -->
|
|
|
+<resources>
|
|
|
+ <!-- ===== 图标尺寸 ===== -->
|
|
|
+ <!-- 桌面图标卡片整体尺寸:80dp x 80dp -->
|
|
|
+ <dimen name="icon_card_size">80dp</dimen>
|
|
|
+ <!-- 图标内实际图片大小:56dp x 56dp -->
|
|
|
+ <dimen name="icon_image_size">56dp</dimen>
|
|
|
+ <!-- 图标卡片圆角:20dp(HarmonyOS 标志性大圆角) -->
|
|
|
+ <dimen name="icon_card_radius">20dp</dimen>
|
|
|
+ <!-- 图标标签文字大小:12sp -->
|
|
|
+ <dimen name="icon_label_size">12sp</dimen>
|
|
|
+ <!-- 图标网格间距:24dp -->
|
|
|
+ <dimen name="icon_grid_spacing">24dp</dimen>
|
|
|
+ <!-- 卡片投影高度:4dp -->
|
|
|
+ <dimen name="card_elevation">4dp</dimen>
|
|
|
+
|
|
|
+ <!-- ===== Dock 栏尺寸 ===== -->
|
|
|
+ <!-- Dock 栏高度:64dp -->
|
|
|
+ <dimen name="dock_height">64dp</dimen>
|
|
|
+ <!-- Dock 栏圆角:24dp -->
|
|
|
+ <dimen name="dock_radius">24dp</dimen>
|
|
|
+ <!-- Dock 栏内图标大小:48dp x 48dp -->
|
|
|
+ <dimen name="dock_icon_size">48dp</dimen>
|
|
|
+
|
|
|
+ <!-- ===== 控制中心尺寸 ===== -->
|
|
|
+ <!-- 控制中心卡片圆角:24dp -->
|
|
|
+ <dimen name="control_center_radius">24dp</dimen>
|
|
|
+ <!-- 控制中心开关尺寸:64dp x 64dp -->
|
|
|
+ <dimen name="control_switch_size">64dp</dimen>
|
|
|
+
|
|
|
+ <!-- ===== 时间日期尺寸 ===== -->
|
|
|
+ <!-- 时间文字大小:48sp -->
|
|
|
+ <dimen name="time_text_size">48sp</dimen>
|
|
|
+ <!-- 日期文字大小:14sp -->
|
|
|
+ <dimen name="date_text_size">14sp</dimen>
|
|
|
+
|
|
|
+ <!-- ===== 设置页尺寸 ===== -->
|
|
|
+ <!-- 设置列表项高度:56dp -->
|
|
|
+ <dimen name="setting_item_height">56dp</dimen>
|
|
|
+</resources>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.14 res/values/themes.xml
|
|
|
+
|
|
|
+全屏主题和样式定义:
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 主题样式文件:定义应用的全局外观 -->
|
|
|
+<resources>
|
|
|
+ <!-- ===== 应用基础主题 ===== -->
|
|
|
+ <!-- 继承自 Material 的深色主题(适合深色桌面背景) -->
|
|
|
+ <style name="Theme.HarmonyLauncher" parent="Theme.MaterialComponents.Dark.NoActionBar">
|
|
|
+ <!-- 主色调:鸿蒙蓝 -->
|
|
|
+ <item name="colorPrimary">@color/harmony_blue</item>
|
|
|
+ <!-- 主色调变体 -->
|
|
|
+ <item name="colorPrimaryVariant">#005BB5</item>
|
|
|
+ <!-- 强调色 -->
|
|
|
+ <item name="colorAccent">@color/harmony_blue</item>
|
|
|
+ <!-- 窗口背景:默认深蓝黑 -->
|
|
|
+ <item name="android:windowBackground">@color/gradient_start</item>
|
|
|
+ </style>
|
|
|
+
|
|
|
+ <!-- ===== 全屏无标题栏主题(用于 Launcher 和设置页) ===== -->
|
|
|
+ <style name="Theme.HarmonyLauncher.Fullscreen" parent="Theme.HarmonyLauncher">
|
|
|
+ <!-- 隐藏 ActionBar(标题栏) -->
|
|
|
+ <item name="android:windowActionBar">false</item>
|
|
|
+ <!-- 无标题 -->
|
|
|
+ <item name="android:windowNoTitle">true</item>
|
|
|
+ <!-- 全屏显示 -->
|
|
|
+ <item name="android:windowFullscreen">true</item>
|
|
|
+ <!-- 透明状态栏背景 -->
|
|
|
+ <item name="android:statusBarColor">@android:color/transparent</item>
|
|
|
+ <!-- 透明导航栏背景 -->
|
|
|
+ <item name="android:navigationBarColor">@android:color/transparent</item>
|
|
|
+ </style>
|
|
|
+</resources>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3.5.15 res/drawable/ 关键背景 XML
|
|
|
+
|
|
|
+以下是桌面核心视觉元素的 Drawable 定义:
|
|
|
+
|
|
|
+**bg_gradient.xml**(桌面背景渐变):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 桌面背景渐变:深蓝黑到靛蓝,营造 HarmonyOS 科技感 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <gradient
|
|
|
+ android:angle="135"
|
|
|
+ android:endColor="@color/gradient_end"
|
|
|
+ android:startColor="@color/gradient_start"
|
|
|
+ android:type="linear" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_icon_card.xml**(图标卡片常态背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 图标卡片常态背景:半透明圆角矩形(毛玻璃效果基底) -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <solid android:color="@color/icon_card_normal" />
|
|
|
+ <corners android:radius="@dimen/icon_card_radius" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_icon_card_pressed.xml**(图标卡片按下态背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 图标卡片按下态背景:更高透明度,提供视觉反馈 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <solid android:color="@color/icon_card_pressed" />
|
|
|
+ <corners android:radius="@dimen/icon_card_radius" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_dock.xml**(Dock 栏背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- Dock 栏背景:半透明黑色圆角矩形(上方圆角) -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <solid android:color="@color/dock_background" />
|
|
|
+ <!-- 只设置上方圆角,下方保持直角贴合屏幕底部 -->
|
|
|
+ <corners
|
|
|
+ android:topLeftRadius="@dimen/dock_radius"
|
|
|
+ android:topRightRadius="@dimen/dock_radius" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_control_center.xml**(控制中心背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 控制中心背景:近不透明的深色圆角卡片 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <solid android:color="@color/control_center_background" />
|
|
|
+ <!-- 只设置下方圆角,上方拉出时与屏幕边缘自然过渡 -->
|
|
|
+ <corners
|
|
|
+ android:bottomLeftRadius="@dimen/control_center_radius"
|
|
|
+ android:bottomRightRadius="@dimen/control_center_radius" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_switch_on.xml**(开关开启态背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 控制中心开关开启态:鸿蒙蓝圆形背景 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="oval">
|
|
|
+ <solid android:color="@color/switch_on_background" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**bg_switch_off.xml**(开关关闭态背景):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 控制中心开关关闭态:深灰圆形背景 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="oval">
|
|
|
+ <solid android:color="@color/switch_off_background" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+**handle_bar.xml**(控制中心拖动手柄):
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- 控制中心顶部拖动手柄:细横条,提示用户可拖动 -->
|
|
|
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ android:shape="rectangle">
|
|
|
+ <solid android:color="#66FFFFFF" />
|
|
|
+ <corners android:radius="2dp" />
|
|
|
+</shape>
|
|
|
+```
|
|
|
+
|
|
|
+> **图标说明**:上述布局中引用的 `ic_*.xml` 图标(如 `ic_wifi`、`ic_bluetooth` 等)建议使用 Android Studio 内置的 **Vector Asset Studio** 导入 Material Design 图标,或从 [Material Icons](https://fonts.google.com/icons) 下载 SVG 后转为 Vector Drawable。对于迎检演示,使用简单的白色线形图标即可达到 HarmonyOS 风格效果。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、原生业务 App 详细设计
|
|
|
+
|
|
|
+### 4.1 Android Studio 工程结构
|
|
|
+
|
|
|
+```
|
|
|
+com.medical.robotapp/
|
|
|
+├── MainActivity.kt # WebView 容器 Activity
|
|
|
+├── bridge/
|
|
|
+│ └── RobotBridge.kt # JSBridge 接口实现
|
|
|
+├── sdk/
|
|
|
+│ └── OrionSDKManager.kt # 猎户星空 SDK 封装
|
|
|
+├── service/
|
|
|
+│ └── NavigationCallback.kt # 导航状态监听
|
|
|
+└── util/
|
|
|
+ └── WebViewUtil.kt # WebView 配置工具
|
|
|
+```
|
|
|
+
|
|
|
+### 4.2 WebView 容器配置
|
|
|
+
|
|
|
+```kotlin
|
|
|
+val webView = findViewById<WebView>(R.id.webView)
|
|
|
+webView.settings.apply {
|
|
|
+ javaScriptEnabled = true
|
|
|
+ domStorageEnabled = true
|
|
|
+ allowFileAccess = true
|
|
|
+ allowUniversalAccessFromFileURLs = true
|
|
|
+ useWideViewPort = true
|
|
|
+ loadWithOverviewMode = true
|
|
|
+ setSupportZoom(false)
|
|
|
+}
|
|
|
+webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
|
|
+webView.webChromeClient = WebChromeClient()
|
|
|
+webView.addJavascriptInterface(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 方法名 | 参数 | 返回值(通过回调) | 说明 | 本期实现 |
|
|
|
+|---|---|---|---|---|
|
|
|
+| `RobotBridge.navigate(destination, callbackId)` | destination: String, callbackId: String | {code, msg} | 导航到指定位置 | ✅ |
|
|
|
+| `RobotBridge.stopNavigation(callbackId)` | callbackId: String | {code, msg} | 停止导航 | 预留 |
|
|
|
+| `RobotBridge.getPlaceList(callbackId)` | callbackId: String | JSON 数组 | 获取所有定位点 | ✅ |
|
|
|
+| `RobotBridge.getPosition(callbackId)` | callbackId: String | {x,y,theta} | 获取当前坐标 | 预留 |
|
|
|
+| `RobotBridge.playTTS(text, callbackId)` | text: String, callbackId: String | {code, msg} | TTS 语音播报 | ✅ |
|
|
|
+| `RobotBridge.stopTTS(callbackId)` | callbackId: String | {code, msg} | 停止播报 | 预留 |
|
|
|
+| `RobotBridge.getBattery(callbackId)` | callbackId: String | {level} | 获取电量 | ✅ |
|
|
|
+
|
|
|
+**通信机制说明**:Android WebView 的 `@JavascriptInterface` 只支持基本类型参数(String/int/boolean),无法直接传递 JS 函数对象。因此采用 "callbackId + 全局回调池" 模式:H5 侧生成唯一 callbackId 并注册回调函数到 `window.__robotCallbacks`,将 callbackId 字符串传给 Native;Native 处理完成后通过 `webView.evaluateJavascript()` 执行 `window.__robotCallbacks[callbackId](result)` 回推结果。
|
|
|
+
|
|
|
+**Android 端 Bridge 实现**:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+class RobotBridge(private val context: Context, private val webView: WebView) {
|
|
|
+
|
|
|
+ @JavascriptInterface
|
|
|
+ fun navigate(destination: String, callbackId: String) {
|
|
|
+ thread {
|
|
|
+ RobotApi.startNavigation(destination)
|
|
|
+ val result = JSONObject().apply {
|
|
|
+ put("code", 0)
|
|
|
+ put("msg", "navigation_started")
|
|
|
+ put("destination", destination)
|
|
|
+ }
|
|
|
+ webView.post {
|
|
|
+ webView.evaluateJavascript(
|
|
|
+ "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)",
|
|
|
+ null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @JavascriptInterface
|
|
|
+ fun getPlaceList(callbackId: String) {
|
|
|
+ thread {
|
|
|
+ val places = RobotApi.getPlaceList()
|
|
|
+ val result = JSONArray(places)
|
|
|
+ webView.post {
|
|
|
+ webView.evaluateJavascript(
|
|
|
+ "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)",
|
|
|
+ null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @JavascriptInterface
|
|
|
+ fun playTTS(text: String, callbackId: String) {
|
|
|
+ thread {
|
|
|
+ speechApi.playText(text)
|
|
|
+ val result = JSONObject().apply {
|
|
|
+ put("code", 0)
|
|
|
+ put("msg", "tts_started")
|
|
|
+ }
|
|
|
+ webView.post {
|
|
|
+ webView.evaluateJavascript(
|
|
|
+ "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)",
|
|
|
+ null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @JavascriptInterface
|
|
|
+ fun getBattery(callbackId: String) {
|
|
|
+ thread {
|
|
|
+ val level = RobotApi.getBatteryLevel()
|
|
|
+ val result = JSONObject().apply {
|
|
|
+ put("code", 0)
|
|
|
+ put("level", level)
|
|
|
+ }
|
|
|
+ webView.post {
|
|
|
+ webView.evaluateJavascript(
|
|
|
+ "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)",
|
|
|
+ null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 以上仅示例本期必须实现的核心接口,预留接口(stopNavigation、getPosition、stopTTS)遵循相同的 callbackId 协议模式,按需在后续迭代中实现。
|
|
|
+
|
|
|
+### 4.4 猎户星空 SDK 集成步骤
|
|
|
+
|
|
|
+1. **获取 AAR 包**:从猎户星空开发者平台下载 RobotOS SDK AAR,放入 `app/libs/`
|
|
|
+2. **build.gradle 配置**:
|
|
|
+
|
|
|
+```gradle
|
|
|
+dependencies {
|
|
|
+ implementation fileTree(dir: 'libs', include: ['*.aar'])
|
|
|
+ implementation 'com.orionstar:robot-sdk:1.x.x'
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+3. **AndroidManifest 声明权限**:
|
|
|
+
|
|
|
+```xml
|
|
|
+<uses-permission android:name="android.permission.INTERNET" />
|
|
|
+<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
|
+<uses-permission android:name="com.orionstar.robot.permission.ROBOT_CONTROL" />
|
|
|
+```
|
|
|
+
|
|
|
+4. **Application 中初始化**:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+class RobotApp : Application() {
|
|
|
+ override fun onCreate() {
|
|
|
+ super.onCreate()
|
|
|
+ RobotApi.init(this)
|
|
|
+ RobotApi.connectServer { status ->
|
|
|
+ Log.d("RobotSDK", "Server 连接状态: $status")
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+5. **导航状态监听**:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+RobotApi.setNavigationListener(object : NavigationListener {
|
|
|
+ override fun onStatus(statusCode: Int, data: String?) {
|
|
|
+ when (statusCode) {
|
|
|
+ 32730001 -> Log.d("NAV", "开始导航")
|
|
|
+ 32730004 -> Log.d("NAV", "避障中")
|
|
|
+ 32730011 -> Log.d("NAV", "堵死")
|
|
|
+ 32730009 -> Log.d("NAV", "定位丢失")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onResult(resultCode: Int, data: String?) {
|
|
|
+ when (resultCode) {
|
|
|
+ 32610007 -> Log.d("NAV", "到达目的地")
|
|
|
+ -32620001 -> Log.d("NAV", "未定位")
|
|
|
+ -32620009 -> Log.d("NAV", "路径规划失败")
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 4.5 参考的关键 API
|
|
|
+
|
|
|
+| API | 功能 | 所属模块 |
|
|
|
+|-----|------|---------|
|
|
|
+| `RobotApi.startNavigation(destination)` | 启动导航到指定位置点 | 导航 API |
|
|
|
+| `RobotApi.getPlaceList()` | 获取地图中所有位置点列表 | 地图与定位 |
|
|
|
+| `RobotApi.getPosition()` | 获取当前坐标 `{x, y, theta}` | 地图与定位 |
|
|
|
+| `RobotApi.getBatteryLevel()` | 获取电量百分比(1-100) | 电量控制 |
|
|
|
+| `speechApi.playText(text)` | TTS 语音播报 | 语音 API |
|
|
|
+| `speechApi.stopTTS()` | 停止 TTS 播放 | 语音 API |
|
|
|
+| `speechApi.setRecognizable(boolean)` | 开启/关闭语音识别 | 语音 API |
|
|
|
+| `NavigationComponent` | OPK 导航组件(备选方案) | OPK 插件 |
|
|
|
+
|
|
|
+### 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 14.0 (API 34)` —— 编译目标版本
|
|
|
+ - `Android 7.0 (API 24)` —— 最低支持版本(对应 minSdk)
|
|
|
+ - 在 **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**: `Kotlin`(现代 Android 开发首选语言,语法与 JavaScript/TypeScript 更接近)
|
|
|
+ - **Minimum SDK**: `API 24: Android 7.0 (Nougat)`(猎户星空机器人系统基于 Android 7.0+)
|
|
|
+ - **Build configuration language**: `Kotlin (DSL)`(使用 Kotlin 脚本配置 Gradle,类型更安全)
|
|
|
+4. 点击 **Finish**,等待 Gradle 首次同步完成(首次可能需要下载依赖,耗时 5-15 分钟)
|
|
|
+5. 同步完成后,左侧 Project 面板应显示工程结构,顶部工具栏出现绿色运行按钮,表示工程创建成功
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4.7 补充:完整的 build.gradle.kts(Module: app)
|
|
|
+
|
|
|
+> 以下文件位于 `MedicalRobotApp/app/build.gradle.kts`,是应用模块的构建配置。每一行都带有中文注释,说明其作用。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+/**
|
|
|
+ * app 模块构建配置文件
|
|
|
+ * 作用:定义编译版本、依赖库、构建变体等
|
|
|
+ * 位置:MedicalRobotApp/app/build.gradle.kts
|
|
|
+ */
|
|
|
+
|
|
|
+// 插件声明:应用 Android 应用程序插件和 Kotlin Android 插件
|
|
|
+plugins {
|
|
|
+ // Android 应用插件,提供构建 APK 的能力
|
|
|
+ alias(libs.plugins.android.application)
|
|
|
+ // Kotlin Android 插件,支持 Kotlin 语言编译
|
|
|
+ alias(libs.plugins.kotlin.android)
|
|
|
+}
|
|
|
+
|
|
|
+// Android 构建设置
|
|
|
+android {
|
|
|
+ // 命名空间:与 AndroidManifest.xml 中的 package 属性一致
|
|
|
+ // 用于生成 R 类和其他资源引用
|
|
|
+ namespace = "com.emoon.medical.robot"
|
|
|
+
|
|
|
+ // 编译 SDK 版本:使用 API 34(Android 14)进行编译
|
|
|
+ // 决定了可以使用的最新 Android API
|
|
|
+ compileSdk = 34
|
|
|
+
|
|
|
+ // 默认配置:所有构建变体共享的基础配置
|
|
|
+ defaultConfig {
|
|
|
+ // 应用包名,设备上唯一标识此应用
|
|
|
+ applicationId = "com.emoon.medical.robot"
|
|
|
+
|
|
|
+ // 最低支持的 Android 版本:API 24(Android 7.0)
|
|
|
+ // 低于此版本的设备无法安装此应用
|
|
|
+ minSdk = 24
|
|
|
+
|
|
|
+ // 目标 SDK 版本:API 34(Android 14)
|
|
|
+ // 表示应用已在此版本上充分测试,系统会启用该版本的行为特性
|
|
|
+ targetSdk = 34
|
|
|
+
|
|
|
+ // 版本号:内部版本标识,每次发布必须递增
|
|
|
+ versionCode = 1
|
|
|
+
|
|
|
+ // 版本名称:对外展示的用户友好版本号
|
|
|
+ versionName = "1.0.0"
|
|
|
+
|
|
|
+ // 测试运行器:使用 AndroidJUnit4 进行单元测试
|
|
|
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建类型配置
|
|
|
+ buildTypes {
|
|
|
+ // 发布(Release)构建配置
|
|
|
+ release {
|
|
|
+ // 是否启用代码压缩和混淆(发布时建议开启以减小体积)
|
|
|
+ isMinifyEnabled = false
|
|
|
+
|
|
|
+ // 混淆规则文件:proguard-rules.pro 中定义了保留哪些类不被混淆
|
|
|
+ proguardFiles(
|
|
|
+ getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
|
+ "proguard-rules.pro"
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调试(Debug)构建配置
|
|
|
+ debug {
|
|
|
+ // Debug 模式不启用混淆,方便调试和查看堆栈
|
|
|
+ isMinifyEnabled = false
|
|
|
+
|
|
|
+ // 开启 Debug 签名,无需手动配置签名密钥
|
|
|
+ isDebuggable = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 编译选项
|
|
|
+ compileOptions {
|
|
|
+ // 源码兼容性:Java 17
|
|
|
+ sourceCompatibility = JavaVersion.VERSION_17
|
|
|
+ // 目标兼容性:Java 17
|
|
|
+ targetCompatibility = JavaVersion.VERSION_17
|
|
|
+ }
|
|
|
+
|
|
|
+ // Kotlin 编译选项
|
|
|
+ kotlinOptions {
|
|
|
+ // JVM 目标版本:与 Java 版本保持一致
|
|
|
+ jvmTarget = "17"
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建特性开关
|
|
|
+ buildFeatures {
|
|
|
+ // 启用 ViewBinding:自动生成绑定类,替代 findViewById,类型安全且无需额外依赖
|
|
|
+ viewBinding = true
|
|
|
+ }
|
|
|
+
|
|
|
+ // 打包选项
|
|
|
+ packaging {
|
|
|
+ resources {
|
|
|
+ // 排除重复的资源文件,避免打包冲突
|
|
|
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 依赖声明:项目所需的外部库
|
|
|
+dependencies {
|
|
|
+ // ===== AndroidX 核心库 =====
|
|
|
+
|
|
|
+ // AppCompat 库:提供向后兼容的 ActionBar 和主题支持
|
|
|
+ // 即使 minSdk 是 24,也能使用新版 API 的兼容实现
|
|
|
+ implementation("androidx.appcompat:appcompat:1.6.1")
|
|
|
+
|
|
|
+ // Material Design 组件库:提供按钮、卡片、对话框等符合 Material 规范的 UI 组件
|
|
|
+ implementation("com.google.android.material:material:1.11.0")
|
|
|
+
|
|
|
+ // ConstraintLayout:灵活高效的布局容器,适合复杂界面
|
|
|
+ // 相比嵌套 LinearLayout,性能更好且更易于维护
|
|
|
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
|
|
+
|
|
|
+ // WebKit 库:提供增强型 WebView 支持
|
|
|
+ // 包含现代 WebView API,如 WebViewClient、WebChromeClient 等
|
|
|
+ implementation("androidx.webkit:webkit:1.9.0")
|
|
|
+
|
|
|
+ // Core KTX:Kotlin 扩展函数,简化 Android 核心 API 的调用
|
|
|
+ implementation("androidx.core:core-ktx:1.12.0")
|
|
|
+
|
|
|
+ // Activity KTX:Activity 相关的 Kotlin 扩展
|
|
|
+ implementation("androidx.activity:activity-ktx:1.8.0")
|
|
|
+
|
|
|
+ // Lifecycle 相关库:支持 ViewModel、LiveData 等生命周期感知组件
|
|
|
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
|
|
+
|
|
|
+ // ===== 猎户星空 RobotOS SDK =====
|
|
|
+
|
|
|
+ // 方式一:通过本地 AAR 文件引入(推荐,当前团队使用此方式)
|
|
|
+ // 将 robotos-sdk.aar 文件放入 app/libs/ 目录下,然后使用以下方式引入
|
|
|
+ implementation(files("libs/robotos-sdk.aar"))
|
|
|
+
|
|
|
+ // 方式二:通过 Maven 仓库引入(如果猎户星空后续开放 Maven 仓库)
|
|
|
+ // 取消下面一行的注释,并注释掉上面的 files 方式
|
|
|
+ // implementation("com.orionstar:robotos-sdk:2.x.x")
|
|
|
+
|
|
|
+ // ===== JSON 处理库 =====
|
|
|
+
|
|
|
+ // Gson:Google 的 JSON 序列化/反序列化库
|
|
|
+ // 用于 JSBridge 中 Native 与 H5 之间的 JSON 数据转换
|
|
|
+ implementation("com.google.code.gson:gson:2.10.1")
|
|
|
+
|
|
|
+ // ===== 测试库(开发阶段使用) =====
|
|
|
+
|
|
|
+ // JUnit 4:单元测试框架
|
|
|
+ testImplementation("junit:junit:4.13.2")
|
|
|
+
|
|
|
+ // AndroidX Test:Android 测试扩展库
|
|
|
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
|
|
+
|
|
|
+ // Espresso:UI 自动化测试框架
|
|
|
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **说明**:`libs/robotos-sdk.aar` 文件需要从猎户星空开发者平台获取。将 AAR 文件放入 `app/libs/` 目录后,Gradle 会自动识别。如果 SDK 有 Maven 仓库,可切换到方式二,移除本地 AAR 依赖。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4.8 补充:完整的 AndroidManifest.xml
|
|
|
+
|
|
|
+> 以下文件位于 `MedicalRobotApp/app/src/main/AndroidManifest.xml`,是 Android 应用的配置文件。声明了应用组件、权限、主题等核心信息。每行都有中文注释。
|
|
|
+
|
|
|
+```xml
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- AndroidManifest.xml:Android 应用的入口配置文件 -->
|
|
|
+<!-- 作用:声明应用包名、组件(Activity/Service/BroadcastReceiver)、权限、主题等 -->
|
|
|
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ xmlns:tools="http://schemas.android.com/tools">
|
|
|
+
|
|
|
+ <!-- ===== 权限声明 ===== -->
|
|
|
+ <!-- 权限分为普通权限(安装时自动授予)和危险权限(运行时需动态申请) -->
|
|
|
+
|
|
|
+ <!-- INTERNET:访问网络的权限,普通权限
|
|
|
+ 用途:WebView 加载 H5 页面、调用后端 API、与猎户星空云服务通信 -->
|
|
|
+ <uses-permission android:name="android.permission.INTERNET" />
|
|
|
+
|
|
|
+ <!-- ACCESS_NETWORK_STATE:获取网络连接状态,普通权限
|
|
|
+ 用途:判断当前是否有网络连接,用于离线提示和重试逻辑 -->
|
|
|
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
|
+
|
|
|
+ <!-- RECORD_AUDIO:录制音频,危险权限(Android 6.0+ 需运行时动态申请)
|
|
|
+ 用途:语音对话功能,采集用户语音并发送给语音识别服务 -->
|
|
|
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
|
+
|
|
|
+ <!-- CAMERA:访问摄像头,危险权限(运行时动态申请)
|
|
|
+ 用途:可选,用于身份证 OCR 识别、舌诊拍照等功能
|
|
|
+ 如果 H5 页面通过 WebView 调用相机,也需要此权限 -->
|
|
|
+ <uses-permission android:name="android.permission.CAMERA" />
|
|
|
+
|
|
|
+ <!-- WRITE_EXTERNAL_STORAGE:写入外部存储,危险权限
|
|
|
+ 用途:保存图片、日志文件等
|
|
|
+ Android 10+ 推荐使用 Scoped Storage(分区存储)替代 -->
|
|
|
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
|
+ android:maxSdkVersion="28" />
|
|
|
+
|
|
|
+ <!-- READ_EXTERNAL_STORAGE:读取外部存储,危险权限
|
|
|
+ 用途:读取已保存的文件、相册图片等 -->
|
|
|
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
|
+ android:maxSdkVersion="28" />
|
|
|
+
|
|
|
+ <!-- FOREGROUND_SERVICE:前台服务权限(Android 9.0+ 需要)
|
|
|
+ 用途:如果 SDK 需要在后台保持连接,可能用到前台服务 -->
|
|
|
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
|
+
|
|
|
+ <!-- WAKE_LOCK:保持屏幕唤醒,普通权限
|
|
|
+ 用途:迎检演示时防止屏幕自动熄灭 -->
|
|
|
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
|
+
|
|
|
+ <!-- ===== 应用配置 ===== -->
|
|
|
+
|
|
|
+ <application
|
|
|
+ <!-- 应用名称:显示在系统设置中的应用列表中 -->
|
|
|
+ android:label="@string/app_name"
|
|
|
+
|
|
|
+ <!-- 应用图标:显示在桌面和任务列表中 -->
|
|
|
+ android:icon="@mipmap/ic_launcher"
|
|
|
+
|
|
|
+ <!-- 应用简介:长文本描述 -->
|
|
|
+ android:description="@string/app_description"
|
|
|
+
|
|
|
+ <!-- 自定义 Application 类:用于全局初始化和 SDK 连接管理
|
|
|
+ 必须在 java/com/emoon/medical/robot/ 目录下存在同名类文件 -->
|
|
|
+ android:name=".MedicalRobotApplication"
|
|
|
+
|
|
|
+ <!-- 应用主题:全屏无标题栏主题,适合机器人 kiosk 模式
|
|
|
+ 在 res/values/themes.xml 中定义 -->
|
|
|
+ android:theme="@style/Theme.MedicalRobotApp.Fullscreen"
|
|
|
+
|
|
|
+ <!-- 允许明文 HTTP 通信:true 表示允许访问 HTTP(非 HTTPS)URL
|
|
|
+ 迎检环境为内网,后端可能未配置 SSL,因此需要开启
|
|
|
+ 生产环境部署到公网时,应关闭此选项并强制使用 HTTPS -->
|
|
|
+ android:usesCleartextTraffic="true"
|
|
|
+
|
|
|
+ <!-- 网络安全配置文件:自定义网络安全策略
|
|
|
+ 用于配置证书信任、明文通信域名白名单等 -->
|
|
|
+ android:networkSecurityConfig="@xml/network_security_config"
|
|
|
+
|
|
|
+ <!-- 支持从其他应用打开此应用的数据文件(如 content:// URI) -->
|
|
|
+ android:allowBackup="false"
|
|
|
+
|
|
|
+ <!-- 是否支持提取原生库(so 文件)到文件系统 -->
|
|
|
+ android:extractNativeLibs="true"
|
|
|
+
|
|
|
+ <!-- 请求的最大内存:WebView 渲染复杂 H5 页面需要较大内存 -->
|
|
|
+ android:largeHeap="true"
|
|
|
+
|
|
|
+ <!-- 工具命名空间:用于覆盖库中的属性 -->
|
|
|
+ tools:targetApi="34">
|
|
|
+
|
|
|
+ <!-- ===== Activity 声明 ===== -->
|
|
|
+ <!-- Activity 是 Android 的界面容器,每个可见页面都是一个 Activity -->
|
|
|
+
|
|
|
+ <!-- MainActivity:主界面,WebView 容器
|
|
|
+ 这是用户打开应用后看到的第一个页面 -->
|
|
|
+ <activity
|
|
|
+ <!-- Activity 的完整类名(相对于包名) -->
|
|
|
+ android:name=".MainActivity"
|
|
|
+
|
|
|
+ <!-- 是否导出:false 表示不允许其他应用直接启动此 Activity
|
|
|
+ 提高安全性,防止恶意应用劫持 -->
|
|
|
+ android:exported="false"
|
|
|
+
|
|
|
+ <!-- 屏幕方向:landscape 表示强制横屏
|
|
|
+ 机器人屏幕为横屏,固定方向可避免布局错乱 -->
|
|
|
+ android:screenOrientation="landscape"
|
|
|
+
|
|
|
+ <!-- 启动模式:singleTask 确保只有一个实例存在
|
|
|
+ 从 Launcher 返回时不会创建新实例 -->
|
|
|
+ android:launchMode="singleTask"
|
|
|
+
|
|
|
+ <!-- 配置变更处理:当屏幕方向或键盘状态改变时,不重建 Activity
|
|
|
+ 避免 WebView 页面重新加载导致状态丢失 -->
|
|
|
+ android:configChanges="orientation|screenSize|keyboardHidden">
|
|
|
+
|
|
|
+ <!-- Intent Filter:定义此 Activity 如何被启动 -->
|
|
|
+ <intent-filter>
|
|
|
+ <!-- MAIN:标记此 Activity 为应用入口 -->
|
|
|
+ <action android:name="android.intent.action.MAIN" />
|
|
|
+ <!-- LAUNCHER:在应用启动器中显示图标,用户点击后启动此 Activity -->
|
|
|
+ <category android:name="android.intent.category.LAUNCHER" />
|
|
|
+ </intent-filter>
|
|
|
+ </activity>
|
|
|
+
|
|
|
+ </application>
|
|
|
+
|
|
|
+</manifest>
|
|
|
+```
|
|
|
+
|
|
|
+> **安全提示**:`android:usesCleartextTraffic="true"` 仅适用于内网迎检环境。若后续部署到公网,应移除此属性或配合 `network_security_config.xml` 配置域名白名单,强制使用 HTTPS 通信。
|
|
|
+
|
|
|
+### 4.9 补充:完整的工程目录结构
|
|
|
+
|
|
|
+> 以下是创建完成后的完整工程目录结构,帮助 Web 开发者快速理解 Android 项目的组织方式。
|
|
|
+
|
|
|
+```
|
|
|
+MedicalRobotApp/ // 项目根目录
|
|
|
+├── app/ // 应用模块(核心代码都在这里)
|
|
|
+│ ├── src/main/ // 主源码目录(还有 test/ 和 androidTest/ 用于测试)
|
|
|
+│ │ ├── java/com/emoon/medical/robot/ // Kotlin/Java 源码根目录,按包名层级组织
|
|
|
+│ │ │ ├── MedicalRobotApplication.kt // Application 类:全局初始化、SDK 连接
|
|
|
+│ │ │ ├── MainActivity.kt // 主 Activity:WebView 容器,唯一可见界面
|
|
|
+│ │ │ ├── bridge/ // JSBridge 相关代码
|
|
|
+│ │ │ │ └── RobotBridge.kt // JSBridge 实现:Native 与 H5 的通信桥梁
|
|
|
+│ │ │ └── sdk/ // SDK 封装层
|
|
|
+│ │ │ └── RobotSDKManager.kt // SDK 管理器:封装猎户星空 API,统一错误处理
|
|
|
+│ │ ├── 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/ // 本地依赖库目录
|
|
|
+│ │ └── robotos-sdk.aar // 猎户星空 RobotOS SDK(从官方获取)
|
|
|
+│ └── build.gradle.kts // 模块级构建配置(依赖、编译选项)
|
|
|
+├── build.gradle.kts // 项目级构建配置(Gradle 插件版本)
|
|
|
+├── settings.gradle.kts // 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.kts` 相当于 `package.json` + `webpack.config.js`(依赖和构建配置)
|
|
|
+> - `AndroidManifest.xml` 相当于 `web.xml` 或应用入口配置
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4.10 补充:MedicalRobotApplication.kt 完整代码
|
|
|
+
|
|
|
+> Application 类是 Android 应用的全局入口,在应用启动时第一个被初始化。这里负责初始化猎户星空 SDK 和建立与机器人系统服务的连接。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.medical.robot
|
|
|
+
|
|
|
+import android.app.Application
|
|
|
+import android.util.Log
|
|
|
+
|
|
|
+/**
|
|
|
+ * 应用全局初始化类
|
|
|
+ * 职责:
|
|
|
+ * 1. 初始化猎户星空 RobotOS SDK
|
|
|
+ * 2. 建立与机器人系统服务的连接
|
|
|
+ * 3. 管理全局状态(SDK 连接状态、Mock 模式开关等)
|
|
|
+ *
|
|
|
+ * 生命周期:
|
|
|
+ * - 应用进程启动时,系统首先创建此类的实例并调用 onCreate()
|
|
|
+ * - 在应用运行期间保持单例,直到进程被杀死
|
|
|
+ * - 任何 Activity、Service 都可以通过 (application as MedicalRobotApplication) 访问
|
|
|
+ *
|
|
|
+ * 注意:必须在 AndroidManifest.xml 的 <application> 标签中通过 android:name 属性声明此类,
|
|
|
+ * 否则系统不会调用它。
|
|
|
+ */
|
|
|
+class MedicalRobotApplication : Application() {
|
|
|
+
|
|
|
+ // ===== Companion Object:Kotlin 的静态成员替代方案 =====
|
|
|
+ // 用于存放全局可访问的静态常量和变量
|
|
|
+ companion object {
|
|
|
+ // 日志标签:所有此类相关的日志都使用此标签,方便在 logcat 中过滤
|
|
|
+ const val TAG = "MedicalRobot"
|
|
|
+
|
|
|
+ // 全局 SDK 管理器实例
|
|
|
+ // lateinit 表示延迟初始化,在 onCreate() 中赋值
|
|
|
+ // private set 表示外部只能读取不能修改
|
|
|
+ lateinit var sdkManager: RobotSDKManager
|
|
|
+ private set
|
|
|
+
|
|
|
+ // Mock 模式开关:true 表示使用模拟数据(无需真机即可开发调试)
|
|
|
+ // false 表示调用真实 SDK(需要部署到机器人真机)
|
|
|
+ // 开发阶段建议设为 true,联调阶段设为 false
|
|
|
+ var useMockMode: Boolean = true
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 应用创建时的初始化方法
|
|
|
+ * 系统回调:应用进程启动后第一个被调用的方法
|
|
|
+ * 注意:此方法执行时间过长会阻塞应用启动,因此只应做轻量级初始化
|
|
|
+ */
|
|
|
+ override fun onCreate() {
|
|
|
+ // 调用父类实现,确保框架级初始化正常完成
|
|
|
+ super.onCreate()
|
|
|
+
|
|
|
+ // 输出应用启动日志,方便在 logcat 中确认初始化流程
|
|
|
+ Log.i(TAG, "========================================")
|
|
|
+ Log.i(TAG, "应用启动,开始初始化...")
|
|
|
+ Log.i(TAG, "Mock 模式: $useMockMode")
|
|
|
+ Log.i(TAG, "========================================")
|
|
|
+
|
|
|
+ // 初始化 SDK 管理器,传入 Application 上下文
|
|
|
+ // 上下文(Context)是 Android 中访问系统资源和服务的关键对象
|
|
|
+ sdkManager = RobotSDKManager(this)
|
|
|
+
|
|
|
+ // 执行 SDK 初始化(连接机器人系统服务)
|
|
|
+ // 如果是 Mock 模式,初始化会快速完成并返回模拟的连接成功状态
|
|
|
+ sdkManager.initialize()
|
|
|
+
|
|
|
+ Log.i(TAG, "应用初始化完成")
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 应用终止时的清理方法
|
|
|
+ * 系统回调:应用进程即将被杀死时调用(不保证一定被调用)
|
|
|
+ * 用于释放资源、断开连接等清理操作
|
|
|
+ */
|
|
|
+ override fun onTerminate() {
|
|
|
+ Log.i(TAG, "应用终止,执行清理...")
|
|
|
+ // 断开 SDK 连接,释放资源
|
|
|
+ sdkManager.release()
|
|
|
+ // 调用父类实现
|
|
|
+ super.onTerminate()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 内存不足时的回调
|
|
|
+ * 系统回调:系统内存紧张时调用
|
|
|
+ * 应在此释放不必要的缓存和资源
|
|
|
+ */
|
|
|
+ override fun onLowMemory() {
|
|
|
+ Log.w(TAG, "系统内存不足,释放资源...")
|
|
|
+ super.onLowMemory()
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **Web 开发者提示**:`Application` 类类似于 Spring Boot 的 `@SpringBootApplication` 主类,是全局配置的入口。`onCreate()` 类似于 Spring 的 `CommandLineRunner` 或 `@PostConstruct` 方法。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4.11 补充:RobotSDKManager.kt 完整代码
|
|
|
+
|
|
|
+> SDK 管理封装类:统一封装猎户星空 RobotOS SDK 的所有 API 调用,对外提供简洁的接口,内部处理连接管理、错误处理和 Mock 模式切换。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+package com.emoon.medical.robot
|
|
|
+
|
|
|
+import android.content.Context
|
|
|
+import android.util.Log
|
|
|
+import org.json.JSONArray
|
|
|
+import org.json.JSONObject
|
|
|
+
|
|
|
+/**
|
|
|
+ * 猎户星空 RobotOS SDK 管理封装类
|
|
|
+ * 职责:
|
|
|
+ * 1. 封装 SDK 的初始化和连接管理
|
|
|
+ * 2. 提供简洁的业务 API(导航、TTS、位置查询、电量等)
|
|
|
+ * 3. 统一管理回调接口,将 SDK 的异步结果转换为业务友好的回调
|
|
|
+ * 4. 支持 Mock 模式,在没有真机时返回模拟数据
|
|
|
+ *
|
|
|
+ * 设计模式:外观模式(Facade Pattern),对外隐藏 SDK 的复杂调用细节
|
|
|
+ */
|
|
|
+class RobotSDKManager(private val context: Context) {
|
|
|
+
|
|
|
+ // 日志标签
|
|
|
+ private val tag = "RobotSDKManager"
|
|
|
+
|
|
|
+ // ===== Mock 模式标志 =====
|
|
|
+ // 通过 MedicalRobotApplication.useMockMode 统一控制
|
|
|
+ private val isMock: Boolean
|
|
|
+ get() = MedicalRobotApplication.useMockMode
|
|
|
+
|
|
|
+ // ===== 连接状态 =====
|
|
|
+ // 记录 SDK 与机器人系统服务的连接状态
|
|
|
+ private var isConnected = false
|
|
|
+
|
|
|
+ // ===== 回调接口定义 =====
|
|
|
+ // 使用接口(Interface)定义回调规范,调用方实现此接口接收结果
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用操作回调接口
|
|
|
+ * 适用于导航、TTS 等只需要知道成功/失败的操作
|
|
|
+ */
|
|
|
+ interface OperationCallback {
|
|
|
+ /**
|
|
|
+ * 操作成功时调用
|
|
|
+ * @param data 可选的返回数据(JSON 格式字符串)
|
|
|
+ */
|
|
|
+ fun onSuccess(data: String = "{}")
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 操作失败时调用
|
|
|
+ * @param code 错误码
|
|
|
+ * @param message 错误描述
|
|
|
+ */
|
|
|
+ fun onError(code: Int, message: String)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导航状态监听接口
|
|
|
+ * 用于接收导航过程中的实时状态更新(开始导航、避障、堵死、到达等)
|
|
|
+ */
|
|
|
+ interface NavigationListener {
|
|
|
+ /**
|
|
|
+ * 导航状态变化时调用
|
|
|
+ * @param statusCode 状态码(如 32730001 表示开始导航)
|
|
|
+ * @param data 附加数据
|
|
|
+ */
|
|
|
+ fun onStatus(statusCode: Int, data: String?)
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导航结果回调
|
|
|
+ * @param resultCode 结果码(如 32610007 表示到达目的地)
|
|
|
+ * @param data 附加数据
|
|
|
+ */
|
|
|
+ fun onResult(resultCode: Int, data: String?)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 位置信息数据类
|
|
|
+ * 封装机器人的当前坐标和定位状态
|
|
|
+ */
|
|
|
+ data class Position(
|
|
|
+ val x: Double, // X 坐标(地图坐标系,单位:米)
|
|
|
+ val y: Double, // Y 坐标
|
|
|
+ val theta: Double, // 朝向角度(弧度,0 表示正东方向)
|
|
|
+ val isEstimated: Boolean // 是否已完成定位
|
|
|
+ )
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 位置点数据类
|
|
|
+ * 封装地图中预设的导航目标点信息
|
|
|
+ */
|
|
|
+ data class Place(
|
|
|
+ val name: String, // 位置点名称(如 "导诊台"、"神经内科")
|
|
|
+ val x: Double, // X 坐标
|
|
|
+ val y: Double, // Y 坐标
|
|
|
+ val theta: Double // 到达后的朝向角度
|
|
|
+ )
|
|
|
+
|
|
|
+ // ===== 初始化与连接管理 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化 SDK 并建立与机器人系统服务的连接
|
|
|
+ * 应在 Application.onCreate() 中调用
|
|
|
+ */
|
|
|
+ fun initialize() {
|
|
|
+ if (isMock) {
|
|
|
+ // Mock 模式:模拟初始化成功,无需连接真实服务
|
|
|
+ Log.i(tag, "[Mock] SDK 初始化成功(模拟模式)")
|
|
|
+ isConnected = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 真实 SDK 初始化路径
|
|
|
+ try {
|
|
|
+ Log.i(tag, "开始初始化猎户星空 SDK...")
|
|
|
+
|
|
|
+ // 初始化 RobotApi,传入 Application Context
|
|
|
+ // RobotApi.init(context)
|
|
|
+
|
|
|
+ // 连接 SDK Server(机器人系统后台服务)
|
|
|
+ // RobotApi.connectServer { status ->
|
|
|
+ // Log.i(tag, "SDK Server 连接状态: $status")
|
|
|
+ // isConnected = status == 1 // 1 表示连接成功
|
|
|
+ // }
|
|
|
+
|
|
|
+ // 设置导航状态监听
|
|
|
+ // RobotApi.setNavigationListener(object : NavigationListener { ... })
|
|
|
+
|
|
|
+ Log.i(tag, "SDK 初始化完成,等待连接...")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "SDK 初始化失败: ${e.message}", e)
|
|
|
+ isConnected = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 释放 SDK 资源,断开连接
|
|
|
+ * 应在 Application.onTerminate() 或 Activity.onDestroy() 中调用
|
|
|
+ */
|
|
|
+ fun release() {
|
|
|
+ if (isMock) {
|
|
|
+ Log.i(tag, "[Mock] SDK 资源已释放")
|
|
|
+ isConnected = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // RobotApi.disconnectServer()
|
|
|
+ isConnected = false
|
|
|
+ Log.i(tag, "SDK 连接已断开")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "释放 SDK 资源失败: ${e.message}", e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取当前 SDK 连接状态
|
|
|
+ * @return true 表示已连接,false 表示未连接
|
|
|
+ */
|
|
|
+ fun isConnected(): Boolean = isConnected
|
|
|
+
|
|
|
+ // ===== 导航 API =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动导航到指定位置点
|
|
|
+ * @param destination 目标位置点名称(如 "导诊台"、"神经内科")
|
|
|
+ * @param callback 操作结果回调
|
|
|
+ */
|
|
|
+ fun startNavigation(destination: String, callback: OperationCallback) {
|
|
|
+ Log.i(tag, "开始导航到: $destination")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ // Mock 模式:模拟导航成功,延迟 500ms 后回调
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ Log.i(tag, "[Mock] 导航到 [$destination] 成功")
|
|
|
+ callback.onSuccess(
|
|
|
+ JSONObject().apply {
|
|
|
+ put("destination", destination)
|
|
|
+ put("mock", true)
|
|
|
+ }.toString()
|
|
|
+ )
|
|
|
+ }, 500)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 真实 SDK 调用路径
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK 导航 API
|
|
|
+ // RobotApi.startNavigation(destination)
|
|
|
+
|
|
|
+ // 设置导航结果监听
|
|
|
+ // RobotApi.setNavigationListener(object : NavigationListener {
|
|
|
+ // override fun onResult(resultCode: Int, data: String?) {
|
|
|
+ // when (resultCode) {
|
|
|
+ // 32610007 -> callback.onSuccess() // 到达目的地
|
|
|
+ // -32620001 -> callback.onError(resultCode, "未定位")
|
|
|
+ // -32620009 -> callback.onError(resultCode, "路径规划失败")
|
|
|
+ // else -> callback.onError(resultCode, "导航失败: $data")
|
|
|
+ // }
|
|
|
+ // }
|
|
|
+ // })
|
|
|
+
|
|
|
+ callback.onSuccess()
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "导航调用异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "导航异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止当前导航
|
|
|
+ * @param callback 操作结果回调
|
|
|
+ */
|
|
|
+ fun stopNavigation(callback: OperationCallback) {
|
|
|
+ Log.i(tag, "停止导航")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ Log.i(tag, "[Mock] 导航已停止")
|
|
|
+ callback.onSuccess()
|
|
|
+ }, 200)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK 停止运动 API
|
|
|
+ // RobotApi.stopMove()
|
|
|
+ callback.onSuccess()
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "停止导航异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "停止导航异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 位置与地图 API =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取地图中所有预设位置点列表
|
|
|
+ * @param callback 结果回调,返回位置点列表的 JSON 字符串
|
|
|
+ */
|
|
|
+ fun getPlaceList(callback: OperationCallback) {
|
|
|
+ Log.i(tag, "获取位置点列表")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ // Mock 模式:返回模拟的医院位置点数据
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ val mockPlaces = JSONArray().apply {
|
|
|
+ put(JSONObject().apply {
|
|
|
+ put("name", "导诊台")
|
|
|
+ put("x", 1.5)
|
|
|
+ put("y", 2.0)
|
|
|
+ put("theta", 1.57)
|
|
|
+ })
|
|
|
+ put(JSONObject().apply {
|
|
|
+ put("name", "神经内科")
|
|
|
+ put("x", 5.2)
|
|
|
+ put("y", 3.8)
|
|
|
+ put("theta", 0.0)
|
|
|
+ })
|
|
|
+ put(JSONObject().apply {
|
|
|
+ put("name", "心血管内科")
|
|
|
+ put("x", 5.2)
|
|
|
+ put("y", 6.5)
|
|
|
+ put("theta", 0.0)
|
|
|
+ })
|
|
|
+ put(JSONObject().apply {
|
|
|
+ put("name", "检验科")
|
|
|
+ put("x", 8.0)
|
|
|
+ put("y", 2.0)
|
|
|
+ put("theta", -1.57)
|
|
|
+ })
|
|
|
+ put(JSONObject().apply {
|
|
|
+ put("name", "药房")
|
|
|
+ put("x", 10.5)
|
|
|
+ put("y", 5.0)
|
|
|
+ put("theta", 3.14)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ Log.i(tag, "[Mock] 返回 ${mockPlaces.length()} 个位置点")
|
|
|
+ callback.onSuccess(mockPlaces.toString())
|
|
|
+ }, 300)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK 获取位置点列表
|
|
|
+ // val places = RobotApi.getPlaceList()
|
|
|
+ // callback.onSuccess(JSONArray(places).toString())
|
|
|
+ callback.onSuccess("[]")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "获取位置点列表异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "获取位置点列表异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取机器人当前位置坐标
|
|
|
+ * @param callback 结果回调,返回坐标 JSON 字符串
|
|
|
+ */
|
|
|
+ fun getPosition(callback: OperationCallback) {
|
|
|
+ Log.i(tag, "获取当前位置")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ val mockPosition = JSONObject().apply {
|
|
|
+ put("x", 3.5)
|
|
|
+ put("y", 4.2)
|
|
|
+ put("theta", 0.78)
|
|
|
+ put("isEstimated", true)
|
|
|
+ }
|
|
|
+ Log.i(tag, "[Mock] 当前位置: (3.5, 4.2)")
|
|
|
+ callback.onSuccess(mockPosition.toString())
|
|
|
+ }, 200)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK 获取当前位置
|
|
|
+ // val pos = RobotApi.getPosition()
|
|
|
+ // val isEstimated = RobotApi.isRobotEstimate()
|
|
|
+ // val result = JSONObject().apply {
|
|
|
+ // put("x", pos.x)
|
|
|
+ // put("y", pos.y)
|
|
|
+ // put("theta", pos.theta)
|
|
|
+ // put("isEstimated", isEstimated)
|
|
|
+ // }
|
|
|
+ // callback.onSuccess(result.toString())
|
|
|
+ callback.onSuccess("{}")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "获取位置异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "获取位置异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查机器人是否已完成定位
|
|
|
+ * @param callback 结果回调,返回 JSON 字符串 {"isEstimated": true/false}
|
|
|
+ */
|
|
|
+ fun isRobotEstimate(callback: OperationCallback) {
|
|
|
+ if (isMock) {
|
|
|
+ callback.onSuccess("{\"isEstimated\": true}")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // val result = RobotApi.isRobotEstimate()
|
|
|
+ // callback.onSuccess("{\"isEstimated\": $result}")
|
|
|
+ callback.onSuccess("{\"isEstimated\": false}")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ callback.onError(-2, "检查定位状态异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== TTS 语音 API =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 播放 TTS 语音播报
|
|
|
+ * @param text 要播报的文本内容
|
|
|
+ * @param callback 操作结果回调
|
|
|
+ */
|
|
|
+ fun playTTS(text: String, callback: OperationCallback) {
|
|
|
+ Log.i(tag, "TTS 播报: $text")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ Log.i(tag, "[Mock] TTS 播报完成: $text")
|
|
|
+ callback.onSuccess(
|
|
|
+ JSONObject().apply {
|
|
|
+ put("text", text)
|
|
|
+ put("mock", true)
|
|
|
+ }.toString()
|
|
|
+ )
|
|
|
+ }, text.length * 200L) // 模拟朗读时间:每个字 200ms
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK TTS API
|
|
|
+ // speechApi.playText(text)
|
|
|
+ callback.onSuccess()
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "TTS 播报异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "TTS 播报异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止 TTS 语音播报
|
|
|
+ * @param callback 操作结果回调
|
|
|
+ */
|
|
|
+ fun stopTTS(callback: OperationCallback) {
|
|
|
+ Log.i(tag, "停止 TTS")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ callback.onSuccess()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // speechApi.stopTTS()
|
|
|
+ callback.onSuccess()
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "停止 TTS 异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "停止 TTS 异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 电量 API =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取机器人当前电量
|
|
|
+ * @param callback 结果回调,返回 JSON 字符串 {"level": 85}
|
|
|
+ */
|
|
|
+ fun getBatteryLevel(callback: OperationCallback) {
|
|
|
+ Log.i(tag, "获取电量")
|
|
|
+
|
|
|
+ if (isMock) {
|
|
|
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
|
+ val mockLevel = 85 // 模拟电量 85%
|
|
|
+ Log.i(tag, "[Mock] 当前电量: $mockLevel%")
|
|
|
+ callback.onSuccess("{\"level\": $mockLevel}")
|
|
|
+ }, 100)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!isConnected) {
|
|
|
+ callback.onError(-1, "SDK 未连接")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用猎户星空 SDK 电量 API
|
|
|
+ // val level = RobotApi.getBatteryLevel()
|
|
|
+ // callback.onSuccess("{\"level\": $level}")
|
|
|
+ callback.onSuccess("{\"level\": 0}")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Log.e(tag, "获取电量异常: ${e.message}", e)
|
|
|
+ callback.onError(-2, "获取电量异常: ${e.message}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **Mock 数据说明**:`getPlaceList()` 返回 5 个模拟医院位置点(导诊台、神经内科、心血管内科、检验科、药房);`getPosition()` 返回固定坐标 (3.5, 4.2);`getBatteryLevel()` 返回固定电量 85%;`playTTS()` 按字数模拟延迟。开发者在 PC 模拟器上运行即可看到完整的业务流程。
|
|
|
+
|
|
|
+### 4.12 补充:MainActivity.kt 完整代码(WebView 容器)
|
|
|
+
|
|
|
+> MainActivity 是应用的唯一直接可见界面,本质上是一个全屏的 WebView 容器,负责加载 H5 页面并桥接 Native 能力。以下代码完整可用,每行都有中文注释。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+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 概念:页面创建 → 可见 → 可交互 → 后台 → 销毁
|
|
|
+ */
|
|
|
+class MainActivity : AppCompatActivity() {
|
|
|
+
|
|
|
+ // ===== 视图组件声明 =====
|
|
|
+ // lateinit 表示延迟初始化,在 onCreate() 中通过 findViewById 赋值
|
|
|
+
|
|
|
+ // WebView:Android 内置浏览器组件,用于加载和显示 H5 页面
|
|
|
+ private lateinit var webView: WebView
|
|
|
+
|
|
|
+ // ProgressBar:页面加载时的进度指示器(转圈动画)
|
|
|
+ private lateinit var progressBar: ProgressBar
|
|
|
+
|
|
|
+ // RobotBridge:JSBridge 实例,负责 Native 与 H5 之间的通信
|
|
|
+ private lateinit var robotBridge: RobotBridge
|
|
|
+
|
|
|
+ // 页面加载失败标志:用于记录当前是否处于错误状态
|
|
|
+ private var hasLoadError = false
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Activity 创建时调用(系统回调)
|
|
|
+ * 这是设置布局、初始化组件的核心方法
|
|
|
+ */
|
|
|
+ @SuppressLint("SetJavaScriptEnabled") // 抑制 "启用 JavaScript 可能有安全风险" 的编译器警告
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
+ // 调用父类实现,确保框架级初始化完成
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
+
|
|
|
+ // ===== 第 1 步:全屏沉浸式设置 =====
|
|
|
+ // 隐藏状态栏和导航栏,提供 kiosk 模式体验
|
|
|
+ hideSystemUI()
|
|
|
+
|
|
|
+ // 强制横屏:机器人屏幕为横屏,锁定方向防止旋转
|
|
|
+ requestedOrientation = 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 fun hideSystemUI() {
|
|
|
+ // 如果 Android 版本 >= 11(API 30),使用新的 WindowInsetsController API
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
|
+ // 设置窗口为全屏布局模式,内容延伸到状态栏和导航栏下方
|
|
|
+ window.setDecorFitsSystemWindows(false)
|
|
|
+
|
|
|
+ // 获取窗口的 Insets 控制器,控制系统栏的显示/隐藏
|
|
|
+ window.insetsController?.let { controller ->
|
|
|
+ // 隐藏状态栏(显示时间、电量等系统信息的顶部栏)
|
|
|
+ controller.hide(WindowInsets.Type.statusBars())
|
|
|
+ // 隐藏导航栏(底部的返回/主页/多任务键)
|
|
|
+ controller.hide(WindowInsets.Type.navigationBars())
|
|
|
+ // 设置系统栏行为:用户交互时自动隐藏(防止用户滑动调出导航栏)
|
|
|
+ controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Android 10 及以下版本使用传统的 systemUiVisibility 标志
|
|
|
+ @Suppress("DEPRECATION")
|
|
|
+ window.decorView.systemUiVisibility = (
|
|
|
+ // 全屏模式:内容延伸到状态栏后面
|
|
|
+ View.SYSTEM_UI_FLAG_FULLSCREEN
|
|
|
+ // 隐藏导航栏
|
|
|
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
|
+ // 沉浸式模式:用户交互后仍保持隐藏
|
|
|
+ or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
|
|
+ // 内容延伸到导航栏后面
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
|
+ // 内容延伸到状态栏后面
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
|
+ // 保持布局稳定,防止系统栏显示/隐藏时内容跳动
|
|
|
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保持屏幕常亮:迎检演示时防止屏幕自动熄灭
|
|
|
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 配置 WebView 的各项设置
|
|
|
+ * WebView 是 Android 内置的浏览器引擎(基于 Chromium),用于渲染 H5 页面
|
|
|
+ */
|
|
|
+ @SuppressLint("SetJavaScriptEnabled")
|
|
|
+ private fun setupWebView() {
|
|
|
+ webView.settings.apply {
|
|
|
+ // ===== JavaScript 支持 =====
|
|
|
+ // 启用 JavaScript:Vue 等现代前端框架需要 JS 才能运行,必须开启
|
|
|
+ javaScriptEnabled = true
|
|
|
+
|
|
|
+ // 启用 DOM Storage(Web Storage API):Vuex/Pinia 等状态管理库依赖此特性
|
|
|
+ domStorageEnabled = true
|
|
|
+
|
|
|
+ // 启用数据库存储:部分 H5 应用使用 Web SQL 或 IndexedDB
|
|
|
+ databaseEnabled = true
|
|
|
+
|
|
|
+ // 允许文件访问:WebView 可以加载本地文件(如 assets 中的资源)
|
|
|
+ allowFileAccess = true
|
|
|
+
|
|
|
+ // 允许内容访问:WebView 可以访问 ContentProvider 提供的内容
|
|
|
+ allowContentAccess = true
|
|
|
+
|
|
|
+ // 允许从文件 URL 访问其他文件 URL:本地 H5 页面可能需要加载本地其他资源
|
|
|
+ allowFileAccessFromFileURLs = true
|
|
|
+
|
|
|
+ // 允许从文件 URL 访问任意来源:本地页面可能需要访问网络资源
|
|
|
+ allowUniversalAccessFromFileURLs = true
|
|
|
+
|
|
|
+ // 允许自动播放媒体(音频/视频):语音播报功能需要自动播放
|
|
|
+ mediaPlaybackRequiresUserGesture = false
|
|
|
+
|
|
|
+ // 允许混合内容(HTTP + HTTPS):内网环境可能同时存在两种协议
|
|
|
+ mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
|
+
|
|
|
+ // 使用宽视口:H5 页面可以设置自己的 viewport,WebView 按页面要求渲染
|
|
|
+ useWideViewPort = true
|
|
|
+
|
|
|
+ // 以概览模式加载页面:页面宽度适配屏幕宽度
|
|
|
+ loadWithOverviewMode = true
|
|
|
+
|
|
|
+ // 禁用缩放按钮:kiosk 模式下不允许用户手动缩放
|
|
|
+ setSupportZoom(false)
|
|
|
+ builtInZoomControls = false
|
|
|
+ displayZoomControls = false
|
|
|
+
|
|
|
+ // 设置缓存策略:优先使用缓存,加快页面加载速度
|
|
|
+ cacheMode = WebSettings.LOAD_DEFAULT
|
|
|
+
|
|
|
+ // 设置 User-Agent:追加自定义标识,H5 侧可通过此判断是否在机器人环境中
|
|
|
+ // H5 代码示例:if (navigator.userAgent.includes('MedicalRobot')) { ... }
|
|
|
+ userAgentString = "$userAgentString 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.webViewClient = object : WebViewClient() {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 页面开始加载时调用
|
|
|
+ * @param view WebView 实例
|
|
|
+ * @param url 正在加载的 URL
|
|
|
+ */
|
|
|
+ override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
|
|
+ super.onPageStarted(view, url, favicon)
|
|
|
+ // 显示加载动画,提示用户页面正在加载
|
|
|
+ progressBar.visibility = View.VISIBLE
|
|
|
+ hasLoadError = false
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 页面加载完成时调用
|
|
|
+ * @param view WebView 实例
|
|
|
+ * @param url 已加载完成的 URL
|
|
|
+ */
|
|
|
+ override fun onPageFinished(view: WebView?, url: String?) {
|
|
|
+ super.onPageFinished(view, url)
|
|
|
+ // 隐藏加载动画
|
|
|
+ progressBar.visibility = View.GONE
|
|
|
+
|
|
|
+ // 如果之前加载失败,现在成功了,清除错误标志
|
|
|
+ if (hasLoadError) {
|
|
|
+ hasLoadError = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 页面加载出错时调用
|
|
|
+ * @param view WebView 实例
|
|
|
+ * @param request 失败的请求信息
|
|
|
+ * @param error 错误详情
|
|
|
+ */
|
|
|
+ override fun onReceivedError(
|
|
|
+ view: WebView?,
|
|
|
+ request: WebResourceRequest?,
|
|
|
+ error: WebResourceError?
|
|
|
+ ) {
|
|
|
+ super.onReceivedError(view, request, error)
|
|
|
+ // 标记加载失败状态
|
|
|
+ hasLoadError = true
|
|
|
+ progressBar.visibility = View.GONE
|
|
|
+
|
|
|
+ // 在主线程显示错误提示
|
|
|
+ runOnUiThread {
|
|
|
+ Toast.makeText(
|
|
|
+ this@MainActivity,
|
|
|
+ "页面加载失败,请检查网络连接",
|
|
|
+ Toast.LENGTH_LONG
|
|
|
+ ).show()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 可选:加载本地离线提示页
|
|
|
+ // webView.loadUrl("file:///android_asset/error.html")
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拦截 URL 加载请求
|
|
|
+ * 返回 true 表示由应用处理此 URL,返回 false 表示由 WebView 继续加载
|
|
|
+ * @param view WebView 实例
|
|
|
+ * @param request 加载请求
|
|
|
+ */
|
|
|
+ override fun shouldOverrideUrlLoading(
|
|
|
+ view: WebView?,
|
|
|
+ request: WebResourceRequest?
|
|
|
+ ): Boolean {
|
|
|
+ val url = request?.url?.toString() ?: return false
|
|
|
+
|
|
|
+ // 示例:拦截特定的自定义协议(如 robot://settings)
|
|
|
+ // if (url.startsWith("robot://")) {
|
|
|
+ // handleRobotProtocol(url)
|
|
|
+ // return true
|
|
|
+ // }
|
|
|
+
|
|
|
+ // 默认行为:由 WebView 继续加载
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== WebChromeClient:处理 JS 弹窗和高级功能 =====
|
|
|
+ // WebChromeClient 处理需要与 UI 交互的 Web 功能
|
|
|
+ webView.webChromeClient = object : WebChromeClient() {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理 JavaScript 的 alert() 弹窗
|
|
|
+ * 默认行为是弹出系统对话框,这里使用原生 AlertDialog 替代
|
|
|
+ */
|
|
|
+ override fun onJsAlert(
|
|
|
+ view: WebView?,
|
|
|
+ url: String?,
|
|
|
+ message: String?,
|
|
|
+ result: android.webkit.JsResult?
|
|
|
+ ): Boolean {
|
|
|
+ // 创建 AlertDialog 替代默认弹窗
|
|
|
+ AlertDialog.Builder(this@MainActivity)
|
|
|
+ .setTitle("提示") // 对话框标题
|
|
|
+ .setMessage(message) // 显示 JS 传来的消息内容
|
|
|
+ .setPositiveButton("确定") { _, _ ->
|
|
|
+ // 用户点击确定后,通知 JS 弹窗已确认
|
|
|
+ result?.confirm()
|
|
|
+ }
|
|
|
+ .setCancelable(false) // 禁止点击外部取消,确保 JS 流程继续
|
|
|
+ .show()
|
|
|
+ return true // 返回 true 表示已处理此弹窗
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理文件选择(如 <input type="file">)
|
|
|
+ * 用于 H5 页面上传图片、拍照等功能
|
|
|
+ */
|
|
|
+ override fun onShowFileChooser(
|
|
|
+ view: WebView?,
|
|
|
+ filePathCallback: android.webkit.ValueCallback<Array<android.net.Uri>>?,
|
|
|
+ fileChooserParams: FileChooserParams?
|
|
|
+ ): Boolean {
|
|
|
+ // 实际实现需要启动相机或文件选择器
|
|
|
+ // 简化示例:返回取消,H5 侧可降级处理
|
|
|
+ filePathCallback?.onReceiveValue(null)
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 注入 JSBridge 到 WebView
|
|
|
+ * 通过 @JavascriptInterface 注解,将 Kotlin 方法暴露给 JavaScript 调用
|
|
|
+ */
|
|
|
+ private fun injectJSBridge() {
|
|
|
+ // 创建 RobotBridge 实例,传入 WebView 和 SDK 管理器
|
|
|
+ robotBridge = RobotBridge(webView, MedicalRobotApplication.sdkManager)
|
|
|
+
|
|
|
+ // 将 RobotBridge 对象注入到 WebView 的 JavaScript 环境中
|
|
|
+ // 第二个参数 "RobotBridge" 是 JS 侧访问此对象时使用的名称
|
|
|
+ // JS 调用方式:window.RobotBridge.navigate("导诊台", "cb_001")
|
|
|
+ webView.addJavascriptInterface(robotBridge, "RobotBridge")
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 加载 H5 页面
|
|
|
+ * 支持两种方式:远程服务器(开发推荐)或本地 assets(离线模式)
|
|
|
+ */
|
|
|
+ private fun 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 fun onBackPressed() {
|
|
|
+ // 检查 WebView 是否有历史记录可以后退
|
|
|
+ if (webView.canGoBack()) {
|
|
|
+ // WebView 可以后退(如从报告详情页返回到首页)
|
|
|
+ webView.goBack()
|
|
|
+ } else {
|
|
|
+ // WebView 已无历史记录,返回到 Launcher(不退出应用进程)
|
|
|
+ // moveTaskToBack 将当前任务移到后台,类似点击 Home 键
|
|
|
+ // true 表示即使当前 Activity 是根 Activity 也执行
|
|
|
+ moveTaskToBack(true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 窗口焦点变化时调用
|
|
|
+ * 系统回调:Activity 获得或失去焦点时调用
|
|
|
+ * 用于在弹窗关闭后重新隐藏系统 UI
|
|
|
+ */
|
|
|
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
|
|
|
+ super.onWindowFocusChanged(hasFocus)
|
|
|
+ // 当 Activity 重新获得焦点时,再次隐藏系统 UI
|
|
|
+ // 防止用户通过滑动调出导航栏后,焦点变化导致系统 UI 保持显示
|
|
|
+ if (hasFocus) {
|
|
|
+ hideSystemUI()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 系统内存不足时调用
|
|
|
+ * 系统回调:系统内存紧张时调用,应释放不必要的资源
|
|
|
+ */
|
|
|
+ override fun onLowMemory() {
|
|
|
+ super.onLowMemory()
|
|
|
+ // 清理 WebView 缓存,释放内存
|
|
|
+ webView.clearCache(true)
|
|
|
+ // 可选:清理历史记录、Cookie 等
|
|
|
+ // webView.clearHistory()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Activity 销毁时调用
|
|
|
+ * 系统回调:Activity 被销毁前调用,用于清理资源
|
|
|
+ */
|
|
|
+ override fun 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
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- activity_main.xml:MainActivity 的界面布局文件 -->
|
|
|
+<!-- 作用:定义 WebView 容器和加载动画的位置与样式 -->
|
|
|
+
|
|
|
+<!-- ConstraintLayout:灵活的约束布局容器
|
|
|
+ xmlns:android:Android 命名空间,所有 Android 属性都需要此前缀
|
|
|
+ xmlns:app:应用级自定义属性命名空间(ConstraintLayout 的约束属性用此前缀)
|
|
|
+ android:layout_width/height="match_parent":宽高填满父容器(即整个屏幕)
|
|
|
+ android:background:背景颜色,使用主题中定义的背景色 -->
|
|
|
+<androidx.constraintlayout.widget.ConstraintLayout
|
|
|
+ xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
+ xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
|
+ android:layout_width="match_parent"
|
|
|
+ android:layout_height="match_parent"
|
|
|
+ android:background="?attr/colorSurface">
|
|
|
+
|
|
|
+ <!-- WebView:网页渲染组件
|
|
|
+ android:id="@+id/webview":定义视图 ID,在 Kotlin 代码中通过 R.id.webview 引用
|
|
|
+ android:layout_width="0dp":ConstraintLayout 中 0dp 表示由约束决定尺寸
|
|
|
+ app:layout_constraintStart_toStartOf="parent":左边缘与父容器左边缘对齐
|
|
|
+ app:layout_constraintEnd_toEndOf="parent":右边缘与父容器右边缘对齐
|
|
|
+ app:layout_constraintTop_toTopOf="parent":上边缘与父容器上边缘对齐
|
|
|
+ app:layout_constraintBottom_toBottomOf="parent":下边缘与父容器下边缘对齐
|
|
|
+ 以上四个约束的组合效果:WebView 填满整个 ConstraintLayout(即全屏) -->
|
|
|
+ <WebView
|
|
|
+ android:id="@+id/webview"
|
|
|
+ android:layout_width="0dp"
|
|
|
+ android:layout_height="0dp"
|
|
|
+ app:layout_constraintStart_toStartOf="parent"
|
|
|
+ app:layout_constraintEnd_toEndOf="parent"
|
|
|
+ app:layout_constraintTop_toTopOf="parent"
|
|
|
+ app:layout_constraintBottom_toBottomOf="parent" />
|
|
|
+
|
|
|
+ <!-- ProgressBar:圆形进度指示器(转圈圈)
|
|
|
+ style="?android:attr/progressBarStyleLarge":使用系统大号的进度圈样式
|
|
|
+ android:visibility="gone":初始状态不可见,页面开始加载时通过代码设为 visible
|
|
|
+ app:layout_constraintStart/End/Top/Bottom_toStart/End/Top/BottomOf="parent":在父容器中居中
|
|
|
+ 注意:同时设置 top 和 bottom 约束到 parent,配合 0dp 高度可实现垂直居中
|
|
|
+ 同时设置 start 和 end 约束到 parent,配合 0dp 宽度可实现水平居中 -->
|
|
|
+ <ProgressBar
|
|
|
+ android:id="@+id/progress_bar"
|
|
|
+ style="?android:attr/progressBarStyleLarge"
|
|
|
+ android:layout_width="wrap_content"
|
|
|
+ android:layout_height="wrap_content"
|
|
|
+ android:visibility="gone"
|
|
|
+ app:layout_constraintStart_toStartOf="parent"
|
|
|
+ app:layout_constraintEnd_toEndOf="parent"
|
|
|
+ app:layout_constraintTop_toTopOf="parent"
|
|
|
+ app:layout_constraintBottom_toBottomOf="parent" />
|
|
|
+
|
|
|
+</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
+```
|
|
|
+
|
|
|
+> **Web 开发者提示**:`ConstraintLayout` 类似于 CSS Flexbox + Absolute Positioning 的结合体。`match_parent` 类似于 `width: 100%`,`wrap_content` 类似于 `width: auto`(由内容决定)。`0dp` 在 ConstraintLayout 中表示由约束决定尺寸,类似于 CSS 中同时设置 `left: 0; right: 0;`。
|
|
|
+
|
|
|
+### 4.14 补充:RobotBridge.kt 完整 JSBridge 实现
|
|
|
+
|
|
|
+> RobotBridge 是 Native 与 H5 之间的通信桥梁。H5 页面通过 `window.RobotBridge.xxx()` 调用 Native 方法,Native 通过 `webView.evaluateJavascript()` 将结果回传给 H5。以下代码包含全部 7 个接口的完整实现,支持 Mock 模式。
|
|
|
+
|
|
|
+```kotlin
|
|
|
+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.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 的回调函数被执行,处理返回数据
|
|
|
+ */
|
|
|
+class RobotBridge(
|
|
|
+ private val webView: WebView,
|
|
|
+ private val sdkManager: RobotSDKManager
|
|
|
+) {
|
|
|
+
|
|
|
+ // 日志标签
|
|
|
+ private val tag = "RobotBridge"
|
|
|
+
|
|
|
+ // 主线程 Handler:用于从子线程切换回主线程操作 WebView
|
|
|
+ // WebView 的所有操作必须在主线程执行
|
|
|
+ private val mainHandler = Handler(Looper.getMainLooper())
|
|
|
+
|
|
|
+ // Mock 模式标志:从 Application 全局配置读取
|
|
|
+ private val isMock: Boolean
|
|
|
+ get() = MedicalRobotApplication.useMockMode
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 统一回调方法:将结果 JSON 字符串回传给 H5
|
|
|
+ * @param callbackId H5 传入的回调标识
|
|
|
+ * @param resultJson 结果数据的 JSON 字符串
|
|
|
+ */
|
|
|
+ private fun callbackToH5(callbackId: String, resultJson: String) {
|
|
|
+ // 构造要执行的 JavaScript 代码
|
|
|
+ // 先检查 __robotCallbacks 和指定 callbackId 是否存在,避免空指针
|
|
|
+ val 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';
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ """.trimIndent()
|
|
|
+
|
|
|
+ // 切换到主线程执行(WebView 必须在主线程操作)
|
|
|
+ mainHandler.post {
|
|
|
+ // evaluateJavascript:在 WebView 中执行 JavaScript 代码
|
|
|
+ // 第二个参数是结果回调(此处不需要,传 null)
|
|
|
+ webView.evaluateJavascript(jsCode, null)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构造标准成功响应 JSON
|
|
|
+ * @param data 业务数据(可选)
|
|
|
+ * @return JSON 字符串 {"code": 0, "msg": "success", ...data}
|
|
|
+ */
|
|
|
+ private fun successJson(data: Map<String, Any?> = emptyMap()): String {
|
|
|
+ val json = JSONObject()
|
|
|
+ json.put("code", 0)
|
|
|
+ json.put("msg", "success")
|
|
|
+ data.forEach { (key, value) -> json.put(key, value) }
|
|
|
+ return json.toString()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构造标准错误响应 JSON
|
|
|
+ * @param code 错误码
|
|
|
+ * @param message 错误描述
|
|
|
+ * @return JSON 字符串 {"code": code, "msg": "message"}
|
|
|
+ */
|
|
|
+ private fun errorJson(code: Int, message: String): String {
|
|
|
+ val json = JSONObject()
|
|
|
+ json.put("code", code)
|
|
|
+ json.put("msg", message)
|
|
|
+ return json.toString()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 1:导航到指定位置 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导航到指定位置点
|
|
|
+ * @JavascriptInterface 注解:将此方法暴露给 JavaScript 调用
|
|
|
+ * @param destination 目标位置点名称(如 "导诊台"、"神经内科")
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun navigate(destination: String, callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到导航请求: destination=$destination, callbackId=$callbackId")
|
|
|
+
|
|
|
+ // 在子线程中执行 SDK 调用,避免阻塞 WebView 的 JS 线程
|
|
|
+ Thread {
|
|
|
+ // 调用 SDK 管理器的导航方法
|
|
|
+ sdkManager.startNavigation(destination, object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ // 导航成功,构造成功响应并回传
|
|
|
+ val result = successJson(mapOf(
|
|
|
+ "destination" to destination,
|
|
|
+ "sdkData" to data
|
|
|
+ ))
|
|
|
+ Log.i(tag, "导航成功,回传结果: $result")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ // 导航失败,构造错误响应并回传
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "导航失败,回传结果: $result")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 2:停止导航 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止当前导航
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun stopNavigation(callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到停止导航请求: callbackId=$callbackId")
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.stopNavigation(object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ val result = successJson()
|
|
|
+ Log.i(tag, "停止导航成功")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "停止导航失败: $message")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 3:获取位置点列表 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取地图中所有预设位置点列表
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun getPlaceList(callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到获取位置点列表请求: callbackId=$callbackId")
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.getPlaceList(object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ // data 是 JSON 数组字符串,直接放入响应中
|
|
|
+ val result = successJson(mapOf("places" to data))
|
|
|
+ Log.i(tag, "获取位置点列表成功")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "获取位置点列表失败: $message")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 4:获取当前位置 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取机器人当前坐标位置
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun getPosition(callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到获取位置请求: callbackId=$callbackId")
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.getPosition(object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ val result = successJson(mapOf("position" to data))
|
|
|
+ Log.i(tag, "获取位置成功: $data")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "获取位置失败: $message")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 5:TTS 语音播报 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 播放 TTS 语音播报
|
|
|
+ * @param text 要播报的文本内容
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun playTTS(text: String, callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到 TTS 请求: text=$text, callbackId=$callbackId")
|
|
|
+
|
|
|
+ // 输入校验:文本不能为空
|
|
|
+ if (text.isBlank()) {
|
|
|
+ val result = errorJson(-3, "TTS 文本不能为空")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.playTTS(text, object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ val result = successJson(mapOf("text" to text))
|
|
|
+ Log.i(tag, "TTS 播报成功: $text")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "TTS 播报失败: $message")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 6:停止 TTS =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止 TTS 语音播报
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun stopTTS(callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到停止 TTS 请求: callbackId=$callbackId")
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.stopTTS(object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ val result = successJson()
|
|
|
+ Log.i(tag, "停止 TTS 成功")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val result = errorJson(code, message)
|
|
|
+ Log.e(tag, "停止 TTS 失败: $message")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }.start()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 接口 7:获取电量 =====
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取机器人当前电量
|
|
|
+ * @param callbackId H5 生成的回调标识
|
|
|
+ */
|
|
|
+ @JavascriptInterface
|
|
|
+ fun getBattery(callbackId: String) {
|
|
|
+ Log.i(tag, "JSBridge 收到获取电量请求: callbackId=$callbackId")
|
|
|
+
|
|
|
+ Thread {
|
|
|
+ sdkManager.getBatteryLevel(object : RobotSDKManager.OperationCallback {
|
|
|
+ override fun onSuccess(data: String) {
|
|
|
+ // data 是 JSON 字符串 {"level": 85}
|
|
|
+ val result = successJson(mapOf("battery" to data))
|
|
|
+ Log.i(tag, "获取电量成功: $data")
|
|
|
+ callbackToH5(callbackId, result)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onError(code: Int, message: String) {
|
|
|
+ val 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.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.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.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.kt 中配置):
|
|
|
+ ```kotlin
|
|
|
+ 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
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
+<!-- network_security_config.xml:网络安全配置文件 -->
|
|
|
+<!-- 作用:自定义应用的网络安全策略,覆盖系统默认行为 -->
|
|
|
+<!-- 注意:此配置仅适用于内网迎检环境,生产环境应使用 HTTPS -->
|
|
|
+<network-security-config>
|
|
|
+
|
|
|
+ <!-- base-config:基础安全配置,应用于所有域名 -->
|
|
|
+ <!-- cleartextTrafficPermitted="true":允许明文 HTTP 通信 -->
|
|
|
+ <!-- 默认情况下 Android 9.0+ 禁止明文 HTTP,必须配置此选项才能访问 HTTP URL -->
|
|
|
+ <base-config cleartextTrafficPermitted="true">
|
|
|
+ <!-- trust-anchors:定义信任的证书颁发机构 -->
|
|
|
+ <trust-anchors>
|
|
|
+ <!-- 信任系统预装的 CA 证书(大多数 HTTPS 网站使用) -->
|
|
|
+ <certificates src="system" />
|
|
|
+ <!-- 如需信任用户安装的自定义证书(如内网自签名证书),添加以下行: -->
|
|
|
+ <!-- <certificates src="user" /> -->
|
|
|
+ </trust-anchors>
|
|
|
+ </base-config>
|
|
|
+
|
|
|
+ <!-- 如需针对特定域名配置(比全局配置更安全),使用 domain-config: -->
|
|
|
+ <!--
|
|
|
+ <domain-config cleartextTrafficPermitted="true">
|
|
|
+ <domain includeSubdomains="true">192.168.1.100</domain>
|
|
|
+ <domain includeSubdomains="true">localhost</domain>
|
|
|
+ <trust-anchors>
|
|
|
+ <certificates src="system" />
|
|
|
+ </trust-anchors>
|
|
|
+ </domain-config>
|
|
|
+ -->
|
|
|
+
|
|
|
+</network-security-config>
|
|
|
+```
|
|
|
+
|
|
|
+> **安全警告**:`<base-config cleartextTrafficPermitted="true">` 会允许所有域名的明文 HTTP 通信。在生产环境中,建议改用注释中的 `<domain-config>` 方式,仅允许特定内网 IP 或域名的明文通信,其他请求仍强制使用 HTTPS。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、H5 前端适配改造
|
|
|
+
|
|
|
+> 本章面向对移动端开发不熟悉的全栈 Web 工程师(Java + Vue 背景),提供可直接复制使用的完整代码。当前前端基于 Vue 3 + Composition API,构建工具为 `@vue/cli-service`。
|
|
|
+
|
|
|
+### 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 部分修改
|
|
|
+
|
|
|
+在 `<script>` 顶部新增 import:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { ref, computed } from 'vue'
|
|
|
+import { isRobotEnv, navigateTo } from '@/api/robot' // 新增:引入机器人桥接
|
|
|
+```
|
|
|
+
|
|
|
+在 `setup()` 函数内新增 `isRobot` 状态和 `goToDepartment` 方法:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ setup(props, { emit }) {
|
|
|
+ const selectedDepartment = ref('')
|
|
|
+ const isRobot = ref(isRobotEnv()) // 新增:机器人环境检测状态
|
|
|
+
|
|
|
+ // ... 原有 computed 和 methods 不变 ...
|
|
|
+
|
|
|
+ // 新增:机器人导航方法
|
|
|
+ const goToDepartment = async () => {
|
|
|
+ const deptName = selectedDepartmentInfo.value?.name
|
|
|
+ if (!deptName) return
|
|
|
+ try {
|
|
|
+ await navigateTo(deptName)
|
|
|
+ } catch (e) {
|
|
|
+ console.error('导航调用失败:', e)
|
|
|
+ alert('导航启动失败:' + (e?.message || '未知错误'))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ selectedDepartment,
|
|
|
+ departments,
|
|
|
+ selectedDepartmentInfo,
|
|
|
+ handleDepartmentChange,
|
|
|
+ confirmSelection,
|
|
|
+ isRobot, // 新增:暴露到模板
|
|
|
+ goToDepartment // 新增:暴露到模板
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+#### 2)template 部分修改
|
|
|
+
|
|
|
+在确认按钮下方新增"带我去"按钮(仅机器人环境显示,且需先选择科室):
|
|
|
+
|
|
|
+```vue
|
|
|
+ <button
|
|
|
+ class="confirm-button"
|
|
|
+ :disabled="!selectedDepartment"
|
|
|
+ @click="confirmSelection"
|
|
|
+ >
|
|
|
+ 确认选择
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 新增:机器人导航按钮 -->
|
|
|
+ <button
|
|
|
+ v-if="isRobot && selectedDepartment"
|
|
|
+ class="navigate-button"
|
|
|
+ @click="goToDepartment"
|
|
|
+ >
|
|
|
+ <span class="nav-icon">🤖</span>
|
|
|
+ 带我去
|
|
|
+ </button>
|
|
|
+```
|
|
|
+
|
|
|
+#### 3)style 部分新增
|
|
|
+
|
|
|
+在 `<style scoped>` 末尾追加以下样式:
|
|
|
+
|
|
|
+```css
|
|
|
+/* 带我去按钮 — 大尺寸、触屏友好、医疗蓝配色 */
|
|
|
+.navigate-button {
|
|
|
+ width: 100%;
|
|
|
+ padding: 16px;
|
|
|
+ margin-top: 12px;
|
|
|
+ background: #e6f7ff;
|
|
|
+ color: #1a5f9e;
|
|
|
+ border: 2px solid #1a5f9e;
|
|
|
+ border-radius: 12px;
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ min-height: 52px; /* 触屏最小触控高度 52px */
|
|
|
+}
|
|
|
+
|
|
|
+.navigate-button:hover {
|
|
|
+ background: #1a5f9e;
|
|
|
+ color: white;
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 8px 20px rgba(26, 95, 158, 0.4);
|
|
|
+}
|
|
|
+
|
|
|
+.nav-icon {
|
|
|
+ font-size: 20px;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**兼容性说明:**
|
|
|
+- 现有组件使用 `setup()` 而非 `methods:`,因此 `isRobotEnv` 不作为方法直接使用,而是在 setup 内调用并赋值给 `isRobot` ref
|
|
|
+- `v-if="isRobot && selectedDepartment"` 同时控制两个条件:必须在机器人环境且已选择科室才显示导航按钮
|
|
|
+- 按钮最小高度 **52px**,超过 iOS 人机界面指南推荐的 44px 最小触控区域,适合机器人触摸屏操作
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.3 ChatInterface.vue 横屏适配改造
|
|
|
+
|
|
|
+> 当前 `ChatInterface.vue` 采用竖屏手机风格设计(`max-width: 480px`),在机器人横屏(通常为 1280×800 或类似分辨率)上会导致两侧大量留白、内容区域过小。本节给出完整的横屏适配方案。
|
|
|
+
|
|
|
+#### 当前需要修改的硬编码竖屏尺寸
|
|
|
+
|
|
|
+| 选择器 | 当前竖屏值 | 问题 |
|
|
|
+|--------|-----------|------|
|
|
|
+| `.chat-container` | `max-width: 480px; max-height: 900px` | 横屏时容器宽度被限制,两侧留白 |
|
|
|
+| `.chat-header` | `padding: 12px 15px` | 横屏时头部过矮,视觉拥挤 |
|
|
|
+| `.header-avatar` | `width: 36px; height: 36px` | 机器人屏幕观看距离远,头像过小 |
|
|
|
+| `.header-title` | `font-size: 14px` | 字体偏小,远处看不清 |
|
|
|
+| `.header-subtitle` | `font-size: 9px` | 几乎不可读 |
|
|
|
+| `.message-avatar` | `width: 36px; height: 36px` | 头像尺寸不足 |
|
|
|
+| `.message-text` | `font-size: 14px; padding: 12px 16px` | 消息文字偏小 |
|
|
|
+| `.voice-button` | `width: 40px; height: 40px` | 接近 44px 下限,横屏需更大 |
|
|
|
+| `.send-button` | `width: 40px; height: 40px` | 同上 |
|
|
|
+| `.quick-action-btn` | `padding: 7px 12px; font-size: 12px` | 快捷按钮文字偏小 |
|
|
|
+| `.input-hint` | `font-size: 12px` | 提示文字偏小 |
|
|
|
+
|
|
|
+#### 横屏适配完整 CSS
|
|
|
+
|
|
|
+将以下 `@media` 规则追加到 `ChatInterface.vue` 的 `<style scoped>` 末尾(放在所有现有样式之后):
|
|
|
+
|
|
|
+```css
|
|
|
+@media screen and (orientation: landscape) {
|
|
|
+ /* 容器:取消 480px 宽度限制,占满屏幕 */
|
|
|
+ .chat-container {
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 100vh;
|
|
|
+ height: 100vh;
|
|
|
+ border-radius: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 头部:增大内边距和元素尺寸 */
|
|
|
+ .chat-header {
|
|
|
+ padding: 16px 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header-avatar {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header-title {
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header-subtitle {
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 消息列表:增大间距 */
|
|
|
+ .chat-messages {
|
|
|
+ padding: 20px 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message {
|
|
|
+ gap: 14px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 消息头像:增大至 44px(满足最小触控区域) */
|
|
|
+ .message-avatar {
|
|
|
+ width: 44px;
|
|
|
+ height: 44px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-avatar svg {
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 消息内容:放宽宽度限制 */
|
|
|
+ .message-content {
|
|
|
+ max-width: 80%;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 消息气泡:增大字体和内边距 */
|
|
|
+ .message-text {
|
|
|
+ padding: 14px 20px;
|
|
|
+ font-size: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 输入区域:整体放大 */
|
|
|
+ .chat-input-container {
|
|
|
+ padding: 14px 24px 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .chat-input-wrapper {
|
|
|
+ gap: 12px;
|
|
|
+ padding: 6px 6px 6px 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 语音按钮:增大至 48px */
|
|
|
+ .voice-button {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ min-width: 48px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .voice-icon {
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .voice-label {
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 输入框:增大字体 */
|
|
|
+ .chat-input {
|
|
|
+ font-size: 17px;
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 发送按钮:增大至 48px */
|
|
|
+ .send-button {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .send-button svg {
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 提示文字 */
|
|
|
+ .input-hint {
|
|
|
+ font-size: 14px;
|
|
|
+ margin: 10px 0 0 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .input-hint svg {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 快捷按钮:增大触控区域和字体 */
|
|
|
+ .quick-actions {
|
|
|
+ gap: 12px;
|
|
|
+ padding: 16px 24px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .quick-action-btn {
|
|
|
+ padding: 10px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .quick-action-btn svg {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 加载动画尺寸同步放大 */
|
|
|
+ .loading-message .message-content {
|
|
|
+ padding: 20px 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .typing-indicator span {
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**横屏适配关键数值总结:**
|
|
|
+
|
|
|
+| 项目 | 竖屏值 | 横屏值 | 设计理由 |
|
|
|
+|------|--------|--------|---------|
|
|
|
+| 容器 max-width | 480px | 100% | 占满机器人屏幕,消除两侧留白 |
|
|
|
+| 容器 max-height | 900px | 100vh | 占满高度 |
|
|
|
+| 头像尺寸 | 36px | 48px | 观看距离远,需要更大图标 |
|
|
|
+| 消息字体 | 14px | 16px | 远处可读 |
|
|
|
+| 标题字体 | 14px | 18px | 远处可读 |
|
|
|
+| 输入框字体 | 15px | 17px | 远处可读 |
|
|
|
+| 按钮尺寸 | 40px | 48px | 超过 44px 最小触控区域 |
|
|
|
+| 快捷按钮字体 | 12px | 14px | 远处可读 |
|
|
|
+| 消息内边距 | 12px 16px | 14px 20px | 视觉呼吸感 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.4 vue.config.js 的 WebView 兼容配置
|
|
|
+
|
|
|
+> 当前 `vue.config.js` 已有基础的 `outputDir` 和 `devServer.proxy` 配置。以下展示需要**新增或修改**的项。
|
|
|
+
|
|
|
+#### 修改后的完整 vue.config.js
|
|
|
+
|
|
|
+```javascript
|
|
|
+const { defineConfig } = require('@vue/cli-service')
|
|
|
+
|
|
|
+module.exports = defineConfig({
|
|
|
+ transpileDependencies: true,
|
|
|
+
|
|
|
+ // 构建输出目录
|
|
|
+ outputDir: 'dist',
|
|
|
+
|
|
|
+ // publicPath 策略:
|
|
|
+ // - 远程服务器加载模式(开发/测试阶段):使用 '/',WebView 加载 http://192.168.x.x:8080
|
|
|
+ // - 本地 assets 加载模式(APK 内嵌):使用 './',确保所有资源使用相对路径
|
|
|
+ publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
|
|
|
+
|
|
|
+ devServer: {
|
|
|
+ port: 8080,
|
|
|
+ host: '0.0.0.0', // 允许局域网内其他设备(包括机器人)访问
|
|
|
+ allowedHosts: 'all', // WebView 请求不会被 webpack-dev-server 拒绝
|
|
|
+ proxy: {
|
|
|
+ '/api': {
|
|
|
+ target: 'http://localhost:3380',
|
|
|
+ changeOrigin: true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ chainWebpack: config => {
|
|
|
+ config.plugin('html').tap(args => {
|
|
|
+ args[0].title = '甘肃省中医院'
|
|
|
+ return args
|
|
|
+ })
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+#### 配置项说明
|
|
|
+
|
|
|
+| 配置项 | 修改前 | 修改后 | 原因 |
|
|
|
+|--------|--------|--------|------|
|
|
|
+| `devServer.host` | 未设置(默认 localhost) | `'0.0.0.0'` | 让局域网设备(机器人 WebView)可通过 IP 访问开发服务器 |
|
|
|
+| `devServer.allowedHosts` | 未设置 | `'all'` | webpack-dev-server 5.x 默认只允许 localhost,WebView 通过 IP 访问会被 403 拒绝 |
|
|
|
+| `publicPath` | `'/'` | `process.env.NODE_ENV === 'production' ? './' : '/'` | 生产构建使用相对路径,确保 APK 内嵌 assets 加载时资源路径正确 |
|
|
|
+| `outputDir` | `'dist'` | 保持 `'dist'` | 构建产物默认输出到 `dist/`;如需直接打包到 Android 工程,可改为 `'../../android-app/app/src/main/assets/web'`(需根据实际 Android 工程路径调整) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.5 H5 页面在 WebView 中的常见坑和解决方案
|
|
|
+
|
|
|
+以下表格汇总了机器人 WebView 环境下最常见的问题,建议在前端联调阶段逐项排查:
|
|
|
+
|
|
|
+| 问题 | 表现 | 原因 | 解决方案 |
|
|
|
+|------|------|------|---------|
|
|
|
+| 页面空白 | WebView 加载后白屏 | JS 执行错误被 WebView 静默吞掉 | Chrome DevTools 远程调试查看 Console |
|
|
|
+| 跨域请求失败 | API 调用报 CORS 错误 | WebView 对 CORS 策略更严格 | 后端配置 CORS 允许所有来源(迎检环境) |
|
|
|
+| 文件上传无反应 | 点击拍照/上传按钮无响应 | WebView 需要 `WebChromeClient.onShowFileChooser` | 确认 MainActivity 中已实现 |
|
|
|
+| 键盘遮挡输入框 | 弹出键盘时输入框被遮挡 | WebView 的 softInputMode 配置 | AndroidManifest 中设置 `adjustResize` |
|
|
|
+| localStorage 丢失 | 刷新后数据丢失 | WebView DOM Storage 未启用 | `webView.settings.domStorageEnabled = true` |
|
|
|
+| 页面缩放异常 | 页面自动缩放导致布局错乱 | 未设置 viewport meta | 确认 `index.html` 中有正确的 viewport meta 标签 |
|
|
|
+| HTTPS 证书错误 | 加载 HTTPS 页面失败 | 自签名证书不受信任 | `WebViewClient.onReceivedSslError` 中处理(仅限内网) |
|
|
|
+| 视频/音频无法播放 | 语音功能不工作 | 需要 `mediaPlaybackRequiresUserGesture = false` | WebView settings 中配置 |
|
|
|
+| CSS 动画卡顿 | 页面滚动和动画不流畅 | 未启用硬件加速 | WebView 启用 `setLayerType(LAYER_TYPE_HARDWARE)` |
|
|
|
+
|
|
|
+**联调检查清单:**
|
|
|
+
|
|
|
+1. 确认 `index.html` 中的 viewport:`<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">`
|
|
|
+2. 确认 AndroidManifest 中 Activity 的 `android:windowSoftInputMode="adjustResize"`
|
|
|
+3. 确认 WebView 初始化代码包含:
|
|
|
+ ```kotlin
|
|
|
+ webView.settings.apply {
|
|
|
+ javaScriptEnabled = true
|
|
|
+ domStorageEnabled = true
|
|
|
+ mediaPlaybackRequiresUserGesture = false
|
|
|
+ }
|
|
|
+ webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
|
|
+ ```
|
|
|
+4. 使用 Chrome `chrome://inspect/#devices` 连接机器人 WebView 查看 Console 错误
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.6 前端打包部署到机器人的完整流程
|
|
|
+
|
|
|
+#### 部署方式 A:远程服务器加载(推荐开发阶段)
|
|
|
+
|
|
|
+1. 后端 Spring Boot 启动在服务器 `192.168.1.100:8080`
|
|
|
+2. 前端 `npm run serve` 启动开发服务器(或 `npm run build` 后由 Spring Boot 提供静态资源)
|
|
|
+3. 机器人上的业务 App WebView 加载 `http://192.168.1.100:8080`
|
|
|
+4. **优点**:修改前端代码后刷新即可生效,无需重新打包 APK
|
|
|
+5. **缺点**:依赖网络,离线无法使用
|
|
|
+
|
|
|
+#### 部署方式 B:打包到 APK 本地 assets(推荐迎检)
|
|
|
+
|
|
|
+1. `cd medical-card-demo/frontend`
|
|
|
+2. `npm run build`
|
|
|
+3. 将 `dist/` 目录下所有文件复制到 Android 工程的 `app/src/main/assets/web/`
|
|
|
+4. 修改 `MainActivity.kt` 中的加载地址为 `file:///android_asset/web/index.html`
|
|
|
+5. 重新构建 APK 并安装
|
|
|
+6. **优点**:离线可用,不依赖网络
|
|
|
+7. **缺点**:每次前端修改都需要重新打包 APK
|
|
|
+
|
|
|
+#### 部署方式 C:混合模式(推荐正式使用)
|
|
|
+
|
|
|
+1. 默认加载远程服务器地址
|
|
|
+2. 如果远程加载失败(网络不可用),自动降级到本地 assets 资源
|
|
|
+3. 在 `MainActivity.kt` 的 `onReceivedError` 中实现降级逻辑:
|
|
|
+
|
|
|
+```kotlin
|
|
|
+// MainActivity.kt 中的 WebViewClient 实现降级加载
|
|
|
+inner class MedicalWebViewClient : WebViewClient() {
|
|
|
+
|
|
|
+ private var hasError = false
|
|
|
+
|
|
|
+ override fun onReceivedError(
|
|
|
+ view: WebView?,
|
|
|
+ request: WebResourceRequest?,
|
|
|
+ error: WebResourceError?
|
|
|
+ ) {
|
|
|
+ super.onReceivedError(view, request, error)
|
|
|
+ // 仅处理主框架错误,忽略子资源(图片、CSS)加载失败
|
|
|
+ if (request?.isForMainFrame == true && !hasError) {
|
|
|
+ hasError = true
|
|
|
+ Log.w("MainActivity", "远程页面加载失败,降级到本地 assets: ${error?.description}")
|
|
|
+ view?.loadUrl("file:///android_asset/web/index.html")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onPageFinished(view: WebView?, url: String?) {
|
|
|
+ super.onPageFinished(view, url)
|
|
|
+ // 本地 assets 加载成功时重置错误标志
|
|
|
+ if (url?.startsWith("file:///android_asset") == true) {
|
|
|
+ hasError = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**三种部署方式对比:**
|
|
|
+
|
|
|
+| 维度 | 方式 A(远程) | 方式 B(本地 assets) | 方式 C(混合) |
|
|
|
+|------|--------------|---------------------|--------------|
|
|
|
+| 网络依赖 | 必须 | 不需要 | 远程失败自动降级 |
|
|
|
+| 前端更新成本 | 低(刷新即可) | 高(需重打 APK) | 中 |
|
|
|
+| 离线可用 | 否 | 是 | 是 |
|
|
|
+| 适用阶段 | 开发调试 | 迎检演示 | 正式运行 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.7 main.js 中的机器人环境初始化
|
|
|
+
|
|
|
+在 Vue 项目的 `main.js` 中,加入机器人环境检测和开发模式 Mock 的初始化逻辑:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// medical-card-demo/frontend/src/main.js
|
|
|
+import { createApp } from 'vue'
|
|
|
+import App from './App.vue'
|
|
|
+import { enableDevMock, isRobotEnv } from '@/api/robot' // 新增:引入机器人桥接
|
|
|
+
|
|
|
+const app = createApp(App)
|
|
|
+
|
|
|
+// 开发模式下启用 Mock(可选,方便浏览器中调试导航流程)
|
|
|
+if (process.env.NODE_ENV === 'development') {
|
|
|
+ enableDevMock()
|
|
|
+}
|
|
|
+
|
|
|
+// 全局注册机器人环境检测
|
|
|
+app.config.globalProperties.$isRobot = isRobotEnv()
|
|
|
+
|
|
|
+app.mount('#app')
|
|
|
+```
|
|
|
+
|
|
|
+**说明:**
|
|
|
+- `enableDevMock()` 仅在开发环境(`npm run serve`)下生效,生产构建(`npm run build`)不会执行
|
|
|
+- `app.config.globalProperties.$isRobot` 让所有组件可通过 `this.$isRobot` 访问环境状态(在 Options API 中)或通过 `getCurrentInstance()` 访问(在 Composition API 中)
|
|
|
+- 如果项目使用 Pinia,也可以将 `isRobotEnv()` 的结果存入全局 Store,供任意组件订阅
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、核心交互流程
|
|
|
+
|
|
|
+### 6.1 完整交互链路
|
|
|
+
|
|
|
+```
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 开机 | --> | BOOT_COMPLETED | --> | Launcher 自启 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 显示桌面 | <-- | 仿鸿蒙 UI | <-- | 桌面主页加载 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ 点击"智慧医疗"图标 v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 业务 App | <-- | Intent | <-- | Launcher 启动 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| WebView | <-- | 加载 localhost | <-- | H5 页面渲染 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ 用户语音/文字对话 v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 推荐科室 | <-- | Spring Boot | <-- | AI 对话交互 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ 点击"带我去"按钮 v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| JSBridge | --> | RobotBridge. | --> | 原生 SDK 调用 |
|
|
|
+| 调用 | | navigate() | | |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 到达科室 | --> | 导航完成回调 | --> | 机器人停止移动 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+ |
|
|
|
+ 按 Home 键 v
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+| 回到桌面 | <-- | HOME 按键 | <-- | Launcher 前台 |
|
|
|
++-----------+ +----------------+ +------------------+
|
|
|
+```
|
|
|
+
|
|
|
+### 6.2 领导视察演示脚本
|
|
|
+
|
|
|
+| 步骤 | 操作人 | 动作 | 解说词/预期效果 |
|
|
|
+|------|--------|------|----------------|
|
|
|
+| 1 | 演示员 | 开机/唤醒机器人 | "这是我们定制了 HarmonyOS 风格界面的豹小秘 Pro 机器人" |
|
|
|
+| 2 | 演示员 | 展示桌面 | "大家可以看到全新的桌面风格,操作流畅" |
|
|
|
+| 3 | 演示员 | 点击"智慧医疗" | "我们现在进入智慧医疗导诊系统" |
|
|
|
+| 4 | 领导/演示员 | 语音/触屏输入"我最近头疼" | AI 回复并推荐科室 |
|
|
|
+| 5 | 演示员 | 展示推荐结果 | "系统根据症状推荐了神经内科" |
|
|
|
+| 6 | 演示员 | 点击"带我去" | "我现在让机器人带路" |
|
|
|
+| 7 | 机器人 | 自动移动 | 机器人播报"请跟我来"并向前移动 |
|
|
|
+| 8 | 演示员 | 跟随机器人到达 | "顺利到达目标科室" |
|
|
|
+| 9 | 演示员 | 按 Home 键 | "按 Home 键即可回到桌面" |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、开发排期(5 天计划)
|
|
|
+
|
|
|
+| 天数 | 开发者 A 任务 | 开发者 B 任务 | 交付物 |
|
|
|
+|------|---|---|---|
|
|
|
+| Day 1 | Launcher 框架 + 桌面 UI 布局 | 业务 App 工程搭建 + WebView 容器 | Launcher 可看桌面,业务 App 可加载 H5 |
|
|
|
+| Day 2 | 下拉控制中心 + 假设置页 | SDK 集成 + JSBridge 桥接层 | Launcher 完整可演示,导航 SDK 可调用 |
|
|
|
+| Day 3 | Launcher 细节打磨(动画、壁纸、图标) | H5 前端适配 + 导航对接 | 两个 App 独立可用 |
|
|
|
+| Day 4 | 整机联调(Launcher → 业务 App 跳转) | 导航全流程联调 | 端到端流程跑通 |
|
|
|
+| Day 5 | Bug 修复 + 演示彩排 | Bug 修复 + 演示彩排 | 交付迎检 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 八、风险与应对
|
|
|
+
|
|
|
+| 风险编号 | 风险描述 | 可能性 | 影响 | 应对策略 |
|
|
|
+|----------|---------|--------|------|---------|
|
|
|
+| R1 | Launcher 替换可能被 RobotOS 限制,无法设为默认桌面 | 中 | 高 | 提前与厂商确认,若不可行则改为点击自启动 + 禁用返回键模拟全屏桌面 |
|
|
|
+| R2 | SDK 在第三方 APK 中运行时权限不足,导航/TTS 调用失败 | 中 | 高 | Day 2 必须完成 SDK 集成验证,发现问题立即联系厂商技术支持 |
|
|
|
+| R3 | 横屏适配导致 H5 页面布局异常(卡片变形、文字截断) | 高 | 中 | Day 3 专门预留 UI 适配时间,使用 Chrome DevTools 横屏模拟器预检 |
|
|
|
+| R4 | 导航过程中系统弹窗(电量低、网络提示)覆盖业务界面 | 中 | 中 | 提前将设备充满电、关闭不必要的系统通知,必要时联系厂商关闭系统弹窗 |
|
|
|
+| R5 | 网络环境导致 H5 加载失败(后端服务未启动/断网) | 中 | 高 | 使用本地 loopback 访问,Spring Boot 设为开机自启;准备离线兜底静态页 |
|
|
|
+| R6 | 迎检现场突发状况(机器人定位丢失、地图异常) | 低 | 高 | 提前到场测试,准备地图重定位操作手册;演示脚本预留"重启恢复"备案 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 九、猎户星空厂商确认问题清单
|
|
|
+
|
|
|
+> 以下问题用于明天与猎户星空厂商会议沟通,每个问题标注优先级(P0 必问 / P1 重要 / P2 可选),并附"为什么要问"说明。
|
|
|
+
|
|
|
+### 9.1 SDK 与开发环境
|
|
|
+
|
|
|
+| 编号 | 问题 | 优先级 | 为什么要问 |
|
|
|
+|------|------|--------|-----------|
|
|
|
+| Q1 | RobotOS SDK AAR 包如何获取?最新版本号是多少?是否有更新日志? | P0 | 决定我们集成的基础依赖版本和获取渠道 |
|
|
|
+| Q2 | 是否提供模拟器或仿真环境用于本地开发调试? | P1 | 2 人团队时间紧,若必须真机调试会大幅增加联调成本 |
|
|
|
+| Q3 | SDK 初始化是否需要授权码或 License?授权绑定设备还是企业? | P0 | 若需授权,需提前申请避免现场无法运行 |
|
|
|
+| Q4 | 目标设备的 Android API Level 是多少?最低兼容版本? | P1 | 决定业务 App 的 `compileSdk` 和 `minSdk` 配置 |
|
|
|
+| Q5 | APK 开发与 OPK 开发,对于本场景(WebView + 导航)更推荐哪种? | P1 | 影响技术选型,OPK 可能限制更多但集成更快 |
|
|
|
+| Q6 | 是否提供 Demo APK 源码可供参考? | P2 | 有参考代码可大幅缩短集成时间 |
|
|
|
+| Q7 | SDK 是否有混淆规则(ProGuard)要求? | P2 | 若开启代码混淆需要额外配置 |
|
|
|
+| Q8 | SDK 版本与 RobotOS 固件版本是否存在绑定关系? | P1 | 确保 SDK 版本与设备固件匹配 |
|
|
|
+
|
|
|
+### 9.2 系统权限与 Launcher 替换
|
|
|
+
|
|
|
+| 编号 | 问题 | 优先级 | 为什么要问 |
|
|
|
+|------|------|--------|-----------|
|
|
|
+| Q9 | RobotOS 是否允许第三方应用声明 `HOME` category 作为默认桌面? | P0 | 这是表层 Launcher 方案的核心前提 |
|
|
|
+| Q10 | 系统是否会拦截或覆盖自定义 Launcher 的 HOME intent? | P0 | 若被拦截,需准备备选方案(如定时拉起) |
|
|
|
+| Q11 | 系统状态栏和虚拟导航栏是否可以完全隐藏?推荐方式是什么? | P1 | 影响全屏沉浸式体验的实现 |
|
|
|
+| Q12 | 开机启动动画/LOGO 是否可以替换或跳过? | P2 | 影响开机到桌面的完整视觉链路 |
|
|
|
+| Q13 | 是否存在应用白名单限制,第三方 APK 需要额外申请权限? | P0 | 若 APK 无法安装或运行,整个方案失效 |
|
|
|
+| Q14 | 系统 OTA 升级后,默认桌面设置是否会重置? | P2 | 影响长期维护,迎检时若被重置需手动恢复 |
|
|
|
+
|
|
|
+### 9.3 导航与硬件能力
|
|
|
+
|
|
|
+| 编号 | 问题 | 优先级 | 为什么要问 |
|
|
|
+|------|------|--------|-----------|
|
|
|
+| Q15 | `RobotApi.startNavigation()` 在第三方 APK 中调用是否受限制? | P0 | 迎检核心功能,必须确认可用 |
|
|
|
+| Q16 | 导航过程中,前台 Activity 是否会被系统强制切换回系统桌面? | P1 | 若被强制切走,用户看不到导航状态 |
|
|
|
+| Q17 | 地图位置点数据当前是否已录入?如何查看和验证? | P0 | 导航目标点必须预先存在才能调用成功 |
|
|
|
+| Q18 | TTS 和 ASR API 在第三方 APK 中是否可正常调用? | P1 | 语音播报是导航体验的重要组成部分 |
|
|
|
+| Q19 | 多个 APK 同时运行(Launcher + 业务 App)时,SDK 连接是否会冲突? | P1 | 涉及架构设计,避免资源争用 |
|
|
|
+| Q20 | 导航状态回调(避障、堵死、到达)的实时性和可靠性如何? | P1 | 影响前端状态展示和异常处理 |
|
|
|
+| Q21 | 是否需要预先进行机器人定位(Estimate)才能启动导航? | P0 | 若需预定位,演示前必须完成此步骤 |
|
|
|
+| Q22 | 导航到达后是否支持自动返回充电桩或原位置? | P2 | 影响演示结束后的恢复流程 |
|
|
|
+
|
|
|
+### 9.4 屏幕与 UI 适配
|
|
|
+
|
|
|
+| 编号 | 问题 | 优先级 | 为什么要问 |
|
|
|
+|------|------|--------|-----------|
|
|
|
+| Q23 | 具体屏幕分辨率、DPI、物理尺寸是多少? | P0 | UI 设计必须基于真实尺寸 |
|
|
|
+| Q24 | 屏幕是否支持横屏锁定?系统是否有强制方向策略? | P1 | 影响 Launcher 和业务 App 的方向配置 |
|
|
|
+| Q25 | 触屏是否支持多点触控?触控采样率如何? | P2 | 影响手势交互体验(如下拉控制中心) |
|
|
|
+| Q26 | 系统是否有强制全屏/非全屏的策略?WebView 全屏是否受限? | P1 | 影响 H5 页面的显示效果 |
|
|
|
+| Q27 | 屏幕是否存在异形区域(刘海、挖孔)需要适配? | P2 | 影响顶部 UI 布局 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十、附录
|
|
|
+
|
|
|
+### 附录 A:猎户星空关键 API 速查表
|
|
|
+
|
|
|
+| 功能分类 | API | 说明 |
|
|
|
+|---------|-----|------|
|
|
|
+| 导航 | `RobotApi.startNavigation(destination)` | 导航到指定位置点 |
|
|
|
+| 导航 | `RobotApi.stopMove()` | 停止运动 |
|
|
|
+| 地图 | `RobotApi.getPlaceList()` | 获取所有位置点列表 |
|
|
|
+| 地图 | `RobotApi.getPosition()` | 获取当前坐标 `{x, y, theta}` |
|
|
|
+| 地图 | `RobotApi.isRobotEstimate()` | 是否已定位 |
|
|
|
+| 语音 | `speechApi.playText(text)` | TTS 播报文本 |
|
|
|
+| 语音 | `speechApi.stopTTS()` | 停止 TTS |
|
|
|
+| 语音 | `speechApi.setRecognizable(boolean)` | 设置语音识别开关 |
|
|
|
+| 电量 | `RobotApi.getBatteryLevel()` | 获取电量百分比 |
|
|
|
+| 系统 | `SystemInfo.getDeviceSn()` | 获取设备 SN |
|
|
|
+
|
|
|
+**导航状态码速查**:
|
|
|
+
|
|
|
+| 状态码 | 说明 |
|
|
|
+|--------|------|
|
|
|
+| 32730001 | 开始导航 |
|
|
|
+| 32730004 | 避障中 |
|
|
|
+| 32730011 | 堵死 |
|
|
|
+| 32730009 | 定位丢失 |
|
|
|
+| 32610007 | 到达目的地 |
|
|
|
+| -32620001 | 未定位 |
|
|
|
+| -32620009 | 路径规划失败 |
|
|
|
+
|
|
|
+### 附录 B:HarmonyOS 4 横屏桌面视觉参考
|
|
|
+
|
|
|
+- **色彩**:主色调为蓝紫渐变(`#4A90E2` → `#7B68EE`),辅助色为纯白和浅灰
|
|
|
+- **图标**:大圆角矩形(`cornerRadius: 24dp`),尺寸统一 72x72dp,纯色背景 + 白色线形图标
|
|
|
+- **字体**:中文使用 HarmonyOS Sans,时间 Widget 使用超大字重(`fontWeight: 600`)
|
|
|
+- **动效**:应用启动时图标轻微缩小后放大(scale 0.9 → 1.0),过渡时长 200ms
|
|
|
+- **Dock**:底部居中,高度 80dp,背景 `rgba(255,255,255,0.15)` 带毛玻璃模糊
|
|
|
+- **壁纸**:建议选用淡雅科技风渐变或浅色调风景图,避免与图标颜色冲突
|
|
|
+
|
|
|
+### 附录 C:术语表
|
|
|
+
|
|
|
+| 术语 | 说明 |
|
|
|
+|------|------|
|
|
|
+| RobotOS | 猎户星空基于 Android 深度定制的机器人操作系统 |
|
|
|
+| APK | Android 应用安装包 |
|
|
|
+| OPK | 猎户星空插件包(基于 React Native) |
|
|
|
+| AAR | Android Archive,Android 库文件格式 |
|
|
|
+| SDK | Software Development Kit,软件开发工具包 |
|
|
|
+| JSBridge | WebView 中 JavaScript 与 Native 代码通信的桥梁 |
|
|
|
+| TTS | Text to Speech,文本转语音 |
|
|
|
+| ASR | Automatic Speech Recognition,自动语音识别 |
|
|
|
+| Intent | Android 组件间通信的机制 |
|
|
|
+| HOME | Android 系统中返回桌面的标准 Intent Category |
|
|
|
+| Estimate | 机器人定位,确认自身在地图中的坐标位置 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+> 本文档基于猎户星空机器人 API 参考手册及现有 medical-card-demo 业务系统设计,具体 API 参数以官方最新文档为准。
|