From 59aa23f7eb8fa4285362355cd734868b0c89e055 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 13 May 2026 23:13:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?perf(stream):=20=E5=8A=A0=E9=80=9F=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E4=B8=B2=E6=B5=81=E7=9A=84=E7=94=A8=E6=88=B7=E6=84=9F?= =?UTF-8?q?=E7=9F=A5=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 6 项优化让点击应用→看到画面更快: 1. ServerInfo 缓存复用 (TTL 30s) ComputerManager 新增 cacheServerInfo/getCachedServerInfo。 轮询路径 (updateComputerFromPoll) 与 AppListPage 刷新均写入缓存; StreamingSession.fetchServerInfo 命中即跳过一次 HTTPS round-trip (典型局域网省 50-300ms)。 2. AI LLM 探测延后到串流就绪后 StreamPage.aboutToAppear 不再立即调用 nvHttp.checkAiAvailable(), 避免与 serverinfo / launchApp 抢带宽和 TLS 资源。改成 launchStream 返回后通过 probeAiAvailableDeferred 异步触发,用户感知延迟 < 2s。 3. AppListPage 预热启动链路资源 aboutToAppear 调用 StreamingSession.getDecoderCapabilities() 触发 .so 加载 + NAPI 注册(首次冷启动省 100-500ms),同时预读三组 设置项让 startStreaming 时走内存缓存。 4. startStreaming 并行化 将 fetchServerInfo (网络) 与 initializeNative (本地) 改为 Promise.all 并行执行,省下两者中较短的耗时。 5. drStart 提前进入 CONNECTED StreamingSession 新增 setDecoderStartedCallback;StreamViewModel 在 drStart 触发时立即把 connectionState 切到 CONNECTED,让 loading 蒙层比原来等 session.start() resolve 更早消失。 6. Settings 预热(随 #3 一起完成) --- entry/src/main/ets/pages/AppListPageV2.ets | 35 +++++++++++- entry/src/main/ets/pages/StreamPage.ets | 41 ++++++++++---- .../src/main/ets/service/ComputerManager.ets | 56 +++++++++++++++++++ .../service/streaming/StreamingSession.ets | 43 +++++++++++++- .../main/ets/viewmodel/StreamViewModel.ets | 12 ++++ 5 files changed, 171 insertions(+), 16 deletions(-) diff --git a/entry/src/main/ets/pages/AppListPageV2.ets b/entry/src/main/ets/pages/AppListPageV2.ets index d2d0098..5960cb2 100644 --- a/entry/src/main/ets/pages/AppListPageV2.ets +++ b/entry/src/main/ets/pages/AppListPageV2.ets @@ -21,7 +21,8 @@ import { PairState } from '../model/ComputerInfo'; import { OptionPickerDialog, OptionPickerDialogConfig, OptionItem } from '../components/dialogs/OptionPickerDialog'; import { ConfirmDialog, ConfirmDialogConfig } from '../components/dialogs/ConfirmDialog'; import { ToastQueue } from '../utils/ToastQueue'; -import { UiSettings } from '../service/SettingsService'; +import { UiSettings, SettingsService } from '../service/SettingsService'; +import { StreamingSession } from '../service/streaming/StreamingSession'; interface AppListPageParams { computerId: string; @@ -152,6 +153,36 @@ struct AppListPageV2 { } else if (this.isLoading) { this.isLoading = false; } + + // 进入应用列表 = 用户即将开始串流,提前预热"启动链路"上的高开销资源: + // 1) Native lib (.so) 加载 + NAPI 注册(首次冷启动 100-500ms) + // 2) 解码器能力查询(StreamingSession 启动时会调用 2 次) + // 3) 流配置 / 输入设置 / 显示设置(首次读取走 Preferences I/O,后续在内存中) + // 全部失败兜底:忽略,正常路径在 startStreaming 里会重新触发。 + this.preheatStreamResources(); + } + + /** + * 预热串流启动链路上的高开销资源(native + 设置)。 + * 不阻塞 UI;任何失败都仅打日志,启动链路会重新走完整路径。 + */ + private preheatStreamResources(): void { + try { + const caps = StreamingSession.getDecoderCapabilities(); + console.info(`AppListPage: native 预热完成, H264=${caps.supportsH264}, HEVC=${caps.supportsHEVC}, AV1=${caps.supportsAV1}`); + } catch (err) { + console.warn(`AppListPage: native 预热失败: ${(err as Error).message}`); + } + const settings = SettingsService.getInstance(); + settings.getStreamConfig().catch((err: Error) => { + console.warn(`AppListPage: stream 配置预热失败: ${err.message}`); + }); + settings.getInputSettings().catch((err: Error) => { + console.warn(`AppListPage: input 配置预热失败: ${err.message}`); + }); + settings.getDisplaySettings().catch((err: Error) => { + console.warn(`AppListPage: display 配置预热失败: ${err.message}`); + }); } aboutToDisappear(): void { @@ -450,6 +481,8 @@ struct AppListPageV2 { if (computer) { computer.runningGameId = serverInfo.currentGame; } + // 写入 ServerInfo 缓存:用户随后点击应用进入串流时可省一次 HTTPS round-trip + this.computerManager.cacheServerInfo(this.computerId, serverInfo); } catch { // 忽略服务器状态获取失败 } diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index efd9fe6..64516cb 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -226,6 +226,7 @@ struct StreamPage { private panZoomHandler: PanZoomHandler = new PanZoomHandler(); private controllerTypes: Map = new Map(); // slot → MoonlightControllerType private aiKeyService: AiKeyService | null = null; + private aiKeyNvHttp: NvHttp | null = null; // 页面参数 private computerId: string = ''; @@ -275,24 +276,15 @@ struct StreamPage { } // 初始化 AI 按键推荐服务 + // 注意:AI 探测的 HTTPS 请求会与串流启动链路抢带宽 / TLS handshake,因此这里只创建 + // 服务实例,把可用性探测推迟到 startStreaming() 成功之后再触发(见 probeAiAvailable)。 if (this.computerId) { try { const computer = ComputerManager.getInstance().getComputer(this.computerId); if (computer) { const nvHttp = NvHttp.fromComputer(computer, this.context); this.aiKeyService = new AiKeyService(nvHttp, this.context); - - // 异步探测 AI 可用性,延迟显示避免与手柄连接等 toast 冲突 - nvHttp.checkAiAvailable().then((available: boolean) => { - if (available) { - setTimeout(() => { - ToastQueue.show({ - message: '✨ AI LLM 已连接,可使用 AIV+ 功能', - duration: 2500 - }); - }, 3000); - } - }); + this.aiKeyNvHttp = nvHttp; } } catch (err) { console.warn(`[StreamPage] AI 服务初始化失败: ${err}`); @@ -462,6 +454,28 @@ struct StreamPage { private static readonly UNHANDLED_USB_DETECT_DELAY_MS: number = 5000; private static readonly UNHANDLED_USB_DETECT_REPEAT_MS: number = 10000; + /** + * 串流就绪后异步探测 AI LLM 可用性。 + * 推迟到此处而非 aboutToAppear,是为了避免与启动链路(serverinfo / launchApp / RTSP) + * 抢带宽和 TLS 资源;用户也已经看到画面,多 1-3 秒延迟感知不到。 + */ + private probeAiAvailableDeferred(): void { + const nvHttp = this.aiKeyNvHttp; + if (!nvHttp) return; + setTimeout(() => { + nvHttp.checkAiAvailable().then((available: boolean) => { + if (available) { + ToastQueue.show({ + message: '✨ AI LLM 已连接,可使用 AIV+ 功能', + duration: 2500 + }); + } + }).catch((err: Error) => { + console.warn(`[StreamPage] AI 可用性探测失败: ${err.message}`); + }); + }, 2000); + } + /** * 调度未识别 USB 手柄检测: * - 串流连接成功后延时执行(让 GCK 充分扫描) @@ -1311,6 +1325,9 @@ struct StreamPage { await this.launchStream(); this.streamStartedAtMs = Date.now(); + // 串流就绪后再做 AI 服务可用性探测,避免与启动链路抢带宽 + this.probeAiAvailableDeferred(); + await this.startClipboardSync(); // 超分辨率状态 Toast diff --git a/entry/src/main/ets/service/ComputerManager.ets b/entry/src/main/ets/service/ComputerManager.ets index c2865ba..00b75e6 100644 --- a/entry/src/main/ets/service/ComputerManager.ets +++ b/entry/src/main/ets/service/ComputerManager.ets @@ -36,6 +36,23 @@ interface PollResult { /** 轮询间隔(毫秒),参照 Android ComputerManagerService */ const SERVERINFO_POLLING_PERIOD_MS = 5000; +/** + * ServerInfo 缓存有效期(毫秒)。 + * + * 用于在 ComputerManager 轮询 / AppListPage 刷新拿到的 ServerInfo 之上, + * 让后续"进入串流"链路可以跳过一次 HTTPS round-trip。仅复用与连接无关的字段 + * (hostname/uniqueId/serverCodecModeSupport/currentGame 等),不会影响诊断准确性。 + */ +const SERVER_INFO_CACHE_TTL_MS = 30_000; + +/** + * ServerInfo 缓存项 + */ +interface CachedServerInfo { + info: ServerInfo; + timestamp: number; +} + /** 全 0 MAC:远程响应或 HTTP 未配对响应中的占位值,不能用它覆盖已发现的真实 MAC */ const ZERO_MAC = '00:00:00:00:00:00'; @@ -63,6 +80,12 @@ export class ComputerManager { /** 离线轮询计数:连续失败次数,达到 INITIAL_POLL_TRIES/OFFLINE_POLL_TRIES 阈值后才标 OFFLINE */ private offlineCount: Map = new Map(); + /** + * ServerInfo 缓存(key=computer uuid)。 + * 由轮询 / 刷新链路写入;StreamingSession.fetchServerInfo 命中即复用,省一次 HTTPS。 + */ + private serverInfoCache: Map = new Map(); + // ═══════════════════════════════════════════════════════════ // 初始化 & 单例 // ═══════════════════════════════════════════════════════════ @@ -368,6 +391,36 @@ export class ComputerManager { return computer ? ComputerPersistence.cloneComputer(computer) : undefined; } + /** + * 缓存最新的 ServerInfo(轮询 / AppList 刷新成功时调用) + * 用于让"进入串流"链路跳过一次 HTTPS /serverinfo 请求。 + */ + cacheServerInfo(uuid: string, info: ServerInfo): void { + if (!uuid) return; + this.serverInfoCache.set(uuid, { info, timestamp: Date.now() }); + } + + /** + * 获取最近 ${SERVER_INFO_CACHE_TTL_MS}ms 内缓存的 ServerInfo,过期/缺失返回 null。 + */ + getCachedServerInfo(uuid: string): ServerInfo | null { + if (!uuid) return null; + const entry = this.serverInfoCache.get(uuid); + if (!entry) return null; + if (Date.now() - entry.timestamp > SERVER_INFO_CACHE_TTL_MS) { + this.serverInfoCache.delete(uuid); + return null; + } + return entry.info; + } + + /** + * 主动让某台电脑的 ServerInfo 缓存失效(例如配对/解配后状态可能改变时) + */ + invalidateServerInfoCache(uuid: string): void { + this.serverInfoCache.delete(uuid); + } + /** * 更新电脑信息(用于配对成功后更新 serverCert 等) * @param uuid 电脑 UUID @@ -854,6 +907,9 @@ export class ComputerManager { // 合并服务器信息(所有字段守卫规则集中在 mergeServerInfo) this.mergeServerInfo(target, info, result.address); + // 缓存最新 ServerInfo,让"进入串流"链路省一次 HTTPS round-trip + this.cacheServerInfo(target.uuid, info); + // 如果仍缺少 IPv6 全局地址,异步补充(不阻塞轮询流程) if (!target.ipv6Address && info.hostname) { this.lookupAndSetIpv6(target, info.hostname); diff --git a/entry/src/main/ets/service/streaming/StreamingSession.ets b/entry/src/main/ets/service/streaming/StreamingSession.ets index aa4cc67..30a2cd5 100644 --- a/entry/src/main/ets/service/streaming/StreamingSession.ets +++ b/entry/src/main/ets/service/streaming/StreamingSession.ets @@ -348,6 +348,8 @@ export class StreamingSession { private resolutionChangedCallback: ResolutionChangedCallback | null = null; private stageProgressCallback: ((stage: number, stageName: string) => void) | null = null; private connectionStatusCallback: ((connectionStatus: number) => void) | null = null; + /** 视频解码器启动回调(drStart):在 LiStartConnection 内部触发,早于 start() resolve */ + private decoderStartedCallback: (() => void) | null = null; // --------------------------------------------------------------------------- // 成员变量 — 手柄状态 @@ -440,6 +442,15 @@ export class StreamingSession { this.connectionStatusCallback = callback; } + /** + * 设置"视频解码器已启动"回调(对应 native drStart)。 + * 此回调在 LiStartConnection 内部、首帧渲染前触发,比 session.start() resolve 更早。 + * UI 可以借此提前隐藏 loading 蒙层,让用户更快看到画面。 + */ + setDecoderStartedCallback(callback: (() => void) | null): void { + this.decoderStartedCallback = callback; + } + // --------------------------------------------------------------------------- // 生命周期 — 启动 / 停止 / 恢复 // --------------------------------------------------------------------------- @@ -473,9 +484,14 @@ export class StreamingSession { try { await this.resolveComputer(computerId, context); - await this.fetchServerInfo(); this.generateInputKey(); - await this.initializeNative(); + // 并行执行:网络请求(fetchServerInfo)与本地 native 初始化没有依赖, + // 可重叠执行节省 50-300ms。两者都会更新 stageProgressCallback 文本, + // 后到者覆盖前者,对用户感知无影响。 + await Promise.all([ + this.fetchServerInfo(), + this.initializeNative(), + ]); this.applyPreConnectionSettings(config); await this.setupVideoSurface(); @@ -896,6 +912,21 @@ export class StreamingSession { private async fetchServerInfo(): Promise { if (!this.nvHttp) throw new Error('nvHttp 未初始化'); + // 命中 ComputerManager 的 ServerInfo 缓存:避免与轮询/AppList 刷新刚拿到的 + // 数据重复一次 HTTPS round-trip(典型局域网 50-200ms,远程更长)。 + const cached = ComputerManager.getInstance().getCachedServerInfo(this.computerId); + if (cached) { + this.serverAppVersion = cached.appVersion || ''; + this.serverGfeVersion = cached.gfeVersion || ''; + this.serverCodecModeSupport = cached.serverCodecModeSupport || 0; + this.cachedCurrentGame = cached.currentGame || 0; + console.info(`串流: 命中 ServerInfo 缓存, currentGame=${this.cachedCurrentGame}, codecSupport=${this.serverCodecModeSupport}`); + if (this.stageProgressCallback) { + this.stageProgressCallback(0, '正在初始化...'); + } + return; + } + // 主机/Sunshine 重启窗口(mDNS/ICMP 已恢复但 HTTPS 监听尚未 ready)会让单次 ServerInfo // 请求必败。委托给 NvHttp.getServerInfoRobust 处理瞬时错误重试 + 取消响应, // 避免在业务层重复维护错误识别 / 重试 / 取消的细节。 @@ -916,6 +947,9 @@ export class StreamingSession { console.info(`服务器版本: app=${this.serverAppVersion}, gfe=${this.serverGfeVersion}, ` + `codecSupport=${this.serverCodecModeSupport}, currentGame=${this.cachedCurrentGame}`); + // 写回缓存供后续短期内复用(场景:连接失败后用户立即重试) + ComputerManager.getInstance().cacheServerInfo(this.computerId, serverInfo); + if (this.stageProgressCallback) { this.stageProgressCallback(0, '正在初始化...'); } @@ -980,7 +1014,10 @@ export class StreamingSession { if (this.codecStateCallback) this.codecStateCallback(actualCodec); if (this.hdrStateCallback) this.hdrStateCallback(actualHdr); }, - drStart: (): void => { console.info('视频解码器启动'); }, + drStart: (): void => { + console.info('视频解码器启动'); + if (this.decoderStartedCallback) this.decoderStartedCallback(); + }, drStop: (): void => { console.info('视频解码器停止'); }, drCleanup: (): void => { console.info('视频解码器清理'); }, arInit: (audioConfiguration: number, sampleRate: number, samplesPerFrame: number): void => { diff --git a/entry/src/main/ets/viewmodel/StreamViewModel.ets b/entry/src/main/ets/viewmodel/StreamViewModel.ets index 578d0ea..2764cb4 100644 --- a/entry/src/main/ets/viewmodel/StreamViewModel.ets +++ b/entry/src/main/ets/viewmodel/StreamViewModel.ets @@ -353,6 +353,18 @@ export class StreamViewModel { this.connectionProgress = Math.min(nativeProgress, 1.0); this.connectionStageText = stageName; }); + + // 视频解码器启动回调:drStart 在 LiStartConnection 内部触发, + // 比 session.start() resolve 更早。提前切到 CONNECTED 让 UI 立即隐藏 loading, + // 缩短"已连接 → 首帧"之间的视觉空窗。 + this.streamingSession.setDecoderStartedCallback(() => { + if (this.connectionState !== StreamConnectionState.CONNECTED) { + console.info('StreamViewModel: drStart 触发,提前进入 CONNECTED 状态'); + this.connectionProgress = 1.0; + this.connectionStageText = '已连接'; + this.connectionState = StreamConnectionState.CONNECTED; + } + }); // 更新进度:获取服务器信息 this.connectionProgress = 0.05; From 5138757b9fdc00373f8b2f74372a4adbb0566443 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 13 May 2026 23:29:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(stream):=20=E4=BF=AE=E5=A4=8D=20ServerI?= =?UTF-8?q?nfo=20=E7=BC=93=E5=AD=98=E9=99=88=E6=97=A7=20currentGame=20?= =?UTF-8?q?=E4=B8=8E=20USB=20undefined=20=E5=99=AA=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. launchApp 成功后立即 invalidateServerInfoCache(computerId) 缓存里的 currentGame 在进入串流后必然变化,30s TTL 内复用 会导致下次 fetchServerInfo 命中缓存时拿到陈旧值,可能误触发 resumeApp 路径或漏 quitApp。 2. UsbDriverService.listKnownUsbGamepads 显式判 undefined 模拟器/无 USB 权限场景下 usbManager.getDevices() 返回 undefined, 原代码进入 for 循环触发 TypeError,每 5 秒一次噪声。 --- entry/src/main/ets/service/streaming/StreamingSession.ets | 3 +++ entry/src/main/ets/service/usbdriver/UsbDriverService.ets | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/entry/src/main/ets/service/streaming/StreamingSession.ets b/entry/src/main/ets/service/streaming/StreamingSession.ets index 30a2cd5..618fbdd 100644 --- a/entry/src/main/ets/service/streaming/StreamingSession.ets +++ b/entry/src/main/ets/service/streaming/StreamingSession.ets @@ -500,6 +500,9 @@ export class StreamingSession { } const cachedCurrentGame = this.getCachedCurrentGame(); await this.launchApp(cachedCurrentGame); + // 成功 launch/resume 后 currentGame 必然变化(变为本次 appId), + // 让缓存立即失效,避免下次进入串流复用陈旧的 currentGame 导致误判 + ComputerManager.getInstance().invalidateServerInfoCache(this.computerId); await this.connectToServer(); this.applyPostConnectionSettings(config); diff --git a/entry/src/main/ets/service/usbdriver/UsbDriverService.ets b/entry/src/main/ets/service/usbdriver/UsbDriverService.ets index 4b66f6e..c0d215e 100644 --- a/entry/src/main/ets/service/usbdriver/UsbDriverService.ets +++ b/entry/src/main/ets/service/usbdriver/UsbDriverService.ets @@ -668,7 +668,11 @@ export class UsbDriverService implements UsbDriverListener { static listKnownUsbGamepads(): KnownUsbGamepadInfo[] { const result: KnownUsbGamepadInfo[] = []; try { - const devices: usbManager.USBDevice[] = usbManager.getDevices(); + // 模拟器/无 USB 权限场景下 getDevices() 可能返回 undefined + const devices: usbManager.USBDevice[] | undefined = usbManager.getDevices(); + if (!devices || devices.length === 0) { + return result; + } for (let i = 0; i < devices.length; i++) { const d = devices[i]; const name = UsbDriverService.identifyKnownGamepad(d); From 720f2482bb8376d77059d6d3c26e0b7bf316ae4a Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 13 May 2026 23:41:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(stream):=20=E9=87=87=E7=BA=B3=20CodeRab?= =?UTF-8?q?bit=20=E4=B8=89=E6=9D=A1=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamPage: AI 探测 setTimeout 在 aboutToDisappear 中清理,避免页面销毁后弹 Toast - StreamingSession: drStart 回调用 try/catch 隔离,外部异常不再冒泡到 native 回调链 - StreamViewModel: drStart 提前置 CONNECTED 加 session 实例校验 + 仅 CONNECTING 时生效, 防止旧会话延迟回调把 ERROR/DISCONNECTED 改回 CONNECTED --- entry/src/main/ets/pages/StreamPage.ets | 20 +++++++++++++++++-- .../service/streaming/StreamingSession.ets | 9 ++++++++- .../main/ets/viewmodel/StreamViewModel.ets | 7 ++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 64516cb..acb2925 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -227,6 +227,8 @@ struct StreamPage { private controllerTypes: Map = new Map(); // slot → MoonlightControllerType private aiKeyService: AiKeyService | null = null; private aiKeyNvHttp: NvHttp | null = null; + // AI 可用性探测的延时定时器,aboutToDisappear 时需清理 + private aiProbeTimer: number | null = null; // 页面参数 private computerId: string = ''; @@ -462,9 +464,16 @@ struct StreamPage { private probeAiAvailableDeferred(): void { const nvHttp = this.aiKeyNvHttp; if (!nvHttp) return; - setTimeout(() => { + if (this.aiProbeTimer !== null) { + clearTimeout(this.aiProbeTimer); + this.aiProbeTimer = null; + } + this.aiProbeTimer = setTimeout(() => { + this.aiProbeTimer = null; + // 二次校验:页面已销毁则 aiKeyNvHttp 会被置空 + if (!this.aiKeyNvHttp) return; nvHttp.checkAiAvailable().then((available: boolean) => { - if (available) { + if (available && this.aiKeyNvHttp) { ToastQueue.show({ message: '✨ AI LLM 已连接,可使用 AIV+ 功能', duration: 2500 @@ -688,6 +697,13 @@ struct StreamPage { aboutToDisappear(): void { console.info('StreamPage aboutToDisappear - cleaning up'); + // 取消 AI 可用性延时探测,避免页面销毁后回调触发 Toast / 网络请求 + if (this.aiProbeTimer !== null) { + clearTimeout(this.aiProbeTimer); + this.aiProbeTimer = null; + } + this.aiKeyNvHttp = null; + this.stopClipboardSync(); // 取消 Network Boost 场景监听 diff --git a/entry/src/main/ets/service/streaming/StreamingSession.ets b/entry/src/main/ets/service/streaming/StreamingSession.ets index 618fbdd..c1c998a 100644 --- a/entry/src/main/ets/service/streaming/StreamingSession.ets +++ b/entry/src/main/ets/service/streaming/StreamingSession.ets @@ -1019,7 +1019,14 @@ export class StreamingSession { }, drStart: (): void => { console.info('视频解码器启动'); - if (this.decoderStartedCallback) this.decoderStartedCallback(); + // 隔离外部回调异常,避免冒泡进 native 回调链导致连接流程不稳定 + if (this.decoderStartedCallback) { + try { + this.decoderStartedCallback(); + } catch (err) { + console.error(`[StreamingSession] decoderStartedCallback 执行失败: ${err}`); + } + } }, drStop: (): void => { console.info('视频解码器停止'); }, drCleanup: (): void => { console.info('视频解码器清理'); }, diff --git a/entry/src/main/ets/viewmodel/StreamViewModel.ets b/entry/src/main/ets/viewmodel/StreamViewModel.ets index 2764cb4..f43e5bb 100644 --- a/entry/src/main/ets/viewmodel/StreamViewModel.ets +++ b/entry/src/main/ets/viewmodel/StreamViewModel.ets @@ -357,8 +357,13 @@ export class StreamViewModel { // 视频解码器启动回调:drStart 在 LiStartConnection 内部触发, // 比 session.start() resolve 更早。提前切到 CONNECTED 让 UI 立即隐藏 loading, // 缩短"已连接 → 首帧"之间的视觉空窗。 + // 捕获当前 session 实例,防止旧会话延迟回调污染新会话或把 ERROR/DISCONNECTED 改回 CONNECTED。 + const currentSession = this.streamingSession; this.streamingSession.setDecoderStartedCallback(() => { - if (this.connectionState !== StreamConnectionState.CONNECTED) { + if (this.streamingSession !== currentSession) { + return; + } + if (this.connectionState === StreamConnectionState.CONNECTING) { console.info('StreamViewModel: drStart 触发,提前进入 CONNECTED 状态'); this.connectionProgress = 1.0; this.connectionStageText = '已连接';