uni-app 视频通话实战:康复师与患者视频问诊的 6 个致命 Bug 与解决方案

开篇

我们项目的核心功能之一是康复师与患者的视频问诊。技术栈是 uni-app + 腾讯云 TRTC SDK,患者端和康复师端都需要支持一对一实时视频通话。

听起来很标准的需求对吧?但实际上从「能跑通 Demo」到「生产环境稳定运行」,中间踩了 6 个让人头疼的 Bug------有 iOS 权限判断错误导致用户被无故拦截、有 OC 桥接对象释放时机问题导致 Crash、有 nvue 原生组件的样式诡异失效......

这篇文章就用真实的代码和修复过程,把这 6 个坑一个一个填平。

本文假设你已经了解 TRTC UniApp SDK 的基本用法。如果是第一次接触,建议先看腾讯云 TRTC UniApp SDK 完整手册。


一、先看架构:视频通话模块怎么设计的

在讲 Bug 之前,先了解一下我们的架构,否则后面代码可能看不懂。

1.1 整体结构

bash 复制代码
subcom-pkg/video/
├── room.nvue               # 患者端视频房间入口(98行,极薄)
├── room-therapist.nvue      # 康复师端视频房间入口(105行,极薄)
├── VideoRoom.nvue           # 通话 UI 组件(452行,两端共用)
├── ChatPanel.nvue           # 通话中文字聊天面板

common/composables/
├── useTrtcRoom.js           # 视频通话核心逻辑(611行,两端共用)
├── useMediaPermission.js    # 权限检查封装

uni_modules/tuikit-atomic-x/utils/
├── callPermission.ts        # IM 模块通话权限(含 OC 桥接层处理)

1.2 设计思路

核心是 Composable 模式 ------useTrtcRoom.js 封装了所有 TRTC 逻辑(进房、退房、音视频控制、计时、心跳、聊天),患者端和康复师端各自通过回调参数注入不同的 API 调用:

javascript 复制代码
// 患者端 room.nvue
useTrtcRoom({
  onCallEnded: ({ duration }) => roomLeave({ roomId, duration }),     // 患者端 API
  redirectAfterHangup: ({ orderId }) => uni.redirectTo({ ... }),      // 跳回订单详情
});

// 康复师端 room-therapist.nvue
useTrtcRoom({
  onCallEnded: ({ duration }) => therapistLeaveRoom({ roomId, duration }),  // 康复师端 API
  onHeartbeat: ({ currentDuration }) => roomHeartbeat({ ... }),             // 心跳上报
  redirectAfterHangup: ({ appointmentId, isEnd }) => {                      // 结束服务 → 总结页
    if (isEnd) uni.redirectTo({ url: '/summary' });
    else uni.redirectTo({ url: '/appoint' });
  },
});

这种设计让两端共享同一套音视频逻辑,只在外层注入不同的业务行为。好架构,但 Bug 不认架构 ------ 该来的一个不少。


二、Bug 1:iOS 权限三态误判------「从未请求过」被当成「已拒绝」

问题表现

用户首次进入视频通话页面,直接弹出「摄像头或麦克风权限已被拒绝,请前往设置开启」的提示。用户一脸懵:「我还没同意也没拒绝啊,怎么就拒绝了?」

根因分析

原来的权限检查代码用了 TRTC SDK 自带的 permission.judgeIosPermission() 方法:

javascript 复制代码
// ❌ 有问题的代码
const cameraGranted = permission.judgeIosPermission('camera');
const micGranted = permission.judgeIosPermission('record');

if (cameraGranted && micGranted) {
  return true;  // 放行
}
// 否则 → 拦截 + 弹「去设置」提示

问题在于 judgeIosPermission() 只能返回 true(已授权)或 false(未授权),它无法区分 iOS 权限的三种状态:

状态 含义 judgeIosPermission() 返回 正确处理
NotDetermined(从未请求过) 用户还没见过权限弹窗 false 应该放行,TRTC SDK 会自动触发系统弹窗
Denied(已拒绝) 用户明确点了「不允许」 false 应该拦截,引导去设置
Authorized(已授权) 用户已同意 true 放行

