HarmonyOS开发:会议应用——视频会议
HarmonyOS开发:会议应用——视频会议
📌 核心要点:视频会议不是简单的"打开摄像头"——音视频采集编解码、弱网对抗、屏幕共享、白板协作,每个环节都有坑,一个没处理好就是"听不清"“看不到”“卡死了”。
背景与动机
你参加视频会议,对方的声音断断续续,像在水下说话。画面卡成PPT,一秒一帧。你共享屏幕给同事看方案,结果对方看到的是黑屏。你在白板上画了个流程图,同事那边只看到一堆乱线。
用户对视频会议的容忍度极低——声音听不清?直接打电话。画面卡顿?关掉视频。共享不了屏幕?截图发过去。白板不同步?不画了。
视频会议的每个环节都必须"能用"——不是"偶尔能用",是"稳定能用"。
鸿蒙做视频会议有天然优势:系统级的音视频框架、硬件编解码加速、分布式能力跨设备流转。但优势要发挥出来,你得把采集、编码、传输、渲染每个环节都处理好。
核心原理
视频会议的架构核心:采集 → 编码 → 传输 → 解码 → 渲染,五步链路,每一步都可能出问题。
flowchart LR
subgraph 采集层
A[摄像头采集]
B[麦克风采集]
C[屏幕采集]
end
subgraph 编码层
D[H.264/H.265<br/>视频编码]
E[Opus/AAC<br/>音频编码]
end
subgraph 传输层
F[WebRTC/SRTP<br/>实时传输]
G[弱网对抗<br/>FEC/ARQ/JitterBuffer]
end
subgraph 解码层
H[视频解码]
I[音频解码<br/>AEC回声消除]
end
subgraph 渲染层
J[视频渲染]
K[音频播放]
L[白板渲染]
end
A --> D
B --> E
C --> D
D --> F
E --> F
F --> G
G --> H
G --> I
H --> J
I --> K
C --> L
classDef capture fill:#1565C0,color:#fff,stroke:#0D47A1
classDef encode fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef transport fill:#E65100,color:#fff,stroke:#BF360C
classDef decode fill:#6A1B9A,color:#fff,stroke:#4A148C
classDef render fill:#00897B,color:#fff,stroke:#004D40
class A,B,C capture
class D,E encode
class F,G transport
class H,I decode
class J,K,L render
音视频采集
鸿蒙的音视频采集使用@kit.MediaKit:
- 摄像头:
Camera模块,支持前后摄像头切换、分辨率调节、美颜滤镜 - 麦克风:
AudioCapturer模块,支持降噪、自动增益控制(AGC) - 屏幕:
ScreenCapture模块,支持指定区域采集、排除自身窗口
编解码
视频编码推荐H.264(兼容性最好)或H.265(压缩率更高)。音频编码推荐Opus(低延迟、高质量)。
鸿蒙提供硬件编解码加速,通过MediaCodec调用底层硬件,CPU占用极低。
弱网对抗
视频会议最怕弱网。丢包、延迟、抖动,任何一个都会导致体验崩溃。
弱网对抗三板斧:
- FEC(前向纠错):多发一些冗余数据,丢了包也能恢复
- ARQ(自动重传):丢了包请求重发,适合延迟低的场景
- JitterBuffer(抖动缓冲):缓冲一段数据再播放,消除网络抖动
回声消除
视频会议的"回声"问题:你的声音从对方扬声器出来,又被对方麦克风收进去传回来,你就听到了自己刚才说的话。
AEC(Acoustic Echo Cancellation)回声消除:把扬声器播放的信号从麦克风信号中减掉,只保留人声。
代码实战
基础用法:音视频通话实现
先实现最核心的功能——打开摄像头、采集音频、建立通话。
// VideoCallManager.ets - 视频通话管理
import { camera } from '@kit.CameraKit';
import { media } from '@kit.MediaKit';
import { audio } from '@kit.AudioKit';
// 通话状态
export enum CallState {
IDLE = 'idle', // 空闲
CONNECTING = 'connecting', // 连接中
CONNECTED = 'connected', // 已连接
MUTED = 'muted', // 静音
ENDED = 'ended', // 已结束
}
// 视频配置
export interface VideoConfig {
width: number; // 视频宽度
height: number; // 视频高度
frameRate: number; // 帧率
bitRate: number; // 码率
cameraPosition: camera.CameraPosition; // 摄像头位置
}
// 默认视频配置
const DEFAULT_VIDEO_CONFIG: VideoConfig = {
width: 640,
height: 480,
frameRate: 15,
bitRate: 500000, // 500kbps
cameraPosition: camera.CameraPosition.CAMERA_POSITION_FRONT,
};
export class VideoCallManager {
private static instance: VideoCallManager;
private cameraManager: camera.CameraManager | null = null;
private videoEncoder: media.AVCodec | null = null;
private audioCapturer: audio.AudioCapturer | null = null;
private audioRenderer: audio.AudioRenderer | null = null;
private currentCamera: camera.CameraDevice | null = null;
private callState: CallState = CallState.IDLE;
private videoConfig: VideoConfig = DEFAULT_VIDEO_CONFIG;
// 回调
onVideoFrame?: (frame: ArrayBuffer) => void;
onAudioFrame?: (frame: ArrayBuffer) => void;
onCallStateChanged?: (state: CallState) => void;
private constructor() {}
static getInstance(): VideoCallManager {
if (!VideoCallManager.instance) {
VideoCallManager.instance = new VideoCallManager();
}
return VideoCallManager.instance;
}
// 初始化摄像头
async initCamera(surfaceId: string): Promise<boolean> {
try {
this.cameraManager = camera.getCameraManager(undefined);
// 获取摄像头列表
const cameras = this.cameraManager.getSupportedCameras();
if (cameras.length === 0) {
console.error('[VideoCall] 没有可用的摄像头');
return false;
}
// 选择前置摄像头
this.currentCamera = cameras.find(
c => c.cameraPosition === this.videoConfig.cameraPosition
) || cameras[0];
console.info(`[VideoCall] 摄像头初始化完成: ${this.currentCamera.cameraId}`);
return true;
} catch (error) {
console.error(`[VideoCall] 摄像头初始化失败: ${JSON.stringify(error)}`);
return false;
}
}
// 开始视频采集
async startVideoCapture(surfaceId: string): Promise<void> {
if (!this.cameraManager || !this.currentCamera) {
console.error('[VideoCall] 摄像头未初始化');
return;
}
try {
// 创建相机输入
const cameraInput = this.cameraManager.createCameraInput(this.currentCamera);
await cameraInput.open();
// 创建预览输出
const previewOutput = this.cameraManager.createPreviewOutput(
{ width: this.videoConfig.width, height: this.videoConfig.height },
surfaceId
);
// 创建会话
const session = this.cameraManager.createSession(camera.SceneMode.NORMAL);
session.beginConfig();
session.addInput(cameraInput);
session.addOutput(previewOutput);
await session.commitConfig();
await session.start();
console.info('[VideoCall] 视频采集已启动');
} catch (error) {
console.error(`[VideoCall] 启动视频采集失败: ${JSON.stringify(error)}`);
}
}
// 开始音频采集
async startAudioCapture(): Promise<void> {
try {
const audioCapturerOptions: audio.AudioCapturerOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
channels: audio.AudioChannel.CHANNEL_1,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW,
},
capturerInfo: {
source: audio.SourceType.SOURCE_TYPE_MIC,
capturerFlags: 0,
},
};
this.audioCapturer = await audio.createAudioCapturer(audioCapturerOptions);
// 开启AEC回声消除
this.audioCapturer.on('readData', (buffer: ArrayBuffer) => {
// 将采集到的音频数据通过回调传出
this.onAudioFrame?.(buffer);
});
await this.audioCapturer.start();
console.info('[VideoCall] 音频采集已启动');
} catch (error) {
console.error(`[VideoCall] 启动音频采集失败: ${JSON.stringify(error)}`);
}
}
// 播放远端音频
async startAudioPlayback(): Promise<void> {
try {
const audioRendererOptions: audio.AudioRendererOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
channels: audio.AudioChannel.CHANNEL_1,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW,
},
rendererInfo: {
usage: audio.StreamUsage.STREAM_USAGE_VOICE_COMMUNICATION,
rendererFlags: 0,
},
};
this.audioRenderer = await audio.createAudioRenderer(audioRendererOptions);
await this.audioRenderer.start();
console.info('[VideoCall] 音频播放已启动');
} catch (error) {
console.error(`[VideoCall] 启动音频播放失败: ${JSON.stringify(error)}`);
}
}
// 写入远端音频数据
async writeRemoteAudio(buffer: ArrayBuffer): Promise<void> {
if (this.audioRenderer) {
await this.audioRenderer.write(buffer);
}
}
// 切换前后摄像头
async switchCamera(): Promise<boolean> {
if (!this.cameraManager) return false;
const cameras = this.cameraManager.getSupportedCameras();
const targetPosition = this.videoConfig.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT
? camera.CameraPosition.CAMERA_POSITION_BACK
: camera.CameraPosition.CAMERA_POSITION_FRONT;
const targetCamera = cameras.find(c => c.cameraPosition === targetPosition);
if (!targetCamera) {
console.warn('[VideoCall] 没有可切换的摄像头');
return false;
}
this.currentCamera = targetCamera;
this.videoConfig.cameraPosition = targetPosition;
console.info(`[VideoCall] 切换到${targetPosition === camera.CameraPosition.CAMERA_POSITION_FRONT ? '前置' : '后置'}摄像头`);
return true;
}
// 静音/取消静音
toggleMute(): boolean {
if (!this.audioCapturer) return false;
if (this.callState === CallState.MUTED) {
this.audioCapturer.start();
this.callState = CallState.CONNECTED;
} else {
this.audioCapturer.stop();
this.callState = CallState.MUTED;
}
this.onCallStateChanged?.(this.callState);
return true;
}
// 结束通话
async endCall(): Promise<void> {
// 停止视频采集
if (this.audioCapturer) {
await this.audioCapturer.stop();
await this.audioCapturer.release();
this.audioCapturer = null;
}
// 停止音频播放
if (this.audioRenderer) {
await this.audioRenderer.stop();
await this.audioRenderer.release();
this.audioRenderer = null;
}
this.callState = CallState.ENDED;
this.onCallStateChanged?.(this.callState);
console.info('[VideoCall] 通话已结束');
}
// 获取当前通话状态
getCallState(): CallState {
return this.callState;
}
}
进阶用法:屏幕共享与白板
屏幕共享让参会者看到你的屏幕内容,白板让参会者在同一块画布上协作。
// ScreenShareManager.ets - 屏幕共享管理
import { screenCapture } from '@kit.ScreenCaptureKit';
import { display } from '@kit.DisplayKit';
import { image } from '@kit.ImageKit';
// 屏幕共享状态
export enum ShareState {
IDLE = 'idle',
SHARING = 'sharing',
PAUSED = 'paused',
}
// 白板数据
export interface WhiteboardData {
boardId: string;
strokes: Stroke[]; // 笔画列表
currentPage: number;
totalPages: number;
}
// 笔画
export interface Stroke {
strokeId: string;
points: Point[];
color: string;
width: number;
tool: DrawingTool;
userId: string;
timestamp: number;
}
export interface Point {
x: number;
y: number;
pressure?: number; // 压感
}
// 绘图工具
export enum DrawingTool {
PEN = 'pen',
HIGHLIGHTER = 'highlighter',
ERASER = 'eraser',
SHAPE = 'shape',
TEXT = 'text',
}
export class ScreenShareManager {
private static instance: ScreenShareManager;
private capture: screenCapture.ScreenCapture | null = null;
private shareState: ShareState = ShareState.IDLE;
private whiteboardData: WhiteboardData = {
boardId: 'wb_001',
strokes: [],
currentPage: 1,
totalPages: 1,
};
// 回调
onScreenFrame?: (pixelMap: image.PixelMap) => void;
onWhiteboardUpdate?: (data: WhiteboardData) => void;
onShareStateChanged?: (state: ShareState) => void;
private constructor() {}
static getInstance(): ScreenShareManager {
if (!ScreenShareManager.instance) {
ScreenShareManager.instance = new ScreenShareManager();
}
return ScreenShareManager.instance;
}
// 开始屏幕共享
async startScreenShare(): Promise<boolean> {
try {
// 创建屏幕采集实例
this.capture = screenCapture.createScreenCapture();
// 配置采集参数
const config: screenCapture.ScreenCaptureConfig = {
width: 1920,
height: 1080,
frameRate: 15,
// 排除自身窗口,避免镜像循环
excludeSelf: true,
};
// 开始采集
await this.capture.start(config);
// 监听屏幕帧数据
this.capture.on('frameAvailable', async () => {
const pixelMap = await this.capture?.getPixelMap();
if (pixelMap) {
this.onScreenFrame?.(pixelMap);
}
});
this.shareState = ShareState.SHARING;
this.onShareStateChanged?.(this.shareState);
console.info('[ScreenShare] 屏幕共享已启动');
return true;
} catch (error) {
console.error(`[ScreenShare] 启动失败: ${JSON.stringify(error)}`);
return false;
}
}
// 停止屏幕共享
async stopScreenShare(): Promise<void> {
if (this.capture) {
await this.capture.stop();
this.capture = null;
}
this.shareState = ShareState.IDLE;
this.onShareStateChanged?.(this.shareState);
console.info('[ScreenShare] 屏幕共享已停止');
}
// 暂停/恢复屏幕共享
async togglePauseShare(): Promise<void> {
if (!this.capture) return;
if (this.shareState === ShareState.SHARING) {
await this.capture.stop();
this.shareState = ShareState.PAUSED;
} else if (this.shareState === ShareState.PAUSED) {
await this.capture.start({
width: 1920, height: 1080, frameRate: 15, excludeSelf: true,
});
this.shareState = ShareState.SHARING;
}
this.onShareStateChanged?.(this.shareState);
}
// ========== 白板功能 ==========
// 添加笔画
addStroke(stroke: Stroke): void {
this.whiteboardData.strokes.push(stroke);
this.onWhiteboardUpdate?.(this.whiteboardData);
}
// 撤销最后一笔
undoStroke(userId: string): void {
// 找到该用户最后一笔并移除
for (let i = this.whiteboardData.strokes.length - 1; i >= 0; i--) {
if (this.whiteboardData.strokes[i].userId === userId) {
this.whiteboardData.strokes.splice(i, 1);
break;
}
}
this.onWhiteboardUpdate?.(this.whiteboardData);
}
// 清空白板
clearWhiteboard(userId: string): void {
// 只清除该用户的笔画
this.whiteboardData.strokes = this.whiteboardData.strokes.filter(
s => s.userId !== userId
);
this.onWhiteboardUpdate?.(this.whiteboardData);
}
// 获取白板数据
getWhiteboardData(): WhiteboardData {
return this.whiteboardData;
}
// 应用远程白板更新
applyRemoteUpdate(data: WhiteboardData): void {
this.whiteboardData = data;
this.onWhiteboardUpdate?.(this.whiteboardData);
}
getShareState(): ShareState {
return this.shareState;
}
}
完整示例:视频会议页面
把音视频通话、屏幕共享、白板协作串起来,做一个完整的会议页面。
// MeetingPage.ets - 视频会议页面
import { VideoCallManager, CallState, VideoConfig } from '../meeting/VideoCallManager';
import { ScreenShareManager, ShareState, Stroke, Point, DrawingTool } from '../meeting/ScreenShareManager';
import { emitter } from '@kit.BasicServicesKit';
@Entry
@Component
struct MeetingPage {
@State callState: CallState = CallState.IDLE;
@State shareState: ShareState = ShareState.IDLE;
@State isMuted: boolean = false;
@State isCameraOff: boolean = false;
@State showWhiteboard: boolean = false;
@State meetingTime: string = '00:00';
@State participantCount: number = 3;
@State currentStrokes: Stroke[] = [];
// 绘图状态
private drawingPoints: Point[] = [];
private strokeColor: string = '#FF0000';
private strokeWidth: number = 3;
private currentTool: DrawingTool = DrawingTool.PEN;
// 会议计时器
private meetingStartTime: number = 0;
private timerId: number = -1;
// 管理器
private callManager: VideoCallManager = VideoCallManager.getInstance();
private shareManager: ScreenShareManager = ScreenShareManager.getInstance();
aboutToAppear(): void {
// 注册状态回调
this.callManager.onCallStateChanged = (state: CallState) => {
this.callState = state;
this.isMuted = state === CallState.MUTED;
};
this.shareManager.onShareStateChanged = (state: ShareState) => {
this.shareState = state;
};
}
aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
}
}
build() {
Stack() {
Column() {
// 视频区域
if (!this.showWhiteboard) {
this.VideoArea()
} else {
this.WhiteboardArea()
}
// 底部工具栏
this.ToolBar()
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#1A1A2E')
}
// 视频区域
@Builder
VideoArea() {
Stack() {
// 远端视频(大画面)
Column() {
Text('远端视频画面')
.fontSize(16)
.fontColor('#FFFFFF')
.opacity(0.6)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#2D2D44')
// 本地视频(小窗)
Column() {
Text('本地视频')
.fontSize(12)
.fontColor('#FFFFFF')
.opacity(0.6)
}
.width(120)
.height(160)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#3D3D5C')
.borderRadius(12)
.position({ x: 16, y: 16 })
.shadow({ radius: 8, color: '#40000000', offsetY: 2 })
// 会议信息
Column() {
Text(`会议中 · ${this.participantCount}人`)
.fontSize(14)
.fontColor('#FFFFFF')
.opacity(0.8)
Text(this.meetingTime)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 4 })
}
.position({ x: '50%', y: 16 })
.translate({ x: '-50%' })
// 屏幕共享指示
if (this.shareState === ShareState.SHARING) {
Row() {
Circle().width(8).height(8).fill('#FF4444')
Text('屏幕共享中')
.fontSize(12)
.fontColor('#FFFFFF')
.margin({ left: 6 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor('#333355')
.position({ x: '50%', y: 80 })
.translate({ x: '-50%' })
}
}
.layoutWeight(1)
}
// 白板区域
@Builder
WhiteboardArea() {
Stack() {
// 白板画布
Canvas(this.getWhiteboardContext())
.width('100%')
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.onTouch((event: TouchEvent) => {
this.handleWhiteboardTouch(event);
})
// 白板工具栏
Row() {
ForEach([
{ tool: DrawingTool.PEN, icon: '✏️', label: '画笔' },
{ tool: DrawingTool.HIGHLIGHTER, icon: '🖍️', label: '荧光笔' },
{ tool: DrawingTool.ERASER, icon: '🧹', label: '橡皮' },
], (item: { tool: DrawingTool, icon: string, label: string }) => {
Column() {
Text(item.icon).fontSize(20)
Text(item.label)
.fontSize(10)
.fontColor(this.currentTool === item.tool ? '#1565C0' : '#666666')
}
.padding(8)
.borderRadius(8)
.backgroundColor(this.currentTool === item.tool ? '#E3F2FD' : Color.Transparent)
.onClick(() => { this.currentTool = item.tool; })
})
// 颜色选择
Row() {
ForEach(['#FF0000', '#0000FF', '#00AA00', '#000000'], (color: string) => {
Circle()
.width(20)
.height(20)
.fill(color)
.border({ width: this.strokeColor === color ? 2 : 0, color: '#FFFFFF' })
.margin({ left: 4, right: 4 })
.onClick(() => { this.strokeColor = color; })
})
}
.margin({ left: 8 })
// 撤销
Text('撤销')
.fontSize(14)
.fontColor('#1565C0')
.margin({ left: 12 })
.onClick(() => {
this.shareManager.undoStroke('current_user');
})
}
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#F5F5F5')
.borderRadius(8)
.position({ x: '50%', y: 16 })
.translate({ x: '-50%' })
}
.layoutWeight(1)
}
// 底部工具栏
@Builder
ToolBar() {
Row() {
// 静音
this.ToolButton(
this.isMuted ? $r('app.media.ic_mic_off') : $r('app.media.ic_mic_on'),
this.isMuted ? '取消静音' : '静音',
this.isMuted,
() => this.callManager.toggleMute()
)
// 摄像头开关
this.ToolButton(
this.isCameraOff ? $r('app.media.ic_camera_off') : $r('app.media.ic_camera_on'),
this.isCameraOff ? '打开摄像头' : '关闭摄像头',
this.isCameraOff,
() => { this.isCameraOff = !this.isCameraOff; }
)
// 屏幕共享
this.ToolButton(
$r('app.media.ic_screen_share'),
this.shareState === ShareState.SHARING ? '停止共享' : '共享屏幕',
this.shareState === ShareState.SHARING,
() => this.toggleScreenShare()
)
// 白板
this.ToolButton(
$r('app.media.ic_whiteboard'),
this.showWhiteboard ? '关闭白板' : '打开白板',
this.showWhiteboard,
() => { this.showWhiteboard = !this.showWhiteboard; }
)
// 挂断
Column() {
Image($r('app.media.ic_call_end'))
.width(28).height(28)
.fillColor('#FFFFFF')
Text('挂断')
.fontSize(10)
.fontColor('#FFFFFF')
.margin({ top: 4 })
}
.width(56).height(56)
.justifyContent(FlexAlign.Center)
.borderRadius(28)
.backgroundColor('#C62828')
.onClick(() => this.endMeeting())
}
.width('100%')
.height(88)
.justifyContent(FlexAlign.SpaceEvenly)
.padding({ bottom: 16 })
.backgroundColor('#1A1A2E')
}
// 工具按钮
@Builder
ToolButton(icon: Resource, label: string, isActive: boolean, action: () => void) {
Column() {
Image(icon)
.width(28).height(28)
.fillColor(isActive ? '#FF4444' : '#FFFFFF')
Text(label)
.fontSize(10)
.fontColor(isActive ? '#FF4444' : '#FFFFFF')
.margin({ top: 4 })
}
.width(56).height(56)
.justifyContent(FlexAlign.Center)
.borderRadius(28)
.backgroundColor(isActive ? '#3D1111' : '#2D2D44')
.onClick(action)
}
// 切换屏幕共享
private async toggleScreenShare(): Promise<void> {
if (this.shareState === ShareState.IDLE) {
await this.shareManager.startScreenShare();
} else {
await this.shareManager.stopScreenShare();
}
}
// 白板触摸处理
private handleWhiteboardTouch(event: TouchEvent): void {
const touch = event.touches[0];
const point: Point = { x: touch.x, y: touch.y };
if (event.type === TouchType.Down) {
this.drawingPoints = [point];
} else if (event.type === TouchType.Move) {
this.drawingPoints.push(point);
} else if (event.type === TouchType.Up) {
// 完成一笔,添加到白板
const stroke: Stroke = {
strokeId: `stroke_${Date.now()}`,
points: this.drawingPoints,
color: this.strokeColor,
width: this.strokeWidth,
tool: this.currentTool,
userId: 'current_user',
timestamp: Date.now(),
};
this.shareManager.addStroke(stroke);
this.drawingPoints = [];
}
}
// 结束会议
private async endMeeting(): Promise<void> {
await this.callManager.endCall();
await this.shareManager.stopScreenShare();
if (this.timerId !== -1) {
clearInterval(this.timerId);
}
console.info('[Meeting] 会议已结束');
}
// 获取白板绘图上下文(简化)
private getWhiteboardContext(): CanvasRenderingContext2D {
// 实际实现中需要创建并管理Canvas上下文
return new CanvasRenderingContext2D(new Settings());
}
}
踩坑与注意事项
坑1:摄像头权限和麦克风权限必须同时申请
视频会议需要摄像头和麦克风两个权限。如果你分开申请,用户可能只给了摄像头权限没给麦克风权限——结果就是有画面没声音。
建议:在会议开始前一次性申请所有权限,任何一个没给都不进入会议。
坑2:后台采集会被系统杀掉
用户把App切到后台,摄像头和麦克风采集会被系统暂停。如果会议正在进行中,切出去回个微信,回来发现会议断了。
解决方案:申请长时任务(Continuous Task),保持后台采集。但要注意,长时任务会显示通知栏提示,用户可能手动关掉。
坑3:回声消除不是万能的
AEC回声消除在安静环境下效果很好,但在以下场景会失效:
- 外放音量太大,麦克风收到的回声信号超过AEC处理能力
- 两个人在同一房间开会,两个设备互相回声
- 使用蓝牙耳机,延迟太大导致AEC来不及处理
建议:默认使用听筒模式或耳机,外放时自动降低扬声器音量。
坑4:屏幕共享的隐私问题
屏幕共享时,如果通知弹出来,通知内容会被所有参会者看到。微信消息、短信验证码、邮件标题——都可能泄露。
解决方案:共享前自动开启"勿扰模式",屏蔽通知。共享结束后自动恢复。或者只共享指定窗口,不共享整个屏幕。
坑5:白板同步的延迟
白板协作要求实时同步,但网络延迟可能导致笔画不同步。你画了一条线,同事1秒后才看到。
解决方案:
- 本地先渲染,不等服务端确认
- 使用WebSocket长连接,减少握手延迟
- 笔画数据压缩后传输,减少带宽占用
坑6:多人会议的布局问题
2人通话很简单,一人一个画面。3人呢?4人呢?9人呢?
建议:
- 2-4人:宫格布局,每人一个等大方格
- 5-9人:1大+8小,说话的人大画面
- 10人以上:画廊模式,只显示正在说话的人
HarmonyOS 6适配说明
HarmonyOS 6对视频会议相关能力的增强:
- AI降噪增强:音频采集新增AI降噪能力,可以消除键盘声、空调声、敲桌声等非人声噪音,只保留人声。
// HarmonyOS 6 AI降噪
import { audio } from '@kit.AudioKit';
// 创建带AI降噪的音频采集器
const capturerOptions: audio.AudioCapturerOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
channels: audio.AudioChannel.CHANNEL_1,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW,
},
capturerInfo: {
source: audio.SourceType.SOURCE_TYPE_MIC,
capturerFlags: audio.AudioCapturerFlag.AI_NOISE_SUPPRESSION, // AI降噪标志
},
};
-
虚拟背景:摄像头新增虚拟背景能力,自动抠图替换背景,不需要绿幕。
-
分布式会议:会议可以在设备间流转。手机上开会,走到办公室自动流转到智慧屏,画面更大体验更好。
-
会议录制增强:录制时自动生成会议纪要,AI识别发言内容,提取关键信息和待办事项。
-
低功耗模式:新增低功耗视频会议模式,降低帧率和分辨率,延长续航。适合长时间会议场景。
总结
视频会议的五步链路——采集、编码、传输、解码、渲染,每一步都可能出问题。音质不好查AEC,画质不好查编码参数,卡顿查弱网对抗,共享黑屏查权限和配置。
核心记住三点:
- 权限要提前申请,摄像头、麦克风、屏幕录制缺一不可,分开申请容易漏
- 回声消除要重视,不用耳机开会时AEC是唯一防线,参数调不好就是回声地狱
- 弱网对抗要做,丢包5%以上没有FEC和ARQ,视频就卡成PPT
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐⭐ 音视频编解码、弱网对抗、AEC,每个都是深水区 |
| 使用频率 | ⭐⭐⭐⭐ 远程办公标配,但不是每个App都需要 |
| 重要程度 | ⭐⭐⭐⭐⭐ 做好了是亮点,做不好用户直接用腾讯会议 |
视频会议是技术含量最高的OA功能之一。没准备好就别碰——先做好预览和文档协作,视频会议可以后期集成。
- 点赞
- 收藏
- 关注作者
评论(0)