在React Native上使用Agora开发多频道音视频应用

自从Agora SDK for React Native v3.0.0发布后,现在用户可以同时加入无限数量的频道。但你同时只能向一个频道发布自己的摄像头视频流。

这种功能在多房间的情况下真的很方便,你既可以发送和接收主房间的音视频,同时也可以接收次要房间的音视频

我们将使用Agora RTC SDK for React Native 作为我们的例子

在深入了解它的工作原理之前,我们先来看看几个关键点

  • 我们将使用SDK连接到第一个频道并正常加入音视频通话。推送我们的视频流,同时也会接收该频道上其他用户的视频。

  • 接下来,我们将加入第二个频道来接收该频道所有用户的直播。注意,频道2上的用户将无法接收我们的视频。

  • 两个频道是分开的:频道1和频道2上的用户看不到对方。我们可以扩展此功能来加入所需的更多频道。

示例程序结构

这就是应用的结构:

.
├── android
├── components
│ └── Permission.ts
│ └── Style.ts
├── ios
├── App.tsx
.

下载源码

如果你想跳到代码中自己尝试一下,可以看看自述文件了解如何运行app的步骤。代码在GitHub上开源。此App使用channel-1和channel-2作为频道名称。

当你运行App时,你会看到两个按钮:一个是加入通话,一个是结束通话。当你点击开始通话时,你应该会在最上面一行看到你的视频,其中包含频道1的视频。而底行包含来自频道2的视频。

注意:本指南没有实现Token鉴权,建议所有在生产环境中运行的RTE应用都采用Token鉴权。有关Agora平台内基于Token鉴权的更多信息,请参考校验用户权限

应用程序如何工作

App.tsx
App.tsx将是进入应用程序的入口。我们的所有代码都在这个文件中:

import React, { Component } from 'react';
import { Platform, ScrollView, Text, TouchableOpacity, View } from 'react-native';
import RtcEngine, { RtcChannel, RtcLocalView, RtcRemoteView, VideoRenderMode } from 'react-native-agora';
import requestCameraAndAudioPermission from './components/Permission';
import styles from './components/Style';

interface Props {}

/**
 * @property appId Used to 
 * @property token Used to join a channel
 * @property channelNameOne Channel Name for the current session
 * @property channelNameTwo Second Channel Name for the current session
 * @property joinSucceed State variable for storing success
 * @property peerIdsOne Array for storing connected peers on first channel
 * @property peerIdsTwo Array for storing connected peers on second channel
 */
interface State {
  appId: string;
  token: string | null;
  channelNameOne: string;
  channelNameTwo: string;
  joinSucceed: boolean;
  peerIdsOne: number[];
  peerIdsTwo: number[];
}

