uni-app跨端策略
uni-app 跨端策略
uni-app 同一份代码出 H5、微信小程序、支付宝小程序、APP(含 iOS / Android)。WebRTC 在这几端的能力完全不一样,硬塞一套 API 必崩。本篇讲实际项目里如何通过条件编译、按端选 SDK、错误码归一来做"一份业务代码、各端跑通"。
1. uni-app 的几条原则
- 不要假设跨端能力一致:H5 用浏览器 WebRTC,小程序用厂商 SDK,APP 走原生(plus / Native.js)。
- 按端切实现,按端不切业务:业务层调统一接口;底层在
// #ifdef块里替换。 - 类型先行:用 TypeScript 写一份接口,每端实现各自 stub。
- 错误码统一:各厂商错误码都不一样,必须归一才能让上层业务做用户提示。
什么时候适用:业务确实要覆盖 4 端以上,团队规模有限不能维护多 repo;对厂商 SDK 锁定可接受。 什么时候不适用:业务只在 H5 + iOS APP 上跑——直接 H5 + WKWebView 更省事;或者 4 端要求差异极大(比如要接抖音、快手、微信、支付宝、APP、PC Electron),那就分仓更清晰。
2. 平台对应能力速查
| 端 | 条件编译标识 | 推荐底层 | 限制 |
|---|---|---|---|
| H5 | H5 | 原生 WebRTC | 一般无限制 |
| 微信小程序 | MP-WEIXIN | TRTC 小程序 SDK | 类目、单 pusher |
| 支付宝小程序 | MP-ALIPAY | 蚂蚁 RTC SDK | 类目 |
| 百度 / 抖音 / QQ 等 | MP-BAIDU / MP-TOUTIAO / MP-QQ | 各家 SDK | 看业务覆盖 |
| App-Plus(iOS/Android 原生) | APP-PLUS | 原生 SDK 模块 | 需 5+App 或 nvue + 原生插件 |
| App-Plus-NVUE | APP-NVUE | 同上 | weex 渲染限制 |
3. 项目结构(实战范式)
my-rtc-app/
├── pages/
│ └── room/
│ ├── room.vue
│ └── room.ts
├── packages/
│ ├── rtc-core/ # 接口层 + 错误码
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── errors.ts
│ ├── rtc-h5/ # 原生 WebRTC
│ │ └── index.ts
│ ├── rtc-mp-weixin/ # TRTC 小程序
│ │ └── index.ts
│ ├── rtc-mp-alipay/ # 蚂蚁 RTC
│ │ └── index.ts
│ └── rtc-app/ # APP(接 RN 风格 / 原生模块)
│ └── index.ts
├── manifest.json
├── pages.json
└── tsconfig.json
4. 接口层
// packages/rtc-core/types.ts
export type Platform = 'h5' | 'mp-weixin' | 'mp-alipay' | 'app';
export interface JoinOpts {
appId: string;
roomId: string;
userId: string;
userSig: string;
}
export interface VideoOpts {
width?: number;
height?: number;
frameRate?: number;
facing?: 'front' | 'back';
bitrate?: number;
}
export interface RtcEngine {
init(platform: Platform): Promise<void>;
join(opts: JoinOpts): Promise<void>;
leave(): Promise<void>;
startLocalVideo(opts?: VideoOpts): Promise<void>;
stopLocalVideo(): Promise<void>;
startLocalAudio(): Promise<void>;
stopLocalAudio(): Promise<void>;
on(event: RtcEvent, cb: (payload: any) => void): void;
off(event: RtcEvent, cb: (payload: any) => void): void;
}
export type RtcEvent =
| 'user-joined'
| 'user-left'
| 'remote-video-available'
| 'remote-video-unavailable'
| 'connection-state-changed'
| 'network-quality'
| 'error';
// packages/rtc-core/errors.ts
export const RtcError = {
PERMISSION_DENIED: 'PERMISSION_DENIED',
NETWORK_UNREACHABLE: 'NETWORK_UNREACHABLE',
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
KICKED_OUT: 'KICKED_OUT',
DEVICE_BUSY: 'DEVICE_BUSY',
CODEC_NOT_SUPPORTED: 'CODEC_NOT_SUPPORTED',
CATEGORY_FORBIDDEN: 'CATEGORY_FORBIDDEN',
UNKNOWN: 'UNKNOWN',
} as const;
export type RtcError = typeof RtcError[keyof typeof RtcError];
5. 条件编译入口
// packages/rtc-core/index.ts
import type { RtcEngine } from './types';
let engine: RtcEngine;
// #ifdef H5
import h5Engine from '../rtc-h5';
engine = h5Engine;
// #endif
// #ifdef MP-WEIXIN
import mpWxEngine from '../rtc-mp-weixin';
engine = mpWxEngine;
// #endif
// #ifdef MP-ALIPAY
import mpAlipayEngine from '../rtc-mp-alipay';
engine = mpAlipayEngine;
// #endif
// #ifdef APP-PLUS
import appEngine from '../rtc-app';
engine = appEngine;
// #endif
export default engine;
export * from './types';
export * from './errors';
业务页面只 import 这一份:
// pages/room/room.ts
import rtc, { RtcError } from '@/packages/rtc-core';
await rtc.init('h5'); // 实际 platform 在编译时已经确定,传值仅给 SDK 自己用
await rtc.join({ appId, roomId, userId, userSig });
await rtc.startLocalVideo({ width: 1280, height: 720, frameRate: 30 });
rtc.on('user-joined', (u) => console.log('joined', u.userId));
rtc.on('error', (e) => {
if (e.code === RtcError.PERMISSION_DENIED) {
uni.showToast({ title: '请开启摄像头权限', icon: 'none' });
}
});
6. 各端实现要点
6.1 H5
// packages/rtc-h5/index.ts
import { EventEmitter } from 'eventemitter3';
import type { RtcEngine, JoinOpts, VideoOpts } from '../rtc-core/types';
class H5Engine extends EventEmitter implements RtcEngine {
private pc?: RTCPeerConnection;
private local?: MediaStream;
async init() {
if (!navigator.mediaDevices?.getUserMedia) {
throw Object.assign(new Error('no webrtc'), { code: 'CODEC_NOT_SUPPORTED' });
}
}
async join(opts: JoinOpts) {
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
bundlePolicy: 'max-bundle',
});
// 信令省略,连到自研 SFU 或 LiveKit
}
async startLocalVideo(opts?: VideoOpts) {
this.local = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: opts?.width ?? 1280 },
height: { ideal: opts?.height ?? 720 },
frameRate: { ideal: opts?.frameRate ?? 30 },
facingMode: opts?.facing === 'back' ? 'environment' : 'user',
},
audio: true,
});
this.local.getTracks().forEach(t => this.pc!.addTrack(t, this.local!));
}
// ...
}
export default new H5Engine();
6.2 微信小程序
// packages/rtc-mp-weixin/index.ts
// #ifdef MP-WEIXIN
import TRTC from 'trtc-wx-sdk';
import { EventEmitter } from 'eventemitter3';
import type { RtcEngine, JoinOpts, VideoOpts } from '../rtc-core/types';
import { RtcError } from '../rtc-core/errors';
class WxEngine extends EventEmitter implements RtcEngine {
private trtc = TRTC.create();
async init() {
this.trtc.on('REMOTE_USER_JOIN', (u: any) => this.emit('user-joined', { userId: u.userId }));
this.trtc.on('REMOTE_USER_LEAVE', (u: any) => this.emit('user-left', { userId: u.userId }));
this.trtc.on('ERROR', (e: any) => this.emit('error', { code: mapWxError(e.code), raw: e }));
}
async join(opts: JoinOpts) {
await this.trtc.enterRoom({
sdkAppId: Number(opts.appId),
userId: opts.userId,
userSig: opts.userSig,
strRoomId: opts.roomId,
scene: 'rtc',
});
}
async startLocalVideo(opts?: VideoOpts) {
await this.trtc.startLocalVideo({
mirror: opts?.facing !== 'back',
videoWidth: opts?.width ?? 1280,
videoHeight: opts?.height ?? 720,
videoFps: opts?.frameRate ?? 15,
});
}
// ...
}
function mapWxError(code: number): RtcError {
switch (code) {
case -1001: return RtcError.NETWORK_UNREACHABLE;
case -1308: return RtcError.PERMISSION_DENIED;
case -3301: return RtcError.ROOM_NOT_FOUND;
case -3308: return RtcError.TOKEN_EXPIRED;
default: return RtcError.UNKNOWN;
}
}
export default new WxEngine();
// #endif
6.3 支付宝小程序
// packages/rtc-mp-alipay/index.ts
// #ifdef MP-ALIPAY
import { EventEmitter } from 'eventemitter3';
import type { RtcEngine, JoinOpts } from '../rtc-core/types';
class AlipayEngine extends EventEmitter implements RtcEngine {
async init() {
// 蚂蚁 RTC 小程序版 my.createRtcRoomContext()
}
async join(opts: JoinOpts) {
const ctx = my.createRtcRoomContext({
appId: opts.appId,
channel: opts.roomId,
userId: opts.userId,
token: opts.userSig,
});
await new Promise<void>((resolve, reject) => {
ctx.join({
success: () => resolve(),
fail: (e: any) => reject(Object.assign(new Error('join fail'), { code: mapAliError(e) })),
});
});
}
// ...
}
function mapAliError(_e: any) { /* ... */ return 'UNKNOWN'; }
export default new AlipayEngine();
// #endif
6.4 APP(uni-app + 原生模块)
App 端有两条路:
- HBuilder 5+ Plus 模块:使用
uni.requireNativePlugin引入打包过的 TRTC / 声网原生插件。 - renderjs / nvue:在 nvue 里调用原生 weex 模块。
// packages/rtc-app/index.ts
// #ifdef APP-PLUS
const TRTCNative = uni.requireNativePlugin('TencentTRTC') as any;
import { EventEmitter } from 'eventemitter3';
import type { RtcEngine, JoinOpts, VideoOpts } from '../rtc-core/types';
class AppEngine extends EventEmitter implements RtcEngine {
async init() {
TRTCNative.on('onRemoteUserEnterRoom', (u: any) =>
this.emit('user-joined', { userId: u.userId }));
}
async join(opts: JoinOpts) {
return new Promise<void>((resolve, reject) => {
TRTCNative.enterRoom(
{
sdkAppId: Number(opts.appId),
userId: opts.userId,
userSig: opts.userSig,
roomId: opts.roomId,
scene: 0, // VIDEOCALL
},
(res: any) => res.code === 0 ? resolve() : reject(res),
);
});
}
async startLocalVideo(opts?: VideoOpts) {
TRTCNative.startLocalPreview({ frontCamera: opts?.facing !== 'back' });
}
// ...
}
export default new AppEngine();
// #endif
7. 视频渲染:每端不一样
<template>
<view class="rtc-room">
<!-- #ifdef H5 -->
<video ref="local" autoplay muted playsinline class="local"></video>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<live-pusher
id="trtc-pusher"
mode="RTC"
autopush
@statechange="onPushState"
class="local"
/>
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<rtc-room ref="aliRoom" mode="rtc" class="local" />
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<view ref="localView" class="local" />
<!-- #endif -->
<view class="remotes">
<!-- 各端遍历远端用户 -->
</view>
</view>
</template>
H5 视频元素直接 videoEl.srcObject = remoteStream;小程序通过 <live-player> 拉远端流;APP 用原生插件拿到 surface id 再赋给 nvue 的 view。
8. manifest.json 关键配置
{
"mp-weixin": {
"appid": "wx0000000000",
"permission": {
"scope.camera": { "desc": "用于视频通话" },
"scope.record": { "desc": "用于语音通话" }
},
"requiredBackgroundModes": ["audio"],
"requiredPrivateInfos": ["chooseLocation"]
},
"mp-alipay": {
"permission": ["camera", "record"]
},
"app-plus": {
"modules": {
"Camera": {},
"Audio": {}
},
"distribute": {
"ios": {
"privacyDescription": {
"NSCameraUsageDescription": "用于视频通话",
"NSMicrophoneUsageDescription": "用于语音通话"
},
"UIBackgroundModes": ["audio", "voip"]
},
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.FOREGROUND_SERVICE\"/>",
"<uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_MICROPHONE\"/>"
]
}
}
}
}
9. 跨端互通的服务端约束
rtc-core 只能屏蔽客户端差异,互通靠服务端:
- 同一房间服务:要么所有端接 TRTC / 声网,要么自研 SFU 接入 RTMP / WHIP 网关。
- Token 服务:按平台签发 userSig(TRTC)、token(声网)、JWT(自研),但要保证
roomId和userId一致。 - Codec 最大公约数:H.264 Constrained Baseline。SDP / SFU 配置里把 H.264 提到第一。
- 音频采样率:48 kHz 全平台 OK。
10. 开发与调试
10.1 多端真机调试矩阵
| 端 | 调试入口 | 注意 |
|---|---|---|
| H5 | Chrome / Safari 真机 | HTTPS 必须 |
| 微信小程序 | 微信开发者工具 + 真机调试 | 小程序基础库 ≥ 2.10 |
| 支付宝小程序 | 支付宝开发者工具 | mPaaS 基础库版本 |
| APP iOS | HBuilderX 真机 + 自定义基座 | 必须打自定义基座(含原生插件) |
| APP Android | HBuilderX 自定义基座 | 同上 |
10.2 自定义基座注意
uni-app 接原生 RTC 插件后,官方 HBuilder 标准基座没有这些原生模块,必须打自定义基座才能真机调试。这是上手 uni-app + 原生 RTC 的最大坑之一。
11. 失败时的样子
| 现象 | 大概原因 | 处理 |
|---|---|---|
H5 上跑得通,小程序里报 getUserMedia is not a function | 没条件编译,H5 代码漏到小程序 | 用 // #ifdef H5 裹住 |
| 微信里 live-pusher 黑屏 | 类目未通过 | 申请实时音视频类目 |
| 支付宝端 my.createRtcRoomContext 不存在 | 基础库太旧或不在白名单 | 升级或申请白名单 |
APP 端 requireNativePlugin('TencentTRTC') 拿到 null | 没打自定义基座 | 重新打 |
| 4 端互通失败 | 不是同一 RTC 平台 | 全部接 TRTC / 声网 |
| 多端错误码不一致 | 没归一 | 在每端 mapXxxError |
| 切到 nvue 后 vue 组件丢能力 | nvue 与 vue 隔离 | 业务尽量在 vue,nvue 只渲染 |
12. 反模式
| 反模式 | 后果 |
|---|---|
| 不用条件编译,直接 if (process.env.PLATFORM) | 打包后所有端都把 SDK 带进去,包大小爆炸 |
上层业务直接调 wx.createLivePlayerContext | 换端立刻崩,无法复用 |
| 各端错误码原样传给 UI | 用户看不懂、UI 也写不动 |
把 H5 的 MediaStream 类型直接写进 rtc-core | 编译到小程序时引用一连串浏览器 API 报错 |
| APP 端不打自定义基座 | 真机调试一直拿不到原生插件 |
用 web-view 嵌 H5 WebRTC 蒙混过关 | iOS 端拿不到摄像头权限 |
一个仓库 4 端共用 dependencies,全部 dependencies | 小程序构建时打包进 react-native 包,构建失败 |
13. uni-app 项目验收 Checklist
- 一份业务代码 4 端能跑(H5 / 微信 / 支付宝 / APP)
- 各端 SDK 通过条件编译隔离,包体不互相污染
- 错误码归一,UI 提示一致
- 服务端 token 服务覆盖所有端
- 4 端进入同一 roomId 能互相听到看到
- 弱网下各端都能自动重连
- 后台切换、来电中断恢复正常
- manifest.json 各端权限/类目齐全
- 自定义基座流程文档化
- CI 跑 H5 build 通过;微信 / 支付宝 / APP 至少有一次构建测试
14. 权威资料
- uni-app 条件编译: https://uniapp.dcloud.net.cn/tutorial/platform.html
- uni-app 原生插件: https://nativesupport.dcloud.net.cn/NativePlugin/course/ios.html
- TRTC uni-app 集成: https://cloud.tencent.com/document/product/647/35074
- 蚂蚁 RTC 小程序: https://opendocs.alipay.com/mini/component/rtc-room
- 微信 live-pusher 类目: https://developers.weixin.qq.com/miniprogram/product/material/
- 同目录
微信小程序方案.md - 实战项目
L6-跨端连麦SDK - 核对日期:2026-06-22