跳到主要内容

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. 平台对应能力速查

条件编译标识推荐底层限制
H5H5原生 WebRTC一般无限制
微信小程序MP-WEIXINTRTC 小程序 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-NVUEAPP-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 端有两条路:

  1. HBuilder 5+ Plus 模块:使用 uni.requireNativePlugin 引入打包过的 TRTC / 声网原生插件。
  2. 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;小程序通过 &lt;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 只能屏蔽客户端差异,互通靠服务端:

  1. 同一房间服务:要么所有端接 TRTC / 声网,要么自研 SFU 接入 RTMP / WHIP 网关。
  2. Token 服务:按平台签发 userSig(TRTC)、token(声网)、JWT(自研),但要保证 roomIduserId 一致。
  3. Codec 最大公约数:H.264 Constrained Baseline。SDP / SFU 配置里把 H.264 提到第一。
  4. 音频采样率:48 kHz 全平台 OK。

10. 开发与调试

10.1 多端真机调试矩阵

调试入口注意
H5Chrome / Safari 真机HTTPS 必须
微信小程序微信开发者工具 + 真机调试小程序基础库 ≥ 2.10
支付宝小程序支付宝开发者工具mPaaS 基础库版本
APP iOSHBuilderX 真机 + 自定义基座必须打自定义基座(含原生插件)
APP AndroidHBuilderX 自定义基座同上

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. 权威资料