judgeIosPermission() 把「从未请求过」和「已拒绝」都返回 false,导致先入为主的逻辑直接把「从未请求过」也拦截了。

修复方案

绕过 TRTC SDK 的封装,直接调用 iOS 原生 API 读取底层状态码:

javascript 复制代码
// ✅ 修复后的代码(useMediaPermission.js)
async function checkIosPermissions() {
  let cameraStatus = 0;
  let micStatus = 0;

  // #ifdef APP-PLUS
  // 直接读原生状态码,精确区分三种状态
  const AVCaptureDevice = plus.ios.import("AVCaptureDevice");
  cameraStatus = AVCaptureDevice.authorizationStatusForMediaType('vide');
  plus.ios.deleteObject(AVCaptureDevice);

  const AVAudioSession = plus.ios.import("AVAudioSession");
  micStatus = AVAudioSession.sharedInstance().recordPermission();
  plus.ios.deleteObject(AVAudioSession);
  // #endif

  // 相机: 0=未请求, 1=受限, 2=已拒绝, 3=已授权
  // 麦克风: 1970168948=未请求, 1684369017=已拒绝, 1735552628=已授权
  const cameraDenied = cameraStatus === 2;
  const micDenied = micStatus === 1684369017;

  // 只有明确拒绝才拦截
  if (!cameraDenied && !micDenied) return true;

  // 已拒绝 → 引导去设置
  uni.showModal({
    title: '权限提示',
    content: '摄像头或麦克风权限已被拒绝,请在系统设置中开启后重试',
    confirmText: '去设置',
    success: (res) => {
      if (res.confirm) permission.gotoAppPermissionSetting();
    },
  });
  return false;
}

💡 教训:跨平台封装层的便利性是有代价的。当封装掩盖了平台原生语义时(比如 iOS 的三态权限被简化成了布尔值),宁可绕过去直接调原生 API。


三、Bug 2:OC 桥接对象过早释放导致 Crash

问题表现

iOS 端请求麦克风权限时偶发崩溃。不是必现,但线上 Crash 率明显异常。

根因分析

plus.ios 是 uni-app 提供的 OC 桥接层,通过 plus.ios.import() 创建的对象必须手动调用 plus.ios.deleteObject() 释放。问题出在释放时机:

typescript 复制代码
// ❌ 有问题的代码
function requestIosMicPermission(): Promise<boolean> {
  return new Promise((resolve) => {
    const AVAudioSession = plus.ios.import('AVAudioSession');
    const session = AVAudioSession.sharedInstance();
    session.requestRecordPermission((granted: boolean) => {
      resolve(granted);
    });
    // ⚠️ deleteObject 在异步回调外部同步执行!
    plus.ios.deleteObject(session);       // ← 此时回调可能还没执行
    plus.ios.deleteObject(AVAudioSession); // ← OC 对象已被释放
  });
}

requestRecordPermission 是异步回调 。deleteObject 在 Promise 构造函数中同步执行,在回调触发之前就把 OC 桥接对象释放了。当回调最终触发时,引用的对象已经是野指针 → Crash。

修复方案

typescript 复制代码
// ✅ 修复后的代码(callPermission.ts)
function requestIosMicPermission(): Promise<boolean> {
  return new Promise((resolve) => {
    const AVAudioSession = plus.ios.import('AVAudioSession');
    const session = AVAudioSession.sharedInstance();
    session.requestRecordPermission((granted: boolean) => {
      // ✅ deleteObject 必须在回调内部执行
      plus.ios.deleteObject(session);
      plus.ios.deleteObject(AVAudioSession);
      resolve(granted);
    });
    // ❌ 这里绝对不能放 deleteObject!
  });
}

同样的修复也应用到了摄像头权限请求:

typescript 复制代码
function requestIosCameraPermission(): Promise<boolean> {
  return new Promise((resolve) => {
    const AVCaptureDevice = plus.ios.import('AVCaptureDevice');
    AVCaptureDevice.requestAccessForMediaTypeCompletionHandler('vide', (granted: boolean) => {
      // ✅ 回调内释放
      plus.ios.deleteObject(AVCaptureDevice);
      resolve(granted);
    });
  });
}