我们先编写import声明。接下来,为我们的应用状态定义一个接口,包含以下内容:

  • appId: Agora App ID

  • token:为加入该频道而生成的Token

  • channelNameOne:频道1的名称

  • channelNameTwo: 频道2的名称

  • joinSucceed:如果我们连接成功,就用这个布尔值来存储

  • peerIdsOne: 数组,用于存储频道1中其他用户的UID

  • peerIdsTwo: 数组,用于存储频道2中其他用户的UID

      ...
    
      export default class App extends Component<Props, State> {
        _engine?: RtcEngine;
        _channel?: RtcChannel;
    
        constructor(props) {
          super(props);
          this.state = {
            appId: 'ENTER YOUR APP ID',
            token: null,                                                //using token as null for App ID without certificate
            channelNameOne: 'channel-1',
            channelNameTwo: 'channel-2',
            joinSucceed: false,
            peerIdsOne: [],
            peerIdsTwo: [],
          };
          if (Platform.OS === 'android') {
            // Request required permissions from Android
            requestCameraAndAudioPermission().then(() => {
              console.log('requested!');
            });
          }
        }
    
        componentDidMount() {
          this.init();
        }
    
        componentWillUnmount() {
          this.destroy();
        }
        
      ...
    

我们定义一个基于类的组件:_rtcEngine变量将存储RtcEngine类的实例,_channel变量将存储RtcChannel类的实例,我们可以用它来访问SDK函数。

在构造函数中,设置我们的状态变量,并申请在Android上录制音频的权限。(使用权限中的帮助函数,如下所述)。当组件被挂载时,调用init函数,它初始化RTC引擎和RTC频道。当组件卸载时,销毁我们的引擎和频道实例。

RTC初始化

...
  /**
   * @name init
   * @description Function to initialize the Rtc Engine, attach event listeners and actions
   */
  init = async () => {
    const { appId, channelNameTwo } = this.state;
    this._engine = await RtcEngine.create(appId);
    this._channel = await RtcChannel.create(channelNameTwo);
    await this._engine.enableVideo();

    this._engine.addListener('Error', (err) => {
      console.log('Error', err);
    });

    this._channel.addListener('Error', (err) => {
      console.log('Error', err);
    });

    this._engine.addListener('UserJoined', (uid, elapsed) => {
      console.log('UserJoined', uid, elapsed);
      // Get current peer IDs
      const { peerIdsOne } = this.state;
      // If new user
      if (peerIdsOne.indexOf(uid) === -1) {
        this.setState({
          // Add peer ID to state array
          peerIdsOne: [...peerIdsOne, uid],
        });
      }
    });

    this._engine.addListener('UserOffline', (uid, reason) => {
      console.log('UserOffline', uid, reason);
      const { peerIdsOne } = this.state;
      this.setState({
        // Remove peer ID from state array one
        peerIdsOne: peerIdsOne.filter((id) => id !== uid),
      });
    });

    this._channel.addListener('UserJoined', (uid, elapsed) => {
      console.log('UserJoined', uid, elapsed);
      // Get current peer IDs
      const { peerIdsTwo } = this.state;
      // If new user
      if (peerIdsTwo.indexOf(uid) === -1) {
        this.setState({
          // Add peer ID to state array
          peerIdsTwo: [...peerIdsTwo, uid],
        });
      }
    });

    this._channel.addListener('UserOffline', (uid, reason) => {
      console.log('UserOffline', uid, reason);
      const { peerIdsTwo } = this.state;
      this.setState({
        // Remove peer ID from state array two
        peerIdsTwo: peerIdsTwo.filter((id) => id !== uid),
      });
    });

    // If Local user joins RTC channel
    this._channel.addListener('JoinChannelSuccess', (channel, uid, elapsed) => {
      console.log('JoinChannelSuccess', channel, uid, elapsed);
      // Set state variable to true
      this.setState({
        joinSucceed: true,
      });
    });
  };
...

我们使用App ID来创建我们的引擎实例。该引擎实例将用于连接到频道1,在那里我们同时发送和接收视频。我们还使用第二个频道的名称来创建我们的频道实例。频道实例将仅用于接收来自频道2的视频。

当我们加入频道时,RTC会对每个在场的用户以及之后加入的每个新用户触发一个userJoined事件。当用户离开频道时,会触发userOffline事件。我们在 _engine 和 _channel 上使用事件监听器来存储和维护 peerIdsOne 和 peerIdsTwo 数组,这两个数组中包含了两个频道中用户的 UID。

我们还为joinChannelSuccess附加了一个监听器来更新我们的状态变量,这个状态变量是在调用中时用来渲染UI的。

我们按钮的功能

...
  /**
   * @name startCall
   * @description Function to start the call
   */
  startCall = async () => {
    // channelOptions object used to auto subscribe to remote streams on second channel
    let channelOptions = {
      autoSubscribeAudio: true,
      autoSubscribeVideo: true,
    };

    // Join Channel One using RtcEngine object, null token and channel name and UID as 0 to have the SDK auto generate it
    await this._engine?.joinChannel(
      this.state.token,
      this.state.channelNameOne,
      null,
      0
    );

    // Join Channel Two using RtcChannel object, null token, uid as 0, channel name and channelOptions object
    await this._channel?.joinChannel(this.state.token, null, 0, channelOptions);
  };

  /**
   * @name endCall
   * @description Function to end the call by leaving both channels
   */
  endCall = async () => {
    await this._engine?.leaveChannel();
    await this._channel?.leaveChannel();
    this.setState({ peerIdsOne: [], peerIdsTwo: [], joinSucceed: false });
  };

  /**
   * @name destroy
   * @description Function to destroy the RtcEngine and RtcChannel instances
   */
  destroy = async () => {
    await this._channel?.destroy();
    await this._engine?.destroy();
  };
...

startCall 函数使用joinChannel方法加入两个频道。

endCall 函数使用leaveChannel方法离开两个频道并更新状态。

destroy 函数销毁了我们的引擎和频道的实例。

渲染用户界面

 ...

  render() {
    return (
      <View style={styles.max}>
        <View style={styles.max}>
          <View style={styles.buttonHolder}>
            <TouchableOpacity onPress={this.startCall} style={styles.button}>
              <Text style={styles.buttonText}> Start Call </Text>
            </TouchableOpacity>
            <TouchableOpacity onPress={this.endCall} style={styles.button}>
              <Text style={styles.buttonText}> End Call </Text>
            </TouchableOpacity>
          </View>
          {this._renderVideos()}
        </View>
      </View>
    );
  }

  _renderVideos = () => {
    const { joinSucceed } = this.state;
    return joinSucceed ? (
      <View style={styles.fullView}>
        {this._renderRemoteVideosOne()}
        {this._renderRemoteVideosTwo()}
      </View>
    ) : null;
  };

  _renderRemoteVideosOne = () => {
    const { peerIdsOne } = this.state;
    return (
      <ScrollView
        style={styles.scrollHolder}
        contentContainerStyle={styles.scrollView}
        horizontal={true}
      >
        <RtcLocalView.SurfaceView
          style={styles.remote}
          channelId={this.state.channelNameOne}
          renderMode={VideoRenderMode.Hidden}
        />
        {peerIdsOne.map((value) => {
          return (
            <RtcRemoteView.SurfaceView
              style={styles.remote}
              uid={value}
              channelId={this.state.channelNameOne}
              renderMode={VideoRenderMode.Hidden}
              zOrderMediaOverlay={true}
              key={value}
            />
          );
        })}
      </ScrollView>
    );
  };

  _renderRemoteVideosTwo = () => {
    const { peerIdsTwo } = this.state;
    return (
      <ScrollView
        style={styles.scrollHolder}
        contentContainerStyle={styles.scrollView}
        horizontal={true}
      >
        {peerIdsTwo.map((value) => {
          return (
            <RtcRemoteView.SurfaceView
              style={styles.remote}
              uid={value}
              channelId={this.state.channelNameTwo}
              renderMode={VideoRenderMode.Hidden}
              zOrderMediaOverlay={true}
              key={value}
            />
          );
        })}
      </ScrollView>
    );
  };
}

