From c2d8a0f40ee47a315dd89384c844812b864a9794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Tue, 21 Apr 2026 22:30:53 +0800 Subject: [PATCH] =?UTF-8?q?docs(helper):=20=E6=9B=B4=E6=96=B0=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E8=AF=86=E5=88=AB=E5=92=8C=E5=9B=BE=E5=83=8F=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E5=B7=A5=E5=85=B7=E7=B1=BB=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 AsrHelper 添加详细的类注释,说明工作流程、连接管理和场景路由机制 - 为 BitmapDecodeHelper 添加类注释,解释 AR 眼镜低内存场景下的优化策略 - 为 GlassMediaServiceHelper 添加类注释,说明双检锁懒加载缓存机制 - 为 MyApplication 添加类注释,详细说明多域名 AppComponent 初始化和 SDK 绑定流程 - 为各个关键方法添加详细的 KDoc 注释,包括参数说明和使用场景 - 优化代码注释的中文表达,使其更加清晰易懂 --- CLAUDE.md | 170 ++++++++++++++++++ .../com/nova/brain/glass/MyApplication.java | 39 +++- .../com/nova/brain/glass/helper/AsrHelper.kt | 118 +++++++++--- .../brain/glass/helper/BitmapDecodeHelper.kt | 26 +++ .../glass/helper/GlassMediaServiceHelper.kt | 22 +++ 5 files changed, 344 insertions(+), 31 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34c2aab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,170 @@ +# C大脑智能眼镜应用 + +Rokid Glass 3 AR 眼镜的工业质检辅助应用。支持语音唤醒、在线 ASR/TTS、OCR 识别、AI 对话等功能,用于喷涂质检和单证检验两类业务场景。 + +--- + +## 项目结构 + +``` +c-brain-glass/ +├── app/ 主应用模块(UI、ViewModel、业务逻辑) +├── base/ 网络框架、UI 基类、工具类(Java) +├── core/ AR 相关扩展、自定义 View(Kotlin) +└── server/ 服务端相关代码(不参与 app 构建) +``` + +### app 模块包结构 + +``` +com.nova.brain.glass +├── ui/ 14 个 Activity(页面) +├── viewmodel/ 15 个 ViewModel +├── repository/ Retrofit 接口(Service.kt、Service3.kt)+ 请求拦截器 +├── helper/ 工具单例(ASR、离线关键词、媒体服务、位图解码等) +├── model/ 数据模型(请求体、响应体) +├── common/ SharedPreferences 配置、崩溃处理器 +└── MyApplication.java 全局初始化入口 +``` + +--- + +## 构建与运行 + +```bash +# 调试包 +./gradlew :app:assembleDebug + +# 正式包 +./gradlew :app:assembleRelease +``` + +- **最低 SDK**: 26(Android 8.0) +- **目标 SDK**: 29(Android 10) +- **编译 SDK**: 33 +- **Kotlin**: 2.2.0 / **Java**: 11 + +### 语音服务配置 + +语音相关密钥在 `app/build.gradle` 的 `buildConfigField` 中维护: + +| 字段 | 说明 | +|------|------| +| `SPEECH_DOMAIN` | ASR/TTS 服务器地址(IP:端口) | +| `SPEECH_AK` / `SPEECH_SK` | 鉴权 AccessKey / SecretKey | +| `SPEECH_ASR_PATH` | ASR WebSocket 路径 | +| `SPEECH_TTS_PATH` | TTS WebSocket 路径 | + +--- + +## 关键模块说明 + +### 语音交互流程 + +``` +离线唤醒词(飞宝飞宝 / C大脑) + └─ AsrHelper.onOfflineCmd() + ├─ 无网络 → showNoNetworkDialog() + └─ 有网络 → TTS 播报"在呢,您请说" + └─ TtsClient.onFinished() + └─ asrStartMic() 开始麦克风采集 + └─ AsrClient.onFinalResult() + ├─ scene=="decision" → onDirectChat(ChatActivity AI对话) + └─ 其他场景 → IntentRecognizeHelper.recognize() 意图识别 + └─ 页面导航回调 +``` + +**相关文件**: +- `helper/AsrHelper.kt` — ASR/TTS 全局管理,包含连接状态机和指数退避重连 +- `helper/OfflineCmdServiceHelper.kt` — 离线关键词注册/注销,按页面场景切换 +- `helper/IntentRecognizeHelper.kt` — 调用后台接口将语音文本映射为导航动作 + +### 场景路由(AsrHelper.scene) + +| scene 值 | 设置页面 | ASR 结果处理 | +|----------|---------|------------| +| `"home"` | WelcomeActivity | 意图识别,导航到任务中心或决策中心 | +| `"list"` | TaskListActivity | 意图识别,支持打开任务详情 | +| `"decision"` | ChatActivity | 跳过意图识别,直接发送给 AI 对话 | + +### 多 AppComponent 网络架构 + +`MyApplication` 为不同服务域名各建一套 OkHttpClient + Retrofit: + +| 字段 | 域名 | 用途 | +|------|------|------| +| `appComponent` | `baseUrl` | 主后台(任务、审阅、决策中心) | +| `appComponent1` | vicp.fun | 意图识别接口 | +| `appComponent2` | vicp.fun | 喷涂质检接口 | +| `appComponent3` | vicp.fun | 单证检验接口 | + +通过 `HttpManager.getApi(component, Service::class.java)` 获取对应实例。 + +### 位图解码 + +`helper/BitmapDecodeHelper.kt` 采用两步采样:先 `inJustDecodeBounds=true` 只读尺寸,再按 ImageView 实际大小计算 `inSampleSize`,使用 `RGB_565` 格式(比 ARGB_8888 省 50% 内存)。ImageView 解码任务通过 Activity 内部的 `imageDecodeExecutor`(单线程池)执行,在 `onDestroy()` 中 `shutdown()`。 + +--- + +## 业务流程 + +### 喷涂质检 + +``` +SprayingActivity → 获取任务信息 + └─ SprayingOCRActivity → 拍照上传,服务端 OCR 识别 + └─ SprayingResultActivity → 展示 OCR 结果(两码比对) + ├─ 通过/不合格 → SprayingFinishActivity → 确认提交 → 返回任务列表 + └─ 人工更正 → SprayingManualResultActivity → 手动选择结果 +``` + +### 单证检验 + +``` +InspectionActivity → 拍照上传,服务端识别单证 + └─ InspectionResultActivity → 展示检验结果 + ├─ 合格/不合格 → InspectionCompleteActivity + └─ 缺失单证 → InspectionMissingActivity → 补充上传 +``` + +--- + +## Activity 与离线关键词管理 + +每个 Activity 在 `onResume` 注册专属关键词,在 `onPause` 注销,避免后台页面响应语音: + +```kotlin +override fun onResume() { + OfflineCmdServiceHelper.addListenerXxx() // 注册本页关键词 + OfflineCmdServiceHelper.addOnLineListener(listener) +} +override fun onPause() { + OfflineCmdServiceHelper.removeListenerXxx() + OfflineCmdServiceHelper.removeOnLineListener(listener) +} +``` + +通用关键词(退出、返回、继续、下一个)在 `init()` 时一次性注册,不随页面切换变化。 + +--- + +## 主要依赖 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| `glass3.open.sdk` | 2.1.7-E | Rokid Glass 硬件 SDK | +| `online-speech` | 0.1.0 | Rokid 在线 ASR/TTS | +| `retrofit2` | 2.4.0 | HTTP 网络请求 | +| `rxjava2` / `rxandroid` | — | 异步流处理 | +| `dagger` | 2.40.5 | 依赖注入 | +| `glide` | 4.10.0 | 图片加载 | +| `markwon` | 4.6.2 | Markdown 渲染(AI 对话) | + +--- + +## 注意事项 + +- **Glass SDK 初始化顺序**:`OfflineCmdServiceHelper.init()` 必须在 `AsrHelper.init()` 之前,因为后者依赖前者注册唤醒词。 +- **内存**:设备内存有限,ImageView 在 `onDestroy()` 中需调用 `setImageDrawable(null)` 释放 Bitmap 引用;解码线程池在 `onDestroy()` 中 `shutdown()`。 +- **SSL**:内网服务器使用自签证书,`trustAllCerts = true`,生产环境应替换为正式证书。 +- **网络切换**:ASR/TTS 连接在网络断开后进入指数退避重连,网络恢复时立即重置并重连,无需手动干预。 \ No newline at end of file diff --git a/app/src/main/java/com/nova/brain/glass/MyApplication.java b/app/src/main/java/com/nova/brain/glass/MyApplication.java index fafacf9..c2002d1 100644 --- a/app/src/main/java/com/nova/brain/glass/MyApplication.java +++ b/app/src/main/java/com/nova/brain/glass/MyApplication.java @@ -11,22 +11,41 @@ import com.xuqm.base.di.component.AppComponent; import com.xuqm.base.di.manager.HttpManager; /** + * 应用全局入口。 + * + *

