开篇
我们项目的核心功能之一是康复师与患者的视频问诊。技术栈是 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修复 前端实战