我们定义了渲染函数,用于显示开始和结束通话的按钮,并显示两个频道的用户视频。
我们定义了一个_renderVideos函数来渲染我们两个频道中使用频道1和频道2的_renderRemoteVideosOne和_renderRemoteVideosTwo函数的视频,每个函数都包含scrollViews用来显示频道的音视频。我们使用存储在peerId数组中的UID,通过传递给RtcRemoteView.SurfaceView组件来渲染远程用户的视频。

权限

import {PermissionsAndroid} from 'react-native'

/**
 * @name requestCameraAndAudioPermission
 * @description Function to request permission for Audio and Camera
 */
export default async function requestCameraAndAudioPermission() {
    try {
        const granted = await PermissionsAndroid.requestMultiple([
            PermissionsAndroid.PERMISSIONS.CAMERA,
            PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
        ])
        if (
            granted['android.permission.RECORD_AUDIO'] === PermissionsAndroid.RESULTS.GRANTED
            && granted['android.permission.CAMERA'] === PermissionsAndroid.RESULTS.GRANTED
        ) {
            console.log('You can use the cameras & mic')
        } else {
            console.log('Permission denied')
        }
    } catch (err) {
        console.warn(err)
    }
}

我们正在导出一个帮助函数来申请Android操作系统中的麦克风权限。

样式

import { Dimensions, StyleSheet } from 'react-native';

const dimensions = {
  width: Dimensions.get('window').width,
  height: Dimensions.get('window').height,
};

export default StyleSheet.create({
  max: {
    flex: 1,
  },
  buttonHolder: {
    height: 100,
    marginVertical: 15,
    alignItems: 'center',
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'space-evenly',
  },
  button: {
    paddingHorizontal: 20,
    paddingVertical: 10,
    backgroundColor: '#0093E9',
    borderRadius: 25,
  },
  buttonText: {
    color: '#fff',
  },
  fullView: {
    width: dimensions.width,
    height: dimensions.height - 130,
  },
  remote: {
    width: (dimensions.height - 150) / 2,
    height: (dimensions.height - 150) / 2,
    marginHorizontal: 2.5,
  },
  scrollView: {
    paddingHorizontal: 2.5, justifyContent: 'center', alignItems: 'center'
  },
  scrollHolder: { flex: 1, borderWidth: 1 },
});

Style.ts 文件包含了组件的样式。

结论

这就是我们如何构建一个可以同时连接两个频道的音视频通话App的方法。你可以通过React Native API 参考来查看可以帮助你快速添加其他功能的方法,比如静音麦克风、设置音频配置文件和音频混合。

获取更多文档、Demo、技术帮助

image

推荐阅读
作者信息
Sophia
TA 暂未填写个人简介
文章
10
相关专栏
SDK 教程
55 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。