负责以下初始化工作: + *

    + *
  1. 为不同服务域名各自创建独立的 {@link AppComponent},避免 OkHttp 连接池跨域名串用。
  2. + *
  3. 绑定 Rokid Glass SDK 安全服务;连接成功后顺序初始化离线关键词和在线语音模块。
  4. + *
+ * + *

各 AppComponent 对应关系: + *

+ * * @author xuqm */ public class MyApplication extends App { + // 主后台服务地址(通过内网穿透暴露到公网) public static String baseUrl = "http://22fs132201.imwork.net"; // public static String baseUrl = "http://192.168.6.20"; - // 意图识别 + // 意图识别服务的 Dagger 网络组件 public static AppComponent appComponent1; - // 喷涂 + // 喷涂质检服务的 Dagger 网络组件 public static AppComponent appComponent2; + // 单证检验服务的 Dagger 网络组件 public static AppComponent appComponent3; @Override public void onCreate() { super.onCreate(); + // 为每个域名单独构建 OkHttpClient + Retrofit 实例,互不干扰 appComponent = HttpManager.getAppComponent(baseUrl, new HeaderInterceptor(getApplicationContext())); appComponent1 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext())); appComponent2 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext())); @@ -37,7 +56,6 @@ public class MyApplication extends App { // appComponent3 = HttpManager.getAppComponent("http://192.168.22.199:8820/", new HeaderInterceptor(getApplicationContext())); initSdk(); - } @Override @@ -45,27 +63,34 @@ public class MyApplication extends App { return super.showLog(); } - + /** + * 绑定 Rokid Glass 安全服务。 + * + *

Glass SDK 需要通过系统 AIDL 服务完成硬件鉴权,绑定成功后才能使用离线关键词和相机等能力。 + * 离线关键词({@link OfflineCmdServiceHelper})和在线语音({@link AsrHelper})必须在此回调中初始化, + * 否则底层 SDK 尚未就绪,调用会静默失败。 + */ void initSdk() { - // 如果SDK已经初始化了,则直接返回 + // SDK 已就绪时无需重复绑定(进程保活场景下可能触发) if (GlassSdk.isReady()) { return; } GlassSdk.bindSecurityService(Utils.getApp(), new IServiceConnectionCallback() { @Override public void onServiceConnected() { + // 先注册离线关键词,再初始化在线语音(ASR 唤醒词依赖离线服务) OfflineCmdServiceHelper.INSTANCE.init(); AsrHelper.INSTANCE.init(); } @Override public void onServiceDisconnected() { - + // 系统服务断开,硬件能力暂时不可用,等待系统自动重连 } @Override public void onBindingDied() { - + // Binder 死亡(通常是系统进程崩溃),需要重新绑定;当前版本依赖系统自恢复 } }); } diff --git a/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt index e3b3df2..b849d28 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt +++ b/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt @@ -27,6 +27,25 @@ import com.rokid.online.speech.open.OpenSdkAudioSource import com.xuqm.base.common.AppManager import kotlin.system.exitProcess +/** + * 在线语音识别(ASR)与语音合成(TTS)的全局管理单例。 + * + * ## 工作流程 + * 1. 离线关键词引擎检测到唤醒词("飞宝飞宝" / "C大脑")→ [onOfflineCmd] + * 2. TTS 播报唤醒响应("在呢,您请说")→ [TtsClient.Listener.onFinished] + * 3. 启动麦克风开始 ASR 流式识别 → [asrStartMic] + * 4. 识别完成后调用 [IntentRecognizeHelper.recognize] 将文本映射为页面导航动作 + * + * ## 连接管理 + * - ASR 和 TTS 各自维护独立的 WebSocket 连接,断线后采用指数退避策略自动重连 + * (基础间隔 3s,最大间隔 30s,最多重试 5 次) + * - 超过重试上限后置为 paused 状态;网络恢复时([networkCallback])重置并立即重连 + * + * ## 场景路由 + * [scene] 由各 Activity 在 onResume/onPause 中设置,决定 ASR 结果的处理方式: + * - "home" / "list" → 意图识别后执行页面跳转 + * - "decision" → 文本直接发送给 ChatActivity 发起 AI 对话 + */ object AsrHelper : OfflineCmdListener { private const val TAG = "AsrHelper" @@ -40,38 +59,45 @@ object AsrHelper : OfflineCmdListener { private val ASR_PATH get() = BuildConfig.SPEECH_ASR_PATH private val TTS_PATH get() = BuildConfig.SPEECH_TTS_PATH - // 唤醒词:Nova Nova + // 支持的唤醒词(与 OfflineCmdServiceHelper.registerAsrWakeWord 中注册的词保持一致) private const val WAKE_WORD1 = "飞宝飞宝" private var sdk: OnlineSpeechSdk? = null private var asr: AsrClient? = null private var tts: TtsClient? = null + // audioSource 和 ttsPlayer 由 SDK 内部管理生命周期,随 asr/tts 连接一同关闭 private val audioSource = OpenSdkAudioSource() private val ttsPlayer = AndroidPcmTtsStreamPlayer() - private var isConnected = false - private var isMicRunning = false - private var isTtsConnected = false - private var isAsrConnecting = false - private var isTtsConnecting = false - private var pendingStartMic = false - private var isClosed = false - private var asrReconnectAttempt = 0 - private var ttsReconnectAttempt = 0 - private var asrReconnectPaused = false - private var ttsReconnectPaused = false + // ---- 连接状态机 ---- + private var isConnected = false // ASR WebSocket 已连接 + private var isMicRunning = false // 麦克风正在采集音频 + private var isTtsConnected = false // TTS WebSocket 已连接 + private var isAsrConnecting = false // ASR 正在建立连接(防止重复发起) + private var isTtsConnecting = false // TTS 正在建立连接(防止重复发起) + private var pendingStartMic = false // ASR 未连接时收到启动麦克风请求,连接成功后补发 + private var isClosed = false // 已调用 close(),阻止重连循环 + private var asrReconnectAttempt = 0 // ASR 当前重连次数 + private var ttsReconnectAttempt = 0 // TTS 当前重连次数 + private var asrReconnectPaused = false // ASR 重连已达上限,暂停直到网络恢复 + private var ttsReconnectPaused = false // TTS 重连已达上限,暂停直到网络恢复 private var connectivityManager: ConnectivityManager? = null private var networkCallbackRegistered = false + // 唤醒响应文本(TTS 播报,播报结束后自动开启麦克风) private const val WAKE_RESPONSE = "在呢,您请说" + // 用户说话超时:30 秒内无最终结果则自动停止麦克风 private const val LISTENING_TIMEOUT_MS = 30_000L + // 重连退避参数 private const val BASE_RECONNECT_DELAY_MS = 3_000L private const val MAX_RECONNECT_DELAY_MS = 30_000L private const val MAX_RECONNECT_ATTEMPTS = 5 private val mainHandler = Handler(Looper.getMainLooper()) - private var listeningDialog: AlertDialog? = null - private var noNetworkDialog: AlertDialog? = null + private var listeningDialog: AlertDialog? = null // 正在聆听的半透明提示框 + private var noNetworkDialog: AlertDialog? = null // 无网络时的全屏提示框 + + // 30 秒内无最终结果时自动停止麦克风,防止长时间占用音频焦点 private val listeningTimeoutRunnable = Runnable { if (!isMicRunning) return@Runnable runCatching { asr?.stopAsrWithMic() } @@ -79,6 +105,8 @@ object AsrHelper : OfflineCmdListener { dismissListeningDialog() Log.d(TAG, "ASR listening timeout after ${LISTENING_TIMEOUT_MS}ms") } + + // 每秒检查一次网络;恢复后关闭无网对话框,否则持续显示 private val networkCheckRunnable = object : Runnable { override fun run() { if (isNetworkAvailable()) { @@ -89,6 +117,8 @@ object AsrHelper : OfflineCmdListener { mainHandler.postDelayed(this, 1000L) } } + + // 延迟重连任务(由 scheduleAsrReconnect 投递到 mainHandler) private val asrReconnectRunnable = Runnable { if (shouldReconnectAsr()) { asrConnect() @@ -99,6 +129,8 @@ object AsrHelper : OfflineCmdListener { ttsConnect() } } + + // 网络状态监听:恢复时立即重连,不需要等待下一轮轮询 private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { Log.d(TAG, "Network available, reconnect speech channels if needed") @@ -112,27 +144,34 @@ object AsrHelper : OfflineCmdListener { } } - // 拼接每次识别会话中的中间结果 + // 用 StringBuilder 累积本次会话的中间识别结果,避免频繁创建短生命周期 String 对象 private val currentPartial = StringBuilder() - /** 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护 */ + /** + * 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护。 + * 可选值:"home" | "list" | "decision" + */ var scene: String = "home" - /** goToDecisionCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ + /** goToDecisionCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ var onGoToDecisionCenter: ((action: RecognizeAction) -> Unit)? = null - /** goToTaskCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ + /** goToTaskCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ var onGoToTaskCenter: ((action: RecognizeAction) -> Unit)? = null - /** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端 */ + /** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端辅助意图识别 */ var extraProvider: (() -> List)? = null - /** openTaskDetail 命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */ + /** openTaskDetail 意图命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */ var onOpenTaskDetail: ((action: RecognizeAction) -> Unit)? = null - /** scene == "decision" 时直接用 ASR 文本发起对话,由 ChatActivity 注册 */ + /** scene == "decision" 时跳过意图识别,直接将 ASR 文本发给 ChatActivity 发起对话 */ var onDirectChat: ((text: String) -> Unit)? = null + /** + * 初始化 SDK、注册唤醒词并建立 ASR/TTS 连接。 + * 必须在 GlassSdk.bindSecurityService 的 onServiceConnected 回调中调用。 + */ fun init() { isClosed = false val cfg = OnlineSpeechSdkConfig( @@ -143,6 +182,7 @@ object AsrHelper : OfflineCmdListener { deviceId = DEVICE_ID, asrPath = ASR_PATH, ttsPath = TTS_PATH, + // 内网自签证书,跳过 SSL 校验 trustAllCerts = true, staticHttpHeaders = mapOf( "appCredential" to "userInfo", @@ -158,12 +198,12 @@ object AsrHelper : OfflineCmdListener { asr = sdk!!.createAsrClient().attachAudioSource(audioSource).also { setupAsrCallbacks(it) } tts = sdk!!.createTtsClient().attachStreamPlayer(ttsPlayer).also { setupTtsCallbacks(it) } - // 注册离线关键词 Nova Nova,GlassSdk 触发后启动 ASR + // 注册唤醒词到离线引擎,触发后进入在线 ASR 流程 OfflineCmdServiceHelper.registerAsrWakeWord() OfflineCmdServiceHelper.addOnLineListener(this) registerNetworkCallback() - // 自动建立 ASR / TTS 连接 + // 立即建立 ASR / TTS 连接,缩短首次唤醒的响应延迟 resetReconnectState() asrConnect() ttsConnect() @@ -199,8 +239,13 @@ object AsrHelper : OfflineCmdListener { } } + /** + * 启动麦克风开始 ASR 识别。 + * 若 ASR 连接尚未就绪,则置 [pendingStartMic]=true,等 onOpen 回调时补发。 + */ private fun asrStartMic() { if (!isConnected) { + // 重连已暂停时(超出最大次数),先重置再重试 if (asrReconnectPaused) { resetAsrReconnectState() } @@ -231,6 +276,10 @@ object AsrHelper : OfflineCmdListener { mainHandler.removeCallbacks(listeningTimeoutRunnable) } + /** + * 安排 ASR 重连任务(指数退避)。 + * 超过 [MAX_RECONNECT_ATTEMPTS] 次后置 paused,等待网络恢复事件重置。 + */ private fun scheduleAsrReconnect(delayMs: Long? = null) { if (!shouldReconnectAsr()) return if (asrReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { @@ -241,11 +290,13 @@ object AsrHelper : OfflineCmdListener { } asrReconnectAttempt += 1 val actualDelayMs = delayMs ?: calculateReconnectDelay(asrReconnectAttempt) + // 先取消已有的延迟任务,防止重复投递 mainHandler.removeCallbacks(asrReconnectRunnable) mainHandler.postDelayed(asrReconnectRunnable, actualDelayMs) Log.d(TAG, "ASR reconnect scheduled in ${actualDelayMs}ms, attempt=$asrReconnectAttempt") } + /** 安排 TTS 重连任务(指数退避),逻辑同 [scheduleAsrReconnect]。 */ private fun scheduleTtsReconnect(delayMs: Long? = null) { if (!shouldReconnectTts()) return if (ttsReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { @@ -279,6 +330,10 @@ object AsrHelper : OfflineCmdListener { return !isClosed && !isTtsConnected && !isTtsConnecting && !ttsReconnectPaused && isNetworkAvailable() } + /** + * 指数退避延迟:attempt=1 → 3s,attempt=2 → 6s,attempt=3 → 12s,上限 30s。 + * 使用位移而非 pow() 避免浮点运算。 + */ private fun calculateReconnectDelay(attempt: Int): Long { val factor = 1L shl (attempt - 1).coerceAtLeast(0) return (BASE_RECONNECT_DELAY_MS * factor).coerceAtMost(MAX_RECONNECT_DELAY_MS) @@ -533,7 +588,13 @@ object AsrHelper : OfflineCmdListener { }) } - // 离线关键词回调:唤醒词触发时先 TTS 播报,播报结束后启动麦克风 + /** + * 离线关键词回调。 + * + * 唤醒词触发时:显示聆听对话框 → TTS 播报响应 → TTS 结束后启动麦克风(见 onFinished)。 + * 若 TTS 不可用则直接启动麦克风,保证交互不中断。 + * "退出/返回" 等通用关键词由 [handleNoNetworkDialogCmd] 拦截处理,用于无网对话框场景。 + */ override fun onOfflineCmd(cmd: String) { if (handleNoNetworkDialogCmd(cmd)) return if (cmd == WAKE_WORD1 || cmd == "C大脑") { @@ -545,6 +606,7 @@ object AsrHelper : OfflineCmdListener { } showListeningDialog() restartListeningTimeout() + // 提前设置 pending,确保 TTS 播报期间若 ASR 连接断开也能在重连后自动补发 pendingStartMic = true resetReconnectState() if (isTtsConnected) { @@ -584,9 +646,16 @@ object AsrHelper : OfflineCmdListener { return true } + /** + * 关闭并释放所有资源。 + * + * 调用后 [isClosed]=true,所有重连逻辑将拒绝执行,防止已销毁状态下触发新连接。 + * 调用顺序:先取消所有 pending 任务 → 关闭麦克风 → 关闭 WebSocket → 释放 SDK。 + */ fun close() { isClosed = true OfflineCmdServiceHelper.removeOnLineListener(this) + // 取消所有 Handler 延迟任务,防止 close 后仍触发重连或超时回调 mainHandler.removeCallbacks(listeningTimeoutRunnable) mainHandler.removeCallbacks(networkCheckRunnable) mainHandler.removeCallbacks(asrReconnectRunnable) @@ -598,6 +667,7 @@ object AsrHelper : OfflineCmdListener { } dismissListeningDialog() unregisterNetworkCallback() + // 按依赖顺序关闭:ASR/TTS Client → SDK asr?.close() tts?.close() sdk?.close() diff --git a/app/src/main/java/com/nova/brain/glass/helper/BitmapDecodeHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/BitmapDecodeHelper.kt index ff39f15..f9661fa 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/BitmapDecodeHelper.kt +++ b/app/src/main/java/com/nova/brain/glass/helper/BitmapDecodeHelper.kt @@ -4,20 +4,45 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import kotlin.math.max +/** + * 图片采样解码工具。 + * + * 针对 AR 眼镜低内存场景做了两处优化: + * 1. 先用 inJustDecodeBounds=true 只读取图片尺寸,不分配像素内存; + * 2. 用 RGB_565(16-bit)替代默认的 ARGB_8888(32-bit),内存占用减少 50%。 + */ object BitmapDecodeHelper { + + /** + * 按目标显示尺寸对图片进行采样解码,避免加载原图导致 OOM。 + * + * @param path 图片文件的绝对路径 + * @param reqWidth 目标宽度(像素),通常为 ImageView 的测量宽度 + * @param reqHeight 目标高度(像素),通常为 ImageView 的测量高度 + * @return 解码后的 Bitmap;文件不存在或解码失败时返回 null + */ fun decodeSampledBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? { + // 第一次解码:只读尺寸,不分配像素内存 val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, boundsOptions) + // 第二次解码:按计算出的采样率加载 val decodeOptions = BitmapFactory.Options().apply { inSampleSize = calculateInSampleSize(boundsOptions, reqWidth, reqHeight) + // RGB_565 比 ARGB_8888 节省一半内存,眼镜屏幕不需要 alpha 通道 inPreferredConfig = Bitmap.Config.RGB_565 } return BitmapFactory.decodeFile(path, decodeOptions) } + /** + * 计算 2 的幂次采样率,使解码后的尺寸尽量接近但不小于目标尺寸。 + * + * 算法逻辑:每轮将采样率翻倍,直到缩放后的尺寸比目标尺寸小为止, + * 取上一轮的采样率以保证解码图不小于显示区域(防止模糊)。 + */ private fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, @@ -32,6 +57,7 @@ object BitmapDecodeHelper { if (height > reqHeight || width > reqWidth) { val halfHeight = height / 2 val halfWidth = width / 2 + // 只要缩放后的尺寸仍大于等于目标尺寸,就继续翻倍采样率 while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { inSampleSize *= 2 } diff --git a/app/src/main/java/com/nova/brain/glass/helper/GlassMediaServiceHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/GlassMediaServiceHelper.kt index 4ee6c84..66e2b28 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/GlassMediaServiceHelper.kt +++ b/app/src/main/java/com/nova/brain/glass/helper/GlassMediaServiceHelper.kt @@ -6,10 +6,19 @@ import com.rokid.security.system.server.media.IMediaServer import com.rokid.security.system.server.media.callback.ICameraSurfaceCallback import com.rokid.security.system.server.media.callback.PhotoFileCallback +/** + * Rokid Glass 媒体服务的应用层封装。 + * + * Glass SDK 的 IMediaServer 通过系统 AIDL 提供,首次获取有一定延迟。 + * 此处用双检锁(@Volatile + 空检查)做懒加载缓存,避免每次调用都跨进程查询。 + * 所有方法均为可空安全调用(?.),若服务未就绪则静默忽略,不会崩溃。 + */ object GlassMediaServiceHelper { + @Volatile private var mediaService: IMediaServer? = null + /** 获取媒体服务实例(首次调用时从 SDK 获取并缓存)。 */ private fun service(): IMediaServer? { val cached = mediaService if (cached != null) { @@ -20,34 +29,47 @@ object GlassMediaServiceHelper { } } + /** + * 触发拍照,结果通过 [addPhotoCallback] 注册的回调异步返回。 + * + * @param resolution 分辨率常量,如 PhotoResolution.RESOLUTION_720P + * @param path 照片保存的绝对路径 + */ fun takePhoto(resolution: Int, path: String) { service()?.takePhoto(resolution, path) } + /** 注册拍照结果回调(建议在 onResume 中调用)。 */ fun addPhotoCallback(callback: PhotoFileCallback) { service()?.addPhotoCallback(callback) } + /** 注销拍照结果回调(建议在 onPause 中调用,防止内存泄漏)。 */ fun removePhotoCallback(callback: PhotoFileCallback) { service()?.removePhotoCallback(callback) } + /** 开始将相机画面共享到指定 Surface(用于预览)。 */ fun startCameraShare(surface: Surface, callback: ICameraSurfaceCallback) { service()?.startCameraShare(surface, callback) } + /** 停止相机画面共享。 */ fun stopCameraShare(callback: ICameraSurfaceCallback) { service()?.stopCameraShare(callback) } + /** 获取当前硬件支持的最大变焦级别。 */ fun getMaxZoomLevel(): Int { return service()?.maxZoomLevel ?: 0 } + /** 获取当前变焦级别。 */ fun getZoomLevel(): Int { return service()?.zoomLevel ?: 0 } + /** 设置变焦级别(0 为广角,最大值见 [getMaxZoomLevel])。 */ fun zoomCamera(level: Int) { service()?.zoomCamera(level) }