💡 教训 :plus.ios.deleteObject() 的释放时机必须与 OC 对象的最后一次使用对齐。对于异步回调,释放必须放在回调内部。


四、Bug 3:nvue 原生组件 CSS 样式完全无效

问题表现

iOS 端进入视频房间后,本地画面不显示、远端画面也不显示。控制台没有报错,TRTC 事件回调正常触发,startLocalPreview 和 startRemoteView 都调用成功了------但屏幕上就是一片黑。

根因分析

<trtc-local-view> 和 <trtc-remote-view> 是 nvue 原生组件,它们的渲染方式与普通 Vue 组件不同。问题出在 CSS 上:

html 复制代码
<!-- ❌ 有问题的代码------只用了 class,没有内联 style -->
<trtc-remote-view
  :userId="remoteUserId"
  :viewId="remoteUserId"
  class="remote-video"
/>

<trtc-local-view
  :viewId="localViewId"
  class="local-video"
/>
css 复制代码
/* 这些 class 在 nvue 原生组件上可能不生效 */
.remote-video {
  flex: 1;
}
.local-video {
  width: 240rpx;
  height: 320rpx;
}

nvue 的原生渲染引擎对 CSS class 的支持是受限的。<trtc-local-view> 和 <trtc-remote-view> 作为原生视图组件,在 iOS 端尤其容易出现 class 样式不生效的问题。

修复方案

关键尺寸属性改为内联 style:

html 复制代码
<!-- ✅ 修复后------内联 style + class 双保险 -->
<trtc-remote-view
  v-if="remoteUserId"
  :userId="remoteUserId"
  :viewId="remoteUserId"
  class="remote-video"
  style="flex: 1;"
/>

<trtc-local-view
  v-if="localViewId"
  :viewId="localViewId"
  class="local-video"
  style="width: 240rpx; height: 320rpx;"
/>

💡 教训 :在 nvue 中,原生组件的关键尺寸属性(width、height、flex)尽量用内联 style,不要只依赖 CSS class。这不是 Vue 的 bug,而是 nvue 原生渲染层的限制。


五、Bug 4:通话记录回调数据全部丢失

问题表现

通话结束后跳转到订单详情页,URL 参数全部为空------orderId 为空、duration 为 0、roomId 为空。后端收到的通话结束回调也是空数据。

根因分析

这是一个时序 Bug ,非常隐蔽。看 onRemoteUserLeaveRoom 的处理逻辑:

javascript 复制代码
// ❌ 有问题的代码流程
tc.on("onRemoteUserLeaveRoom", async ({ userId }) => {
  if (userId === remoteUserId.value) {
    stopTimers();

    cleanupCall();  // ← ① 先把所有 ref 重置为空字符串/0
    // ② cleanupCall 里面把 remoteUserId、roomId、orderNo、callDuration 全清了

    await onCallEnded?.({ roomId: roomId.value, duration: callDuration.value });
    // ← ③ 此时 roomId.value 已经是 "",callDuration.value 已经是 0!

    setTimeout(() => {
      redirectAfterHangup?.({ orderNo: orderNo.value, ... });
      // ← ④ 同样全是空值!
    }, 500);
  }
});

cleanupCall() 会把所有状态重置:

javascript 复制代码
function cleanupCall() {
  // ... TRTC 资源清理 ...

  // ⚠️ 这些重置在数据上报之前就执行了!
  remoteUserId.value = "";
  roomId.value = "";
  orderNo.value = "";
  callDuration.value = 0;
  // ...
}

修复方案

在 cleanupCall() 之前快照需要的数据:

javascript 复制代码
// ✅ 修复后------先快照再清理
tc.on("onRemoteUserLeaveRoom", async ({ userId }) => {
  if (userId === remoteUserId.value) {
    stopTimers();

    // ✅ 先快照所有需要的数据
    const hangupInfo = {
      orderNo: orderNo.value,
      orderId: orderId.value,
      appointmentId: appointmentId.value,
      duration: callDuration.value,
      roomId: roomId.value,
      startTime: callStartTime.value,
      endTime: callEndTime.value,
    };

    // 然后上报
    await onCallEnded?.({ roomId: roomId.value, duration: callDuration.value });

    // 最后清理
    cleanupCall();

    // 跳转使用快照数据
    setTimeout(() => {
      redirectAfterHangup?.(hangupInfo);  // ✅ 数据完整
    }, 500);
  }
});

在 useTrtcRoom.js 中专门抽了一个 buildHangupInfo() 函数做数据快照,所有挂断路径(远端离开、主动挂断、异常退出)统一使用。

💡 教训:清理函数和业务逻辑的执行顺序非常重要。清理意味着数据的终结,任何在清理之后还需要使用的数据,都必须在清理之前快照保存。


六、Bug 5:调试代码残留导致 iOS 端严重卡顿

问题表现

iOS 端进入视频房间后,画面卡顿、操作延迟明显。Android 端没有这个问题。

根因分析

在 onEnterRoom 回调中,有一段调试时期的遗留代码:

javascript 复制代码
// ❌ 调试代码残留(21行)
tc.on("onEnterRoom", (result) => {
  if (result > 0) {
    // 遗留的调试逻辑:iOS 端用 setTimeout 延迟启动预览
    if (platform === 'ios') {
      setTimeout(() => {
        try {
          tc.startLocalPreview(true, localViewId.value);
          tc.startLocalAudio(TRTCAudioQuality.TRTCAudioQualityDefault);
        } catch (e) {
          console.error('[TRTC] iOS delayed start failed:', e);
        }
      }, 1500);  // ← 1.5 秒延迟 + 冗余 try-catch
    } else {
      tc.startLocalPreview(true, localViewId.value);
      tc.startLocalAudio(TRTCAudioQuality.TRTCAudioQualityDefault);
    }
    // ... 还有大段调试日志
  }
});

这段代码是调试时为了排查 iOS 摄像头上屏时机问题临时加的,后来问题解决了但代码忘了删。setTimeout 延迟 + 冗余的 try-catch + 大量 console.log 在每次进房时都执行,累积起来严重影响 iOS 端性能。

修复方案

直接删除,恢复为标准流程:

javascript 复制代码
// ✅ 修复后------简洁直接
tc.on("onEnterRoom", (result) => {
  if (result > 0) {
    console.log("[TRTC] enterRoom success, cost:", result, "ms");
    pageStatus.value = "connected";

    tc.startLocalPreview(true, localViewId.value);
    tc.startLocalAudio(TRTCAudioQuality.TRTCAudioQualityDefault);
    tc.enableAudioVolumeEvaluation(300);

    if (orderNo.value) onCallStarted?.(orderNo.value);
    startTimers();
  } else {
    // 错误处理...
  }
});

从 21 行精简到 8 行,去掉了所有 setTimeout、try-catch 和冗余日志。

💡 教训 :调试代码一定要有明确的清理机制。建议在调试代码旁边加 // TODO: 调试完成后删除 注释,并在合代码前全局搜索检查。


七、Bug 6:iOS 跳转系统设置页静默失败

问题表现

当用户拒绝了摄像头/麦克风权限后,App 弹出「去设置」引导。用户点击「去设置」后,什么都没发生------没有跳转、没有报错。

根因分析

原来的跳转代码使用了 plus.ios.import("UIApplication").openURL():

javascript 复制代码
// ❌ 不生效
function gotoAppPermissionSetting() {
  const UIApplication = plus.ios.import("UIApplication");
  UIApplication.sharedApplication().openURL(/* ... */);
  plus.ios.deleteObject(UIApplication);
}

但 plus.ios 桥接层对 UIApplication 的 openURL 系列方法支持不完整,调用后静默失败。

修复方案

uni-app 有更简单的跨平台 API:

javascript 复制代码
// ✅ 修复后
function gotoAppPermissionSetting() {
  // 方式一:uni-app 内置 API(推荐,两端通用)
  uni.openAppAuthorizeSetting();

  // 方式二:直接调用系统 URL Scheme(iOS 备选)
  // plus.runtime.openURL("app-settings:");
}

