claude-export-5c7c8951-20260426-000638.md 601 KB


session_id: 5c7c8951-d475-4d01-af3f-ead40c1a76 exported_at: 2026-04-26T00:06:38+08:00 work_dir: /Users/destiny/dev/emoon/demos/card_demo message_count: 73

token_count: 165440

claude Session Export

Overview

  • Topic: The user just ran /init slash command. The system has analyzed the…
  • Conversation: 4 turns | 35 tool calls | 165,440 tokens

  • Turn 1

    User

    The user just ran /init slash command. The system has analyzed the codebase and generated an AGENTS.md file. Latest AGENTS.md file content:

    card_demo 项目指南

    本文件面向 AI 编程助手。项目主要使用中文进行文档和注释编写,代码标识符采用中文拼音或英文混合风格。


    项目概述

    card_demo 是一个面向甘肃省中医院的 AI 智慧医疗导诊演示系统,包含:

    1. medical-card-demo/ —— 核心 Web 应用,基于 SpringBoot + Vue 的卡片式对话导诊与挂号系统。
    2. RobotSample-main/ —— 猎户星空(Orion Star)机器人 Android SDK 示例工程,用于在豹小秘系列机器人上运行原生 APK,实现导航、语音、视觉等硬件能力。
    3. 根目录技术文档 —— 包含猎户星空 API 参考手册、仿鸿蒙机器人系统技术方案、合作分析报告等。

    项目总体目标是:在机器人终端上部署智慧医疗导诊系统,实现“语音/触屏对话 → AI 导诊推荐 → 机器人带路导航”的完整闭环。


    技术栈

    medical-card-demo(Web 应用)

    层级 技术
    后端 Spring Boot 3.2、Java 17、Maven 3.6+
    AI 接入 阿里云百炼(DashScope)qwen-turbo / qwen-vl-plus
    前端 Vue 3.3、Vue CLI 5、Pinia 2、Axios
    其他 ZXing(二维码生成)、Lombok、SSE 流式响应

    RobotSample-main(机器人 APK)

    层级 技术
    平台 Android 9.0(API 28)、RobotOS
    构建 Gradle 5.6.4、Android Gradle Plugin 3.6.1
    语言 Java 8
    SDK 猎户星空 robotservice.jar

    项目结构

    card_demo/
    ├── medical-card-demo/           # 医疗卡片挂号演示系统
    │   ├── backend/
    │   │   ├── pom.xml              # Maven 构建配置
    │   │   ├── startup.sh           # 一键构建启动脚本
    │   │   ├── src/main/java/com/medical/demo/
    │   │   │   ├── MedicalCardDemoApplication.java
    │   │   │   ├── config/          # WebConfig(CORS 等)
    │   │   │   ├── controller/      # ChatController、ReportAnalysisController
    │   │   │   ├── dto/             # 数据传输对象(ChatRequestDTO、DepartmentDTO 等)
    │   │   │   └── service/         # 业务逻辑层
    │   │   │       ├── ChatService.java              # 主流程编排
    │   │   │       ├── IntentRecognitionService.java # 意图识别
    │   │   │       ├── DepartmentRecommendationService.java
    │   │   │       ├── DoctorScheduleService.java
    │   │   │       ├── AppointmentService.java
    │   │   │       ├── IDCardOCRService.java         # 身份证 OCR(VL 模型)
    │   │   │       ├── ReportAnalysisService.java    # 检验/检查报告解读
    │   │   │       ├── TongueDiagnosisService.java   # 中医舌诊
    │   │   │       ├── VoiceToTextService.java       # 语音转文字
    │   │   │       ├── FAQService.java               # 常见问题匹配
    │   │   │       └── ...
    │   │   └── src/main/resources/
    │   │       ├── application.yml  # 服务端配置(含百炼 API Key)
    │   │       └── static/          # 前端构建产物(由 Maven 自动复制)
    │   ├── frontend/
    │   │   ├── package.json         # npm 配置
    │   │   ├── vue.config.js        # Vue CLI 配置、代理、页面标题
    │   │   └── src/
    │   │       ├── main.js          # Vue 应用入口(Pinia)
    │   │       ├── App.vue          # 根组件(仅挂载 ChatInterface)
    │   │       ├── api/chat.js      # 前端 API 封装(SSE + axios)
    │   │       └── components/
    │   │           ├── ChatInterface.vue
    │   │           └── cards/       # 各类卡片组件
    │   ├── docs/
    │   │   └── 省中导诊台.txt        # FAQ 问答语料(问题+回答格式)
    │   ├── CALL_FLOW.md             # 完整调用流程与架构图
    │   ├── CONVERSATION_UPGRADE.md  # 对话式交互改造说明
    │   ├── FAQ_TEST_CASES.md        # FAQ 批量测试用例
    │   └── README.md                # 项目主文档
    │
    ├── RobotSample-main/            # 猎户星空机器人 Android 示例
    │   ├── build.gradle             # 顶层 Gradle 配置
    │   ├── settings.gradle          # 仅包含 ':app'
    │   └── app/
    │       ├── build.gradle         # App 模块配置(applicationId: com.ainirobot.robotos)
    │       ├── libs/robotservice.jar # 猎户星空 SDK
    │       └── src/main/java/com/ainirobot/robotos/
    │           ├── MainActivity.java
    │           ├── application/     # RobotOSApplication、SpeechCallback、ModuleCallback
    │           ├── fragment/        # 各业务场景 Fragment
    │           ├── view/            # 公共自定义 View
    │           └── maputils/        # 地图/定位工具类
    │
    ├── 仿鸿蒙机器人系统技术方案.md
    ├── 猎户星空API完整参考手册.md
    ├── 猎户星空合作分析报告.md
    └── 猎户星空合作分析报告_详细版.md
    

    构建与运行

    环境要求

    • Java 17+
    • Maven 3.6+
    • Node.js 16+
    • Android 开发环境(如需编译 RobotSample)

    medical-card-demo 一键启动(推荐)

    cd medical-card-demo/backend
    ./startup.sh
    

    该脚本会依次完成:

    1. 检查 javamvnnode 环境;
    2. 构建后端 mvn clean package -DskipTests
    3. 前端 npm install && npm run build
    4. frontend/dist/ 复制到 backend/src/main/resources/static/
    5. 重新打包并启动 SpringBoot(端口 3380);
    6. 最后启动前端开发服务器。

    访问地址:http://localhost:3380

    手动启动(前后端分离开发)

    # 终端1:后端
    cd medical-card-demo/backend
    mvn spring-boot:run        # 默认端口 8080(application.yml)
                               # 生产/一键脚本通常期望 3380
    
    # 终端2:前端
    cd medical-card-demo/frontend
    npm install
    npm run serve              # 端口 8080,代理 /api -> localhost:3380
    

    打包部署

    cd medical-card-demo/frontend
    npm install && npm run build
    
    cd ../backend
    mvn clean package
    java -jar target/medical-card-demo-1.0.0.jar
    

    RobotSample-main 编译

    cd RobotSample-main
    ./gradlew assembleDebug
    

    注意:必须在机器人屏幕上手动点击启动 APK,不能通过 IDE 的 Debug 按钮运行,否则无法获取 API 权限。


    核心接口与流程

    REST API(SSE 流式响应)

    所有聊天相关接口均返回 text/event-stream,前端通过 handleSSEStream() 解析。

    接口 路径 说明
    发送消息 POST /api/v1/chat/messages 意图识别 → 功能路由
    选择科室 POST /api/v1/chat/select-department 返回医生排班卡片
    选择医生 POST /api/v1/chat/select-doctor 返回具体排班时段
    提交挂号 POST /api/v1/chat/appointment 返回挂号成功卡片
    身份证 OCR POST /api/v1/chat/idcard-ocr VL 模型识别身份证信息
    提交建档 POST /api/v1/chat/record 保存患者档案
    语音转文字 POST /api/v1/chat/audio 上传音频 Blob
    舌象校验 POST /api/v1/chat/check-tongue-image 判断是否为有效舌象照片
    舌诊分析 POST /api/v1/chat/ai-diagnosis 根据舌象+主诉生成报告
    报告解读 POST /api/v1/chat/report-analysis VL 模型解读检验/检查报告
    获取日期 GET /api/v1/chat/available-dates 返回今天/明天/后天

    主业务流程

    1. 用户输入KeywordExtractionService.extractKeywords() 提取意图;
    2. 意图路由ChatService 根据意图分发到挂号/建档/报告解读/舌诊/FAQ;
    3. 挂号流程 → 如需建档先引导 IDCardOCRService → 推荐科室 → 选择医生/日期/时段 → 生成二维码;
    4. FAQ 流程FAQService 读取 docs/省中导诊台.txt,按关键词覆盖率 / Jaccard 相似度匹配;
    5. 报告解读ReportAnalysisService 调用 qwen-vl-plus 分析图片并返回 Markdown;
    6. 舌诊TongueDiagnosisService 校验舌象合法性后生成中医诊断报告。

    代码风格与约定

    Java 后端

    • 包名:com.medical.demo
    • DTO 使用 Lombok 注解(@Data 等),字段命名采用 camelCase;
    • Service 层以 *Service.java 命名,使用 @Service + @Autowired 注入;
    • Controller 统一加 @CrossOrigin(origins = "*")
    • 日志使用 lombok.extern.slf4j.Slf4j
    • 所有流式输出通过 SseEmitter 推送 JSON 事件,格式示例:

      { "type": "text", "content": "..." }
      { "type": "card", "cardType": "department-selection", "data": { ... } }
      { "type": "message_end" }
      

    Vue 前端

    • 单文件组件(SFC),Options API 风格;
    • API 统一封装在 src/api/chat.js
    • 组件名采用 PascalCase(如 DepartmentSelectionCard.vue);
    • 静态资源放在 public/src/assets/
    • 页面标题在 vue.config.jschainWebpack 中配置为 "甘肃省中医院"

    测试策略

    • 单元测试:后端依赖 spring-boot-starter-test,但当前没有大量覆盖的单元测试类;
    • 集成/手工测试:以 FAQ_TEST_CASES.mdCALL_FLOW.md 作为测试依据;
    • FAQ 批量验证:文档中提供了 curl 示例和 Bash 批量脚本,可直接运行验证;
    • 前端:无自动化 E2E 测试,依赖手动在 Chrome 移动端模拟器中演示。

    安全注意事项

    1. 硬编码 API Keymedical-card-demo/backend/src/main/resources/application.yml 中硬编码了阿里云百炼 API Key(bailian.api-key)。严禁提交到公开仓库,生产环境应改为环境变量或配置中心注入。
    2. CORS 全开放application.ymlWebConfigallowed-origins: "*",生产环境需收紧为具体域名。
    3. 数据存储:当前所有会话数据、患者档案均保存在内存 ConcurrentHashMap 中,重启后数据丢失,且存在并发安全和数据持久化风险。
    4. 二维码为演示用途:挂号成功页生成的二维码无法真实支付。

    开发提示

    • 如需修改 FAQ 内容,直接编辑 medical-card-demo/docs/省中导诊台.txt,格式固定为:

      问题:xxx?
      回答:xxx。
      
    • 如需调整前端页面标题或代理端口,修改 medical-card-demo/frontend/vue.config.js

    • 后端端口在 application.yml 中设为 8080,但 startup.sh 和演示环境通常使用 3380,注意保持一致。

    • 机器人 APK(RobotSample)与 Web 应用通过 JSBridge 方案进行通信(详见 仿鸿蒙机器人系统技术方案.md),前端点击“带我去”后触发机器人导航。

    Turn 2

    User

    阅读文档 @仿鸿蒙机器人系统技术方案.md,由于技术方案的调整,现在我需要将文档中 代码块内的 Kotlin→Java 重写,调整时请参考 @RobotSample-main/ 文件夹 demo 工程内对应各个模块的实现及调用方案(如导航、语音等)

    Assistant

    Thinking 用户要求我将 `仿鸿蒙机器人系统技术方案.md` 文档中的 Kotlin 代码块重写为 Java,并且需要参考 `RobotSample-main/` 文件夹中的 demo 工程实现。 这是一个比较大的任务,我需要: 1. 先读取 `仿鸿蒙机器人系统技术方案.md` 文档,了解其中有哪些 Kotlin 代码块 2. 探索 `RobotSample-main/` 目录,了解 demo 工程的结构和各个模块的实现 3. 将 Kotlin 代码转换为 Java,并参考 demo 工程的调用方案 让我先启动两个探索代理来并行工作: - 一个读取技术方案文档并找出所有 Kotlin 代码块 - 一个探索 RobotSample-main 的工程结构和实现

    Tool Call: Agent (读取技术方案文档)

    {
      "description": "读取技术方案文档",
      "prompt": "请读取 /Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md 文档,找出文档中所有的 Kotlin 代码块。对每个代码块,记录:\n1. 代码块所在的大致位置(前后文标题/段落)\n2. 代码块的内容\n3. 代码块的功能描述(如导航模块、语音模块等)\n\n请完整输出所有 Kotlin 代码块的信息。",
      "subagent_type": "explore"
    }
    

    Tool Call: Agent (探索RobotSample工程)

    {
      "description": "探索RobotSample工程",
      "prompt": "请深入探索 /Users/destiny/dev/emoon/demos/card_demo/RobotSample-main/ 目录下的 Android demo 工程,重点关注以下内容:\n\n1. 工程的整体结构(所有 Java 文件)\n2. 导航模块的实现方式(如 LeadActivity、导航相关 Fragment/Activity)\n3. 语音模块的实现方式(如语音播放、语音识别、TTS 等)\n4. 地图相关模块\n5. 硬件控制相关(如 LED、运动控制等)\n6. 各个模块中调用 robotservice.jar SDK 的具体 API 和方法签名\n\n请详细列出关键 Java 文件的路径、类名、关键方法名及其签名,特别是那些可能被 Kotlin 代码引用的对应实现。需要看到具体的代码实现。",
      "subagent_type": "explore"
    }
    
    Tool Result: Agent (`读取技术方案文档`) agent_id: a756444c9 resumed: false actual_subagent_type: explore status: completed [summary] 我已完整读取了文档的全部 5246 行内容。经过逐行分析,文档中标记为 `java` 但实际上使用 **Kotlin 语法**(`val`/`var`、`fun`、`class X : Y()`、`data class`、`when`、`apply { }`、`override fun`、`?.` 安全调用等)的代码块共有 **20 处**。 以下是所有 Kotlin 代码块的完整信息,按文档出现顺序排列: --- ## 1. LauncherActivity 下拉手势检测(简化版) **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.3 技术实现要点 → 下拉手势拦截 **功能**:Launcher 桌面主页的下拉手势检测,用于触发仿 HarmonyOS 控制中心。 ```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) } } ``` --- ## 2. 全屏沉浸式设置(简化版) **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.3 技术实现要点 → 全屏沉浸式 **功能**:隐藏系统状态栏和导航栏,实现仿鸿蒙桌面的全屏沉浸式体验。 ```kotlin window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN ) ``` --- ## 3. LauncherActivity.java 完整代码 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.1 LauncherActivity.java 完整代码 **功能**:仿鸿蒙桌面主页核心 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(R.id.tv_time) val dateView = findViewById(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() } } ``` --- ## 4. AppItem.java 数据类 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.3 AppItem.java 数据类 **功能**:桌面应用的数据模型,定义应用名称、图标资源和类型枚举(BUSINESS/SETTINGS/FAKE)。 ```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 // 装饰图标(点击提示"即将推出") } } ``` --- ## 5. AppGridAdapter.java 图标网格适配器 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.4 AppGridAdapter.java 图标网格适配器 **功能**:RecyclerView 的适配器实现,负责将 AppItem 数据渲染为桌面图标卡片,包含点击事件和按下态效果。 ```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, // 应用列表数据源 private val onItemClick: (AppItem) -> Unit // 点击回调函数(lambda) ) : RecyclerView.Adapter() { /** * 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 } ``` --- ## 6. ControlCenterView.java 下拉控制中心 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.6 ControlCenterView.java 下拉控制中心 **功能**:仿 HarmonyOS 下拉控制中心自定义 View,包含快捷开关(WiFi/蓝牙/移动数据/飞行模式/位置服务)、亮度/音量滑块和关闭按钮。所有交互仅切换 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().apply { put("wifi", true) // WiFi:默认开启 put("bluetooth", false) // 蓝牙:默认关闭 put("mobile", true) // 移动数据:默认开启 put("airplane", false) // 飞行模式:默认关闭 put("location", true) // 位置服务:默认开启 } // 开关视图映射表(键为开关名称,值为对应的 ImageView) private val switchViews = HashMap() 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(iconId) val labelView = findViewById(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(R.id.seekbar_brightness) val valueText = findViewById(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(R.id.seekbar_volume) val valueText = findViewById(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(R.id.btn_close_control).setOnClickListener { // 获取父 Activity 并调用其 hideControlCenter 方法 (context as? LauncherActivity)?.hideControlCenter() } } } ``` --- ## 7. FakeSettingsActivity.java 假设置页 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.8 FakeSettingsActivity.java 假设置页 **功能**:假设置页面 Activity,仿 HarmonyOS 设置页风格,包含设置列表和"关于本机"对话框,展示假设备信息。 ```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(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(R.id.tv_device_name).text = "设备名称:豹小秘 Pro" aboutView.findViewById(R.id.tv_system_theme).text = "系统主题:HarmonyOS 风格 4.0" aboutView.findViewById(R.id.tv_processor).text = "处理器:Kirin 9000S" aboutView.findViewById(R.id.tv_ram).text = "运行内存:4 GB" aboutView.findViewById(R.id.tv_storage).text = "存储空间:64 GB" aboutView.findViewById(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, private val onClick: (SettingItem) -> Unit ) : RecyclerView.Adapter() { 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 } } ``` --- ## 8. BootReceiver.java 开机自启接收器 **位置**:三、仿鸿蒙 Launcher App 详细设计 → 3.5.10 BootReceiver.java 开机自启接收器(备用方案) **功能**:接收系统 `BOOT_COMPLETED` 广播,自动启动 LauncherActivity(备用自启方案)。 ```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) } } } ``` --- ## 9. WebView 容器配置 **位置**:四、原生业务 App 详细设计 → 4.2 WebView 容器配置 **功能**:配置 WebView 的 JavaScript、DOM 存储、文件访问、缩放等设置,并注入 RobotBridge。 ```kotlin val webView = findViewById(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") ``` --- ## 10. JSBridge Android 端实现(简化版) **位置**:四、原生业务 App 详细设计 → 4.3 JSBridge 通信协议设计 → Android 端 Bridge 实现 **功能**:RobotBridge 核心接口的简化实现,包含导航、获取位置点列表、TTS 播报、获取电量四个接口,通过 `callbackId` 模式回调 H5。 ```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 ) } } } } ``` --- ## 11. SDK Application 初始化 **位置**:四、原生业务 App 详细设计 → 4.4 猎户星空 SDK 集成步骤 → Application 中初始化 **功能**:在 Application.onCreate() 中初始化猎户星空 SDK 并连接服务。 ```kotlin class RobotApp : Application() { override fun onCreate() { super.onCreate() RobotApi.init(this) RobotApi.connectServer { status -> Log.d("RobotSDK", "Server 连接状态: $status") } } } ``` --- ## 12. 导航状态监听 **位置**:四、原生业务 App 详细设计 → 4.4 猎户星空 SDK 集成步骤 → 导航状态监听 **功能**:设置导航状态监听器,处理开始导航、避障、堵死、定位丢失、到达目的地、未定位、路径规划失败等状态码。 ```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", "路径规划失败") } } }) ``` --- ## 13. build.gradle(Module: app)— Gradle Kotlin DSL **位置**:四、原生业务 App 详细设计 → 4.7 补充:完整的 build.gradle(Module: app) **功能**:业务 App 模块的 Gradle 构建配置(使用 Kotlin DSL),定义编译版本、依赖库(AndroidX、猎户星空 SDK、Gson)、构建变体等。 ```kotlin /** * app 模块构建配置文件 * 作用:定义编译版本、依赖库、构建变体等 * 位置:MedicalRobotApp/app/build.gradle */ // 插件声明:应用 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") } ``` --- ## 14. MedicalRobotApplication.java 完整代码 **位置**:四、原生业务 App 详细设计 → 4.10 补充:MedicalRobotApplication.java 完整代码 **功能**:应用全局初始化类(Application),负责初始化猎户星空 SDK、建立机器人系统服务连接、管理 Mock 模式开关和全局 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 的 标签中通过 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() } } ``` --- ## 15. RobotSDKManager.java 完整代码 **位置**:四、原生业务 App 详细设计 → 4.11 补充:RobotSDKManager.java 完整代码 **功能**:猎户星空 RobotOS SDK 管理封装类(外观模式),统一封装导航、TTS、位置查询、电量等 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}") } } } ``` --- ## 16. MainActivity.java 完整代码(WebView 容器) **位置**:四、原生业务 App 详细设计 → 4.12 补充:MainActivity.java 完整代码(WebView 容器) **功能**:业务 App 的唯一直接可见界面,全屏 WebView 容器,负责加载 H5 页面、注入 JSBridge、处理页面加载事件、管理 WebView 生命周期。 ```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 表示已处理此弹窗 } /** * 处理文件选择(如 ) * 用于 H5 页面上传图片、拍照等功能 */ override fun onShowFileChooser( view: WebView?, filePathCallback: android.webkit.ValueCallback>?, 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() } } ``` --- ## 17. RobotBridge.java 完整 JSBridge 实现 **位置**:四、原生业务 App 详细设计 → 4.14 补充:RobotBridge.java 完整 JSBridge 实现 **功能**:完整的 JSBridge 桥接类,实现 7 个 Native 接口(导航、停止导航、位置点列表、当前位置、TTS、停止 TTS、电量),通过 `callbackId` 模式与 H5 双向通信,支持 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 = 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() } } ``` --- ## 18. WebView 远程调试配置 **位置**:四、原生业务 App 详细设计 → 4.16.5 远程调试 WebView **功能**:在 Debug 模式下启用 WebView 远程调试,支持 Chrome DevTools 连接。 ```kotlin if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true) } ``` --- ## 19. WebView 常用设置 **位置**:五、H5 前端适配改造 → 5.5 H5 页面在 WebView 中的常见坑和解决方案 **功能**:WebView 核心初始化配置(JavaScript、DOM 存储、媒体自动播放、硬件加速)。 ```kotlin webView.settings.apply { javaScriptEnabled = true domStorageEnabled = true mediaPlaybackRequiresUserGesture = false } webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) ``` --- ## 20. MedicalWebViewClient 降级加载 **位置**:五、H5 前端适配改造 → 5.6 前端打包部署到机器人的完整流程 → 混合模式 **功能**:WebViewClient 实现,当远程服务器页面加载失败时,自动降级加载本地 assets 中的离线 H5 资源。 ```kotlin 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 } } } ``` --- ## 汇总 | 序号 | 功能模块 | 位置(章节) | 代码性质 | |------|---------|-------------|---------| | 1 | 下拉手势检测(简化) | 3.3 技术实现要点 | Kotlin | | 2 | 全屏沉浸式(简化) | 3.3 技术实现要点 | Kotlin | | 3 | LauncherActivity | 3.5.1 | Kotlin | | 4 | AppItem 数据类 | 3.5.3 | Kotlin | | 5 | AppGridAdapter | 3.5.4 | Kotlin | | 6 | ControlCenterView | 3.5.6 | Kotlin | | 7 | FakeSettingsActivity | 3.5.8 | Kotlin | | 8 | BootReceiver | 3.5.10 | Kotlin | | 9 | WebView 容器配置 | 4.2 | Kotlin | | 10 | RobotBridge(简化版) | 4.3 | Kotlin | | 11 | SDK Application 初始化 | 4.4 | Kotlin | | 12 | 导航状态监听 | 4.4 | Kotlin | | 13 | build.gradle(Kotlin DSL) | 4.7 | Gradle Kotlin DSL | | 14 | MedicalRobotApplication | 4.10 | Kotlin | | 15 | RobotSDKManager | 4.11 | Kotlin | | 16 | MainActivity | 4.12 | Kotlin | | 17 | RobotBridge(完整版) | 4.14 | Kotlin | | 18 | WebView 远程调试 | 4.16.5 | Kotlin | | 19 | WebView 常用设置 | 5.5 | Kotlin | | 20 | MedicalWebViewClient | 5.6 | Kotlin | **说明**:文档中所有代码块均标记为 `java`,但实际语法均为 Kotlin(使用了 `val`/`var`、`fun`、`data class`、`apply { }`、`when`、`?.` 安全调用、`override fun`、字符串模板等 Kotlin 特性)。其中第 13 项为 Gradle Kotlin DSL 构建脚本,其余均为 Android 应用层 Kotlin 代码。
    Tool Result: Agent (`探索RobotSample工程`) agent_id: a45675098 resumed: false actual_subagent_type: explore status: completed [summary] 以下是针对 `RobotSample-main` Android Demo 工程的深入探索报告。 --- ## 1. 工程整体结构 ### 1.1 目录与构建配置 - **应用模块**: `app/` - **compileSdk**: 29, **minSdk**: 16, **Java 8** - **核心依赖**: `app/libs/robotservice.jar`(猎户星空/ OrionStar SDK) - **其他依赖**: AndroidX AppCompat、ConstraintLayout、Gson 2.7 ### 1.2 全部 Java 文件清单 | 路径 | 类名 | 说明 | |---|---|---| | `com.ainirobot.robotos` | | | | `MainActivity.java` | `MainActivity` | 入口 Activity,Splash + Fragment 切换容器 | | `LogTools.java` | `LogTools` | 全局日志工具,支持 OnLogListener 回调 | | `application/RobotOSApplication.java` | `RobotOSApplication` | Application,初始化 RobotApi 与 SkillApi | | `application/ModuleCallback.java` | `ModuleCallback` | 继承 `ModuleCallbackApi`,接收系统请求与硬件异常 | | `application/SpeechCallback.java` | `SpeechCallback` | 继承 `SkillCallback`,语音播放/识别回调 | | `audio/AudioManager.java` | `AudioManager` | 麦克风音频录制管理(PCM/WAV) | | `audio/WavHeader.java` | `WavHeader` | WAV 文件头生成 | | `fragment/BaseFragment.java` | `BaseFragment` | Fragment 基类,封装返回/结果视图 | | `fragment/MainFragment.java` | `MainFragment` | 主菜单,跳转各功能 Fragment | | `fragment/LeadFragment.java` | `LeadFragment` | **引领/巡逻**功能 | | `fragment/NavigationFragment.java` | `NavigationFragment` | **导航**功能(含闸机路线判断) | | `fragment/NavFragment.java` | `NavFragment` | **地图可视化导航**(带地图显示) | | `fragment/SpeechFragment.java` | `SpeechFragment` | **语音 TTS / 语义查询** | | `fragment/AudioFragment.java` | `AudioFragment` | 麦克风录音 Demo | | `fragment/VisionFragment.java` | `VisionFragment` | 视觉/人脸检测与注册 | | `fragment/BodyFollowFragment.java` | `BodyFollowFragment` | 人体跟随(Deprecated) | | `fragment/ChargeFragment.java` | `ChargeFragment` | **自动回充/充电管理** | | `fragment/LocationFragment.java` | `LocationFragment` | 定位、位置点设置/获取 | | `fragment/SportFragment.java` | `SportFragment` | **运动控制(底盘+头部)** | | `fragment/ElectricDoorControlFragment.java` | `ElectricDoorControlFragment` | 电动门状态监听+控制 | | `fragment/ElectricDoorActionControlFragment.java` | `ElectricDoorActionControlFragment` | 电动门动作封装控制 | | `fragment/FailedFragment.java` | `FailedFragment` | SDK 连接失败页 | | `view/MapView.java` | `MapView` | 自定义地图绘制 View(Bitmap+Matrix) | | `view/BackView.java` | `BackView` | 返回按钮 View | | `view/ResultView.java` | `ResultView` | 结果展示 View | | `maputils/MapppUtils.java` | `MapppUtils` | 地图 PGM 解析、坐标转换工具 | | `maputils/RoverMap.java` | `RoverMap` | 地图数据实体(Bitmap、分辨率、坐标原点) | | `maputils/Pose2d.java` | `Pose2d` | 二维坐标实体 (x, y, theta, status) | | `maputils/PoseBean.java` | `PoseBean` | 带名称的点位实体 | | `maputils/Constant.java` | `Constant` | 地图颜色常量、导航点枚举 | | `maputils/...` | `DialogUtils`/`GlobalData`/`GsonUtil`/`SpecialPlaceUtil` 等 | 辅助工具 | --- ## 2. 导航模块实现 ### 2.1 核心类与职责 | 类 | 职责 | |---|---| | `LeadFragment` | **引领(带人走)** + **巡航(巡逻)** | | `NavigationFragment` | 基于**点位名称**的导航,含闸机路线判断 | | `NavFragment` | **地图交互式导航**:加载地图 Bitmap、显示点位、点击导航 | ### 2.2 关键代码实现与 SDK API #### LeadFragment(引领 & 巡航) ```java // 文件: fragment/LeadFragment.java // 1. 获取地图所有位置点 List route = RobotApi.getInstance().getPlaceList(); // 2. 开始巡航 RobotApi.getInstance().startCruise(int reqId, List route, int startPoint, List dockingPoints, ActionListener listener); // 3. 开始引领(需先获取视野内人体) List personList = PersonApi.getInstance().getAllBodyList(); Person person = PersonUtils.getBestBody(personList, 3); LeadingParams params = new LeadingParams(); params.setPersonId(person.getId()); params.setDestinationName("目标点位"); params.setLostTimer(10 * 1000); params.setDetectDelay(5 * 1000); params.setMaxDistance(3); RobotApi.getInstance().startLead(int reqId, LeadingParams params, ActionListener listener); // 4. 停止引领 RobotApi.getInstance().stopLead(int reqId, boolean isResetHW); ``` #### NavigationFragment(普通导航) ```java // 文件: fragment/NavigationFragment.java // 1. 开始导航到点位 RobotApi.getInstance().startNavigation(int reqId, String placeName, double coordinateDeviation, long time, ActionListener listener); // 2. 停止导航 RobotApi.getInstance().stopNavigation(int reqId); // 3. 转向目标点(不移动) RobotApi.getInstance().resumeSpecialPlaceTheta(int reqId, String placeName, CommandListener listener); // 4. 获取闸机通行路线 RobotApi.getInstance().getGatePassingRoute(int reqId, String placeName, CommandListener listener); // 5. 计算两点距离 double distance = RobotApi.getInstance().getPlaceOrPoseDistance(String name, Pose pose); ``` #### NavFragment(地图导航) ```java // 文件: fragment/NavFragment.java // 1. 获取当前地图名 RobotApi.getInstance().getMapName(int reqId, CommandListener listener); // 2. 获取地图 PGM 文件描述符(共享内存) ParcelFileDescriptor pfd = ShareMemoryApi.getInstance().getMapPgmPFD(String mapName); // 3. 获取国际化点位列表 RobotApi.getInstance().getInternationalPlaceList(int reqId, String mapName, CommandListener listener); // 4. 注册位姿状态监听 RobotApi.getInstance().registerStatusListener(String type, StatusListener listener); RobotApi.getInstance().unregisterStatusListener(StatusListener listener); // 5. 静态方法封装导航(供地图点击调用) public static void startNavigation(String placeName) { RobotApi.getInstance().startNavigation(0, placeName, 1.5, 10 * 1000, mNavigationListener); } ``` ### 2.3 回调监听器签名 ```java // ActionListener(用于 startNavigation/startLead/startCruise) public interface ActionListener { void onResult(int status, String responseString) throws RemoteException; void onError(int errorCode, String errorString) throws RemoteException; void onStatusUpdate(int status, String data) throws RemoteException; } // CommandListener(用于 getMapName/resumeSpecialPlaceTheta 等) public interface CommandListener { void onResult(int result, String message, String extraData); void onStatusUpdate(int status, String data, String extraData); void onError(int errorCode, String errorString, String extraData) throws RemoteException; } // StatusListener(用于 registerStatusListener) public interface StatusListener { void onStatusUpdate(String type, String value); } ``` --- ## 3. 语音模块实现 ### 3.1 核心类与职责 | 类 | 职责 | |---|---| | `SpeechFragment` | UI 层:TTS 播放、停止、语义查询 | | `RobotOSApplication` | 初始化 `SkillApi`,注册 `SpeechCallback` | | `SpeechCallback` | 继承 `SkillCallback`,接收语音事件(开始/停止/音量/ASR 结果) | | `AudioManager` | 独立音频录制(AudioRecord),保存 WAV | ### 3.2 关键代码实现与 SDK API #### RobotOSApplication 中语音初始化 ```java // 文件: application/RobotOSApplication.java private void initSkillApi() { mSkillApi = new SkillApi(); mSkillApi.addApiEventListener(new ApiListener() { @Override public void handleApiConnected() { mSkillApi.registerCallBack(mSkillCallback); // SpeechCallback } }); mSkillApi.connectApi(mContext); } public SkillApi getSkillApi() { if (mSkillApi.isApiConnectedService()) { return mSkillApi; } return null; } ``` #### SpeechFragment(TTS & 语义查询) ```java // 文件: fragment/SpeechFragment.java private SkillApi mSkillApi = RobotOSApplication.getInstance().getSkillApi(); // 1. 播放 TTS private void playText(String text) { mSkillApi.playText(new TTSEntity("sid-1234567890", text), mTextListener); } // 2. 停止 TTS private void stopTTS() { mSkillApi.stopTTS(); } // 3. 文本语义查询(直接通过语音技能查询) private void queryByText(String text) { mSkillApi.queryByText(text); } // 4. TTS 播放监听器 private TextListener mTextListener = new TextListener() { @Override public void onStart() { } @Override public void onStop() { } @Override public void onComplete() { } @Override public void onError() { } }; ``` #### SpeechCallback(语音系统回调) ```java // 文件: application/SpeechCallback.java public class SpeechCallback extends SkillCallback { @Override public void onSpeechParResult(String s) throws RemoteException { } @Override public void onStart() throws RemoteException { } @Override public void onStop() throws RemoteException { } @Override public void onVolumeChange(int i) throws RemoteException { } @Override public void onQueryEnded(int i) throws RemoteException { } @Override public void onQueryAsrResult(String asrResult) throws RemoteException { } } ``` #### ModuleCallback(接收系统语音指令) ```java // 文件: application/ModuleCallback.java public class ModuleCallback extends ModuleCallbackApi { @Override public boolean onSendRequest(int reqId, String reqType, String reqText, String reqParam) throws RemoteException { // 接收底层发起的语音 request 请求 return true; } } ``` #### AudioManager(本地录音) ```java // 文件: audio/AudioManager.java // 使用 Android AudioRecord 直接录制麦克风 public int startRecord(int channelConfig, int bufferSize, AudioRecordCallback callback) public String stopRecord() ``` --- ## 4. 地图相关模块 ### 4.1 核心类 | 类 | 职责 | |---|---| | `NavFragment` | 地图业务逻辑:加载地图、获取点位、点击导航 | | `MapView` | 自定义 View:地图缩放/拖拽/点位绘制/机器人位姿绘制 | | `RoverMap` | 地图数据实体:`Bitmap bitmap`, `double x, y, res`, `byte[] extra` | | `MapppUtils` | PGM 地图解析(zip/pfd)、像素坐标与物理坐标转换 | | `Pose2d` / `PoseBean` | 坐标模型 | ### 4.2 关键代码实现 #### NavFragment 地图加载流程 ```java // 文件: fragment/NavFragment.java // 1. 获取地图名 -> 获取地图文件描述符 -> 解析 RoverMap RobotApi.getInstance().getMapName(0, new CommandListener() { @Override public void onResult(int result, String message, String extraData) { getMap(message); // 加载地图 } }); private void getMap(String name) { // 通过共享内存获取 PGM ParcelFileDescriptor mapPgmPFD = ShareMemoryApi.getInstance().getMapPgmPFD(name); FileDescriptor fd = mapPgmPFD.getFileDescriptor(); mRoverMap = MapppUtils.loadPFD2RoverMap(new FileInputStream(fd)); mMapView.setBitmap(mRoverMap.bitmap); mMapView.setResolution(mRoverMap.res); // 获取点位列表 RobotApi.getInstance().getInternationalPlaceList(0, name, new CommandListener() { @Override public void onResult(int result, String message, String extraData) { List placeBeanList = gson.fromJson(message, ...); // 转为 PoseBean 并绘制 mMapView.setPoseBeans(poseBeans); } }); } ``` #### MapppUtils 坐标转换 ```java // 文件: maputils/MapppUtils.java public static Pose2d pose2PixelByRoverMap(RoverMap costMap, Pose2d pose) { Pose2d newPose = new Pose2d(pose.x, pose.y, pose.t, pose.status); if (costMap != null) { newPose.x -= costMap.x; newPose.x /= costMap.res; newPose.y -= costMap.y; newPose.y /= costMap.res; newPose.y = costMap.height - newPose.y; } return newPose; } ``` #### MapView 核心功能 - **MapMode**: `PREVIEW`(预览)、`EDIT`(编辑)、`COLLECT`(采集)、`POINT`(点击选点) - **绘制内容**: 地图 Bitmap、机器人位姿(红色箭头)、点位图标、比例尺、网格线 - **交互**: 双指缩放/旋转、单指拖拽、点位点击触发 `OnPlaceClickListener` --- ## 5. 硬件控制相关 > **注意**: 该 Demo 中**未包含 LED 灯效控制**的具体示例代码,但 `robotservice.jar` 中存在 `LedLightBean` 类。以下列出 Demo 中实际实现的硬件控制。 ### 5.1 运动控制(SportFragment) ```java // 文件: fragment/SportFragment.java // 底盘运动 RobotApi.getInstance().goForward(int reqId, float speed, CommandListener listener); // 前进 RobotApi.getInstance().goBackward(int reqId, float speed, CommandListener listener); // 后退 RobotApi.getInstance().turnLeft(int reqId, float angle, CommandListener listener); // 左转 RobotApi.getInstance().turnRight(int reqId, float angle, CommandListener listener); // 右转 RobotApi.getInstance().stopMove(int reqId, CommandListener listener); // 停止 // 头部云台控制 RobotApi.getInstance().moveHead(int reqId, String horizontalMode, String verticalMode, int horizontalAngle, int verticalAngle, CommandListener listener); // 示例: moveHead(reqId, "relative", "relative", 0, -10, listener) // 抬头 ``` ### 5.2 充电控制(ChargeFragment) ```java // 文件: fragment/ChargeFragment.java // 自动回充 RobotApi.getInstance().startNaviToAutoChargeAction(int reqId, long timeout, ActionListener listener); RobotApi.getInstance().stopAutoChargeAction(int reqId, boolean forceStop); // 离开充电桩 RobotApi.getInstance().leaveChargingPile(int reqId, float distance, float speed, CommandListener listener); // 禁用/启用自动充电策略 RobotApi.getInstance().disableBattery(); RobotApi.getInstance().enableBattery(); // 获取电量 RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO); ``` ### 5.3 电动门控制(两个 Fragment) #### ElectricDoorControlFragment(状态监听 + 控制) ```java // 文件: fragment/ElectricDoorControlFragment.java // 注册电动门状态监听 RobotApi.getInstance().registerStatusListener( Definition.STATUS_CAN_ELECTRIC_DOOR_CTRL, statusListener); // 控制电动门 RobotApi.getInstance().setElectricDoorCtrl(int reqId, int doorCmd, CommandListener listener); // doorCmd 取值: // Definition.CAN_DOOR_DOOR1_DOOR2_OPEN / CLOSE // Definition.CAN_DOOR_DOOR3_DOOR4_OPEN / CLOSE // Definition.CAN_DOOR_ALL_OPEN / CLOSE // 查询状态 RobotApi.getInstance().getElectricDoorStatus(int reqId, CommandListener listener); ``` #### ElectricDoorActionControlFragment(动作封装) ```java // 文件: fragment/ElectricDoorActionControlFragment.java RobotApi.getInstance().startControlElectricDoor(int reqId, int doorCmd, ActionListener listener); ``` ### 5.4 人体跟随(BodyFollowFragment,Deprecated) ```java // 文件: fragment/BodyFollowFragment.java PersonApi.getInstance().registerPersonListener(mPersonListener); RobotApi.getInstance().startBodyFollowAction(int reqId, int personId, ActionListener listener); RobotApi.getInstance().stopBodyFollowAction(int reqId); ``` --- ## 6. robotservice.jar SDK API 汇总 ### 6.1 核心入口类 | 类 | 说明 | |---|---| | `com.ainirobot.coreservice.client.RobotApi` | **主 API 入口**(单例) | | `com.ainirobot.coreservice.client.speech.SkillApi` | **语音技能 API** | | `com.ainirobot.coreservice.client.person.PersonApi` | **人体/人脸 API** | | `com.ainirobot.coreservice.client.robotsetting.RobotSettingApi` | **机器人设置 API** | | `com.ainirobot.coreservice.client.ashmem.ShareMemoryApi` | **共享内存 API**(地图/图像) | ### 6.2 RobotApi 关键方法签名(由 Demo 推断) ```java // 连接与状态 void connectServer(Context context, ApiListener listener) boolean isApiConnectedService() boolean isActive() void setCallback(ModuleCallbackApi callback) void setResponseThread(HandlerThread thread) // 导航 void startNavigation(int reqId, String placeName, double deviation, long time, ActionListener listener) void startNavigation(int reqId, Pose pose, double deviation, long time, ActionListener listener) void stopNavigation(int reqId) // 引领 void startLead(int reqId, LeadingParams params, ActionListener listener) void stopLead(int reqId, boolean isResetHW) // 巡航 void startCruise(int reqId, List route, int startPoint, List dockingPoints, ActionListener listener) // 运动控制 void goForward(int reqId, float speed, CommandListener listener) void goBackward(int reqId, float speed, CommandListener listener) void turnLeft(int reqId, float angle, CommandListener listener) void turnRight(int reqId, float angle, CommandListener listener) void stopMove(int reqId, CommandListener listener) void moveHead(int reqId, String hMode, String vMode, int hAngle, int vAngle, CommandListener listener) // 回充 void startNaviToAutoChargeAction(int reqId, long timeout, ActionListener listener) void stopAutoChargeAction(int reqId, boolean forceStop) void leaveChargingPile(int reqId, float distance, float speed, CommandListener listener) void disableBattery() void enableBattery() // 电动门 void setElectricDoorCtrl(int reqId, int cmd, CommandListener listener) void getElectricDoorStatus(int reqId, CommandListener listener) void startControlElectricDoor(int reqId, int cmd, ActionListener listener) // 人体跟随 void startBodyFollowAction(int reqId, int personId, ActionListener listener) void stopBodyFollowAction(int reqId) // 地图与位置 List getPlaceList() void getMapName(int reqId, CommandListener listener) void getInternationalPlaceList(int reqId, String mapName, CommandListener listener) void getGatePassingRoute(int reqId, String placeName, CommandListener listener) double getPlaceOrPoseDistance(String name, Pose pose) void getPosition(int reqId, CommandListener listener) void setLocation(int reqId, String name, CommandListener listener) void removeLocation(int reqId, String name, CommandListener listener) void setPoseEstimate(int reqId, String jsonParams, CommandListener listener) void isRobotEstimate(int reqId, CommandListener listener) void isRobotInlocations(int reqId, String jsonParams, CommandListener listener) boolean isRobotEstimate() boolean isChargePileExits() // 转向 void resumeSpecialPlaceTheta(int reqId, String placeName, CommandListener listener) // 状态监听 void registerStatusListener(String type, StatusListener listener) void unregisterStatusListener(StatusListener listener) // 视觉/人脸 void getPictureById(int reqId, int personId, int type, CommandListener listener) void getPersonInfoFromNet(int reqId, String userId, List facePics, CommandListener listener) void startRegister(int reqId, String name, int timeout, int count, int type, ActionListener listener) ``` ### 6.3 SkillApi 关键方法签名 ```java // 文件: com.ainirobot.coreservice.client.speech.SkillApi void connectApi(Context context) boolean isApiConnectedService() void addApiEventListener(ApiListener listener) void registerCallBack(SkillCallback callback) // TTS void playText(TTSEntity ttsEntity, TextListener listener) void stopTTS() // 语义查询 void queryByText(String text) ``` ### 6.4 PersonApi 关键方法签名 ```java // 文件: com.ainirobot.coreservice.client.person.PersonApi void registerPersonListener(PersonListener listener) void unregisterPersonListener(PersonListener listener) List getAllBodyList() List getAllFaceList() List getCompleteFaceList() List getAllPersons() ``` ### 6.5 关键 Bean / Listener 类 ```java // ActionBean com.ainirobot.coreservice.client.actionbean.Pose com.ainirobot.coreservice.client.actionbean.LeadingParams com.ainirobot.coreservice.client.actionbean.PlaceBean // Listeners com.ainirobot.coreservice.client.listener.ActionListener com.ainirobot.coreservice.client.listener.CommandListener com.ainirobot.coreservice.client.listener.StatusListener com.ainirobot.coreservice.client.listener.TextListener com.ainirobot.coreservice.client.person.PersonListener com.ainirobot.coreservice.client.speech.SkillCallback com.ainirobot.coreservice.client.module.ModuleCallbackApi com.ainirobot.coreservice.client.ApiListener ``` --- ## 7. 给 Kotlin 引用的对应说明 由于 Demo 全部为 Java 编写,若要在 Kotlin 中调用,需注意以下对应关系: | Java 概念 | Kotlin 对应写法 | |---|---| | `RobotApi.getInstance()` | `RobotApi.getInstance()`(单例调用不变) | | `new ActionListener() { ... }` | `object : ActionListener { ... }` | | `List route = ...` | `val route: List = ...` | | `PersonUtils.getBestBody(list, 3)` | `PersonUtils.getBestBody(list, 3)`(静态方法变为伴生对象或 @JvmStatic) | | `LeadingParams params = new LeadingParams()` | `val params = LeadingParams()` | | `CommandListener` / `ActionListener` | 可用 SAM 转换(若接口为函数式接口):`CommandListener { result, msg, extra -> ... }` | | `throws RemoteException` | Kotlin 无需显式捕获,但建议用 try-catch | --- ## 总结 该 Demo 完整覆盖了猎户星空机器人 SDK 的核心能力: 1. **导航**:普通导航、引领、巡航、闸机路线规划。 2. **语音**:TTS 播放、语义查询、ASR 结果回调。 3. **地图**:PGM 地图解析、共享内存读取、自定义 View 绘制与交互。 4. **硬件**:底盘运动、头部云台、自动回充、电动门控制。 5. **视觉**:人脸检测、人体跟随(旧版)、人脸注册。 若需进一步 Kotlin 化或封装为 Repository/ViewModel 层,可直接基于上述 `RobotApi` / `SkillApi` / `PersonApi` 单例进行包装。
    Thinking 现在我有了所有需要的信息: 1. 文档中所有 20 处 Kotlin 代码块的详细内容 2. RobotSample-main demo 工程的真实 Java 实现和 SDK API 签名 我的任务是将文档中所有 Kotlin 代码块重写为 Java,同时参考 RobotSample-main 中对应的实现来调整 API 调用方式(特别是导航、语音等模块)。 这是一个很大的文档修改任务。我需要: 1. 先读取原始文档 2. 逐个替换 Kotlin 代码块为 Java 代码块 3. 确保代码与 RobotSample 中的 SDK API 调用一致 由于文档很大(5246 行),我不能一次性重写整个文档。我需要读取文档,然后逐个代码块进行替换。 让我先创建一个 TODO list 来跟踪进度,然后开始工作。 文档有 20 个 Kotlin 代码块需要转换。我需要: 1. 读取原始文档内容 2. 对每个代码块,用 StrReplaceFile 进行替换 但考虑到文档非常大,而且有很多代码块,我需要先读取文档来了解其结构,然后逐步替换。 让我开始吧。先读取文档的前部分,了解格式。

    Tool Call: SetTodoList

    {
      "todos": [
        {
          "title": "读取原始文档,分析结构和代码块位置",
          "status": "in_progress"
        },
        {
          "title": "转换 Launcher 相关 Kotlin 代码(5个代码块)",
          "status": "pending"
        },
        {
          "title": "转换 WebView/JSBridge 相关 Kotlin 代码(4个代码块)",
          "status": "pending"
        },
        {
          "title": "转换 SDK 集成相关 Kotlin 代码(4个代码块)",
          "status": "pending"
        },
        {
          "title": "转换 Application/Manager/Bridge 完整类(5个代码块)",
          "status": "pending"
        },
        {
          "title": "转换 Gradle 和杂项代码块(2个代码块)",
          "status": "pending"
        },
        {
          "title": "验证替换完整性",
          "status": "pending"
        }
      ]
    }
    
    Tool Result: SetTodoList Todo list updated Todo list updated

    Turn 3

    User

    有 token 了,继续跑吧

    Assistant

    Thinking 用户提醒我继续工作。我需要继续将 `仿鸿蒙机器人系统技术方案.md` 中的 Kotlin 代码块重写为 Java,并参考 RobotSample-main 中的实现。 我已经有了: 1. 所有 20 个 Kotlin 代码块的详细内容 2. RobotSample-main demo 工程的真实 Java 实现和 SDK API 签名 现在需要读取原始文档并开始替换。由于文档很大(5246 行),我需要分块读取和处理。 让我先读取文档,然后开始逐个替换代码块。我会从文档的开头开始,逐步往下处理。 先读取文档的前 1000 行。

    Tool Call: ReadFile (/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md)

    {
      "path": "/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md",
      "line_offset": 1,
      "n_lines": 1000
    }
    
    Tool Result: ReadFile (`/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md`) 1000 lines read from file starting from line 1. Total lines in file: 5246. 1 # 仿鸿蒙机器人系统技术方案 2 3 > **版本**: v2.0 4 > **日期**: 2026-04-23 5 > **编写**: 技术团队 6 > **状态**: 待评审 7 8 --- 9 10 ## 修改记录 11 12 | 版本 | 日期 | 修改人 | 修改内容 | 13 |------|------|--------|---------| 14 | v1.0 | 2026-04-23 | 技术团队 | 初稿完成 | 15 | v2.0 | 2026-04-23 | 技术团队 | 根据猎户星空厂商会议反馈更新:Java 开发语言、真实 SDK API、Android 9.0 适配 | 16 17 --- 18 19 ## 一、项目概述与目标 20 21 ### 1.1 迎检需求背景 22 23 本项目面向领导视察场景,目标是在猎户星空豹小秘系列机器人上部署一套**智慧医疗导诊系统**。机器人基于 **Android 9.0(API 28)+ RobotOS** 定制系统,需在视觉上伪装为 HarmonyOS 4 风格,同时保留完整的导航带路能力。现有业务系统(Vue 前端 + Spring Boot 后端)已完成功能开发,现需构建原生安卓外壳以适配机器人硬件环境。 24 25 ### 1.2 四条硬性约束 26 27 | 约束编号 | 约束内容 | 原因 | 28 |----------|---------|------| 29 | C1 | **不改机器人系统** | 无系统刷机权限,OTA 升级由厂商控制 | 30 | C2 | **必须使用猎户星空原生 SDK** | 导航、TTS、电量等能力只有官方 SDK 能提供 | 31 | C3 | **2 人全栈团队,1 周交付,Java 开发** | 人力资源和时间窗口极为有限,方案必须极简,开发语言统一为 Java | 32 | C4 | **纯视觉伪装,不 claim 真鸿蒙** | 避免法律风险,仅 UI 层面模仿 HarmonyOS 4 风格 | 33 34 ### 1.3 迎检成功标准 35 36 1. 开机后进入仿鸿蒙桌面,视觉风格以假乱真 37 2. 点击"智慧医疗"图标正常进入业务系统 38 3. 用户对话后推荐科室,点击"带我去"机器人实际移动带路 39 4. 全程无系统弹窗、无崩溃、无白屏 40 5. 对外口径统一为"仿鸿蒙风格 UI 定制",不对外宣称已部署正式 HarmonyOS 系统,避免合规风险 41 42 --- 43 44 ## 二、整体架构设计 45 46 ### 2.1 三层架构图 47 48 ```mermaid 49 graph TB 50 subgraph 表层["表层:仿 HarmonyOS 4 Launcher App(APK #1)"] 51 L1[桌面主页] 52 L2[下拉控制中心] 53 L3[假设置页] 54 end 55 56 subgraph 中层["中层:原生业务 App(APK #2)"] 57 W[WebView 容器] 58 JB[JSBridge 桥接层] 59 SDK[猎户星空 SDK] 60 end 61 62 subgraph 底层["底层:RobotOS 原生系统"] 63 OS[Android 系统] 64 NAV[导航服务] 65 TTS[TTS 引擎] 66 end 67 68 L1 -->|Intent 启动| W 69 W -->|JS 调用| JB 70 JB -->|SDK API| SDK 71 SDK -->|RobotOS Service| NAV 72 SDK -->|RobotOS Service| TTS 73 OS -->|系统服务| NAV 74 OS -->|系统服务| TTS 75 ``` 76 77 ### 2.2 各层职责边界 78 79 | 层级 | APK 数量 | 核心职责 | 技术栈 | 80 |------|---------|---------|--------| 81 | 表层 Launcher | 1 | 替代系统桌面,提供仿鸿蒙 UI 壳 | Android Native(Java) | 82 | 中层业务 App | 1 | WebView 加载 H5,桥接原生能力 | Android Native + Vue H5 | 83 | 底层系统 | 0(不动) | 提供导航、语音、电量等硬件能力 | RobotOS(Android 定制版) | 84 85 ### 2.3 数据流与调用链路 86 87 1. **开机启动**:SDK 注册自启(Action: `action.orionstar.default.app`)→ Launcher App 自启 → 显示仿鸿蒙桌面 88 2. **进入业务**:用户点击"智慧医疗"图标 → Launcher 发送 Intent 启动业务 App 89 3. **业务交互**:WebView 加载 `http://localhost:8080` → 用户与 AI 对话 90 4. **触发导航**:H5 调用 `RobotBridge.navigate("内科门诊")` → JSBridge → `RobotApi.getInstance().startNavigation()` → 机器人移动 91 5. **返回桌面**:用户按 Home 键 → 业务 App 退后台 → Launcher 回到前台 92 93 ### 2.4 两个 APK 的关系 94 95 Launcher App 与业务 App 为**两个独立 APK**,通过标准 Android Intent 机制交互: 96 97 - Launcher 不直接包含业务逻辑,仅作为入口和视觉伪装层 98 - 业务 App 包内嵌 WebView,加载本地或局域网 H5 页面 99 - 业务 App 不处理 HOME 键,按 Home 时系统回调 Launcher(因 Launcher 是默认桌面) 100 - **已知风险**:按 Home 键会回到原厂桌面而非自定义 Launcher(需通过设为默认桌面规避) 101 102 --- 103 104 ## 三、仿鸿蒙 Launcher App 详细设计 105 106 ### 3.1 UI 设计规范 107 108 参考 HarmonyOS 4 横屏桌面视觉特征: 109 110 - **圆角大图标卡片**:应用图标采用 24dp 圆角矩形,尺寸 72x72dp,带轻量投影 111 - **毛玻璃效果**:控制中心、文件夹背景使用 `BlurMaskFilter` / `RenderScript` 实现高斯模糊 112 - **底部 Dock 栏**:横屏下 Dock 位于底部,固定 4-5 个高频应用,背景为半透明白色 `rgba(255,255,255,0.2)` 113 - **时间日期 Widget**:桌面右上角显示大号时间(HH:mm)和日期(MM月dd日 星期X),字体使用鸿蒙风格无衬线体 114 - **壁纸风格**:蓝紫渐变 / 淡雅风景图,与系统默认壁纸拉开差异以显"新系统"感 115 116 #### 3.1.1 精确设计参数 117 118 **配色方案(HarmonyOS 4 风格)** 119 120 | 用途 | 颜色值 | 说明 | 121 |------|--------|------| 122 | 桌面背景渐变起始 | #1A1A2E | 深蓝黑 | 123 | 桌面背景渐变结束 | #16213E | 靛蓝 | 124 | 图标卡片背景 | #FFFFFF 15% opacity | 毛玻璃白 | 125 | 图标卡片按下态 | #FFFFFF 25% opacity | 按下反馈 | 126 | 图标标签文字 | #FFFFFF | 纯白 | 127 | Dock 栏背景 | #000000 30% opacity | 半透明黑 | 128 | 控制中心背景 | #1A1A2E 95% opacity | 近不透明深色 | 129 | 控制中心开关-开启 | #007DFF | 鸿蒙蓝 | 130 | 控制中心开关-关闭 | #404040 | 深灰 | 131 | 时间文字 | #FFFFFF | 纯白 | 132 | 日期文字 | #FFFFFF 70% opacity | 半透明白 | 133 | 设置页背景 | #F1F3F5 | 浅灰白 | 134 | 设置页卡片 | #FFFFFF | 纯白 | 135 | 设置页标题文字 | #000000 | 纯黑 | 136 | 设置页副文字 | #999999 | 灰色 | 137 138 **尺寸规范(基于 10 寸横屏 1280x800 分辨率)** 139 140 | 元素 | 尺寸 | 说明 | 141 |------|------|------| 142 | 桌面图标卡片 | 80x80dp | 含内边距的整体触控区域 | 143 | 图标内图像 | 56x56dp | 实际图标大小 | 144 | 图标卡片圆角 | 20dp | HarmonyOS 标志性大圆角 | 145 | 图标标签字号 | 12sp | 图标下方文字 | 146 | 图标网格间距 | 24dp | 图标之间的间距 | 147 | Dock 栏高度 | 64dp | 底部固定栏 | 148 | Dock 栏圆角 | 24dp | 上方圆角 | 149 | Dock 栏内图标 | 48x48dp | 稍小于桌面图标 | 150 | 控制中心圆角 | 24dp | 卡片圆角 | 151 | 控制中心开关 | 64x64dp | 单个开关触控区域 | 152 | 时间字号 | 48sp | 桌面时钟 | 153 | 日期字号 | 14sp | 日期文字 | 154 | 设置页列表项高度 | 56dp | 单行设置项 | 155 | 毛玻璃模糊半径 | 25px(RenderScript) | 背景模糊效果 | 156 | 卡片投影 | elevation 4dp, color #00000020 | 微弱投影 | 157 158 ### 3.2 核心页面 159 160 #### 页面 1:桌面主页(Landscape) 161 162 - 横屏网格布局:2 行 x 4 列应用图标 163 - 应用列表:智慧医疗(真入口)、设置、相机、文件管理、日历、时钟、计算器、相册 164 - 仅"智慧医疗"可点击,其余为装饰性图标(点击可弹出"功能开发中"Toast) 165 - 右上角放置时间日期 Widget 166 167 #### 页面 2:下拉控制中心 168 169 - 触发方式:单指从屏幕顶部 50px 区域下拉 170 - 布局:圆角卡片(16dp 圆角),分为快捷开关区 + 滑块区 171 - 快捷开关(假交互):WiFi、蓝牙、移动数据、飞行模式、手电筒、截图 172 - 滑块:亮度、音量(仅 UI 滑动效果,不修改系统值) 173 - 所有开关点击仅切换本地 UI 状态(图标变色),不做真实系统调用 174 175 #### 页面 3:假设置页面 176 177 - 入口:桌面点击"设置"图标进入 178 - 内容: 179 - 设备名称:豹小秘 Pro 180 - 系统主题:HarmonyOS 风格 4.0 181 - 处理器:Kirin 9000S(假数据) 182 - 运行内存:8 GB 183 - 存储空间:128 GB 184 - 序列号:模拟 SN 号 185 186 ### 3.3 技术实现要点 187 188 **AndroidManifest 声明 HOME**: 189 190 ```xml 191 194 195 196 197 198 199 200 ``` 201 202 **SDK 注册自启(主要方式)与 BOOT_COMPLETED 自启(备用方案)**: 203 204 主要方式:在 `AndroidManifest.xml` 中为 `LauncherActivity` 声明 SDK 注册的 intent-filter `action.orionstar.default.app`,以及 URL Scheme `jerry://main`: 205 206 ```xml 207 208 209 210 211 212 213 214 215 216 217 218 219 ``` 220 221 备用方案:`BOOT_COMPLETED` 广播接收: 222 223 ```xml 224 225 226 227 229 230 231 232 233 ``` 234 235 > **注意**:主要自启方式为猎户星空 SDK 的 `action.orionstar.default.app` 注册机制,`BOOT_COMPLETED` 作为备用/补充方案。若未声明 `RECEIVE_BOOT_COMPLETED` 权限,备用自启逻辑将失效。 236 237 **下拉手势拦截**: 238 239 ```java 240 class LauncherActivity : Activity() { 241 private val gestureDetector by lazy { 242 GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { 243 override fun onFling(e1: MotionEvent?, e2: MotionEvent?, vx: Float, vy: Float): Boolean { 244 if (e1 != null && e1.y < 100 && vy > 200) { 245 showControlCenter() 246 return true 247 } 248 return false 249 } 250 }) 251 } 252 253 override fun onTouchEvent(event: MotionEvent): Boolean { 254 return gestureDetector.onTouchEvent(event) 255 } 256 } 257 ``` 258 259 **全屏沉浸式**: 260 261 ```java 262 window.decorView.systemUiVisibility = ( 263 View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY 264 or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 265 or View.SYSTEM_UI_FLAG_FULLSCREEN 266 ) 267 ``` 268 269 ### 3.4 Launcher 工程搭建指南(面向 Web 开发者) 270 271 本节面向不熟悉 Android 开发的 Web 工程师(Java + Vue 背景),提供从零开始创建 Launcher 工程的完整步骤。 272 273 #### 3.4.1 Android Studio 创建项目 274 275 1. 打开 Android Studio → File → New → New Project 276 2. 选择 **"Empty Views Activity"**(**不要选 Compose**,因为 Web 开发者更熟悉 XML 布局方式) 277 3. 在配置向导中填写: 278 - **Name**: `HarmonyLauncher` 279 - **Package name**: `com.emoon.harmony.launcher` 280 - **Language**: **Java**(Web 开发者更熟悉的语言,与 Vue 后端开发语言一致) 281 - **Minimum SDK**: **API 19 (Android 4.4)**(兼容更多老旧设备) 282 - **Build configuration language**: **Groovy DSL (build.gradle)**(传统 Android 构建配置方式) 283 4. 点击 **Finish**,等待 Gradle Sync 完成(首次可能需要下载依赖,约 5-10 分钟) 284 5. Sync 完成后,在左侧 Project 面板确认 `app/src/main/java/com/emoon/harmony/launcher/` 目录已生成 285 286 > **给 Web 开发者的提示**:Android Studio 的 Gradle Sync 类似于 `npm install`,会在首次打开项目时下载所有依赖。如果遇到网络问题,可在 `File → Settings → Build → Gradle` 中配置国内镜像源。 287 288 #### 3.4.2 完整 build.gradle(Module: app) 289 290 打开 `app/build.gradle`,替换为以下内容。每一行都加了中文注释,帮助理解其用途: 291 292 ```groovy 293 // 应用的 Gradle 构建脚本(类比 Vue 项目的 package.json + vite.config.js) 294 apply plugin: 'com.android.application' 295 296 android { 297 // 编译使用的 SDK 版本(API 29 = Android 10) 298 compileSdkVersion 29 299 300 // 默认配置块(类比 package.json 中的字段) 301 defaultConfig { 302 // 应用包名,全局唯一标识 303 applicationId "com.emoon.harmony.launcher" 304 // 最低支持 Android 版本(API 19 = Android 4.4) 305 minSdkVersion 19 306 // 目标 Android 版本(API 28 = Android 9.0) 307 targetSdkVersion 28 308 // 应用版本号(内部数字版本) 309 versionCode 1 310 // 应用版本名称(对外显示) 311 versionName "1.0.0" 312 } 313 314 // 构建类型配置 315 buildTypes { 316 // 发布构建(类比 npm run build --production) 317 release { 318 // 开启代码混淆(保护源码,减小包体积) 319 minifyEnabled false 320 // 混淆规则文件 321 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 322 } 323 } 324 325 // 编译选项 326 compileOptions { 327 // 源代码兼容 Java 8 328 sourceCompatibility JavaVersion.VERSION_1_8 329 // 目标字节码兼容 Java 8 330 targetCompatibility JavaVersion.VERSION_1_8 331 } 332 333 // 构建特性开关 334 buildFeatures { 335 // 启用 ViewBinding(类似 Vue 的模板绑定,自动生成视图引用) 336 viewBinding true 337 } 338 } 339 340 // 依赖声明(类比 package.json 中的 dependencies) 341 dependencies { 342 // Android 核心支持库(AppCompat 兼容旧版本) 343 implementation 'androidx.appcompat:appcompat:1.2.0' 344 // Material Design 组件库(提供 CardView 等组件) 345 implementation 'com.google.android.material:material:1.3.0' 346 // RecyclerView 列表组件(高性能列表,类比 Vue 的 v-for) 347 implementation 'androidx.recyclerview:recyclerview:1.1.0' 348 // ConstraintLayout 约束布局(灵活布局,类比 CSS Flexbox) 349 implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 350 // CardView 卡片组件(圆角卡片容器) 351 implementation 'androidx.cardview:cardview:1.0.0' 352 } 353 ``` 354 355 > **说明**:这是一个纯 Launcher 工程,**不需要集成猎户星空 SDK**(SDK 集成在独立的业务 App 中)。这样 Launcher APK 体积更小,编译更快。 356 357 #### 3.4.3 完整 AndroidManifest.xml 358 359 打开 `app/src/main/AndroidManifest.xml`,替换为以下内容: 360 361 ```xml 362 363 364 366 367 368 369 370 371 372 373 374 382 383 384 385 391 392 393 394 395 396 397 398 399 400 401 402 403 408 409 410 414 415 416 417 418 419 420 421 422 423 ``` 424 425 > **关键说明**: 426 > - `HOME` + `DEFAULT` category 的组合使此 Activity 能被系统识别为候选桌面 427 > - `screenOrientation="landscape"` 锁定横屏(机器人屏幕为横屏) 428 > - `excludeFromRecents="true"` 防止桌面出现在最近任务列表中 429 > - 主要自启方式为 SDK 注册(`action.orionstar.default.app`),`RECEIVE_BOOT_COMPLETED` 权限是 BootReceiver 备用自启方案生效的前提 430 431 #### 3.4.4 工程目录结构 432 433 创建所有文件后,完整目录结构如下: 434 435 ``` 436 HarmonyLauncher/ 437 ├── app/ 438 │ ├── src/ 439 │ │ └── main/ 440 │ │ ├── java/com/emoon/harmony/launcher/ 441 │ │ │ ├── LauncherActivity.java # 桌面主页 Activity 442 │ │ │ ├── FakeSettingsActivity.java # 假设置页 Activity 443 │ │ │ ├── LauncherApplication.java # 自定义 Application(可选) 444 │ │ │ ├── AppGridAdapter.java # 图标网格适配器 445 │ │ │ ├── AppItem.java # 应用数据类 446 │ │ │ ├── ControlCenterView.java # 下拉控制中心自定义 View 447 │ │ │ └── BootReceiver.java # 开机自启广播接收器(备用方案) 448 │ │ ├── res/ 449 │ │ │ ├── layout/ 450 │ │ │ │ ├── activity_launcher.xml # 桌面主布局 451 │ │ │ │ ├── activity_fake_settings.xml # 设置页布局 452 │ │ │ │ ├── item_app_icon.xml # 单个图标卡片布局 453 │ │ │ │ └── item_setting.xml # 设置列表单项布局 454 │ │ │ ├── drawable/ 455 │ │ │ │ ├── bg_gradient.xml # 桌面背景渐变 456 │ │ │ │ ├── bg_icon_card.xml # 图标卡片背景 457 │ │ │ │ ├── bg_icon_card_pressed.xml # 图标卡片按下态 458 │ │ │ │ ├── bg_dock.xml # Dock 栏背景 459 │ │ │ │ ├── bg_control_center.xml # 控制中心背景 460 │ │ │ │ ├── bg_switch_on.xml # 开关开启态 461 │ │ │ │ ├── bg_switch_off.xml # 开关关闭态 462 │ │ │ │ ├── ic_medical.xml # 智慧医疗图标 463 │ │ │ │ ├── ic_settings.xml # 设置图标 464 │ │ │ │ ├── ic_camera.xml # 相机图标 465 │ │ │ │ ├── ic_files.xml # 文件管理图标 466 │ │ │ │ ├── ic_calendar.xml # 日历图标 467 │ │ │ │ ├── ic_clock.xml # 时钟图标 468 │ │ │ │ ├── ic_calculator.xml # 计算器图标 469 │ │ │ │ ├── ic_weather.xml # 天气图标 470 │ │ │ │ ├── ic_music.xml # 音乐图标 471 │ │ │ │ └── ic_gallery.xml # 图库图标 472 │ │ │ ├── values/ 473 │ │ │ │ ├── colors.xml # 颜色定义 474 │ │ │ │ ├── dimens.xml # 尺寸定义 475 │ │ │ │ ├── strings.xml # 字符串资源 476 │ │ │ │ └── themes.xml # 主题样式 477 │ │ │ └── mipmap-xxxhdpi/ 478 │ │ │ ├── ic_launcher.png # 应用图标 479 │ │ │ └── ic_launcher_round.png # 圆形应用图标 480 │ │ └── AndroidManifest.xml # 应用清单文件 481 │ └── build.gradle # 模块构建配置 482 ├── build.gradle # 项目级构建配置 483 ├── settings.gradle # 项目设置 484 └── gradle.properties # Gradle 属性配置 485 ``` 486 487 > **给 Web 开发者的对照说明**: 488 > - `res/layout/` ≈ Vue 的 `template/` 目录(XML 声明 UI 结构) 489 > - `res/drawable/` ≈ CSS / SVG(定义颜色、形状、图标) 490 > - `res/values/` ≈ CSS 变量 / 主题配置 491 > - `AndroidManifest.xml` ≈ `index.html` + 路由配置 + 权限声明 492 493 ### 3.5 Launcher 核心代码完整实现 494 495 以下是小节编号与源码文件的对照索引: 496 497 | 小节 | 文件 | 说明 | 498 |------|------|------| 499 | 3.5.1 | `LauncherActivity.java` | 桌面主页 Activity,全屏沉浸、手势检测、图标网格 | 500 | 3.5.2 | `activity_launcher.xml` | 桌面主布局 XML | 501 | 3.5.3 | `AppItem.java` | 应用数据类 | 502 | 3.5.4 | `AppGridAdapter.java` | 图标网格适配器 | 503 | 3.5.5 | `item_app_icon.xml` | 单个图标卡片布局 | 504 | 3.5.6 | `ControlCenterView.java` | 下拉控制中心自定义 View | 505 | 3.5.7 | `control_center` 相关 XML | 控制中心布局 | 506 | 3.5.8 | `FakeSettingsActivity.java` | 假设置页 Activity | 507 | 3.5.9 | 设置页布局 XML | `activity_fake_settings.xml` + `item_setting.xml` | 508 | 3.5.10 | `BootReceiver.java` | 开机自启广播接收器(备用方案) | 509 | 3.5.11 | 默认桌面设置方法 | 详细步骤 + adb 备选方案 | 510 | 3.5.12 | `colors.xml` | 配色资源 | 511 | 3.5.13 | `dimens.xml` | 尺寸资源 | 512 | 3.5.14 | `themes.xml` | 主题样式 | 513 | 3.5.15 | `drawable/` 背景 XML | 渐变、卡片、Dock 等背景 | 514 515 #### 3.5.1 LauncherActivity.java 完整代码 516 517 这是 Launcher 的核心 Activity,负责桌面展示、图标网格、手势检测和时间更新。每一行都加了中文注释。 518 519 ```java 520 package com.emoon.harmony.launcher 521 522 import android.app.Activity 523 import android.content.Intent 524 import android.os.Bundle 525 import android.view.GestureDetector 526 import android.view.MotionEvent 527 import android.view.View 528 import android.view.WindowManager 529 import android.widget.TextView 530 import android.widget.Toast 531 import androidx.recyclerview.widget.GridLayoutManager 532 import androidx.recyclerview.widget.RecyclerView 533 import java.util.Calendar 534 535 /** 536 * 桌面主页 Activity 537 * 这是用户开机后看到的第一个页面,负责展示仿鸿蒙桌面 UI 538 */ 539 class LauncherActivity : Activity() { 540 541 // 桌面图标网格 RecyclerView(类比 Vue 的列表渲染) 542 private lateinit var recyclerView: RecyclerView 543 // 下拉控制中心容器视图 544 private lateinit var controlCenterView: View 545 // 手势检测器(用于识别下拉手势) 546 private lateinit var gestureDetector: GestureDetector 547 548 /** 549 * Activity 创建时调用(类比 Vue 的 mounted 生命周期) 550 */ 551 override fun onCreate(savedInstanceState: Bundle?) { 552 super.onCreate(savedInstanceState) 553 // 第一步:设置全屏沉浸式,隐藏状态栏和导航栏 554 hideSystemUI() 555 // 第二步:加载 XML 布局文件 556 setContentView(R.layout.activity_launcher) 557 // 第三步:初始化桌面图标网格 558 setupAppGrid() 559 // 第四步:初始化下拉手势检测 560 setupGestureDetector() 561 // 第五步:启动时间显示更新 562 updateTimeDisplay() 563 } 564 565 /** 566 * 隐藏系统状态栏和导航栏,实现全屏沉浸式体验 567 * 这是仿鸿蒙桌面的关键:必须完全隐藏系统 UI,否则会暴露 Android 原生界面 568 */ 569 private fun hideSystemUI() { 570 // 设置窗口为全屏模式(隐藏顶部状态栏) 571 window.setFlags( 572 WindowManager.LayoutParams.FLAG_FULLSCREEN, 573 WindowManager.LayoutParams.FLAG_FULLSCREEN 574 ) 575 // 设置系统 UI 可见性标志(沉浸式模式) 576 window.decorView.systemUiVisibility = ( 577 // 粘性沉浸模式:用户滑动边缘时系统 UI 暂时出现,几秒后自动隐藏 578 View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY 579 // 隐藏导航栏(底部虚拟按键) 580 or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 581 // 隐藏状态栏(顶部时间、电量等) 582 or View.SYSTEM_UI_FLAG_FULLSCREEN 583 // 保持布局稳定,防止系统栏显示/隐藏时布局跳动 584 or View.SYSTEM_UI_FLAG_LAYOUT_STABLE 585 // 允许布局延伸到全屏区域 586 or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 587 // 允许布局延伸到导航栏区域 588 or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 589 ) 590 } 591 592 /** 593 * 初始化桌面图标网格 RecyclerView 594 * 使用 5 列网格布局,展示所有应用图标 595 */ 596 private fun setupAppGrid() { 597 recyclerView = findViewById(R.id.rv_app_grid) 598 // 设置网格布局管理器:5 列(横屏下每行显示 5 个图标) 599 recyclerView.layoutManager = GridLayoutManager(this, 5) 600 // 创建预定义的应用列表(10 个应用图标) 601 val appList = listOf( 602 AppItem("智慧医疗", R.drawable.ic_medical, AppItem.Type.BUSINESS), 603 AppItem("设置", R.drawable.ic_settings, AppItem.Type.SETTINGS), 604 AppItem("相机", R.drawable.ic_camera, AppItem.Type.FAKE), 605 AppItem("文件管理", R.drawable.ic_files, AppItem.Type.FAKE), 606 AppItem("日历", R.drawable.ic_calendar, AppItem.Type.FAKE), 607 AppItem("时钟", R.drawable.ic_clock, AppItem.Type.FAKE), 608 AppItem("计算器", R.drawable.ic_calculator, AppItem.Type.FAKE), 609 AppItem("天气", R.drawable.ic_weather, AppItem.Type.FAKE), 610 AppItem("音乐", R.drawable.ic_music, AppItem.Type.FAKE), 611 AppItem("图库", R.drawable.ic_gallery, AppItem.Type.FAKE) 612 ) 613 // 设置适配器,传入应用列表和点击回调 614 recyclerView.adapter = AppGridAdapter(appList) { item -> 615 onAppClick(item) 616 } 617 } 618 619 /** 620 * 处理应用图标点击事件 621 * 根据应用类型执行不同操作:跳转业务 App、打开设置页、或显示提示 622 */ 623 private fun onAppClick(item: AppItem) { 624 when (item.type) { 625 AppItem.Type.BUSINESS -> { 626 // 业务应用:通过包名跳转到业务 App 627 val intent = packageManager.getLaunchIntentForPackage( 628 "com.emoon.harmony.robot" // 业务 App 的包名(需与业务 App 保持一致) 629 ) 630 if (intent != null) { 631 startActivity(intent) 632 } else { 633 // 开发阶段友好提示:业务 App 未安装 634 Toast.makeText(this, "业务应用未安装", Toast.LENGTH_SHORT).show() 635 } 636 } 637 AppItem.Type.SETTINGS -> { 638 // 设置应用:打开假设置页面 639 startActivity(Intent(this, FakeSettingsActivity::class.java)) 640 } 641 AppItem.Type.FAKE -> { 642 // 装饰图标:点击后显示"即将推出"提示,保持桌面完整性 643 Toast.makeText(this, "${item.name} 即将推出", Toast.LENGTH_SHORT).show() 644 } 645 } 646 } 647 648 /** 649 * 初始化下拉手势检测器 650 * 从屏幕顶部向下滑动时显示控制中心(仿 HarmonyOS 操作逻辑) 651 */ 652 private fun setupGestureDetector() { 653 controlCenterView = findViewById(R.id.control_center_container) 654 gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { 655 /** 656 * 检测快速滑动手势(Fling) 657 * @param e1 手势起点(按下位置) 658 * @param e2 手势终点(抬起位置) 659 * @param velocityX X 方向滑动速度 660 * @param velocityY Y 方向滑动速度(正值表示向下) 661 */ 662 override fun onFling( 663 e1: MotionEvent?, 664 e2: MotionEvent, 665 velocityX: Float, 666 velocityY: Float 667 ): Boolean { 668 if (e1 == null) return false 669 // 判断条件:从屏幕顶部 100px 内开始(e1.y < 100) 670 // 且向下滑动速度大于 500(velocityY > 500) 671 // 且滑动距离大于 100px(e2.y - e1.y > 100) 672 if (e1.y < 100 && velocityY > 500 && e2.y - e1.y > 100) { 673 showControlCenter() 674 return true // 返回 true 表示已消费此手势 675 } 676 return false 677 } 678 }) 679 } 680 681 /** 682 * 显示仿鸿蒙控制中心 683 * 带动画效果:从屏幕上方滑入 684 */ 685 private fun showControlCenter() { 686 controlCenterView.visibility = View.VISIBLE 687 // 初始位置:在屏幕上方(Y 轴负方向偏移控件高度) 688 controlCenterView.translationY = -controlCenterView.height.toFloat() 689 // 执行滑入动画:300ms 内从上方滑到正常位置 690 controlCenterView.animate() 691 .translationY(0f) 692 .setDuration(300) 693 .start() 694 } 695 696 /** 697 * 隐藏控制中心 698 * 带动画效果:向上滑出屏幕 699 */ 700 fun hideControlCenter() { 701 controlCenterView.animate() 702 .translationY(-controlCenterView.height.toFloat()) 703 .setDuration(300) 704 .withEndAction { 705 // 动画结束后将视图设为不可见(节省渲染资源) 706 controlCenterView.visibility = View.GONE 707 } 708 .start() 709 } 710 711 /** 712 * 更新桌面时间日期显示 713 * 使用 Handler 每分钟刷新一次 714 */ 715 private fun updateTimeDisplay() { 716 val handler = android.os.Handler(mainLooper) 717 val timeView = findViewById(R.id.tv_time) 718 val dateView = findViewById(R.id.tv_date) 719 // 创建定时任务 Runnable 720 val runnable = object : Runnable { 721 override fun run() { 722 val now = Calendar.getInstance() 723 // 更新时间:HH:mm 格式(如 14:30) 724 timeView.text = String.format( 725 "%02d:%02d", 726 now.get(Calendar.HOUR_OF_DAY), 727 now.get(Calendar.MINUTE) 728 ) 729 // 星期数组(周日开始) 730 val weekDays = arrayOf("日", "一", "二", "三", "四", "五", "六") 731 // 更新日期:MM月dd日 星期X 格式 732 dateView.text = String.format( 733 "%d月%d日 星期%s", 734 now.get(Calendar.MONTH) + 1, // 月份从 0 开始,需 +1 735 now.get(Calendar.DAY_OF_MONTH), 736 weekDays[now.get(Calendar.DAY_OF_WEEK) - 1] 737 ) 738 // 60 秒后再次执行(60000 毫秒 = 1 分钟) 739 handler.postDelayed(this, 60000) 740 } 741 } 742 // 立即执行第一次(避免等待 1 分钟才显示时间) 743 handler.post(runnable) 744 } 745 746 /** 747 * 拦截触摸事件,传递给手势检测器 748 * 必须重写此方法,否则 GestureDetector 无法接收到触摸事件 749 */ 750 override fun dispatchTouchEvent(ev: MotionEvent): Boolean { 751 gestureDetector.onTouchEvent(ev) 752 return super.dispatchTouchEvent(ev) 753 } 754 755 /** 756 * 拦截返回键 757 * 桌面作为系统入口,不允许通过返回键退出(否则回到系统桌面) 758 */ 759 override fun onBackPressed() { 760 // 如果控制中心正在显示,优先关闭控制中心 761 if (controlCenterView.visibility == View.VISIBLE) { 762 hideControlCenter() 763 } 764 // 否则不做任何操作(桌面不响应返回键退出) 765 } 766 767 /** 768 * 窗口焦点变化时重新隐藏系统 UI 769 * 当从其他 Activity 返回桌面时,确保系统栏保持隐藏 770 */ 771 override fun onWindowFocusChanged(hasFocus: Boolean) { 772 super.onWindowFocusChanged(hasFocus) 773 if (hasFocus) hideSystemUI() 774 } 775 } 776 ``` 777 778 #### 3.5.2 activity_launcher.xml 完整布局 779 780 这是桌面的主布局文件,对应 HarmonyOS 4 桌面的各个 UI 组件: 781 782 ```xml 783 784 785 790 791 792 799 800 801 806 807 808 815 816 817 825 826 827 834 835 836 837 841 842 843 850 851 852 861 862 863 869 870 876 877 883 884 890 891 892 893 894 900 901 902 ``` 903 904 #### 3.5.3 AppItem.java 数据类 905 906 桌面应用的数据模型,类比 Vue 中的 `data()` 返回的对象结构: 907 908 ```java 909 package com.emoon.harmony.launcher 910 911 /** 912 * 桌面应用项数据类 913 * 用于存储每个应用图标的名称、图标资源和类型 914 */ 915 data class AppItem( 916 val name: String, // 应用显示名称(如"智慧医疗") 917 val iconResId: Int, // 图标资源 ID(指向 drawable 中的图标) 918 val type: Type // 应用类型,决定点击后的行为 919 ) { 920 /** 921 * 应用类型枚举 922 * BUSINESS: 真实业务应用(点击跳转) 923 * SETTINGS: 设置入口(点击打开假设置页) 924 * FAKE: 装饰性图标(点击显示提示) 925 */ 926 enum class Type { 927 BUSINESS, // 业务应用(点击跳转到业务 App) 928 SETTINGS, // 设置(点击打开假设置页) 929 FAKE // 装饰图标(点击提示"即将推出") 930 } 931 } 932 ``` 933 934 #### 3.5.4 AppGridAdapter.java 图标网格适配器 935 936 RecyclerView 的适配器实现,负责将应用数据渲染为图标卡片。类比 Vue 中 `v-for` 循环渲染列表组件: 937 938 ```java 939 package com.emoon.harmony.launcher 940 941 import android.view.LayoutInflater 942 import android.view.View 943 import android.view.ViewGroup 944 import android.widget.ImageView 945 import android.widget.TextView 946 import androidx.cardview.widget.CardView 947 import androidx.recyclerview.widget.RecyclerView 948 949 /** 950 * 桌面图标网格适配器 951 * 负责将 AppItem 数据列表渲染为桌面上的图标卡片 952 */ 953 class AppGridAdapter( 954 private val appList: List, // 应用列表数据源 955 private val onItemClick: (AppItem) -> Unit // 点击回调函数(lambda) 956 ) : RecyclerView.Adapter() { 957 958 /** 959 * ViewHolder:缓存每个列表项的视图引用 960 * 避免每次滚动时都调用 findViewById,提升性能(类比 Vue 的虚拟 DOM 复用) 961 */ 962 class AppViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 963 // 图标卡片容器(CardView,实现圆角和阴影) 964 val cardView: CardView = itemView.findViewById(R.id.card_app_icon) 965 // 应用图标图片 966 val iconImage: ImageView = itemView.findViewById(R.id.iv_app_icon) 967 // 应用名称文字 968 val nameText: TextView = itemView.findViewById(R.id.tv_app_name) 969 } 970 971 /** 972 * 创建新的 ViewHolder(当列表需要展示新的项时调用) 973 * @param parent 父视图容器 974 * @param viewType 视图类型(多类型列表时使用,此处只有一种类型) 975 */ 976 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder { 977 // 加载 item_app_icon.xml 布局文件 978 val view = LayoutInflater.from(parent.context) 979 .inflate(R.layout.item_app_icon, parent, false) 980 return AppViewHolder(view) 981 } 982 983 /** 984 * 绑定数据到 ViewHolder(将数据填充到视图中) 985 * @param holder 要绑定的 ViewHolder 986 * @param position 数据在列表中的索引位置 987 */ 988 override fun onBindViewHolder(holder: AppViewHolder, position: Int) { 989 val item = appList[position] 990 // 设置应用图标图片 991 holder.iconImage.setImageResource(item.iconResId) 992 // 设置应用名称文字 993 holder.nameText.text = item.name 994 // 设置点击事件监听器 995 holder.cardView.setOnClickListener { 996 onItemClick(item) // 调用外部传入的点击回调 997 } 998 // 设置按下效果:点击时临时改变背景色(视觉反馈) 999 holder.cardView.setOnTouchListener { v, event -> 1000 when (event.action) {
    Thinking 文档很长,我需要继续读取后续内容,然后开始逐个替换代码块。让我继续读取文档的 1001-2000 行。

    Tool Call: ReadFile (/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md)

    {
      "path": "/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md",
      "line_offset": 1001,
      "n_lines": 1000
    }
    
    Tool Result: ReadFile (`/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md`) 1000 lines read from file starting from line 1001. Total lines in file: 5246. 1001 // 手指按下:切换到按下态背景 1002 android.view.MotionEvent.ACTION_DOWN -> { 1003 holder.cardView.setCardBackgroundColor( 1004 v.context.getColor(R.color.icon_card_pressed) 1005 ) 1006 } 1007 // 手指抬起或取消:恢复常态背景 1008 android.view.MotionEvent.ACTION_UP, 1009 android.view.MotionEvent.ACTION_CANCEL -> { 1010 holder.cardView.setCardBackgroundColor( 1011 v.context.getColor(R.color.icon_card_normal) 1012 ) 1013 } 1014 } 1015 false // 返回 false 表示不拦截触摸事件,继续传递 1016 } 1017 } 1018 1019 /** 1020 * 返回列表项总数 1021 * RecyclerView 通过此方法知道需要渲染多少个列表项 1022 */ 1023 override fun getItemCount(): Int = appList.size 1024 } 1025 ``` 1026 1027 #### 3.5.5 item_app_icon.xml 单个图标卡片布局 1028 1029 这是每个应用图标的布局文件,使用 CardView 实现 HarmonyOS 风格的圆角卡片: 1030 1031 ```xml 1032 1033 1034 1047 1048 1049 1055 1056 1057 1063 1064 1065 1074 1075 1076 1077 ``` 1078 1079 #### 3.5.6 ControlCenterView.java 下拉控制中心 1080 1081 使用自定义 View 实现下拉控制中心(不使用 Fragment,避免复杂生命周期管理)。包含快捷开关和滑块,所有交互仅切换 UI 状态,**不做真实系统调用**。 1082 1083 ```java 1084 package com.emoon.harmony.launcher 1085 1086 import android.content.Context 1087 import android.util.AttributeSet 1088 import android.view.LayoutInflater 1089 import android.view.View 1090 import android.widget.FrameLayout 1091 import android.widget.ImageView 1092 import android.widget.SeekBar 1093 import android.widget.TextView 1094 1095 /** 1096 * 仿 HarmonyOS 下拉控制中心自定义 View 1097 * 从屏幕顶部下滑触发,包含快捷开关和亮度/音量滑块 1098 */ 1099 class ControlCenterView @JvmOverloads constructor( 1100 context: Context, 1101 attrs: AttributeSet? = null, 1102 defStyleAttr: Int = 0 1103 ) : FrameLayout(context, attrs, defStyleAttr) { 1104 1105 // 开关状态存储表(HashMap,键为开关名称,值为开启/关闭状态) 1106 private val switchStates = HashMap().apply { 1107 put("wifi", true) // WiFi:默认开启 1108 put("bluetooth", false) // 蓝牙:默认关闭 1109 put("mobile", true) // 移动数据:默认开启 1110 put("airplane", false) // 飞行模式:默认关闭 1111 put("location", true) // 位置服务:默认开启 1112 } 1113 1114 // 开关视图映射表(键为开关名称,值为对应的 ImageView) 1115 private val switchViews = HashMap() 1116 1117 init { 1118 // 加载控制中心的布局 XML 1119 LayoutInflater.from(context).inflate(R.layout.control_center, this, true) 1120 // 初始化所有开关 1121 initSwitches() 1122 // 初始化亮度滑块 1123 initBrightnessSlider() 1124 // 初始化音量滑块 1125 initVolumeSlider() 1126 // 初始化关闭按钮 1127 initCloseButton() 1128 } 1129 1130 /** 1131 * 初始化快捷开关 1132 * 每个开关点击时仅切换本地 UI 状态,不调用真实系统 API 1133 */ 1134 private fun initSwitches() { 1135 // 定义开关配置:(开关名称,图标 View 的 ID,标签 View 的 ID) 1136 val switchConfigs = listOf( 1137 Triple("wifi", R.id.iv_wifi, R.id.tv_wifi), 1138 Triple("bluetooth", R.id.iv_bluetooth, R.id.tv_bluetooth), 1139 Triple("mobile", R.id.iv_mobile, R.id.tv_mobile), 1140 Triple("airplane", R.id.iv_airplane, R.id.tv_airplane), 1141 Triple("location", R.id.iv_location, R.id.tv_location) 1142 ) 1143 1144 switchConfigs.forEach { (name, iconId, labelId) -> 1145 val iconView = findViewById(iconId) 1146 val labelView = findViewById(labelId) 1147 switchViews[name] = iconView 1148 1149 // 设置初始状态 1150 updateSwitchUI(name, iconView, labelView) 1151 1152 // 设置点击监听器 1153 iconView.setOnClickListener { 1154 // 切换开关状态(取反当前状态) 1155 val newState = !(switchStates[name] ?: false) 1156 switchStates[name] = newState 1157 // 更新 UI 显示 1158 updateSwitchUI(name, iconView, labelView) 1159 } 1160 } 1161 } 1162 1163 /** 1164 * 更新开关的 UI 显示 1165 * @param name 开关名称 1166 * @param iconView 开关图标视图 1167 * @param labelView 开关标签视图 1168 */ 1169 private fun updateSwitchUI(name: String, iconView: ImageView, labelView: TextView) { 1170 val isOn = switchStates[name] ?: false 1171 if (isOn) { 1172 // 开启态:鸿蒙蓝色背景 + 白色图标 1173 iconView.setBackgroundResource(R.drawable.bg_switch_on) 1174 iconView.setColorFilter(context.getColor(android.R.color.white)) 1175 labelView.setTextColor(context.getColor(R.color.harmony_blue)) 1176 } else { 1177 // 关闭态:深灰背景 + 浅灰图标 1178 iconView.setBackgroundResource(R.drawable.bg_switch_off) 1179 iconView.setColorFilter(context.getColor(R.color.gray_text)) 1180 labelView.setTextColor(context.getColor(R.color.gray_text)) 1181 } 1182 } 1183 1184 /** 1185 * 初始化亮度滑块 1186 * 仅改变滑块位置,不修改系统实际亮度 1187 */ 1188 private fun initBrightnessSlider() { 1189 val seekBar = findViewById(R.id.seekbar_brightness) 1190 val valueText = findViewById(R.id.tv_brightness_value) 1191 // 设置初始值 70% 1192 seekBar.progress = 70 1193 valueText.text = "70%" 1194 1195 seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { 1196 // 滑块拖动时实时更新显示 1197 override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { 1198 valueText.text = "${progress}%" 1199 } 1200 // 开始拖动(空实现,但接口要求覆写) 1201 override fun onStartTrackingTouch(seekBar: SeekBar?) {} 1202 // 结束拖动(空实现,但接口要求覆写) 1203 override fun onStopTrackingTouch(seekBar: SeekBar?) {} 1204 }) 1205 } 1206 1207 /** 1208 * 初始化音量滑块 1209 * 仅改变滑块位置,不修改系统实际音量 1210 */ 1211 private fun initVolumeSlider() { 1212 val seekBar = findViewById(R.id.seekbar_volume) 1213 val valueText = findViewById(R.id.tv_volume_value) 1214 // 设置初始值 50% 1215 seekBar.progress = 50 1216 valueText.text = "50%" 1217 1218 seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { 1219 override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { 1220 valueText.text = "${progress}%" 1221 } 1222 override fun onStartTrackingTouch(seekBar: SeekBar?) {} 1223 override fun onStopTrackingTouch(seekBar: SeekBar?) {} 1224 }) 1225 } 1226 1227 /** 1228 * 初始化关闭按钮 1229 * 点击后隐藏控制中心 1230 */ 1231 private fun initCloseButton() { 1232 findViewById(R.id.btn_close_control).setOnClickListener { 1233 // 获取父 Activity 并调用其 hideControlCenter 方法 1234 (context as? LauncherActivity)?.hideControlCenter() 1235 } 1236 } 1237 } 1238 ``` 1239 1240 #### 3.5.7 控制中心布局 XML 1241 1242 控制中心的布局文件 `res/layout/control_center.xml`: 1243 1244 ```xml 1245 1246 1247 1255 1256 1257 1263 1264 1265 1271 1272 1273 1279 1286 1293 1294 1295 1296 1302 1309 1316 1317 1318 1319 1325 1332 1339 1340 1341 1342 1348 1355 1362 1363 1364 1365 1371 1378 1385 1386 1387 1388 1394 1402 1408 1409 1410 1411 1412 1418 1423 1429 1436 1437 1438 1439 1445 1450 1456 1463 1464 1465 1466 ``` 1467 1468 #### 3.5.8 FakeSettingsActivity.java 假设置页 1469 1470 完整的假设置页实现,包含列表式布局和"关于本机"页面: 1471 1472 ```java 1473 package com.emoon.harmony.launcher 1474 1475 import android.app.Activity 1476 import android.os.Bundle 1477 import android.view.LayoutInflater 1478 import android.view.View 1479 import android.view.ViewGroup 1480 import android.widget.ImageView 1481 import android.widget.TextView 1482 import android.widget.Toast 1483 import androidx.recyclerview.widget.LinearLayoutManager 1484 import androidx.recyclerview.widget.RecyclerView 1485 1486 /** 1487 * 假设置页面 Activity 1488 * 仿 HarmonyOS 设置页风格,展示假设备信息 1489 */ 1490 class FakeSettingsActivity : Activity() { 1491 1492 override fun onCreate(savedInstanceState: Bundle?) { 1493 super.onCreate(savedInstanceState) 1494 setContentView(R.layout.activity_fake_settings) 1495 // 初始化设置列表 1496 setupSettingsList() 1497 } 1498 1499 /** 1500 * 初始化设置列表 RecyclerView 1501 */ 1502 private fun setupSettingsList() { 1503 val recyclerView = findViewById(R.id.rv_settings) 1504 // 垂直线性布局(类似 Vue 的垂直列表) 1505 recyclerView.layoutManager = LinearLayoutManager(this) 1506 1507 // 定义设置项数据列表 1508 val settingsItems = listOf( 1509 SettingItem("WLAN", "已连接", R.drawable.ic_wifi), 1510 SettingItem("蓝牙", "已开启", R.drawable.ic_bluetooth), 1511 SettingItem("显示和亮度", "", R.drawable.ic_brightness), 1512 SettingItem("声音和振动", "", R.drawable.ic_volume), 1513 SettingItem("关于本机", "", R.drawable.ic_info) 1514 ) 1515 1516 // 设置适配器 1517 recyclerView.adapter = SettingsAdapter(settingsItems) { item -> 1518 onSettingClick(item) 1519 } 1520 } 1521 1522 /** 1523 * 处理设置项点击事件 1524 */ 1525 private fun onSettingClick(item: SettingItem) { 1526 when (item.title) { 1527 "关于本机" -> showAboutDialog() 1528 else -> Toast.makeText(this, "${item.title} 功能即将推出", Toast.LENGTH_SHORT).show() 1529 } 1530 } 1531 1532 /** 1533 * 显示"关于本机"对话框 1534 * 展示仿造的设备信息,营造 HarmonyOS 系统的视觉假象 1535 */ 1536 private fun showAboutDialog() { 1537 // 使用 AlertDialog 展示关于信息 1538 val aboutView = LayoutInflater.from(this).inflate(R.layout.dialog_about, null) 1539 // 填充设备信息数据 1540 aboutView.findViewById(R.id.tv_device_name).text = "设备名称:豹小秘 Pro" 1541 aboutView.findViewById(R.id.tv_system_theme).text = "系统主题:HarmonyOS 风格 4.0" 1542 aboutView.findViewById(R.id.tv_processor).text = "处理器:Kirin 9000S" 1543 aboutView.findViewById(R.id.tv_ram).text = "运行内存:4 GB" 1544 aboutView.findViewById(R.id.tv_storage).text = "存储空间:64 GB" 1545 aboutView.findViewById(R.id.tv_resolution).text = "分辨率:1280 × 800" 1546 1547 android.app.AlertDialog.Builder(this) 1548 .setView(aboutView) 1549 .setPositiveButton("确定", null) 1550 .show() 1551 } 1552 1553 /** 1554 * 设置项数据类 1555 */ 1556 data class SettingItem( 1557 val title: String, // 设置项标题 1558 val subtitle: String, // 副标题(如"已连接") 1559 val iconResId: Int // 左侧图标资源 ID 1560 ) 1561 1562 /** 1563 * 设置列表适配器 1564 */ 1565 class SettingsAdapter( 1566 private val items: List, 1567 private val onClick: (SettingItem) -> Unit 1568 ) : RecyclerView.Adapter() { 1569 1570 class SettingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 1571 val iconView: ImageView = itemView.findViewById(R.id.iv_setting_icon) 1572 val titleView: TextView = itemView.findViewById(R.id.tv_setting_title) 1573 val subtitleView: TextView = itemView.findViewById(R.id.tv_setting_subtitle) 1574 } 1575 1576 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { 1577 val view = LayoutInflater.from(parent.context) 1578 .inflate(R.layout.item_setting, parent, false) 1579 return SettingViewHolder(view) 1580 } 1581 1582 override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { 1583 val item = items[position] 1584 holder.iconView.setImageResource(item.iconResId) 1585 holder.titleView.text = item.title 1586 holder.subtitleView.text = item.subtitle 1587 holder.itemView.setOnClickListener { onClick(item) } 1588 } 1589 1590 override fun getItemCount(): Int = items.size 1591 } 1592 } 1593 ``` 1594 1595 #### 3.5.9 设置页布局 XML 1596 1597 **activity_fake_settings.xml**(设置页主布局): 1598 1599 ```xml 1600 1601 1602 1608 1609 1610 1616 1617 1623 1624 1632 1633 1634 1635 1640 1641 1642 ``` 1643 1644 **item_setting.xml**(单个设置项布局): 1645 1646 ```xml 1647 1648 1649 1660 1661 1662 1667 1668 1669 1675 1676 1682 1683 1689 1690 1691 1692 1697 1698 1699 ``` 1700 1701 #### 3.5.10 BootReceiver.java 开机自启接收器(备用方案) 1702 1703 完整的开机自启广播接收器实现(`BOOT_COMPLETED` 备用自启方案): 1704 1705 ```java 1706 package com.emoon.harmony.launcher 1707 1708 import android.content.BroadcastReceiver 1709 import android.content.Context 1710 import android.content.Intent 1711 1712 /** 1713 * 开机自启广播接收器 1714 * 接收系统开机完成广播(BOOT_COMPLETED),自动启动 LauncherActivity 1715 */ 1716 class BootReceiver : BroadcastReceiver() { 1717 1718 /** 1719 * 当收到广播时调用 1720 * @param context 应用上下文 1721 * @param intent 收到的广播 Intent 1722 */ 1723 override fun onReceive(context: Context, intent: Intent) { 1724 // 判断广播动作是否为开机完成 1725 if (intent.action == Intent.ACTION_BOOT_COMPLETED) { 1726 // 创建启动 LauncherActivity 的 Intent 1727 val launchIntent = Intent(context, LauncherActivity::class.java).apply { 1728 // FLAG_ACTIVITY_NEW_TASK 是必须的:从非 Activity 上下文启动 Activity 需要此标志 1729 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 1730 } 1731 // 启动桌面 Activity 1732 context.startActivity(launchIntent) 1733 } 1734 } 1735 } 1736 ``` 1737 1738 > **关键提示**:`BootReceiver` 作为备用自启方案,生效需要同时满足三个条件: 1739 > 1. `AndroidManifest.xml` 中声明 `RECEIVE_BOOT_COMPLETED` 权限 1740 > 2. `BootReceiver` 在 Manifest 中正确注册并声明 `BOOT_COMPLETED` 过滤器 1741 > 3. 应用至少被用户手动打开过一次(Android 3.1+ 的安全限制) 1742 > 4. 主要自启方式仍为 SDK 注册机制(`action.orionstar.default.app`) 1743 1744 #### 3.5.11 设置为默认桌面的方法 1745 1746 安装 Launcher APK 后,需要将其设为系统默认桌面。以下是详细步骤: 1747 1748 **方法一:首次按 Home 键选择(推荐)** 1749 1750 1. 通过 adb 安装 Launcher APK:`adb install HarmonyLauncher.apk` 1751 2. 在机器人设备上按 **Home 键**(或点击桌面的 Home 图标) 1752 3. 系统会弹出选择器,询问"要使用哪个应用?" 1753 4. 选择 **HarmonyLauncher** → 勾选 **"设为默认应用"** → 点击 **"始终"** 1754 5. 此后每次按 Home 键都会直接进入仿鸿蒙桌面 1755 1756 **方法二:adb 命令直接设置(自动化部署时使用)** 1757 1758 如果设备没有弹出选择器(某些定制 Android 系统会屏蔽),可通过 adb 命令强制设置: 1759 1760 ```bash 1761 # 查看当前默认桌面组件名 1762 adb shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME 1763 1764 # 设置 HarmonyLauncher 为默认桌面(将下面命令中的包名替换为实际值) 1765 adb shell cmd package set-home-activity com.emoon.harmony.launcher/.LauncherActivity 1766 1767 # 验证设置是否成功 1768 adb shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME 1769 ``` 1770 1771 **方法三:通过设置应用手动切换** 1772 1773 1. 进入系统"设置" → "应用" 1774 2. 找到当前的默认桌面应用(如"Launcher3") 1775 3. 点击"默认打开" → "清除默认操作" 1776 4. 再次按 Home 键,重新选择 HarmonyLauncher 1777 1778 **验证方法**: 1779 1780 - 按 Home 键,确认进入仿鸿蒙桌面(显示蓝紫渐变背景 + 圆角图标网格) 1781 - 重启设备,确认开机后自动进入仿鸿蒙桌面(验证 SDK 注册自启生效,BootReceiver 作为备用方案) 1782 1783 #### 3.5.12 res/values/colors.xml 1784 1785 所有 HarmonyOS 风格配色的 XML 定义: 1786 1787 ```xml 1788 1789 1790 1791 1792 1793 #FFFFFF 1794 1795 #000000 1796 1797 #007DFF 1798 1799 #999999 1800 1801 1802 1803 #26FFFFFF 1804 1805 #40FFFFFF 1806 1807 #4D000000 1808 1809 #F21A1A2E 1810 1811 #007DFF 1812 1813 #404040 1814 1815 #B3FFFFFF 1816 1817 1818 1819 #F1F3F5 1820 1821 #FFFFFF 1822 1823 #000000 1824 1825 #999999 1826 1827 1828 1829 #1A1A2E 1830 1831 #16213E 1832 1833 ``` 1834 1835 #### 3.5.13 res/values/dimens.xml 1836 1837 所有尺寸值的集中定义(便于统一修改和维护): 1838 1839 ```xml 1840 1841 1842 1843 1844 1845 80dp 1846 1847 56dp 1848 1849 20dp 1850 1851 12sp 1852 1853 24dp 1854 1855 4dp 1856 1857 1858 1859 64dp 1860 1861 24dp 1862 1863 48dp 1864 1865 1866 1867 24dp 1868 1869 64dp 1870 1871 1872 1873 48sp 1874 1875 14sp 1876 1877 1878 1879 56dp 1880 1881 ``` 1882 1883 #### 3.5.14 res/values/themes.xml 1884 1885 全屏主题和样式定义: 1886 1887 ```xml 1888 1889 1890 1891 1892 1893 1903 1904 1905 1917 1918 ``` 1919 1920 #### 3.5.15 res/drawable/ 关键背景 XML 1921 1922 以下是桌面核心视觉元素的 Drawable 定义: 1923 1924 **bg_gradient.xml**(桌面背景渐变): 1925 1926 ```xml 1927 1928 1929 1931 1936 1937 ``` 1938 1939 **bg_icon_card.xml**(图标卡片常态背景): 1940 1941 ```xml 1942 1943 1944 1946 1947 1948 1949 ``` 1950 1951 **bg_icon_card_pressed.xml**(图标卡片按下态背景): 1952 1953 ```xml 1954 1955 1956 1958 1959 1960 1961 ``` 1962 1963 **bg_dock.xml**(Dock 栏背景): 1964 1965 ```xml 1966 1967 1968 1970 1971 1972 1975 1976 ``` 1977 1978 **bg_control_center.xml**(控制中心背景): 1979 1980 ```xml 1981 1982 1983 1985 1986 1987 1990 1991 ``` 1992 1993 **bg_switch_on.xml**(开关开启态背景): 1994 1995 ```xml 1996 1997 1998 2000
    Thinking 我需要继续读取文档的后续部分,然后开始进行替换。让我继续读取 2001-3000 行。

    Tool Call: ReadFile (/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md)

    {
      "path": "/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md",
      "line_offset": 2001,
      "n_lines": 1000
    }
    
    Tool Result: ReadFile (`/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md`) 1000 lines read from file starting from line 2001. Total lines in file: 5246. 2001
    2002 ``` 2003 2004 **bg_switch_off.xml**(开关关闭态背景): 2005 2006 ```xml 2007 2008 2009 2011 2012 2013 ``` 2014 2015 **handle_bar.xml**(控制中心拖动手柄): 2016 2017 ```xml 2018 2019 2020 2022 2023 2024 2025 ``` 2026 2027 > **图标说明**:上述布局中引用的 `ic_*.xml` 图标(如 `ic_wifi`、`ic_bluetooth` 等)建议使用 Android Studio 内置的 **Vector Asset Studio** 导入 Material Design 图标,或从 [Material Icons](https://fonts.google.com/icons) 下载 SVG 后转为 Vector Drawable。对于迎检演示,使用简单的白色线形图标即可达到 HarmonyOS 风格效果。 2028 2029 --- 2030 2031 ## 四、原生业务 App 详细设计 2032 2033 ### 4.1 Android Studio 工程结构 2034 2035 ``` 2036 com.medical.robotapp/ 2037 ├── MainActivity.java # WebView 容器 Activity 2038 ├── bridge/ 2039 │ └── RobotBridge.java # JSBridge 接口实现 2040 ├── sdk/ 2041 │ └── RobotSDKManager.java # 猎户星空 SDK 封装(RobotApi/SkillApi/RobotSettingApi) 2042 ├── service/ 2043 │ └── NavigationCallback.java # 导航状态监听(ActionListener 回调) 2044 └── util/ 2045 └── WebViewUtil.java # WebView 配置工具 2046 ``` 2047 2048 ### 4.2 WebView 容器配置 2049 2050 ```java 2051 val webView = findViewById(R.id.webView) 2052 webView.settings.apply { 2053 javaScriptEnabled = true 2054 domStorageEnabled = true 2055 allowFileAccess = true 2056 allowUniversalAccessFromFileURLs = true 2057 useWideViewPort = true 2058 loadWithOverviewMode = true 2059 setSupportZoom(false) 2060 } 2061 webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) 2062 webView.webChromeClient = WebChromeClient() 2063 webView.addJavascriptInterface(RobotBridge(this, webView), "RobotBridge") 2064 2065 // 调试模式:联调阶段开启,支持 Chrome DevTools 远程调试 2066 if (BuildConfig.DEBUG) { 2067 WebView.setWebContentsDebuggingEnabled(true) 2068 } 2069 2070 // 加载本地 H5(Spring Boot 后端部署在 localhost:8080) 2071 webView.loadUrl("http://localhost:8080") 2072 ``` 2073 2074 > **安全提示**:迎检环境为内网受控设备,上述 WebView 配置(allowFileAccess、allowUniversalAccessFromFileURLs)可接受。若后续部署到公网或多方接入环境,需收紧安全配置。 2075 2076 ### 4.3 JSBridge 通信协议设计 2077 2078 | Bridge 方法名 | 参数 | 返回值(通过回调) | 说明 | 底层 SDK 调用 | 本期实现 | 2079 |---|---|---|---|---|---| 2080 | `RobotBridge.navigate(destination, callbackId)` | destination: String, callbackId: String | {code, msg} | 导航到指定位置 | `RobotApi.getInstance().startNavigation(reqId, place, coordinate, timeout, ActionListener)` | ✅ | 2081 | `RobotBridge.stopNavigation(callbackId)` | callbackId: String | {code, msg} | 停止导航 | `RobotApi.getInstance().stopNavigation(reqId)` | 预留 | 2082 | `RobotBridge.getPlaceList(callbackId)` | callbackId: String | JSON 数组 | 获取所有定位点 | `RobotApi.getInstance().getPlaceList(reqId, CommandListener)` | ✅ | 2083 | `RobotBridge.getPosition(callbackId)` | callbackId: String | {x,y,theta} | 获取当前坐标 | `RobotApi.getInstance().getPosition(reqId, CommandListener)` | 预留 | 2084 | `RobotBridge.playTTS(text, callbackId)` | text: String, callbackId: String | {code, msg} | TTS 语音播报 | `SkillApi.getInstance().playText(TTSEntity, TextListener)` | ✅ | 2085 | `RobotBridge.stopTTS(callbackId)` | callbackId: String | {code, msg} | 停止播报 | `SkillApi.getInstance().stopTTS()` | 预留 | 2086 | `RobotBridge.getBattery(callbackId)` | callbackId: String | {level} | 获取电量 | `RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO)` | ✅ | 2087 2088 **通信机制说明**:Android WebView 的 `@JavascriptInterface` 只支持基本类型参数(String/int/boolean),无法直接传递 JS 函数对象。因此采用 "callbackId + 全局回调池" 模式:H5 侧生成唯一 callbackId 并注册回调函数到 `window.__robotCallbacks`,将 callbackId 字符串传给 Native;Native 处理完成后通过 `webView.evaluateJavascript()` 执行 `window.__robotCallbacks[callbackId](result)` 回推结果。 2089 2090 **Android 端 Bridge 实现**: 2091 2092 ```java 2093 class RobotBridge(private val context: Context, private val webView: WebView) { 2094 2095 @JavascriptInterface 2096 fun navigate(destination: String, callbackId: String) { 2097 thread { 2098 RobotApi.startNavigation(destination) 2099 val result = JSONObject().apply { 2100 put("code", 0) 2101 put("msg", "navigation_started") 2102 put("destination", destination) 2103 } 2104 webView.post { 2105 webView.evaluateJavascript( 2106 "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)", 2107 null 2108 ) 2109 } 2110 } 2111 } 2112 2113 @JavascriptInterface 2114 fun getPlaceList(callbackId: String) { 2115 thread { 2116 val places = RobotApi.getPlaceList() 2117 val result = JSONArray(places) 2118 webView.post { 2119 webView.evaluateJavascript( 2120 "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)", 2121 null 2122 ) 2123 } 2124 } 2125 } 2126 2127 @JavascriptInterface 2128 fun playTTS(text: String, callbackId: String) { 2129 thread { 2130 speechApi.playText(text) 2131 val result = JSONObject().apply { 2132 put("code", 0) 2133 put("msg", "tts_started") 2134 } 2135 webView.post { 2136 webView.evaluateJavascript( 2137 "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)", 2138 null 2139 ) 2140 } 2141 } 2142 } 2143 2144 @JavascriptInterface 2145 fun getBattery(callbackId: String) { 2146 thread { 2147 val level = RobotApi.getBatteryLevel() 2148 val result = JSONObject().apply { 2149 put("code", 0) 2150 put("level", level) 2151 } 2152 webView.post { 2153 webView.evaluateJavascript( 2154 "window.__robotCallbacks && window.__robotCallbacks['$callbackId'] && window.__robotCallbacks['$callbackId']($result)", 2155 null 2156 ) 2157 } 2158 } 2159 } 2160 } 2161 ``` 2162 2163 > 以上仅示例本期必须实现的核心接口,预留接口(stopNavigation、getPosition、stopTTS)遵循相同的 callbackId 协议模式,按需在后续迭代中实现。底层通过 `RobotApi`、`SkillApi`、`RobotSettingApi` 三个核心 SDK 类分别调用导航、语音、设备信息等能力,回调接口统一使用 `ActionListener`(长操作)、`CommandListener`(单次命令)、`TextListener`(TTS 播报)。 2164 2165 ### 4.4 猎户星空 SDK 集成步骤 2166 2167 1. **获取 SDK JAR 包**:从猎户星空获取 `robotservice.jar`(约 1.1MB),放入 `app/libs/` 目录 2168 2. **build.gradle 配置**: 2169 2170 ```gradle 2171 dependencies { 2172 implementation fileTree(dir: 'libs', include: ['*.jar']) 2173 } 2174 ``` 2175 2176 3. **AndroidManifest 声明权限**: 2177 2178 ```xml 2179 2180 2181 2182 ``` 2183 2184 4. **Application 中初始化**(连接流程:`Application.onCreate()` → `RobotApi.getInstance().connectServer(context, ApiListener)` → `ApiListener.handleApiConnected()` → `SkillApi.getInstance().connectApi(context)`): 2185 2186 ```java 2187 class RobotApp : Application() { 2188 override fun onCreate() { 2189 super.onCreate() 2190 RobotApi.init(this) 2191 RobotApi.connectServer { status -> 2192 Log.d("RobotSDK", "Server 连接状态: $status") 2193 } 2194 } 2195 } 2196 ``` 2197 2198 5. **导航状态监听**(SDK 提供三种回调接口:`ActionListener` 用于长操作如导航,含 `onResult`/`onError`/`onStatusUpdate` 三个方法;`CommandListener` 用于单次命令如获取位置,含 `onResult` 方法;`TextListener` 用于 TTS 播报,含 `onStart`/`onStop`/`onError`/`onComplete` 四个方法): 2199 2200 ```java 2201 RobotApi.setNavigationListener(object : NavigationListener { 2202 override fun onStatus(statusCode: Int, data: String?) { 2203 when (statusCode) { 2204 32730001 -> Log.d("NAV", "开始导航") 2205 32730004 -> Log.d("NAV", "避障中") 2206 32730011 -> Log.d("NAV", "堵死") 2207 32730009 -> Log.d("NAV", "定位丢失") 2208 } 2209 } 2210 2211 override fun onResult(resultCode: Int, data: String?) { 2212 when (resultCode) { 2213 32610007 -> Log.d("NAV", "到达目的地") 2214 -32620001 -> Log.d("NAV", "未定位") 2215 -32620009 -> Log.d("NAV", "路径规划失败") 2216 } 2217 } 2218 }) 2219 ``` 2220 2221 ### 4.5 参考的关键 API 2222 2223 | API | 功能 | 所属 SDK 类 | 2224 |-----|------|------------| 2225 | `RobotApi.getInstance().startNavigation(reqId, place, coordinate, timeout, ActionListener)` | 启动导航到指定位置点 | RobotApi(导航/位置/运动控制) | 2226 | `RobotApi.getInstance().stopNavigation(reqId)` | 停止当前导航 | RobotApi | 2227 | `RobotApi.getInstance().getPlaceList(reqId, CommandListener)` | 获取地图中所有位置点列表 | RobotApi | 2228 | `RobotApi.getInstance().getPosition(reqId, CommandListener)` | 获取当前坐标 `{x, y, theta}` | RobotApi | 2229 | `RobotSettingApi.getInstance().getRobotString(Definition.ROBOT_SETTINGS_BATTERY_INFO)` | 获取电量信息 | RobotSettingApi(设备信息/电量) | 2230 | `SkillApi.getInstance().playText(TTSEntity, TextListener)` | TTS 语音播报(需先构建 TTSEntity) | SkillApi(语音 TTS/ASR) | 2231 | `SkillApi.getInstance().stopTTS()` | 停止 TTS 播放 | SkillApi | 2232 | `PersonApi.getInstance()` | 人脸识别能力 | PersonApi(人脸识别) | 2233 | `RobotApi.getInstance().connectServer(context, ApiListener)` | 连接 SDK 服务,初始化入口 | RobotApi | 2234 | `SkillApi.getInstance().connectApi(context)` | 连接语音服务(在 ApiListener.handleApiConnected 回调中调用) | SkillApi | 2235 2236 ### 4.6 补充:Android Studio 环境准备和业务 App 工程搭建(面向 Web 开发者) 2237 2238 > 本节专为从未接触过 Android 开发的 Web 全栈工程师编写,每一步都配有详细说明和截图指引对应操作。 2239 2240 #### 4.6.1 Android Studio 环境安装与配置 2241 2242 1. **下载安装 Android Studio** 2243 - 访问 https://developer.android.com/studio 下载最新稳定版(推荐 Android Studio Hedgehog 或更新版本) 2244 - macOS 用户:下载 `.dmg` 文件后,将 Android Studio 拖入 Applications 文件夹 2245 - Windows 用户:运行 `.exe` 安装程序,按向导完成安装 2246 - 首次启动会提示导入设置,选择 "Do not import settings" 2247 - 接着会下载 Android SDK,选择 **"Standard"** 安装即可(包含常用 SDK 和模拟器) 2248 2249 2. **SDK Manager 配置** 2250 - 打开 Android Studio → 顶部菜单 **Android Studio → Preferences**(macOS)或 **File → Settings**(Windows) 2251 - 左侧导航选择 **Appearance & Behavior → System Settings → Android SDK** 2252 - 在 **SDK Platforms** 标签页中勾选以下版本: 2253 - `Android 10.0 (API 29)` —— 编译目标版本(对应 compileSdkVersion) 2254 - `Android 4.4 (API 19)` —— 最低支持版本(对应 minSdkVersion) 2255 - 在 **SDK Tools** 标签页中勾选: 2256 - `Android SDK Build-Tools 34` —— 构建工具 2257 - `Android SDK Platform-Tools` —— 包含 adb、fastboot 等调试工具 2258 - `Android SDK Command-line Tools (latest)` —— 命令行工具 2259 - 点击右下角 **Apply** 按钮,等待下载完成(根据网络情况可能需要 5-20 分钟) 2260 2261 3. **JDK 配置** 2262 - Android Studio 自带 JDK 17(路径通常在 `/Applications/Android Studio.app/Contents/jbr/Contents/Home`) 2263 - 一般无需额外安装 JDK 2264 - 验证路径:点击 **File → Project Structure → SDK Location**,确认 **JDK Location** 字段有值且路径存在 2265 - 如需手动指定:点击右侧文件夹图标,选择本地 JDK 17 安装目录 2266 2267 4. **ADB 环境变量配置** 2268 - ADB(Android Debug Bridge)是连接开发电脑与机器人设备的核心工具 2269 - **macOS 配置**: 2270 ```bash 2271 # 打开终端,编辑 zsh 配置文件 2272 open ~/.zshrc 2273 # 在文件末尾添加以下行(根据实际 SDK 路径调整) 2274 export PATH="$PATH:$HOME/Library/Android/sdk/platform-tools" 2275 # 保存后执行 2276 source ~/.zshrc 2277 ``` 2278 - **Windows 配置**: 2279 - 右键 "此电脑" → 属性 → 高级系统设置 → 环境变量 2280 - 在 "系统变量" 中找到 `Path`,点击编辑 2281 - 点击新建,添加路径:`C:\Users\<你的用户名>\AppData\Local\Android\Sdk\platform-tools` 2282 - 点击确定保存 2283 - **验证配置**:在终端(macOS 的 Terminal 或 Windows 的 CMD/PowerShell)中运行: 2284 ```bash 2285 adb version 2286 ``` 2287 应输出类似 `Android Debug Bridge version 1.0.xxx` 的版本信息 2288 2289 #### 4.6.2 创建业务 App 工程 2290 2291 1. 打开 Android Studio,点击 **File → New → New Project** 2292 2. 在项目模板选择界面,选择 **"Empty Views Activity"**(使用传统 View 系统的空 Activity,适合 Web 开发者理解) 2293 - 注意:不要选择 "Empty Activity"(那是 Jetpack Compose 版本,学习成本更高) 2294 3. 在配置页面填写以下信息: 2295 - **Name**: `MedicalRobotApp`(应用名称) 2296 - **Package name**: `com.emoon.medical.robot`(应用包名,全局唯一标识) 2297 - **Save location**: 选择本地目录(如 `~/Projects/MedicalRobotApp`) 2298 - **Language**: `Java`(猎户星空 SDK Demo 项目使用 Java,团队现有代码库均为 Java) 2299 - **Minimum SDK**: `API 19: Android 4.4 (KitKat)`(猎户星空机器人系统兼容 Android 4.4+,建议 minSdkVersion 设为 19) 2300 - **Build configuration language**: `Groovy (DSL)`(使用传统 Groovy 脚本配置 Gradle,与猎户星空 Demo 项目一致) 2301 4. 点击 **Finish**,等待 Gradle 首次同步完成(首次可能需要下载依赖,耗时 5-15 分钟) 2302 5. 同步完成后,左侧 Project 面板应显示工程结构,顶部工具栏出现绿色运行按钮,表示工程创建成功 2303 2304 --- 2305 2306 ### 4.7 补充:完整的 build.gradle(Module: app) 2307 2308 > 以下文件位于 `MedicalRobotApp/app/build.gradle`,是应用模块的构建配置(使用 Groovy DSL)。每一行都带有中文注释,说明其作用。 2309 2310 ```java 2311 /** 2312 * app 模块构建配置文件 2313 * 作用:定义编译版本、依赖库、构建变体等 2314 * 位置:MedicalRobotApp/app/build.gradle 2315 */ 2316 2317 // 插件声明:应用 Android 应用程序插件和 Kotlin Android 插件 2318 plugins { 2319 // Android 应用插件,提供构建 APK 的能力 2320 alias(libs.plugins.android.application) 2321 // Kotlin Android 插件,支持 Kotlin 语言编译 2322 alias(libs.plugins.kotlin.android) 2323 } 2324 2325 // Android 构建设置 2326 android { 2327 // 命名空间:与 AndroidManifest.xml 中的 package 属性一致 2328 // 用于生成 R 类和其他资源引用 2329 namespace = "com.emoon.medical.robot" 2330 2331 // 编译 SDK 版本:使用 API 34(Android 14)进行编译 2332 // 决定了可以使用的最新 Android API 2333 compileSdk = 34 2334 2335 // 默认配置:所有构建变体共享的基础配置 2336 defaultConfig { 2337 // 应用包名,设备上唯一标识此应用 2338 applicationId = "com.emoon.medical.robot" 2339 2340 // 最低支持的 Android 版本:API 24(Android 7.0) 2341 // 低于此版本的设备无法安装此应用 2342 minSdk = 24 2343 2344 // 目标 SDK 版本:API 34(Android 14) 2345 // 表示应用已在此版本上充分测试,系统会启用该版本的行为特性 2346 targetSdk = 34 2347 2348 // 版本号:内部版本标识,每次发布必须递增 2349 versionCode = 1 2350 2351 // 版本名称:对外展示的用户友好版本号 2352 versionName = "1.0.0" 2353 2354 // 测试运行器:使用 AndroidJUnit4 进行单元测试 2355 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 2356 } 2357 2358 // 构建类型配置 2359 buildTypes { 2360 // 发布(Release)构建配置 2361 release { 2362 // 是否启用代码压缩和混淆(发布时建议开启以减小体积) 2363 isMinifyEnabled = false 2364 2365 // 混淆规则文件:proguard-rules.pro 中定义了保留哪些类不被混淆 2366 proguardFiles( 2367 getDefaultProguardFile("proguard-android-optimize.txt"), 2368 "proguard-rules.pro" 2369 ) 2370 } 2371 2372 // 调试(Debug)构建配置 2373 debug { 2374 // Debug 模式不启用混淆,方便调试和查看堆栈 2375 isMinifyEnabled = false 2376 2377 // 开启 Debug 签名,无需手动配置签名密钥 2378 isDebuggable = true 2379 } 2380 } 2381 2382 // 编译选项 2383 compileOptions { 2384 // 源码兼容性:Java 17 2385 sourceCompatibility = JavaVersion.VERSION_17 2386 // 目标兼容性:Java 17 2387 targetCompatibility = JavaVersion.VERSION_17 2388 } 2389 2390 // Kotlin 编译选项 2391 kotlinOptions { 2392 // JVM 目标版本:与 Java 版本保持一致 2393 jvmTarget = "17" 2394 } 2395 2396 // 构建特性开关 2397 buildFeatures { 2398 // 启用 ViewBinding:自动生成绑定类,替代 findViewById,类型安全且无需额外依赖 2399 viewBinding = true 2400 } 2401 2402 // 打包选项 2403 packaging { 2404 resources { 2405 // 排除重复的资源文件,避免打包冲突 2406 excludes += "/META-INF/{AL2.0,LGPL2.1}" 2407 } 2408 } 2409 } 2410 2411 // 依赖声明:项目所需的外部库 2412 dependencies { 2413 // ===== AndroidX 核心库 ===== 2414 2415 // AppCompat 库:提供向后兼容的 ActionBar 和主题支持 2416 // 即使 minSdk 是 24,也能使用新版 API 的兼容实现 2417 implementation("androidx.appcompat:appcompat:1.6.1") 2418 2419 // Material Design 组件库:提供按钮、卡片、对话框等符合 Material 规范的 UI 组件 2420 implementation("com.google.android.material:material:1.11.0") 2421 2422 // ConstraintLayout:灵活高效的布局容器,适合复杂界面 2423 // 相比嵌套 LinearLayout,性能更好且更易于维护 2424 implementation("androidx.constraintlayout:constraintlayout:2.1.4") 2425 2426 // WebKit 库:提供增强型 WebView 支持 2427 // 包含现代 WebView API,如 WebViewClient、WebChromeClient 等 2428 implementation("androidx.webkit:webkit:1.9.0") 2429 2430 // Core KTX:Kotlin 扩展函数,简化 Android 核心 API 的调用 2431 implementation("androidx.core:core-ktx:1.12.0") 2432 2433 // Activity KTX:Activity 相关的 Kotlin 扩展 2434 implementation("androidx.activity:activity-ktx:1.8.0") 2435 2436 // Lifecycle 相关库:支持 ViewModel、LiveData 等生命周期感知组件 2437 implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") 2438 2439 // ===== 猎户星空 RobotOS SDK ===== 2440 2441 // 方式一:通过本地 AAR 文件引入(推荐,当前团队使用此方式) 2442 // 将 robotos-sdk.aar 文件放入 app/libs/ 目录下,然后使用以下方式引入 2443 implementation(files("libs/robotos-sdk.aar")) 2444 2445 // 方式二:通过 Maven 仓库引入(如果猎户星空后续开放 Maven 仓库) 2446 // 取消下面一行的注释,并注释掉上面的 files 方式 2447 // implementation("com.orionstar:robotos-sdk:2.x.x") 2448 2449 // ===== JSON 处理库 ===== 2450 2451 // Gson:Google 的 JSON 序列化/反序列化库 2452 // 用于 JSBridge 中 Native 与 H5 之间的 JSON 数据转换 2453 implementation("com.google.code.gson:gson:2.10.1") 2454 2455 // ===== 测试库(开发阶段使用) ===== 2456 2457 // JUnit 4:单元测试框架 2458 testImplementation("junit:junit:4.13.2") 2459 2460 // AndroidX Test:Android 测试扩展库 2461 androidTestImplementation("androidx.test.ext:junit:1.1.5") 2462 2463 // Espresso:UI 自动化测试框架 2464 androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 2465 } 2466 ``` 2467 2468 > **说明**:`libs/robotservice.jar` 文件需要从猎户星空获取。将 JAR 文件放入 `app/libs/` 目录后,Gradle 通过 `implementation fileTree(dir: 'libs', include: ['*.jar'])` 自动识别。SDK 核心类包括 `RobotApi`(导航/位置/运动控制)、`SkillApi`(语音 TTS/ASR)、`RobotSettingApi`(设备信息/电量)、`PersonApi`(人脸识别),均通过 `XXXApi.getInstance()` 获取单例。 2469 2470 --- 2471 2472 ### 4.8 补充:完整的 AndroidManifest.xml 2473 2474 > 以下文件位于 `MedicalRobotApp/app/src/main/AndroidManifest.xml`,是 Android 应用的配置文件。声明了应用组件、权限、主题等核心信息。每行都有中文注释。 2475 2476 ```xml 2477 2478 2479 2480 2482 2483 2484 2485 2486 2488 2489 2490 2492 2493 2494 2496 2497 2498 2501 2502 2503 2506 2508 2509 2511 2513 2514 2516 2517 2518 2520 2521 2522 2523 2524 2526 android:label="@string/app_name" 2527 2528 2529 android:icon="@mipmap/ic_launcher" 2530 2531 2532 android:description="@string/app_description" 2533 2534 2536 android:name=".MedicalRobotApplication" 2537 2538 2540 android:theme="@style/Theme.MedicalRobotApp.Fullscreen" 2541 2542 2545 android:usesCleartextTraffic="true" 2546 2547 2549 android:networkSecurityConfig="@xml/network_security_config" 2550 2551 2552 android:allowBackup="false" 2553 2554 2555 android:extractNativeLibs="true" 2556 2557 2558 android:largeHeap="true" 2559 2560 2561 tools:targetApi="34"> 2562 2563 2564 2565 2566 2568 2570 android:name=".MainActivity" 2571 2572 2574 android:exported="false" 2575 2576 2578 android:screenOrientation="landscape" 2579 2580 2582 android:launchMode="singleTask" 2583 2584 2586 android:configChanges="orientation|screenSize|keyboardHidden"> 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 ``` 2601 2602 > **安全提示**:`android:usesCleartextTraffic="true"` 仅适用于内网迎检环境。若后续部署到公网,应移除此属性或配合 `network_security_config.xml` 配置域名白名单,强制使用 HTTPS 通信。 2603 2604 ### 4.9 补充:完整的工程目录结构 2605 2606 > 以下是创建完成后的完整工程目录结构,帮助 Web 开发者快速理解 Android 项目的组织方式。 2607 2608 ``` 2609 MedicalRobotApp/ // 项目根目录 2610 ├── app/ // 应用模块(核心代码都在这里) 2611 │ ├── src/main/ // 主源码目录(还有 test/ 和 androidTest/ 用于测试) 2612 │ │ ├── java/com/emoon/medical/robot/ // Java 源码根目录,按包名层级组织 2613 │ │ │ ├── MedicalRobotApplication.java // Application 类:全局初始化、SDK 连接 2614 │ │ │ ├── MainActivity.java // 主 Activity:WebView 容器,唯一可见界面 2615 │ │ │ ├── bridge/ // JSBridge 相关代码 2616 │ │ │ │ └── RobotBridge.java // JSBridge 实现:Native 与 H5 的通信桥梁 2617 │ │ │ └── sdk/ // SDK 封装层 2618 │ │ │ └── RobotSDKManager.java // SDK 管理器:封装 RobotApi/SkillApi/RobotSettingApi 2619 │ │ ├── res/ // 资源文件目录 2620 │ │ │ ├── layout/ // 布局文件:XML 描述的界面结构 2621 │ │ │ │ └── activity_main.xml // MainActivity 的布局:全屏 WebView 2622 │ │ │ ├── values/ // 值资源:颜色、字符串、主题、尺寸等 2623 │ │ │ │ ├── colors.xml // 颜色定义 2624 │ │ │ │ ├── strings.xml // 字符串定义(应用名称、提示文案等) 2625 │ │ │ │ └── themes.xml // 主题和样式定义 2626 │ │ │ └── xml/ // XML 配置文件 2627 │ │ │ └── network_security_config.xml // 网络安全配置(允许明文 HTTP) 2628 │ │ ├── assets/ // 静态资源目录(不参与编译,原样打包) 2629 │ │ │ └── web/ // H5 打包文件存放位置(离线模式使用) 2630 │ │ │ └── index.html // 本地 H5 入口页面(可选) 2631 │ │ └── AndroidManifest.xml // 应用配置文件(权限、组件声明) 2632 │ ├── libs/ // 本地依赖库目录 2633 │ │ └── robotservice.jar // 猎户星空 SDK(从官方获取,约 1.1MB) 2634 │ └── build.gradle // 模块级构建配置(Groovy DSL,依赖、编译选项) 2635 ├── build.gradle // 项目级构建配置(Gradle 插件版本) 2636 ├── settings.gradle // Gradle 项目设置(包含的模块列表) 2637 ├── gradle.properties // Gradle 属性配置(JVM 参数、代理等) 2638 └── gradle/ // Gradle Wrapper 目录 2639 └── wrapper/ 2640 ├── gradle-wrapper.jar // Gradle Wrapper 可执行文件 2641 └── gradle-wrapper.properties // Wrapper 配置(Gradle 版本号) 2642 ``` 2643 2644 > **与 Web 项目的对比**: 2645 > - `src/main/java/` 相当于 Web 后端的 `src/main/java/`(源码目录) 2646 > - `src/main/res/` 相当于前端项目的 `public/` 或 `assets/`(静态资源) 2647 > - `build.gradle` 相当于 `package.json` + `webpack.config.js`(依赖和构建配置,使用 Groovy DSL) 2648 > - `AndroidManifest.xml` 相当于 `web.xml` 或应用入口配置 2649 2650 --- 2651 2652 ### 4.10 补充:MedicalRobotApplication.java 完整代码 2653 2654 > Application 类是 Android 应用的全局入口,在应用启动时第一个被初始化。这里负责初始化猎户星空 SDK 和建立与机器人系统服务的连接。SDK 初始化流程:`onCreate()` → `RobotApi.getInstance().connectServer(context, ApiListener)` → 在 `handleApiConnected()` 回调中调用 `SkillApi.getInstance().connectApi(context)`。 2655 2656 ```java 2657 package com.emoon.medical.robot 2658 2659 import android.app.Application 2660 import android.util.Log 2661 2662 /** 2663 * 应用全局初始化类 2664 * 职责: 2665 * 1. 初始化猎户星空 RobotOS SDK 2666 * 2. 建立与机器人系统服务的连接 2667 * 3. 管理全局状态(SDK 连接状态、Mock 模式开关等) 2668 * 2669 * 生命周期: 2670 * - 应用进程启动时,系统首先创建此类的实例并调用 onCreate() 2671 * - 在应用运行期间保持单例,直到进程被杀死 2672 * - 任何 Activity、Service 都可以通过 (application as MedicalRobotApplication) 访问 2673 * 2674 * 注意:必须在 AndroidManifest.xml 的 标签中通过 android:name 属性声明此类, 2675 * 否则系统不会调用它。 2676 */ 2677 class MedicalRobotApplication : Application() { 2678 2679 // ===== Companion Object:Kotlin 的静态成员替代方案 ===== 2680 // 用于存放全局可访问的静态常量和变量 2681 companion object { 2682 // 日志标签:所有此类相关的日志都使用此标签,方便在 logcat 中过滤 2683 const val TAG = "MedicalRobot" 2684 2685 // 全局 SDK 管理器实例 2686 // lateinit 表示延迟初始化,在 onCreate() 中赋值 2687 // private set 表示外部只能读取不能修改 2688 lateinit var sdkManager: RobotSDKManager 2689 private set 2690 2691 // Mock 模式开关:true 表示使用模拟数据(无需真机即可开发调试) 2692 // false 表示调用真实 SDK(需要部署到机器人真机) 2693 // 开发阶段建议设为 true,联调阶段设为 false 2694 var useMockMode: Boolean = true 2695 } 2696 2697 /** 2698 * 应用创建时的初始化方法 2699 * 系统回调:应用进程启动后第一个被调用的方法 2700 * 注意:此方法执行时间过长会阻塞应用启动,因此只应做轻量级初始化 2701 */ 2702 override fun onCreate() { 2703 // 调用父类实现,确保框架级初始化正常完成 2704 super.onCreate() 2705 2706 // 输出应用启动日志,方便在 logcat 中确认初始化流程 2707 Log.i(TAG, "========================================") 2708 Log.i(TAG, "应用启动,开始初始化...") 2709 Log.i(TAG, "Mock 模式: $useMockMode") 2710 Log.i(TAG, "========================================") 2711 2712 // 初始化 SDK 管理器,传入 Application 上下文 2713 // 上下文(Context)是 Android 中访问系统资源和服务的关键对象 2714 sdkManager = RobotSDKManager(this) 2715 2716 // 执行 SDK 初始化(连接机器人系统服务) 2717 // 如果是 Mock 模式,初始化会快速完成并返回模拟的连接成功状态 2718 sdkManager.initialize() 2719 2720 Log.i(TAG, "应用初始化完成") 2721 } 2722 2723 /** 2724 * 应用终止时的清理方法 2725 * 系统回调:应用进程即将被杀死时调用(不保证一定被调用) 2726 * 用于释放资源、断开连接等清理操作 2727 */ 2728 override fun onTerminate() { 2729 Log.i(TAG, "应用终止,执行清理...") 2730 // 断开 SDK 连接,释放资源 2731 sdkManager.release() 2732 // 调用父类实现 2733 super.onTerminate() 2734 } 2735 2736 /** 2737 * 内存不足时的回调 2738 * 系统回调:系统内存紧张时调用 2739 * 应在此释放不必要的缓存和资源 2740 */ 2741 override fun onLowMemory() { 2742 Log.w(TAG, "系统内存不足,释放资源...") 2743 super.onLowMemory() 2744 } 2745 } 2746 ``` 2747 2748 > **Web 开发者提示**:`Application` 类类似于 Spring Boot 的 `@SpringBootApplication` 主类,是全局配置的入口。`onCreate()` 类似于 Spring 的 `CommandLineRunner` 或 `@PostConstruct` 方法。 2749 2750 --- 2751 2752 ### 4.11 补充:RobotSDKManager.java 完整代码 2753 2754 > SDK 管理封装类:统一封装猎户星空 SDK 的三个核心 API 类(`RobotApi` 导航/位置/运动控制、`SkillApi` 语音 TTS/ASR、`RobotSettingApi` 设备信息/电量),对外提供简洁的接口,内部处理连接管理、错误处理和 Mock 模式切换。回调接口统一使用 SDK 原生的 `ActionListener`(长操作)、`CommandListener`(单次命令)、`TextListener`(TTS 播报)。 2755 2756 ```java 2757 package com.emoon.medical.robot 2758 2759 import android.content.Context 2760 import android.util.Log 2761 import org.json.JSONArray 2762 import org.json.JSONObject 2763 2764 /** 2765 * 猎户星空 RobotOS SDK 管理封装类 2766 * 职责: 2767 * 1. 封装 SDK 的初始化和连接管理 2768 * 2. 提供简洁的业务 API(导航、TTS、位置查询、电量等) 2769 * 3. 统一管理回调接口,将 SDK 的异步结果转换为业务友好的回调 2770 * 4. 支持 Mock 模式,在没有真机时返回模拟数据 2771 * 2772 * 设计模式:外观模式(Facade Pattern),对外隐藏 SDK 的复杂调用细节 2773 */ 2774 class RobotSDKManager(private val context: Context) { 2775 2776 // 日志标签 2777 private val tag = "RobotSDKManager" 2778 2779 // ===== Mock 模式标志 ===== 2780 // 通过 MedicalRobotApplication.useMockMode 统一控制 2781 private val isMock: Boolean 2782 get() = MedicalRobotApplication.useMockMode 2783 2784 // ===== 连接状态 ===== 2785 // 记录 SDK 与机器人系统服务的连接状态 2786 private var isConnected = false 2787 2788 // ===== 回调接口定义 ===== 2789 // 使用接口(Interface)定义回调规范,调用方实现此接口接收结果 2790 2791 /** 2792 * 通用操作回调接口 2793 * 适用于导航、TTS 等只需要知道成功/失败的操作 2794 */ 2795 interface OperationCallback { 2796 /** 2797 * 操作成功时调用 2798 * @param data 可选的返回数据(JSON 格式字符串) 2799 */ 2800 fun onSuccess(data: String = "{}") 2801 2802 /** 2803 * 操作失败时调用 2804 * @param code 错误码 2805 * @param message 错误描述 2806 */ 2807 fun onError(code: Int, message: String) 2808 } 2809 2810 /** 2811 * 导航状态监听接口 2812 * 用于接收导航过程中的实时状态更新(开始导航、避障、堵死、到达等) 2813 */ 2814 interface NavigationListener { 2815 /** 2816 * 导航状态变化时调用 2817 * @param statusCode 状态码(如 32730001 表示开始导航) 2818 * @param data 附加数据 2819 */ 2820 fun onStatus(statusCode: Int, data: String?) 2821 2822 /** 2823 * 导航结果回调 2824 * @param resultCode 结果码(如 32610007 表示到达目的地) 2825 * @param data 附加数据 2826 */ 2827 fun onResult(resultCode: Int, data: String?) 2828 } 2829 2830 /** 2831 * 位置信息数据类 2832 * 封装机器人的当前坐标和定位状态 2833 */ 2834 data class Position( 2835 val x: Double, // X 坐标(地图坐标系,单位:米) 2836 val y: Double, // Y 坐标 2837 val theta: Double, // 朝向角度(弧度,0 表示正东方向) 2838 val isEstimated: Boolean // 是否已完成定位 2839 ) 2840 2841 /** 2842 * 位置点数据类 2843 * 封装地图中预设的导航目标点信息 2844 */ 2845 data class Place( 2846 val name: String, // 位置点名称(如 "导诊台"、"神经内科") 2847 val x: Double, // X 坐标 2848 val y: Double, // Y 坐标 2849 val theta: Double // 到达后的朝向角度 2850 ) 2851 2852 // ===== 初始化与连接管理 ===== 2853 2854 /** 2855 * 初始化 SDK 并建立与机器人系统服务的连接 2856 * 应在 Application.onCreate() 中调用 2857 */ 2858 fun initialize() { 2859 if (isMock) { 2860 // Mock 模式:模拟初始化成功,无需连接真实服务 2861 Log.i(tag, "[Mock] SDK 初始化成功(模拟模式)") 2862 isConnected = true 2863 return 2864 } 2865 2866 // 真实 SDK 初始化路径 2867 try { 2868 Log.i(tag, "开始初始化猎户星空 SDK...") 2869 2870 // 初始化 RobotApi,传入 Application Context 2871 // RobotApi.init(context) 2872 2873 // 连接 SDK Server(机器人系统后台服务) 2874 // RobotApi.connectServer { status -> 2875 // Log.i(tag, "SDK Server 连接状态: $status") 2876 // isConnected = status == 1 // 1 表示连接成功 2877 // } 2878 2879 // 设置导航状态监听 2880 // RobotApi.setNavigationListener(object : NavigationListener { ... }) 2881 2882 Log.i(tag, "SDK 初始化完成,等待连接...") 2883 } catch (e: Exception) { 2884 Log.e(tag, "SDK 初始化失败: ${e.message}", e) 2885 isConnected = false 2886 } 2887 } 2888 2889 /** 2890 * 释放 SDK 资源,断开连接 2891 * 应在 Application.onTerminate() 或 Activity.onDestroy() 中调用 2892 */ 2893 fun release() { 2894 if (isMock) { 2895 Log.i(tag, "[Mock] SDK 资源已释放") 2896 isConnected = false 2897 return 2898 } 2899 2900 try { 2901 // RobotApi.disconnectServer() 2902 isConnected = false 2903 Log.i(tag, "SDK 连接已断开") 2904 } catch (e: Exception) { 2905 Log.e(tag, "释放 SDK 资源失败: ${e.message}", e) 2906 } 2907 } 2908 2909 /** 2910 * 获取当前 SDK 连接状态 2911 * @return true 表示已连接,false 表示未连接 2912 */ 2913 fun isConnected(): Boolean = isConnected 2914 2915 // ===== 导航 API ===== 2916 2917 /** 2918 * 启动导航到指定位置点 2919 * @param destination 目标位置点名称(如 "导诊台"、"神经内科") 2920 * @param callback 操作结果回调 2921 */ 2922 fun startNavigation(destination: String, callback: OperationCallback) { 2923 Log.i(tag, "开始导航到: $destination") 2924 2925 if (isMock) { 2926 // Mock 模式:模拟导航成功,延迟 500ms 后回调 2927 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 2928 Log.i(tag, "[Mock] 导航到 [$destination] 成功") 2929 callback.onSuccess( 2930 JSONObject().apply { 2931 put("destination", destination) 2932 put("mock", true) 2933 }.toString() 2934 ) 2935 }, 500) 2936 return 2937 } 2938 2939 // 真实 SDK 调用路径 2940 try { 2941 if (!isConnected) { 2942 callback.onError(-1, "SDK 未连接") 2943 return 2944 } 2945 2946 // 调用猎户星空 SDK 导航 API 2947 // RobotApi.startNavigation(destination) 2948 2949 // 设置导航结果监听 2950 // RobotApi.setNavigationListener(object : NavigationListener { 2951 // override fun onResult(resultCode: Int, data: String?) { 2952 // when (resultCode) { 2953 // 32610007 -> callback.onSuccess() // 到达目的地 2954 // -32620001 -> callback.onError(resultCode, "未定位") 2955 // -32620009 -> callback.onError(resultCode, "路径规划失败") 2956 // else -> callback.onError(resultCode, "导航失败: $data") 2957 // } 2958 // } 2959 // }) 2960 2961 callback.onSuccess() 2962 } catch (e: Exception) { 2963 Log.e(tag, "导航调用异常: ${e.message}", e) 2964 callback.onError(-2, "导航异常: ${e.message}") 2965 } 2966 } 2967 2968 /** 2969 * 停止当前导航 2970 * @param callback 操作结果回调 2971 */ 2972 fun stopNavigation(callback: OperationCallback) { 2973 Log.i(tag, "停止导航") 2974 2975 if (isMock) { 2976 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 2977 Log.i(tag, "[Mock] 导航已停止") 2978 callback.onSuccess() 2979 }, 200) 2980 return 2981 } 2982 2983 try { 2984 if (!isConnected) { 2985 callback.onError(-1, "SDK 未连接") 2986 return 2987 } 2988 2989 // 调用猎户星空 SDK 停止运动 API 2990 // RobotApi.stopMove() 2991 callback.onSuccess() 2992 } catch (e: Exception) { 2993 Log.e(tag, "停止导航异常: ${e.message}", e) 2994 callback.onError(-2, "停止导航异常: ${e.message}") 2995 } 2996 } 2997 2998 // ===== 位置与地图 API ===== 2999 3000 /**
    Thinking 我需要继续读取文档的剩余部分,然后开始进行替换。让我继续读取 3001-4000 行和 4001-5246 行。

    Tool Call: ReadFile (/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md)

    {
      "path": "/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md",
      "line_offset": 3001,
      "n_lines": 1000
    }
    

    Tool Call: ReadFile (/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md)

    {
      "path": "/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md",
      "line_offset": 4001,
      "n_lines": 1246
    }
    
    Tool Result: ReadFile (`/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md`) 1000 lines read from file starting from line 3001. Total lines in file: 5246. 3001 * 获取地图中所有预设位置点列表 3002 * @param callback 结果回调,返回位置点列表的 JSON 字符串 3003 */ 3004 fun getPlaceList(callback: OperationCallback) { 3005 Log.i(tag, "获取位置点列表") 3006 3007 if (isMock) { 3008 // Mock 模式:返回模拟的医院位置点数据 3009 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 3010 val mockPlaces = JSONArray().apply { 3011 put(JSONObject().apply { 3012 put("name", "导诊台") 3013 put("x", 1.5) 3014 put("y", 2.0) 3015 put("theta", 1.57) 3016 }) 3017 put(JSONObject().apply { 3018 put("name", "神经内科") 3019 put("x", 5.2) 3020 put("y", 3.8) 3021 put("theta", 0.0) 3022 }) 3023 put(JSONObject().apply { 3024 put("name", "心血管内科") 3025 put("x", 5.2) 3026 put("y", 6.5) 3027 put("theta", 0.0) 3028 }) 3029 put(JSONObject().apply { 3030 put("name", "检验科") 3031 put("x", 8.0) 3032 put("y", 2.0) 3033 put("theta", -1.57) 3034 }) 3035 put(JSONObject().apply { 3036 put("name", "药房") 3037 put("x", 10.5) 3038 put("y", 5.0) 3039 put("theta", 3.14) 3040 }) 3041 } 3042 Log.i(tag, "[Mock] 返回 ${mockPlaces.length()} 个位置点") 3043 callback.onSuccess(mockPlaces.toString()) 3044 }, 300) 3045 return 3046 } 3047 3048 try { 3049 if (!isConnected) { 3050 callback.onError(-1, "SDK 未连接") 3051 return 3052 } 3053 3054 // 调用猎户星空 SDK 获取位置点列表 3055 // val places = RobotApi.getPlaceList() 3056 // callback.onSuccess(JSONArray(places).toString()) 3057 callback.onSuccess("[]") 3058 } catch (e: Exception) { 3059 Log.e(tag, "获取位置点列表异常: ${e.message}", e) 3060 callback.onError(-2, "获取位置点列表异常: ${e.message}") 3061 } 3062 } 3063 3064 /** 3065 * 获取机器人当前位置坐标 3066 * @param callback 结果回调,返回坐标 JSON 字符串 3067 */ 3068 fun getPosition(callback: OperationCallback) { 3069 Log.i(tag, "获取当前位置") 3070 3071 if (isMock) { 3072 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 3073 val mockPosition = JSONObject().apply { 3074 put("x", 3.5) 3075 put("y", 4.2) 3076 put("theta", 0.78) 3077 put("isEstimated", true) 3078 } 3079 Log.i(tag, "[Mock] 当前位置: (3.5, 4.2)") 3080 callback.onSuccess(mockPosition.toString()) 3081 }, 200) 3082 return 3083 } 3084 3085 try { 3086 if (!isConnected) { 3087 callback.onError(-1, "SDK 未连接") 3088 return 3089 } 3090 3091 // 调用猎户星空 SDK 获取当前位置 3092 // val pos = RobotApi.getPosition() 3093 // val isEstimated = RobotApi.isRobotEstimate() 3094 // val result = JSONObject().apply { 3095 // put("x", pos.x) 3096 // put("y", pos.y) 3097 // put("theta", pos.theta) 3098 // put("isEstimated", isEstimated) 3099 // } 3100 // callback.onSuccess(result.toString()) 3101 callback.onSuccess("{}") 3102 } catch (e: Exception) { 3103 Log.e(tag, "获取位置异常: ${e.message}", e) 3104 callback.onError(-2, "获取位置异常: ${e.message}") 3105 } 3106 } 3107 3108 /** 3109 * 检查机器人是否已完成定位 3110 * @param callback 结果回调,返回 JSON 字符串 {"isEstimated": true/false} 3111 */ 3112 fun isRobotEstimate(callback: OperationCallback) { 3113 if (isMock) { 3114 callback.onSuccess("{\"isEstimated\": true}") 3115 return 3116 } 3117 3118 try { 3119 // val result = RobotApi.isRobotEstimate() 3120 // callback.onSuccess("{\"isEstimated\": $result}") 3121 callback.onSuccess("{\"isEstimated\": false}") 3122 } catch (e: Exception) { 3123 callback.onError(-2, "检查定位状态异常: ${e.message}") 3124 } 3125 } 3126 3127 // ===== TTS 语音 API ===== 3128 3129 /** 3130 * 播放 TTS 语音播报 3131 * @param text 要播报的文本内容 3132 * @param callback 操作结果回调 3133 */ 3134 fun playTTS(text: String, callback: OperationCallback) { 3135 Log.i(tag, "TTS 播报: $text") 3136 3137 if (isMock) { 3138 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 3139 Log.i(tag, "[Mock] TTS 播报完成: $text") 3140 callback.onSuccess( 3141 JSONObject().apply { 3142 put("text", text) 3143 put("mock", true) 3144 }.toString() 3145 ) 3146 }, text.length * 200L) // 模拟朗读时间:每个字 200ms 3147 return 3148 } 3149 3150 try { 3151 if (!isConnected) { 3152 callback.onError(-1, "SDK 未连接") 3153 return 3154 } 3155 3156 // 调用猎户星空 SDK TTS API 3157 // speechApi.playText(text) 3158 callback.onSuccess() 3159 } catch (e: Exception) { 3160 Log.e(tag, "TTS 播报异常: ${e.message}", e) 3161 callback.onError(-2, "TTS 播报异常: ${e.message}") 3162 } 3163 } 3164 3165 /** 3166 * 停止 TTS 语音播报 3167 * @param callback 操作结果回调 3168 */ 3169 fun stopTTS(callback: OperationCallback) { 3170 Log.i(tag, "停止 TTS") 3171 3172 if (isMock) { 3173 callback.onSuccess() 3174 return 3175 } 3176 3177 try { 3178 // speechApi.stopTTS() 3179 callback.onSuccess() 3180 } catch (e: Exception) { 3181 Log.e(tag, "停止 TTS 异常: ${e.message}", e) 3182 callback.onError(-2, "停止 TTS 异常: ${e.message}") 3183 } 3184 } 3185 3186 // ===== 电量 API ===== 3187 3188 /** 3189 * 获取机器人当前电量 3190 * @param callback 结果回调,返回 JSON 字符串 {"level": 85} 3191 */ 3192 fun getBatteryLevel(callback: OperationCallback) { 3193 Log.i(tag, "获取电量") 3194 3195 if (isMock) { 3196 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ 3197 val mockLevel = 85 // 模拟电量 85% 3198 Log.i(tag, "[Mock] 当前电量: $mockLevel%") 3199 callback.onSuccess("{\"level\": $mockLevel}") 3200 }, 100) 3201 return 3202 } 3203 3204 try { 3205 if (!isConnected) { 3206 callback.onError(-1, "SDK 未连接") 3207 return 3208 } 3209 3210 // 调用猎户星空 SDK 电量 API 3211 // val level = RobotApi.getBatteryLevel() 3212 // callback.onSuccess("{\"level\": $level}") 3213 callback.onSuccess("{\"level\": 0}") 3214 } catch (e: Exception) { 3215 Log.e(tag, "获取电量异常: ${e.message}", e) 3216 callback.onError(-2, "获取电量异常: ${e.message}") 3217 } 3218 } 3219 } 3220 ``` 3221 3222 > **Mock 数据说明**:`getPlaceList()` 返回 5 个模拟医院位置点(导诊台、神经内科、心血管内科、检验科、药房);`getPosition()` 返回固定坐标 (3.5, 4.2);`getBatteryLevel()` 返回固定电量 85%;`playTTS()` 按字数模拟延迟。Mock 模式下绕过 `RobotApi`/`SkillApi`/`RobotSettingApi` 的真实调用,直接返回模拟数据。开发者在 PC 模拟器上运行即可看到完整的业务流程。 3223 3224 ### 4.12 补充:MainActivity.java 完整代码(WebView 容器) 3225 3226 > MainActivity 是应用的唯一直接可见界面,本质上是一个全屏的 WebView 容器,负责加载 H5 页面并桥接 Native 能力。以下代码完整可用,每行都有中文注释。 3227 3228 ```java 3229 package com.emoon.medical.robot 3230 3231 import android.annotation.SuppressLint 3232 import android.content.pm.ActivityInfo 3233 import android.os.Build 3234 import android.os.Bundle 3235 import android.view.View 3236 import android.view.WindowInsets 3237 import android.view.WindowInsetsController 3238 import android.view.WindowManager 3239 import android.webkit.WebChromeClient 3240 import android.webkit.WebResourceError 3241 import android.webkit.WebResourceRequest 3242 import android.webkit.WebSettings 3243 import android.webkit.WebView 3244 import android.webkit.WebViewClient 3245 import android.widget.ProgressBar 3246 import android.widget.Toast 3247 import androidx.appcompat.app.AlertDialog 3248 import androidx.appcompat.app.AppCompatActivity 3249 3250 /** 3251 * 主界面 Activity:WebView 容器 3252 * 职责: 3253 * 1. 提供全屏沉浸式的 WebView 环境( kiosk 模式,类似银行的自助终端) 3254 * 2. 加载 H5 页面(远程服务器或本地 assets) 3255 * 3. 注入 JSBridge,使 H5 页面能够调用机器人的导航、语音等能力 3256 * 4. 管理 WebView 生命周期和系统 UI 状态 3257 * 3258 * 生命周期: 3259 * onCreate → onStart → onResume → [运行中] → onPause → onStop → onDestroy 3260 * 对应 Web 概念:页面创建 → 可见 → 可交互 → 后台 → 销毁 3261 */ 3262 class MainActivity : AppCompatActivity() { 3263 3264 // ===== 视图组件声明 ===== 3265 // lateinit 表示延迟初始化,在 onCreate() 中通过 findViewById 赋值 3266 3267 // WebView:Android 内置浏览器组件,用于加载和显示 H5 页面 3268 private lateinit var webView: WebView 3269 3270 // ProgressBar:页面加载时的进度指示器(转圈动画) 3271 private lateinit var progressBar: ProgressBar 3272 3273 // RobotBridge:JSBridge 实例,负责 Native 与 H5 之间的通信 3274 private lateinit var robotBridge: RobotBridge 3275 3276 // 页面加载失败标志:用于记录当前是否处于错误状态 3277 private var hasLoadError = false 3278 3279 /** 3280 * Activity 创建时调用(系统回调) 3281 * 这是设置布局、初始化组件的核心方法 3282 */ 3283 @SuppressLint("SetJavaScriptEnabled") // 抑制 "启用 JavaScript 可能有安全风险" 的编译器警告 3284 override fun onCreate(savedInstanceState: Bundle?) { 3285 // 调用父类实现,确保框架级初始化完成 3286 super.onCreate(savedInstanceState) 3287 3288 // ===== 第 1 步:全屏沉浸式设置 ===== 3289 // 隐藏状态栏和导航栏,提供 kiosk 模式体验 3290 hideSystemUI() 3291 3292 // 强制横屏:机器人屏幕为横屏,锁定方向防止旋转 3293 requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE 3294 3295 // ===== 第 2 步:设置布局 ===== 3296 // 将 activity_main.xml 中定义的视图结构加载到 Activity 中 3297 setContentView(R.layout.activity_main) 3298 3299 // ===== 第 3 步:查找视图组件 ===== 3300 // 通过视图 ID 获取布局文件中定义的组件实例 3301 webView = findViewById(R.id.webview) 3302 progressBar = findViewById(R.id.progress_bar) 3303 3304 // ===== 第 4 步:初始化 WebView ===== 3305 setupWebView() 3306 3307 // ===== 第 5 步:注入 JSBridge ===== 3308 // 将 RobotBridge 对象注入到 WebView 的 JavaScript 环境中 3309 // H5 页面可以通过 window.RobotBridge 访问 Native 方法 3310 injectJSBridge() 3311 3312 // ===== 第 6 步:加载页面 ===== 3313 loadPage() 3314 } 3315 3316 /** 3317 * 隐藏系统 UI(状态栏和导航栏),实现全屏沉浸式体验 3318 * 原理:设置窗口的 systemUiVisibility 标志,告诉系统不要显示状态栏和导航栏 3319 */ 3320 private fun hideSystemUI() { 3321 // 如果 Android 版本 >= 11(API 30),使用新的 WindowInsetsController API 3322 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 3323 // 设置窗口为全屏布局模式,内容延伸到状态栏和导航栏下方 3324 window.setDecorFitsSystemWindows(false) 3325 3326 // 获取窗口的 Insets 控制器,控制系统栏的显示/隐藏 3327 window.insetsController?.let { controller -> 3328 // 隐藏状态栏(显示时间、电量等系统信息的顶部栏) 3329 controller.hide(WindowInsets.Type.statusBars()) 3330 // 隐藏导航栏(底部的返回/主页/多任务键) 3331 controller.hide(WindowInsets.Type.navigationBars()) 3332 // 设置系统栏行为:用户交互时自动隐藏(防止用户滑动调出导航栏) 3333 controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 3334 } 3335 } else { 3336 // Android 10 及以下版本使用传统的 systemUiVisibility 标志 3337 @Suppress("DEPRECATION") 3338 window.decorView.systemUiVisibility = ( 3339 // 全屏模式:内容延伸到状态栏后面 3340 View.SYSTEM_UI_FLAG_FULLSCREEN 3341 // 隐藏导航栏 3342 or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 3343 // 沉浸式模式:用户交互后仍保持隐藏 3344 or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY 3345 // 内容延伸到导航栏后面 3346 or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 3347 // 内容延伸到状态栏后面 3348 or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 3349 // 保持布局稳定,防止系统栏显示/隐藏时内容跳动 3350 or View.SYSTEM_UI_FLAG_LAYOUT_STABLE 3351 ) 3352 } 3353 3354 // 保持屏幕常亮:迎检演示时防止屏幕自动熄灭 3355 window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 3356 } 3357 3358 /** 3359 * 配置 WebView 的各项设置 3360 * WebView 是 Android 内置的浏览器引擎(基于 Chromium),用于渲染 H5 页面 3361 */ 3362 @SuppressLint("SetJavaScriptEnabled") 3363 private fun setupWebView() { 3364 webView.settings.apply { 3365 // ===== JavaScript 支持 ===== 3366 // 启用 JavaScript:Vue 等现代前端框架需要 JS 才能运行,必须开启 3367 javaScriptEnabled = true 3368 3369 // 启用 DOM Storage(Web Storage API):Vuex/Pinia 等状态管理库依赖此特性 3370 domStorageEnabled = true 3371 3372 // 启用数据库存储:部分 H5 应用使用 Web SQL 或 IndexedDB 3373 databaseEnabled = true 3374 3375 // 允许文件访问:WebView 可以加载本地文件(如 assets 中的资源) 3376 allowFileAccess = true 3377 3378 // 允许内容访问:WebView 可以访问 ContentProvider 提供的内容 3379 allowContentAccess = true 3380 3381 // 允许从文件 URL 访问其他文件 URL:本地 H5 页面可能需要加载本地其他资源 3382 allowFileAccessFromFileURLs = true 3383 3384 // 允许从文件 URL 访问任意来源:本地页面可能需要访问网络资源 3385 allowUniversalAccessFromFileURLs = true 3386 3387 // 允许自动播放媒体(音频/视频):语音播报功能需要自动播放 3388 mediaPlaybackRequiresUserGesture = false 3389 3390 // 允许混合内容(HTTP + HTTPS):内网环境可能同时存在两种协议 3391 mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW 3392 3393 // 使用宽视口:H5 页面可以设置自己的 viewport,WebView 按页面要求渲染 3394 useWideViewPort = true 3395 3396 // 以概览模式加载页面:页面宽度适配屏幕宽度 3397 loadWithOverviewMode = true 3398 3399 // 禁用缩放按钮:kiosk 模式下不允许用户手动缩放 3400 setSupportZoom(false) 3401 builtInZoomControls = false 3402 displayZoomControls = false 3403 3404 // 设置缓存策略:优先使用缓存,加快页面加载速度 3405 cacheMode = WebSettings.LOAD_DEFAULT 3406 3407 // 设置 User-Agent:追加自定义标识,H5 侧可通过此判断是否在机器人环境中 3408 // H5 代码示例:if (navigator.userAgent.includes('MedicalRobot')) { ... } 3409 userAgentString = "$userAgentString MedicalRobot/1.0" 3410 3411 // 启用硬件加速:利用 GPU 渲染页面,提升动画和滚动性能 3412 webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) 3413 } 3414 3415 // ===== 调试模式配置 ===== 3416 // BuildConfig.DEBUG 在 Debug 构建时为 true,Release 构建时为 false 3417 if (BuildConfig.DEBUG) { 3418 // 启用 WebView 远程调试:允许 Chrome DevTools 连接到此 WebView 3419 // 配合 Chrome 浏览器的 chrome://inspect 页面使用 3420 WebView.setWebContentsDebuggingEnabled(true) 3421 } 3422 3423 // ===== WebViewClient:处理页面加载事件 ===== 3424 // WebViewClient 控制 WebView 如何处理 URL 加载和页面事件 3425 webView.webViewClient = object : WebViewClient() { 3426 3427 /** 3428 * 页面开始加载时调用 3429 * @param view WebView 实例 3430 * @param url 正在加载的 URL 3431 */ 3432 override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { 3433 super.onPageStarted(view, url, favicon) 3434 // 显示加载动画,提示用户页面正在加载 3435 progressBar.visibility = View.VISIBLE 3436 hasLoadError = false 3437 } 3438 3439 /** 3440 * 页面加载完成时调用 3441 * @param view WebView 实例 3442 * @param url 已加载完成的 URL 3443 */ 3444 override fun onPageFinished(view: WebView?, url: String?) { 3445 super.onPageFinished(view, url) 3446 // 隐藏加载动画 3447 progressBar.visibility = View.GONE 3448 3449 // 如果之前加载失败,现在成功了,清除错误标志 3450 if (hasLoadError) { 3451 hasLoadError = false 3452 } 3453 } 3454 3455 /** 3456 * 页面加载出错时调用 3457 * @param view WebView 实例 3458 * @param request 失败的请求信息 3459 * @param error 错误详情 3460 */ 3461 override fun onReceivedError( 3462 view: WebView?, 3463 request: WebResourceRequest?, 3464 error: WebResourceError? 3465 ) { 3466 super.onReceivedError(view, request, error) 3467 // 标记加载失败状态 3468 hasLoadError = true 3469 progressBar.visibility = View.GONE 3470 3471 // 在主线程显示错误提示 3472 runOnUiThread { 3473 Toast.makeText( 3474 this@MainActivity, 3475 "页面加载失败,请检查网络连接", 3476 Toast.LENGTH_LONG 3477 ).show() 3478 } 3479 3480 // 可选:加载本地离线提示页 3481 // webView.loadUrl("file:///android_asset/error.html") 3482 } 3483 3484 /** 3485 * 拦截 URL 加载请求 3486 * 返回 true 表示由应用处理此 URL,返回 false 表示由 WebView 继续加载 3487 * @param view WebView 实例 3488 * @param request 加载请求 3489 */ 3490 override fun shouldOverrideUrlLoading( 3491 view: WebView?, 3492 request: WebResourceRequest? 3493 ): Boolean { 3494 val url = request?.url?.toString() ?: return false 3495 3496 // 示例:拦截特定的自定义协议(如 robot://settings) 3497 // if (url.startsWith("robot://")) { 3498 // handleRobotProtocol(url) 3499 // return true 3500 // } 3501 3502 // 默认行为:由 WebView 继续加载 3503 return false 3504 } 3505 } 3506 3507 // ===== WebChromeClient:处理 JS 弹窗和高级功能 ===== 3508 // WebChromeClient 处理需要与 UI 交互的 Web 功能 3509 webView.webChromeClient = object : WebChromeClient() { 3510 3511 /** 3512 * 处理 JavaScript 的 alert() 弹窗 3513 * 默认行为是弹出系统对话框,这里使用原生 AlertDialog 替代 3514 */ 3515 override fun onJsAlert( 3516 view: WebView?, 3517 url: String?, 3518 message: String?, 3519 result: android.webkit.JsResult? 3520 ): Boolean { 3521 // 创建 AlertDialog 替代默认弹窗 3522 AlertDialog.Builder(this@MainActivity) 3523 .setTitle("提示") // 对话框标题 3524 .setMessage(message) // 显示 JS 传来的消息内容 3525 .setPositiveButton("确定") { _, _ -> 3526 // 用户点击确定后,通知 JS 弹窗已确认 3527 result?.confirm() 3528 } 3529 .setCancelable(false) // 禁止点击外部取消,确保 JS 流程继续 3530 .show() 3531 return true // 返回 true 表示已处理此弹窗 3532 } 3533 3534 /** 3535 * 处理文件选择(如 ) 3536 * 用于 H5 页面上传图片、拍照等功能 3537 */ 3538 override fun onShowFileChooser( 3539 view: WebView?, 3540 filePathCallback: android.webkit.ValueCallback>?, 3541 fileChooserParams: FileChooserParams? 3542 ): Boolean { 3543 // 实际实现需要启动相机或文件选择器 3544 // 简化示例:返回取消,H5 侧可降级处理 3545 filePathCallback?.onReceiveValue(null) 3546 return true 3547 } 3548 } 3549 } 3550 3551 /** 3552 * 注入 JSBridge 到 WebView 3553 * 通过 @JavascriptInterface 注解,将 Kotlin 方法暴露给 JavaScript 调用 3554 */ 3555 private fun injectJSBridge() { 3556 // 创建 RobotBridge 实例,传入 WebView 和 SDK 管理器 3557 robotBridge = RobotBridge(webView, MedicalRobotApplication.sdkManager) 3558 3559 // 将 RobotBridge 对象注入到 WebView 的 JavaScript 环境中 3560 // 第二个参数 "RobotBridge" 是 JS 侧访问此对象时使用的名称 3561 // JS 调用方式:window.RobotBridge.navigate("导诊台", "cb_001") 3562 webView.addJavascriptInterface(robotBridge, "RobotBridge") 3563 } 3564 3565 /** 3566 * 加载 H5 页面 3567 * 支持两种方式:远程服务器(开发推荐)或本地 assets(离线模式) 3568 */ 3569 private fun loadPage() { 3570 // ===== 方式 A:加载远程服务器页面(推荐,开发阶段使用) ===== 3571 // 优势:修改 H5 代码后无需重新打包 APK,刷新即可生效 3572 // 替换为实际的后端服务器 IP 地址和端口 3573 webView.loadUrl("http://192.168.1.100:8080") 3574 3575 // ===== 方式 B:加载本地 assets 中的打包文件(离线模式) ===== 3576 // 优势:无需网络,适合演示环境或网络不稳定场景 3577 // 使用方法:将 Vue 打包后的 dist 目录内容复制到 app/src/main/assets/web/ 下 3578 // 取消下面一行的注释即可切换到本地模式 3579 // webView.loadUrl("file:///android_asset/web/index.html") 3580 } 3581 3582 /** 3583 * 处理返回键事件 3584 * 系统回调:用户按下物理返回键时调用 3585 */ 3586 override fun onBackPressed() { 3587 // 检查 WebView 是否有历史记录可以后退 3588 if (webView.canGoBack()) { 3589 // WebView 可以后退(如从报告详情页返回到首页) 3590 webView.goBack() 3591 } else { 3592 // WebView 已无历史记录,返回到 Launcher(不退出应用进程) 3593 // moveTaskToBack 将当前任务移到后台,类似点击 Home 键 3594 // true 表示即使当前 Activity 是根 Activity 也执行 3595 moveTaskToBack(true) 3596 } 3597 } 3598 3599 /** 3600 * 窗口焦点变化时调用 3601 * 系统回调:Activity 获得或失去焦点时调用 3602 * 用于在弹窗关闭后重新隐藏系统 UI 3603 */ 3604 override fun onWindowFocusChanged(hasFocus: Boolean) { 3605 super.onWindowFocusChanged(hasFocus) 3606 // 当 Activity 重新获得焦点时,再次隐藏系统 UI 3607 // 防止用户通过滑动调出导航栏后,焦点变化导致系统 UI 保持显示 3608 if (hasFocus) { 3609 hideSystemUI() 3610 } 3611 } 3612 3613 /** 3614 * 系统内存不足时调用 3615 * 系统回调:系统内存紧张时调用,应释放不必要的资源 3616 */ 3617 override fun onLowMemory() { 3618 super.onLowMemory() 3619 // 清理 WebView 缓存,释放内存 3620 webView.clearCache(true) 3621 // 可选:清理历史记录、Cookie 等 3622 // webView.clearHistory() 3623 } 3624 3625 /** 3626 * Activity 销毁时调用 3627 * 系统回调:Activity 被销毁前调用,用于清理资源 3628 */ 3629 override fun onDestroy() { 3630 // 释放 WebView 资源,防止内存泄漏 3631 // WebView 持有大量 native 资源,必须显式释放 3632 webView.stopLoading() // 停止正在进行的加载 3633 webView.loadUrl("about:blank") // 加载空白页,释放当前页面资源 3634 webView.clearHistory() // 清除历史记录 3635 webView.removeAllViews() // 移除所有子视图 3636 webView.destroy() // 销毁 WebView 实例,释放 native 内存 3637 3638 // 调用父类实现 3639 super.onDestroy() 3640 } 3641 } 3642 ``` 3643 3644 > **Web 开发者提示**:`AppCompatActivity` 类似于 Web 框架的页面控制器;`onCreate()` 类似于页面挂载时的 `mounted()` 钩子;`onDestroy()` 类似于 `beforeUnmount()` 钩子。`findViewById` 类似于 `document.getElementById()`。 3645 3646 --- 3647 3648 ### 4.13 补充:activity_main.xml 完整布局 3649 3650 > 以下文件位于 `MedicalRobotApp/app/src/main/res/layout/activity_main.xml`。布局非常简单:全屏 WebView + 居中的加载进度条。 3651 3652 ```xml 3653 3654 3655 3656 3657 3662 3668 3669 3677 3685 3686 3692 3702 3703 3704 ``` 3705 3706 > **Web 开发者提示**:`ConstraintLayout` 类似于 CSS Flexbox + Absolute Positioning 的结合体。`match_parent` 类似于 `width: 100%`,`wrap_content` 类似于 `width: auto`(由内容决定)。`0dp` 在 ConstraintLayout 中表示由约束决定尺寸,类似于 CSS 中同时设置 `left: 0; right: 0;`。 3707 3708 ### 4.14 补充:RobotBridge.java 完整 JSBridge 实现 3709 3710 > RobotBridge 是 Native 与 H5 之间的通信桥梁。H5 页面通过 `window.RobotBridge.xxx()` 调用 Native 方法,Native 通过 `webView.evaluateJavascript()` 将结果回传给 H5。以下代码包含全部 7 个接口的完整实现,支持 Mock 模式。底层 SDK 调用方式:导航通过 `RobotApi.getInstance().startNavigation()`,TTS 通过 `SkillApi.getInstance().playText(TTSEntity, TextListener)`,电量通过 `RobotSettingApi.getInstance().getRobotString()`,位置通过 `RobotApi.getInstance().getPosition()`。 3711 3712 ```java 3713 package com.emoon.medical.robot 3714 3715 import android.os.Handler 3716 import android.os.Looper 3717 import android.util.Log 3718 import android.webkit.JavascriptInterface 3719 import android.webkit.WebView 3720 import org.json.JSONObject 3721 3722 /** 3723 * JSBridge 桥接类:连接 H5 JavaScript 与 Android Native 代码 3724 * 职责: 3725 * 1. 接收 H5 通过 window.RobotBridge 发起的调用请求 3726 * 2. 转发请求到 RobotSDKManager 执行实际的 SDK 操作 3727 * 3. 将 SDK 执行结果通过 evaluateJavascript 回传给 H5 3728 * 4. 提供 Mock 模式,支持在 PC 模拟器上完整调试 3729 * 3730 * 通信协议(callbackId 模式): 3731 * 1. H5 生成唯一 callbackId,将回调函数注册到 window.__robotCallbacks[callbackId] 3732 * 2. H5 调用 window.RobotBridge.methodName(arg1, arg2, ..., callbackId) 3733 * 3. Native 的 @JavascriptInterface 方法被触发,在子线程执行 SDK 调用 3734 * 4. SDK 返回结果后,Native 通过 webView.evaluateJavascript 执行: 3735 * window.__robotCallbacks[callbackId](resultJson) 3736 * 5. H5 的回调函数被执行,处理返回数据 3737 */ 3738 class RobotBridge( 3739 private val webView: WebView, 3740 private val sdkManager: RobotSDKManager 3741 ) { 3742 3743 // 日志标签 3744 private val tag = "RobotBridge" 3745 3746 // 主线程 Handler:用于从子线程切换回主线程操作 WebView 3747 // WebView 的所有操作必须在主线程执行 3748 private val mainHandler = Handler(Looper.getMainLooper()) 3749 3750 // Mock 模式标志:从 Application 全局配置读取 3751 private val isMock: Boolean 3752 get() = MedicalRobotApplication.useMockMode 3753 3754 /** 3755 * 统一回调方法:将结果 JSON 字符串回传给 H5 3756 * @param callbackId H5 传入的回调标识 3757 * @param resultJson 结果数据的 JSON 字符串 3758 */ 3759 private fun callbackToH5(callbackId: String, resultJson: String) { 3760 // 构造要执行的 JavaScript 代码 3761 // 先检查 __robotCallbacks 和指定 callbackId 是否存在,避免空指针 3762 val jsCode = """ 3763 (function() { 3764 var cb = window.__robotCallbacks && window.__robotCallbacks['$callbackId']; 3765 if (typeof cb === 'function') { 3766 cb($resultJson); 3767 delete window.__robotCallbacks['$callbackId']; 3768 return 'callback_executed'; 3769 } else { 3770 return 'callback_not_found'; 3771 } 3772 })() 3773 """.trimIndent() 3774 3775 // 切换到主线程执行(WebView 必须在主线程操作) 3776 mainHandler.post { 3777 // evaluateJavascript:在 WebView 中执行 JavaScript 代码 3778 // 第二个参数是结果回调(此处不需要,传 null) 3779 webView.evaluateJavascript(jsCode, null) 3780 } 3781 } 3782 3783 /** 3784 * 构造标准成功响应 JSON 3785 * @param data 业务数据(可选) 3786 * @return JSON 字符串 {"code": 0, "msg": "success", ...data} 3787 */ 3788 private fun successJson(data: Map = emptyMap()): String { 3789 val json = JSONObject() 3790 json.put("code", 0) 3791 json.put("msg", "success") 3792 data.forEach { (key, value) -> json.put(key, value) } 3793 return json.toString() 3794 } 3795 3796 /** 3797 * 构造标准错误响应 JSON 3798 * @param code 错误码 3799 * @param message 错误描述 3800 * @return JSON 字符串 {"code": code, "msg": "message"} 3801 */ 3802 private fun errorJson(code: Int, message: String): String { 3803 val json = JSONObject() 3804 json.put("code", code) 3805 json.put("msg", message) 3806 return json.toString() 3807 } 3808 3809 // ===== 接口 1:导航到指定位置 ===== 3810 3811 /** 3812 * 导航到指定位置点 3813 * @JavascriptInterface 注解:将此方法暴露给 JavaScript 调用 3814 * @param destination 目标位置点名称(如 "导诊台"、"神经内科") 3815 * @param callbackId H5 生成的回调标识 3816 */ 3817 @JavascriptInterface 3818 fun navigate(destination: String, callbackId: String) { 3819 Log.i(tag, "JSBridge 收到导航请求: destination=$destination, callbackId=$callbackId") 3820 3821 // 在子线程中执行 SDK 调用,避免阻塞 WebView 的 JS 线程 3822 Thread { 3823 // 调用 SDK 管理器的导航方法 3824 sdkManager.startNavigation(destination, object : RobotSDKManager.OperationCallback { 3825 override fun onSuccess(data: String) { 3826 // 导航成功,构造成功响应并回传 3827 val result = successJson(mapOf( 3828 "destination" to destination, 3829 "sdkData" to data 3830 )) 3831 Log.i(tag, "导航成功,回传结果: $result") 3832 callbackToH5(callbackId, result) 3833 } 3834 3835 override fun onError(code: Int, message: String) { 3836 // 导航失败,构造错误响应并回传 3837 val result = errorJson(code, message) 3838 Log.e(tag, "导航失败,回传结果: $result") 3839 callbackToH5(callbackId, result) 3840 } 3841 }) 3842 }.start() 3843 } 3844 3845 // ===== 接口 2:停止导航 ===== 3846 3847 /** 3848 * 停止当前导航 3849 * @param callbackId H5 生成的回调标识 3850 */ 3851 @JavascriptInterface 3852 fun stopNavigation(callbackId: String) { 3853 Log.i(tag, "JSBridge 收到停止导航请求: callbackId=$callbackId") 3854 3855 Thread { 3856 sdkManager.stopNavigation(object : RobotSDKManager.OperationCallback { 3857 override fun onSuccess(data: String) { 3858 val result = successJson() 3859 Log.i(tag, "停止导航成功") 3860 callbackToH5(callbackId, result) 3861 } 3862 3863 override fun onError(code: Int, message: String) { 3864 val result = errorJson(code, message) 3865 Log.e(tag, "停止导航失败: $message") 3866 callbackToH5(callbackId, result) 3867 } 3868 }) 3869 }.start() 3870 } 3871 3872 // ===== 接口 3:获取位置点列表 ===== 3873 3874 /** 3875 * 获取地图中所有预设位置点列表 3876 * @param callbackId H5 生成的回调标识 3877 */ 3878 @JavascriptInterface 3879 fun getPlaceList(callbackId: String) { 3880 Log.i(tag, "JSBridge 收到获取位置点列表请求: callbackId=$callbackId") 3881 3882 Thread { 3883 sdkManager.getPlaceList(object : RobotSDKManager.OperationCallback { 3884 override fun onSuccess(data: String) { 3885 // data 是 JSON 数组字符串,直接放入响应中 3886 val result = successJson(mapOf("places" to data)) 3887 Log.i(tag, "获取位置点列表成功") 3888 callbackToH5(callbackId, result) 3889 } 3890 3891 override fun onError(code: Int, message: String) { 3892 val result = errorJson(code, message) 3893 Log.e(tag, "获取位置点列表失败: $message") 3894 callbackToH5(callbackId, result) 3895 } 3896 }) 3897 }.start() 3898 } 3899 3900 // ===== 接口 4:获取当前位置 ===== 3901 3902 /** 3903 * 获取机器人当前坐标位置 3904 * @param callbackId H5 生成的回调标识 3905 */ 3906 @JavascriptInterface 3907 fun getPosition(callbackId: String) { 3908 Log.i(tag, "JSBridge 收到获取位置请求: callbackId=$callbackId") 3909 3910 Thread { 3911 sdkManager.getPosition(object : RobotSDKManager.OperationCallback { 3912 override fun onSuccess(data: String) { 3913 val result = successJson(mapOf("position" to data)) 3914 Log.i(tag, "获取位置成功: $data") 3915 callbackToH5(callbackId, result) 3916 } 3917 3918 override fun onError(code: Int, message: String) { 3919 val result = errorJson(code, message) 3920 Log.e(tag, "获取位置失败: $message") 3921 callbackToH5(callbackId, result) 3922 } 3923 }) 3924 }.start() 3925 } 3926 3927 // ===== 接口 5:TTS 语音播报 ===== 3928 3929 /** 3930 * 播放 TTS 语音播报 3931 * @param text 要播报的文本内容 3932 * @param callbackId H5 生成的回调标识 3933 */ 3934 @JavascriptInterface 3935 fun playTTS(text: String, callbackId: String) { 3936 Log.i(tag, "JSBridge 收到 TTS 请求: text=$text, callbackId=$callbackId") 3937 3938 // 输入校验:文本不能为空 3939 if (text.isBlank()) { 3940 val result = errorJson(-3, "TTS 文本不能为空") 3941 callbackToH5(callbackId, result) 3942 return 3943 } 3944 3945 Thread { 3946 sdkManager.playTTS(text, object : RobotSDKManager.OperationCallback { 3947 override fun onSuccess(data: String) { 3948 val result = successJson(mapOf("text" to text)) 3949 Log.i(tag, "TTS 播报成功: $text") 3950 callbackToH5(callbackId, result) 3951 } 3952 3953 override fun onError(code: Int, message: String) { 3954 val result = errorJson(code, message) 3955 Log.e(tag, "TTS 播报失败: $message") 3956 callbackToH5(callbackId, result) 3957 } 3958 }) 3959 }.start() 3960 } 3961 3962 // ===== 接口 6:停止 TTS ===== 3963 3964 /** 3965 * 停止 TTS 语音播报 3966 * @param callbackId H5 生成的回调标识 3967 */ 3968 @JavascriptInterface 3969 fun stopTTS(callbackId: String) { 3970 Log.i(tag, "JSBridge 收到停止 TTS 请求: callbackId=$callbackId") 3971 3972 Thread { 3973 sdkManager.stopTTS(object : RobotSDKManager.OperationCallback { 3974 override fun onSuccess(data: String) { 3975 val result = successJson() 3976 Log.i(tag, "停止 TTS 成功") 3977 callbackToH5(callbackId, result) 3978 } 3979 3980 override fun onError(code: Int, message: String) { 3981 val result = errorJson(code, message) 3982 Log.e(tag, "停止 TTS 失败: $message") 3983 callbackToH5(callbackId, result) 3984 } 3985 }) 3986 }.start() 3987 } 3988 3989 // ===== 接口 7:获取电量 ===== 3990 3991 /** 3992 * 获取机器人当前电量 3993 * @param callbackId H5 生成的回调标识 3994 */ 3995 @JavascriptInterface 3996 fun getBattery(callbackId: String) { 3997 Log.i(tag, "JSBridge 收到获取电量请求: callbackId=$callbackId") 3998 3999 Thread { 4000 sdkManager.getBatteryLevel(object : RobotSDKManager.OperationCallback {
    Tool Result: ReadFile (`/Users/destiny/dev/emoon/demos/card_demo/仿鸿蒙机器人系统技术方案.md`) 1000 lines read from file starting from line 4001. Total lines in file: 5246. Max 1000 lines reached. 4001 override fun onSuccess(data: String) { 4002 // data 是 JSON 字符串 {"level": 85} 4003 val result = successJson(mapOf("battery" to data)) 4004 Log.i(tag, "获取电量成功: $data") 4005 callbackToH5(callbackId, result) 4006 } 4007 4008 override fun onError(code: Int, message: String) { 4009 val result = errorJson(code, message) 4010 Log.e(tag, "获取电量失败: $message") 4011 callbackToH5(callbackId, result) 4012 } 4013 }) 4014 }.start() 4015 } 4016 } 4017 ``` 4018 4019 > **回调处理说明**:所有接口都采用统一的回调协议。H5 侧调用时需按以下方式封装: 4020 > ```javascript 4021 > // H5 侧调用示例 4022 > const callbackId = 'nav_' + Date.now(); 4023 > window.__robotCallbacks[callbackId] = (result) => { 4024 > if (result.code === 0) { 4025 > console.log('成功:', result); 4026 > } else { 4027 > console.error('失败:', result.msg); 4028 > } 4029 > }; 4030 > window.RobotBridge.navigate('神经内科', callbackId); 4031 > ``` 4032 4033 --- 4034 4035 ### 4.15 补充:WebView 与原生交互原理图解 4036 4037 > 以下图解帮助 Web 开发者理解 H5 页面如何与 Android Native 代码通信。 4038 4039 **通信流程(以导航为例):** 4040 4041 ``` 4042 ┌─────────────────────────────────────────────────────────────────────────────┐ 4043 │ 调用流程(H5 → Native) │ 4044 └─────────────────────────────────────────────────────────────────────────────┘ 4045 4046 H5 页面 (JavaScript) 4047 │ 4048 ├── 用户点击 "带我去神经内科" 按钮 4049 │ 4050 ├── H5 生成唯一 callbackId: "cb_001" 4051 │ └── window.__robotCallbacks["cb_001"] = function(result) { ... } 4052 │ 4053 ├── 调用 Native 方法 4054 │ └── window.RobotBridge.navigate("神经内科", "cb_001") 4055 │ │ 4056 │ ▼ 4057 │ ┌─────────────────────────────────────┐ 4058 │ │ @JavascriptInterface │ 4059 │ │ WebView 拦截并调用 Android 方法 │ 4060 │ │ RobotBridge.navigate(...) │ 4061 │ └─────────────────────────────────────┘ 4062 │ │ 4063 │ ▼ 4064 │ ┌─────────────────────────────────────┐ 4065 │ │ 切换到子线程执行 │ 4066 │ │ Thread { sdkManager.startNavigation(...) } 4067 │ └─────────────────────────────────────┘ 4068 │ │ 4069 │ ▼ 4070 │ ┌─────────────────────────────────────┐ 4071 │ │ 调用猎户星空 SDK API │ 4072 │ │ RobotApi.getInstance() │ 4073 │ │ .startNavigation(...) │ 4074 │ └─────────────────────────────────────┘ 4075 │ │ 4076 │ ▼ 4077 │ ┌─────────────────────────────────────┐ 4078 │ │ 机器人硬件执行导航 │ 4079 │ │ 底盘运动、避障、到达目标点 │ 4080 │ └─────────────────────────────────────┘ 4081 │ 4082 4083 ┌─────────────────────────────────────────────────────────────────────────────┐ 4084 │ 回调流程(Native → H5) │ 4085 └─────────────────────────────────────────────────────────────────────────────┘ 4086 4087 机器人硬件 4088 │ 4089 ├── 导航完成(到达目标点或出错) 4090 │ 4091 ├── SDK 回调结果到 RobotSDKManager 4092 │ 4093 ├── RobotSDKManager 回调到 RobotBridge 4094 │ 4095 ├── RobotBridge 构造 JSON 结果 4096 │ └── { "code": 0, "msg": "success", "destination": "神经内科" } 4097 │ 4098 ├── 切换到主线程(WebView 必须在主线程操作) 4099 │ └── mainHandler.post { ... } 4100 │ 4101 ├── 通过 evaluateJavascript 执行 JS 回调 4102 │ └── webView.evaluateJavascript( 4103 │ "window.__robotCallbacks['cb_001']({...})") 4104 │ │ 4105 │ ▼ 4106 │ ┌─────────────────────────────────────┐ 4107 │ │ H5 的回调函数被执行 │ 4108 │ │ window.__robotCallbacks["cb_001"](result) 4109 │ │ └── 更新 UI:显示 "已到达神经内科" │ 4110 │ └─────────────────────────────────────┘ 4111 │ 4112 └── 清理:delete window.__robotCallbacks["cb_001"] 4113 ``` 4114 4115 **Mermaid 序列图:** 4116 4117 ```mermaid 4118 sequenceDiagram 4119 participant H5 as H5 页面 (JavaScript) 4120 participant WV as WebView 4121 participant RB as RobotBridge 4122 participant SM as RobotSDKManager 4123 participant SDK as 猎户星空 SDK 4124 participant HW as 机器人硬件 4125 4126 H5->>H5: 生成 callbackId = "cb_001" 4127 H5->>H5: window.__robotCallbacks["cb_001"] = callbackFn 4128 H5->>WV: window.RobotBridge.navigate("神经内科", "cb_001") 4129 WV->>RB: @JavascriptInterface navigate() 4130 RB->>SM: startNavigation() 4131 SM->>SDK: RobotApi.getInstance().startNavigation() 4132 SDK->>HW: 执行导航 4133 HW-->>SDK: 导航结果 4134 SDK-->>SM: 回调结果 4135 SM-->>RB: onSuccess/onError 4136 RB->>RB: 构造 JSON 响应 4137 RB->>WV: evaluateJavascript("window.__robotCallbacks['cb_001'](result)") 4138 WV->>H5: 执行 JS 回调函数 4139 H5->>H5: 更新 UI / 提示用户 4140 H5->>H5: delete window.__robotCallbacks["cb_001"] 4141 ``` 4142 4143 > **关键设计决策**: 4144 > 1. **callbackId 模式**:因为 `@JavascriptInterface` 只支持基本类型参数,无法传递 JS 函数对象,所以用字符串 ID 关联回调 4145 > 2. **子线程执行**:SDK 调用可能耗时(如网络请求、硬件操作),必须在子线程执行避免阻塞 WebView 4146 > 3. **主线程回调**:`evaluateJavascript` 必须在主线程调用,因此使用 `Handler(Looper.getMainLooper())` 切换线程 4147 > 4. **SDK 回调接口**:导航等长操作使用 `ActionListener`(含 `onResult`/`onError`/`onStatusUpdate`),获取位置等单次命令使用 `CommandListener`(含 `onResult`),TTS 播报使用 `TextListener`(含 `onStart`/`onStop`/`onError`/`onComplete`) 4148 4149 --- 4150 4151 ### 4.16 补充:ADB 调试完整指南 4152 4153 > ADB(Android Debug Bridge)是 Android 开发的必备调试工具。本节面向从未使用过 ADB 的 Web 开发者,涵盖从连接到排错的完整流程。 4154 4155 #### 4.16.1 连接机器人设备 4156 4157 **方式一:USB 连接(有线连接)** 4158 - 使用 USB 数据线将开发电脑连接到机器人主板的 USB 接口 4159 - 在机器人系统设置中启用 "USB 调试"(通常在 设置 → 开发者选项 → USB 调试) 4160 - 首次连接时,机器人屏幕会弹出 "允许 USB 调试吗?" 的授权对话框,点击 "确定" 4161 4162 **方式二:WiFi 连接(无线连接,推荐)** 4163 - 确保开发电脑和机器人在同一局域网内 4164 - 先用 USB 连接一次,执行以下命令启用网络调试: 4165 ```bash 4166 adb tcpip 5555 4167 ``` 4168 - 断开 USB 线,然后通过 WiFi 连接: 4169 ```bash 4170 # 将 192.168.1.xxx 替换为机器人的实际 IP 地址 4171 adb connect 192.168.1.xxx:5555 4172 ``` 4173 - 后续无需再插 USB 线,直接通过 WiFi 调试 4174 4175 #### 4.16.2 验证连接状态 4176 4177 ```bash 4178 # 查看已连接的设备列表 4179 adb devices 4180 ``` 4181 4182 正常输出示例: 4183 ``` 4184 List of devices attached 4185 192.168.1.100:5555 device 4186 ``` 4187 4188 - `device` 表示连接正常,可以进行调试 4189 - `unauthorized` 表示未授权,需要在机器人屏幕上确认 USB 调试授权 4190 - `offline` 表示设备离线,检查网络或重新连接 4191 4192 #### 4.16.3 安装 APK 4193 4194 ```bash 4195 # 安装 APK 到机器人设备 4196 adb install app/build/outputs/apk/debug/app-debug.apk 4197 4198 # -r 参数:覆盖安装(保留应用数据,升级时使用) 4199 adb install -r app/build/outputs/apk/debug/app-debug.apk 4200 4201 # 如果安装失败,先卸载再安装 4202 adb uninstall com.emoon.medical.robot 4203 adb install app/build/outputs/apk/debug/app-debug.apk 4204 ``` 4205 4206 #### 4.16.4 查看实时日志 4207 4208 ```bash 4209 # 查看所有日志(信息量大,建议配合过滤使用) 4210 adb logcat 4211 4212 # 只查看 MedicalRobot 标签的日志(推荐) 4213 adb logcat -s MedicalRobot:V 4214 4215 # 同时查看多个标签的日志 4216 adb logcat -s MedicalRobot:V RobotSDKManager:V RobotBridge:V 4217 4218 # 查看日志并保存到文件(方便后续分析) 4219 adb logcat -s MedicalRobot:V > robot_log.txt 4220 4221 # 清除旧日志后查看(避免历史日志干扰) 4222 adb logcat -c && adb logcat -s MedicalRobot:V 4223 ``` 4224 4225 > **日志级别说明**:`V` = Verbose(所有级别),`D` = Debug,`I` = Info,`W` = Warn,`E` = Error。`-s MedicalRobot:V` 表示显示 MedicalRobot 标签的所有级别日志。 4226 4227 #### 4.16.5 远程调试 WebView 4228 4229 这是调试 H5 页面最强大的方式,可以直接使用 Chrome DevTools: 4230 4231 1. **确保代码中启用了 WebView 调试**(已在 MainActivity.java 中配置): 4232 ```java 4233 if (BuildConfig.DEBUG) { 4234 WebView.setWebContentsDebuggingEnabled(true) 4235 } 4236 ``` 4237 4238 2. **在开发电脑的 Chrome 浏览器中访问**: 4239 ``` 4240 chrome://inspect 4241 ``` 4242 4243 3. **在 "Remote Target" 区域找到你的 WebView**: 4244 - 显示设备名称 + WebView 加载的 URL 4245 - 例如:`ORIONSTAR-001 - http://192.168.1.100:8080` 4246 4247 4. **点击 "inspect" 按钮**: 4248 - 会打开独立的 Chrome DevTools 窗口 4249 - 功能与桌面端完全一致:Elements、Console、Network、Sources、Application 等 4250 - 可以在 Console 中直接执行 JS 代码测试 RobotBridge 接口 4251 4252 #### 4.16.6 截屏 4253 4254 ```bash 4255 # 截取当前屏幕并保存到电脑 4256 adb exec-out screencap -p > screenshot.png 4257 4258 # 先保存到设备,再拉取到电脑 4259 adb shell screencap -p /sdcard/screen.png 4260 adb pull /sdcard/screen.png ./screen.png 4261 ``` 4262 4263 #### 4.16.7 卸载应用 4264 4265 ```bash 4266 # 按包名卸载应用 4267 adb uninstall com.emoon.medical.robot 4268 4269 # 卸载但保留数据(应用数据不会被删除) 4270 adb shell pm uninstall -k com.emoon.medical.robot 4271 ``` 4272 4273 #### 4.16.8 常用排错命令 4274 4275 ```bash 4276 # 只看错误级别的日志(快速定位崩溃原因) 4277 adb logcat *:E 4278 4279 # 查看当前前台 Activity(确认应用是否在运行) 4280 adb shell dumpsys activity top 4281 4282 # 查看已安装的应用包列表(确认应用是否安装成功) 4283 adb shell pm list packages | grep emoon 4284 4285 # 查看设备信息(Android 版本、SDK 版本等) 4286 adb shell getprop ro.build.version.release 4287 4288 # 查看应用进程是否在运行 4289 adb shell ps | grep medical 4290 4291 # 强制停止应用(相当于系统设置中的 "强行停止") 4292 adb shell am force-stop com.emoon.medical.robot 4293 4294 # 重启设备 4295 adb reboot 4296 4297 # 进入设备的 shell 环境(可以执行 Linux 命令) 4298 adb shell 4299 4300 # 从电脑推送文件到设备 4301 adb push local_file.txt /sdcard/remote_file.txt 4302 4303 # 从设备拉取文件到电脑 4304 adb pull /sdcard/remote_file.txt ./local_file.txt 4305 ``` 4306 4307 > **排错建议**:如果应用启动后白屏,首先检查 `adb logcat *:E` 查看是否有崩溃信息;如果 WebView 页面加载失败,使用 `chrome://inspect` 检查 Network 面板查看请求状态。 4308 4309 --- 4310 4311 ### 4.17 补充:network_security_config.xml 4312 4313 > 以下文件位于 `MedicalRobotApp/app/src/main/res/xml/network_security_config.xml`。用于配置网络安全策略,允许应用访问明文 HTTP 通信(内网环境必需)。 4314 4315 ```xml 4316 4317 4318 4319 4320 4321 4322 4323 4324 4325 4326 4327 4328 4329 4330 4331 4332 4333 4334 4335 4336 4345 4346 4347 ``` 4348 4349 > **安全警告**:`` 会允许所有域名的明文 HTTP 通信。在生产环境中,建议改用注释中的 `` 方式,仅允许特定内网 IP 或域名的明文通信,其他请求仍强制使用 HTTPS。 4350 4351 --- 4352 4353 ## 五、H5 前端适配改造 4354 4355 > 本章面向对移动端开发不熟悉的全栈 Web 工程师(Java + Vue 背景),提供可直接复制使用的完整代码。当前前端基于 Vue 3 + Composition API,构建工具为 `@vue/cli-service`。 4356 > 4357 > **✅ WebView 方案已确认可行**:经与猎户星空厂商会议确认,第三方 APK 内嵌 WebView 加载 H5 页面的方案完全可行,无系统限制。 4358 4359 ### 5.1 新增 robot.js — 机器人原生能力桥接层 4360 4361 在 `medical-card-demo/frontend/src/api/` 目录下新建 `robot.js`,完整代码如下(可直接复制使用): 4362 4363 ```javascript 4364 /** 4365 * robot.js - 机器人原生能力桥接层 4366 * 4367 * 职责: 4368 * 1. 检测当前是否在机器人 WebView 环境中 4369 * 2. 封装所有 JSBridge 调用为 Promise 4370 * 3. 管理全局回调池 4371 * 4. 非机器人环境下提供优雅降级 4372 * 4373 * 文件位置:/src/api/robot.js 4374 */ 4375 4376 // ========== 全局回调池管理 ========== 4377 window.__robotCallbacks = window.__robotCallbacks || {} 4378 let _callbackCounter = 0 4379 4380 /** 4381 * 生成唯一回调 ID 4382 */ 4383 function generateCallbackId(prefix) { 4384 return `${prefix}_${++_callbackCounter}_${Date.now()}` 4385 } 4386 4387 /** 4388 * 检测当前是否在机器人 WebView 环境中 4389 * 判断依据:原生 App 会注入 window.RobotBridge 对象 4390 * 同时检查 UserAgent 中是否包含 MedicalRobot 标识 4391 */ 4392 export function isRobotEnv() { 4393 return !!(window.RobotBridge) || navigator.userAgent.includes('MedicalRobot') 4394 } 4395 4396 /** 4397 * 通用原生方法调用封装 4398 * @param {string} method - RobotBridge 上的方法名 4399 * @param {Array} args - 方法参数(不含 callbackId) 4400 * @param {string} prefix - 回调 ID 前缀 4401 * @param {number} timeout - 超时时间(毫秒),默认 30 秒 4402 * @returns {Promise} 4403 */ 4404 function callNative(method, args = [], prefix = 'cb', timeout = 30000) { 4405 return new Promise((resolve, reject) => { 4406 // 非机器人环境降级处理 4407 if (!isRobotEnv()) { 4408 reject(new Error(`非机器人环境,无法调用 ${method}`)) 4409 return 4410 } 4411 4412 // 检查方法是否存在 4413 if (typeof window.RobotBridge[method] !== 'function') { 4414 reject(new Error(`RobotBridge.${method} 方法不存在`)) 4415 return 4416 } 4417 4418 const cbId = generateCallbackId(prefix) 4419 4420 // 超时处理 4421 const timer = setTimeout(() => { 4422 delete window.__robotCallbacks[cbId] 4423 reject(new Error(`${method} 调用超时(${timeout}ms)`)) 4424 }, timeout) 4425 4426 // 注册回调 4427 window.__robotCallbacks[cbId] = (result) => { 4428 clearTimeout(timer) 4429 delete window.__robotCallbacks[cbId] 4430 if (result && result.code === 0) { 4431 resolve(result) 4432 } else { 4433 reject(result || { code: -1, msg: '未知错误' }) 4434 } 4435 } 4436 4437 // 执行原生调用 4438 try { 4439 window.RobotBridge[method](...args, cbId) 4440 } catch (e) { 4441 clearTimeout(timer) 4442 delete window.__robotCallbacks[cbId] 4443 reject(new Error(`调用 ${method} 异常: ${e.message}`)) 4444 } 4445 }) 4446 } 4447 4448 // ========== 导航相关 API ========== 4449 4450 /** 导航到指定位置(机器人带路) */ 4451 export const navigateTo = (destination) => 4452 callNative('navigate', [destination], 'nav', 60000) // 导航超时 60 秒 4453 4454 /** 停止导航 */ 4455 export const stopNavigation = () => 4456 callNative('stopNavigation', [], 'stopnav') 4457 4458 /** 获取所有位置点列表 */ 4459 export const getPlaceList = () => 4460 callNative('getPlaceList', [], 'places') 4461 4462 /** 获取当前坐标 {x, y, theta} */ 4463 export const getPosition = () => 4464 callNative('getPosition', [], 'pos') 4465 4466 // ========== 语音相关 API ========== 4467 4468 /** TTS 语音播报 */ 4469 export const playTTS = (text) => 4470 callNative('playTTS', [text], 'tts') 4471 4472 /** 停止 TTS */ 4473 export const stopTTS = () => 4474 callNative('stopTTS', [], 'stoptts') 4475 4476 // ========== 设备信息 API ========== 4477 4478 /** 获取电量百分比 */ 4479 export const getBattery = () => 4480 callNative('getBattery', [], 'bat') 4481 4482 // ========== 辅助工具 ========== 4483 4484 /** 4485 * 在非机器人环境下模拟 RobotBridge(开发调试用) 4486 * 在 main.js 中调用此方法可以在浏览器中模拟机器人环境 4487 */ 4488 export function enableDevMock() { 4489 if (isRobotEnv()) return // 真实环境不覆盖 4490 4491 console.warn('[robot.js] 开发模式:启用 Mock RobotBridge') 4492 window.RobotBridge = { 4493 navigate(dest, cbId) { 4494 setTimeout(() => { 4495 window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_navigation_started', destination: dest }) 4496 }, 500) 4497 }, 4498 stopNavigation(cbId) { 4499 setTimeout(() => { 4500 window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_stopped' }) 4501 }, 200) 4502 }, 4503 getPlaceList(cbId) { 4504 setTimeout(() => { 4505 window.__robotCallbacks[cbId]?.({ 4506 code: 0, 4507 data: [ 4508 { name: '门诊大厅', x: 0, y: 0 }, 4509 { name: '内科诊室', x: 10.5, y: 3.2 }, 4510 { name: '外科诊室', x: 15.0, y: -2.1 }, 4511 { name: '药房', x: 5.3, y: 8.7 }, 4512 { name: '检验科', x: 20.0, y: 0.5 } 4513 ] 4514 }) 4515 }, 300) 4516 }, 4517 getPosition(cbId) { 4518 setTimeout(() => { 4519 window.__robotCallbacks[cbId]?.({ code: 0, data: { x: 0.0, y: 0.0, theta: 0.0 } }) 4520 }, 100) 4521 }, 4522 playTTS(text, cbId) { 4523 console.log(`[Mock TTS] 播报: ${text}`) 4524 setTimeout(() => { 4525 window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_tts_done' }) 4526 }, text.length * 100) 4527 }, 4528 stopTTS(cbId) { 4529 setTimeout(() => { 4530 window.__robotCallbacks[cbId]?.({ code: 0, msg: 'mock_tts_stopped' }) 4531 }, 100) 4532 }, 4533 getBattery(cbId) { 4534 setTimeout(() => { 4535 window.__robotCallbacks[cbId]?.({ code: 0, data: { level: 85 } }) 4536 }, 100) 4537 } 4538 } 4539 } 4540 ``` 4541 4542 **代码要点说明:** 4543 4544 | 要点 | 说明 | 4545 |------|------| 4546 | `isRobotEnv()` | 同时检测 `window.RobotBridge` 和 UserAgent,防止误判 | 4547 | `callNative()` | 所有原生调用统一走此函数,自带超时、异常处理、回调清理 | 4548 | callbackId 模式 | 每个调用生成唯一 ID,通过全局 `__robotCallbacks` 池管理,避免回调地狱 | 4549 | `enableDevMock()` | 浏览器开发时模拟机器人环境,无需真机即可调试导航逻辑 | 4550 4551 --- 4552 4553 ### 5.2 DepartmentSelectionCard.vue 改造 — 增加"带我去"导航按钮 4554 4555 > 现有 `DepartmentSelectionCard.vue` 已使用 Vue 3 Composition API(`setup()`)。以下仅展示需要**新增或修改**的代码片段,直接合并到现有文件中即可。 4556 4557 #### 1)script 部分修改 4558 4559 在 `