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 事件回调正常触发,startLocalPreviewstartRemoteView 都调用成功了------但屏幕上就是一片黑。

根因分析

<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 中,原生组件的关键尺寸属性(widthheightflex)尽量用内联 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 桥接层对 UIApplicationopenURL 系列方法支持不完整,调用后静默失败。

修复方案

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?
  • 数据清理前是否快照了后续需要使用的值?
  • 有没有调试代码残留(全局搜索 setTimeoutTODO)?
  • 跳转系统设置是否用了 uni.openAppAuthorizeSetting()

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


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

相关推荐
时光足迹2 小时前
腾讯云 TRTC UniApp SDK 从入门到上线
前端·vue.js·uni-app
时光足迹2 小时前
uni-app 里把加密视频嵌入页面播放?我分析了 4 种方案,只有 1 种接近完美
前端·vue.js·uni-app
时光足迹3 小时前
JPush UniApp UTS 插件完全参考手册:API、事件与厂商通道一网打尽
vue.js·ios·uni-app
时光足迹3 小时前
极光推送全攻略(下):uni-app 代码实现与 iOS 排查实战
vue.js·ios·uni-app
时光足迹3 小时前
极光推送全攻略(上):被iOS证书折磨了三天,我写了一份前端也能看懂的避坑指南
前端·ios·uni-app
Coffeeee6 小时前
闲聊几句,Android老哥们,你们多久没做技改需求了
android·程序员·代码规范
萝卜er6 小时前
Fragment 生命周期与状态恢复-《Android深水区(四)》
android
萝卜er6 小时前
Intent 显式、隐式与 PendingIntent-《Android深水区(五)》
android
Kapaseker9 小时前
一文吃透 Kotlin 集合操作符
android·kotlin