HarmonyOS开发:会议应用——视频会议

举报
Jack20 发表于 2026/06/26 16:49:32 2026/06/26
【摘要】 HarmonyOS开发:会议应用——视频会议📌 核心要点:视频会议不是简单的"打开摄像头"——音视频采集编解码、弱网对抗、屏幕共享、白板协作,每个环节都有坑,一个没处理好就是"听不清"“看不到”“卡死了”。 背景与动机你参加视频会议,对方的声音断断续续,像在水下说话。画面卡成PPT,一秒一帧。你共享屏幕给同事看方案,结果对方看到的是黑屏。你在白板上画了个流程图,同事那边只看到一堆乱线。用...

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对视频会议相关能力的增强:

  1. 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降噪标志
  },
};
  1. 虚拟背景:摄像头新增虚拟背景能力,自动抠图替换背景,不需要绿幕。

  2. 分布式会议:会议可以在设备间流转。手机上开会,走到办公室自动流转到智慧屏,画面更大体验更好。

  3. 会议录制增强:录制时自动生成会议纪要,AI识别发言内容,提取关键信息和待办事项。

  4. 低功耗模式:新增低功耗视频会议模式,降低帧率和分辨率,延长续航。适合长时间会议场景。

总结

视频会议的五步链路——采集、编码、传输、解码、渲染,每一步都可能出问题。音质不好查AEC,画质不好查编码参数,卡顿查弱网对抗,共享黑屏查权限和配置。

核心记住三点:

  • 权限要提前申请,摄像头、麦克风、屏幕录制缺一不可,分开申请容易漏
  • 回声消除要重视,不用耳机开会时AEC是唯一防线,参数调不好就是回声地狱
  • 弱网对抗要做,丢包5%以上没有FEC和ARQ,视频就卡成PPT
评估维度 说明
学习难度 ⭐⭐⭐⭐⭐ 音视频编解码、弱网对抗、AEC,每个都是深水区
使用频率 ⭐⭐⭐⭐ 远程办公标配,但不是每个App都需要
重要程度 ⭐⭐⭐⭐⭐ 做好了是亮点,做不好用户直接用腾讯会议

视频会议是技术含量最高的OA功能之一。没准备好就别碰——先做好预览和文档协作,视频会议可以后期集成。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。