uni.openAppAuthorizeSetting() 是 uni-app 提供的跨平台 API,iOS 和 Android 都能正确跳转到应用的系统设置页。

💡 教训 :能用 uni-app 内置 API 的地方尽量用内置 API,不要绕到 plus.ios 底层去拼接。尤其是 UIApplication 级别的操作,uni-app 通常都有封装好的替代方案。


八、额外的踩坑备忘

除了上面 6 个重点 Bug,还有一些小但同样折磨人的问题:

8.1 必须用自定义基座

TRTC 依赖原生插件,不能在 HBuilder 标准基座中运行。每次调试都要打自定义基座,包体积大、打包慢,但这是硬性限制,无解。

8.2 页面必须是 .nvue

<trtc-local-view> 和 <trtc-remote-view> 是 nvue 原生组件,在普通 .vue 页面中不可用。

8.3 退房后等回调才能再次进房

exitRoom() 之后必须等 onExitRoom 回调触发,才能再次 enterRoom。否则会出现「摄像头被占用」或「已在房间中」的错误。

8.4 enableAudioVolumeEvaluation 的调用顺序

必须在 startLocalAudio 之前调用,否则音量回调不生效。这个顺序要求在官方文档里提了一嘴但很容易忽略。

8.5 权限状态缓存

callPermission.ts 中加了 3 秒的权限状态缓存(PERMISSION_CACHE_TTL),避免频繁 plus.ios.import/deleteObject 导致的性能问题。在频繁进出视频房间的场景下,这个缓存能显著减少 OC 桥接层的开销。


九、总结

6 个 Bug 的根因可以归纳为三大类:

1. iOS 平台特性理解不足(Bug 1、2、6)

  • 权限三态 vs 布尔值
  • OC 桥接对象生命周期
  • UIApplication API 的桥接层限制

2. nvue 原生渲染限制(Bug 3)

  • 原生组件的 CSS 支持不完整
  • 关键样式用内联比 class 更可靠

3. JavaScript 时序问题(Bug 4、5)

  • 清理函数和业务代码的执行顺序
  • 调试代码的生命周期管理

如果你也在用 uni-app + TRTC 做视频通话,建议把这几个检查点加入 Code Review Checklist:

  • iOS 权限判断是否区分了「未请求」和「已拒绝」?
  • plus.ios.deleteObject() 是否在异步回调内部执行?
  • nvue 原生组件的关键尺寸是否用了内联 style?
  • 数据清理前是否快照了后续需要使用的值?
  • 有没有调试代码残留(全局搜索 setTimeout、TODO)?
  • 跳转系统设置是否用了 uni.openAppAuthorizeSetting()?

这些坑填完之后,视频通话模块在生产环境已经稳定运行。希望这篇文章能帮你少走一些弯路。


标签:uni-app TRTC 腾讯云 视频通话 iOS nvue Bug修复 前端实战

相关推荐
传奇开心果编程9 小时前
【Jetpack Compose娓娓道来】第29课:状态容器与UI事件建模——把“散装状态”变成“可穷举的契约”
android·kotlin·移动开发·android jetpack·ui框架·开发范式·声明式ui
五彩小白10 小时前
喵呜打卡项目总结
android·团队开发
古法安卓12 小时前
Android-GPU压测工具
android·java·android studio
xcLeigh14 小时前
KingbaseES数据库教程:初识数据库,从安装到跑通第一条SQL
android·数据库·sql·安装·数据库教程·kes
TimeFine17 小时前
AI 编码工作流的设计(二)
android·ai编程
2401_8818283217 小时前
MySQL操作命令
android·mysql·adb
茶底世界之下17 小时前
雨夜街角裁成方图,人物与冷暖光还能共存吗?
ios
用户607097591490618 小时前
AOSP 14 OTA 升级指导(三):三条升级链路——Java API、自研 HAL 与 TR069
android
前端小D19 小时前
React Native Android 项目搭建
android·react native·react.js
youdexiang19 小时前
IOS录音APP拍照记录能和音频同步吗
ios·音视频