在React Native上使用Agora RTM开发动态频道的视频聊天

Agora RTC(实时音视频)SDK可以让我们在React Native上轻松创建视频聊天App。我们可以通过让视频聊天室使用相同的频道名让多个用户相互交流。

如果你正在创建一个社交视频聊天App,你可能想让用户生成房间供其他用户浏览、加入和交流。你可以使用后端服务器来处理这些请求并向其他用户更新已创建的房间的信息,但这需要编写后端代码并托管你自己的服务器。

本教程中,我们将介绍通过使用Agora RTM(云信令)SDK来实现同样目标的另一种方法。我们将使用用户发送的消息来进行动态视频聊天室的创建和更新,并且使用前端代码来完成。

这样做的好处在于,当你不想建立一个后端服务器时,你可以使用消息来更新其他用户的房间状态。这种方法可以很容易地扩展到完全的房间管理,以及管理允许/拒绝一个用户、让另一个用户静音、将一个用户从房间中移除等功能。

在这个例子中,我们将使用Agora RTC SDKAgora RTM SDK for React Native。在写这篇文章的时候,我使用的是RTC SDK的v3.2.2和RTM SDK的v1.2.2-alpha.3。

项目概述

  • 我们有一个叫 "大厅 "的RTM房间。当有人创建新房间或房间里的成员发生变化时,我们会用它来向用户发出信号。
  • 我们将通过让视频聊天室中最资深的成员向其他人发送消息来实现。房间里最早的成员被认为是资深成员,稍后会有更多的介绍。
  • 我们将以’roomName:memberCount’的形式发送消息,它可以被其他用户处理,并且在他们的应用程序状态中存储房间名和成员计数作为字典。我们将用它来渲染一个房间的列表,其中包含了成员的数量。
  • 一旦我们有了列表,我们就可以通过使用RTC SDK加入房间。我们还需要监听用户的加入和离开,以更新其他人的成员数。这一点只有资深成员才能完成,以避免开销。
  • 我们还需要考虑两种情况为其他用户更新房间信息。第一,当有新用户加入大厅时,每个频道中资历最深的成员会给该用户发送一条同行消息。第二,当一个频道的成员数更新时,我们会给所有连接到大厅的用户发送一条频道消息来更新他们的房间列表。

创建一个Agora账户

注册地址:声网开发者账户注册指南,登录后台。

image
The Project Management tab on the website

导航到 "项目管理 "选项卡下的 "项目列表 "选项卡,点击蓝色的 "创建 "按钮,创建一个项目。(当提示使用App ID+证书时,只选择App ID。)App ID将在你开发应用时用来授权你的请求,而不需要生成Token。把App ID复制到安全的地方,我们一会儿就会用到它。

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

下载源码

你也可以选择直接跳转到代码。代码是开放源码,可以在GitHub上找到。如果自己想尝试一下,请参阅自述文件了解如何运行该应用程序的步骤。


Screenshots from the app running on an Android Emulator

我们的例子结构

这就是我们正在构建的应用程序的结构:

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

App.tsx

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

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

interface Props {}

/**
 * @property appId Agora App ID as string
 * @property token Token for the channel
 * @property channelName Channel Name for the current session
 * @property inCall Boolean to store if we're in an active video chat room
 * @property inLobby Boolean to store if we're in the lobby
 * @property input String to store input
 * @property peerIds Array for storing connected peers during a video chat
 * @property seniors Array storing senior members in the joined channel
 * @property myUsername Username to log in to RTM
 * @property rooms Dictionary to store room names and their member count
 */
interface State {
  appId: string;
  token: string | null;
  channelName: string;
  inCall: boolean;
  inLobby: boolean;
  input: string;
  peerIds: number[];
  seniors: string[];
  myUsername: string;
  rooms: {[name: string]: number};
}
...

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

  • appId:Agora App ID

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

  • inCall:用于存储我们是否在活动的视频聊天室中的布尔值。

  • inLobby:存储我们是否在大厅的布尔值。

  • input:创建新房间时用于存储输入的字符串。

  • peerIdsRTC:用于存储视频聊天室中其他用户的RTC UID的数组。

  • seniors: 储存在我们之前加入视频聊天室的RTM成员的数组。

  • myUsername:登录RTM的本地用户名称。

  • 房间:存储房间名称及其成员数的字典。

            ...
            export default class App extends Component<null, State> {
            _rtcEngine?: RtcEngine;
            _rtmEngine?: RtmEngine;
    
            constructor(props: any) {
              super(props);
              this.state = {
                appId: '30a6bc89994d4222a71eba01c253cbc7',
                token: null,
                channelName: '',
                inCall: false,
                input: '',
                inLobby: false,
                peerIds: [],
                seniors: [],
                myUsername: '' + new Date().getTime(),
                rooms: {},
              };
              if (Platform.OS === 'android') {
                // Request required permissions from Android
                requestCameraAndAudioPermission().then(() => {
                  console.log('requested!');
                });
              }
            }
    
            componentDidMount() {
              // initialize the SDKs
              this.initRTC();
              this.initRTM();
            }
    
            componentWillUnmount() {
              // destroy the engine instances
              this._rtmEngine?.destroyClient();
              this._rtcEngine?.destroy();
            }
       ...
    

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

在构造函数中,设置我们的状态变量,并申请在Android上录制音频的权限。(我们使用下面描述的 permission.ts 中的帮助函数)。当组件被挂载时,我们调用 initRTCinitRTM 函数,这样能使用App ID初始化RTC和RTM引擎。当组件卸载时,我们销毁我们的引擎实例。

RTC初始化

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

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

    this._rtcEngine.addListener('UserJoined', (uid) => {
      // Get current peer IDs
      const {peerIds, inCall, seniors, channelName} = this.state;
      // If new user
      if (peerIds.indexOf(uid) === -1) {
        if (inCall && seniors.length < 2) {
          this._rtmEngine?.sendMessageByChannelId(
            'lobby',
            channelName + ':' + (peerIds.length + 2),
          );
        }
        this.setState({
          // Add peer ID to state array
          peerIds: [...peerIds, uid],
        });
      }
    });

    this._rtcEngine.addListener('UserOffline', (uid) => {
      const {peerIds} = this.state;
      this.setState({
        // Remove peer ID from state array
        peerIds: peerIds.filter((id) => id !== uid),
      });
    });

    // If Local user joins RTC channel
    this._rtcEngine.addListener(
      'JoinChannelSuccess',
      (channel, uid, elapsed) => {
        console.log('JoinChannelSuccess', channel, uid, elapsed);
        this.setState({
          inCall: true,
        });
      },
    );
  };
...

我们使用App ID来创建我们的引擎实例。我们使用 enableVideo 方法设置视频模式下的SDK。

当我们加入频道时,RTC为每个在场的用户和后来加入的新用户触发一个 userJoined 事件。当用户离开频道时,会触发 userOffline 事件。我们使用事件监听器来更新我们的peerIds数组中的UID。我们稍后将使用这个数组来渲染其他用户的视频源。

一旦我们加入了一个频道,SDK就会触发 JoinChannelSuccess 事件。我们将状态变量 inCall 设置 为true,以渲染视频聊天用户界面。

当有新用户加入我们的视频聊天室时,如果我们是之前讨论过的资深成员,我们会用 lobby RTM频道向各频道的所有成员发送更新的用户数的频道消息。

RTM初始化

...
  /**
   * @name initRTM
   * @description Function to initialize the Rtm Engine, attach event listeners and use them to sync usernames
   */
  initRTM = async () => {
    let {appId, myUsername} = this.state;
    this._rtmEngine = new RtmEngine();

    this._rtmEngine.on('error', (evt) => {
      console.log(evt);
    });

    this._rtmEngine.on('channelMemberJoined', (evt) => {
      let {channelName, seniors, peerIds, inCall} = this.state;
      let {channelId, uid} = evt;
      // if we're in call and receive a lobby message and also we're the senior member (oldest member in the channel), signal channel status to joined peer
      if (inCall && channelId === 'lobby' && seniors.length < 2) {
        this._rtmEngine
          ?.sendMessageToPeer({
            peerId: uid,
            text: channelName + ':' + (peerIds.length + 1),
            offline: false,
          })
          .catch((e) => console.log(e));
      }
    });

    this._rtmEngine.on('channelMemberLeft', (evt) => {
      let {channelId, uid} = evt;
      let {channelName, seniors, inCall, peerIds, rooms} = this.state;
      if (channelName === channelId) {
        // Remove seniors UID from state array
        this.setState({
          seniors: seniors.filter((id) => id !== uid),
          rooms: {...rooms, [channelName]: peerIds.length},
        });
        if (inCall && seniors.length < 2) {
          // if we're in call and we're the senior member (oldest member in the channel), signal channel status to all users
          this._rtmEngine
            ?.sendMessageByChannelId(
              'lobby',
              channelName + ':' + (peerIds.length + 1),
            )
            .catch((e) => console.log(e));
        }
      }
    });

    this._rtmEngine.on('channelMessageReceived', (evt) => {
      // received message is of the form - channel:membercount, add it to the state
      let {text} = evt;
      let data = text.split(':');
      this.setState({rooms: {...this.state.rooms, [data[0]]: data[1]}});
    });

    this._rtmEngine.on('messageReceived', (evt) => {
      // received message is of the form - channel:membercount, add it to the state
      let {text} = evt;
      let data = text.split(':');
      this.setState({rooms: {...this.state.rooms, [data[0]]: data[1]}});
    });

    await this._rtmEngine.createClient(appId).catch((e) => console.log(e));
    await this._rtmEngine
      ?.login({uid: myUsername})
      .catch((e) => console.log(e));
    await this._rtmEngine?.joinChannel('lobby').catch((e) => console.log(e));
    this.setState({inLobby: true});
  };
...

我们用RTM发送我们的房名和会员数。我们维护了一个资深成员的数组,也就是在我们之前加入通话的成员。如果资深成员数<2,说明我们是最老的成员,负责发送信号。(本地用户也是数组的一部分)。

首先,我们附加 channelMemberJoinedchannelMemberLeft 监听器,当用户加入或离开RTM频道时,会触发这两个监听器。当用户加入大厅频道时,如果我们是资历最深的成员,我们会给他们发送一条对等消息。如果资深成员离开当前的视频聊天频道,我们会更新数组(如果他们在我们之前到达,我们会从数组中删除他们)。如果我们是更新计数的资深成员,我们也会向大厅发送一个频道消息。

接下来,我们附加 channelMessageReceivedmessageReceived 事件监听器, 它们分别在我们收到频道消息和对等消息时触发。我们拆分 channelName:memberCount 字符串(例如, ‘helloWorld:5’ ), 并使用这两块数据来更新我们的字典。(例如,房间: rooms: { ‘helloWorld’: 5, ‘roomTwo’: 3 } )。

加入一个通话

...
  /**
    * @name joinCall
   * @description Function to join a room and start the call
   */
  joinCall = async (channelName: string) => {
    this.setState({channelName});
    let {token} = this.state;
    // Join RTC Channel using null token and channel name
    await this._rtcEngine?.joinChannel(token, channelName, null, 0);
    await this._rtmEngine
      ?.joinChannel(channelName)
      .catch((e) => console.log(e));
    let {members} = await this._rtmEngine?.getChannelMembersBychannelId(
      channelName,
    );
    // if we're the only member, broadcast to room to all users on RTM
    if (members.length === 1) {
      await this._rtmEngine
        ?.sendMessageByChannelId('lobby', channelName + ':' + 1)
        .catch((e) => console.log(e));
    }
    this.setState({
      inLobby: false,
      seniors: members.map((m: any) => m.uid),
    });
  };
...

我们定义了一个加入调用的函数,它将频道名作为参数。我们用频道名更新状态,并在RTM和RTC上使用 joinChannel 方法加入频道。

我们使用RTM上的 getChannelMembersBychannelId 方法来获取频道上用户的UID。如果我们是唯一的成员,我们就在RTM上向大厅频道发送频道消息让大家更新创建房间的信息。

离开通话

...
  /**
   * @name endCall
   * @description Function to end the call and return to lobby
   */
  endCall = async () => {
    let {channelName, myUsername, peerIds, seniors} = this.state;
    // if we're the senior member, broadcast room to all users before leaving
    if (seniors.length < 2) {
      await this._rtmEngine
        ?.sendMessageByChannelId('lobby', channelName + ':' + peerIds.length)
        .catch((e) => console.log(e));
    }
    await this._rtcEngine?.leaveChannel();

    await this._rtmEngine?.logout();
    await this._rtmEngine?.login({uid: myUsername});
    await this._rtmEngine?.joinChannel('lobby');

    this.setState({
      peerIds: [],
      inCall: false,
      inLobby: true,
      seniors: [],
      channelName: '',
    });
  };
...

我们离开RTM和RTC视频聊天室频道,但保持连接到RTM的大厅频道以保持接收更新。我们通过清除 peerIds 数组、 seniors 数组和 channelName 来更新我们的状态。我们还将 inCall 设置为false, inLobby 设置为true 来渲染大厅用户界面。

渲染我们的用户界面

...
  render() {
    const {inCall, channelName, inLobby} = this.state;
    return (
      <SafeAreaView style={styles.max}>
        <View style={styles.spacer}>
          <Text style={styles.roleText}>
            {inCall ? "You're in " + channelName : 'Lobby: Join/Create a room'}
          </Text>
        </View>
        {this._renderRooms()}
        {this._renderCall()}
        {!inLobby && !inCall ? (
          <Text style={styles.waitText}>Please wait, joining room...</Text>
        ) : null}
      </SafeAreaView>
    );
  }
...

我们定义了显示按钮的渲染函数,如果我们在通话或者大厅里,就可以显示状态。

...
  _renderRooms = () => {
    const {inLobby, rooms, input} = this.state;
    return inLobby ? (
      <View style={styles.fullView}>
        <Text style={styles.subHeading}>Room List</Text>
        <ScrollView>
          {Object.keys(rooms).map((key, index) => {
            return (
              <TouchableOpacity
                key={index}
                onPress={() => this.joinCall(key)}
                style={styles.roomsBtn}>
                <Text>
                  <Text style={styles.roomHead}>{key}</Text>
                  <Text style={styles.whiteText}>
                    {' (' + rooms[key] + ' users)'}
                  </Text>
                </Text>
              </TouchableOpacity>
            );
          })}
          <Text>
            {Object.keys(rooms).length === 0
              ? 'No active rooms, please create new room'
              : null}
          </Text>
        </ScrollView>
        <TextInput
          value={input}
          onChangeText={(val) => this.setState({input: val})}
          style={styles.input}
          placeholder="Enter Room Name"
        />
        <TouchableOpacity
          onPress={async () => {
            input ? await this.joinCall(input) : null;
          }}
          style={styles.button}>
          <Text style={styles.buttonText}>Create Room</Text>
        </TouchableOpacity>
      </View>
    ) : null;
  };
...

我们使用 _renderRooms 函数来渲染一个滚动视图, 该视图在房间字典上迭代,以显示一个创建的房间列表及其成员数。用户可以点击任何房间来加入它,这就调用了 joinCall 函数。我们还渲染一个文本输入,让用户创建一个使用该输入调用相同的 joinCall 函数的房间。

...
   _renderCall = () => {
    const {inCall, peerIds, channelName} = this.state;
    return inCall ? (
      <View style={styles.fullView}>
        <RtcLocalView.SurfaceView
          style={styles.video}
          channelId={channelName}
          renderMode={VideoRenderMode.Hidden}
        />
        <ScrollView>
          {peerIds.map((key, index) => {
            return (
              <RtcRemoteView.SurfaceView
                channelId={channelName}
                renderMode={VideoRenderMode.Hidden}
                key={index}
                uid={key}
                style={styles.video}
              />
            );
          })}
        </ScrollView>
        <TouchableOpacity onPress={this.endCall} style={styles.button}>
          <Text style={styles.buttonText}>Leave Room</Text>
        </TouchableOpacity>
      </View>
    ) : null;
  };
}

当我们连接到视频聊天室后,我们使用 _renderCall 函数来渲染视频。我们使用SDK中的 RtcLocalView 组件来渲染我们自己(本地用户)的视频。我们在滚动视图中使用 RtcRemoteView 来渲染用存储在 peerIds 数组中的UID 连接用户的视频。我们还会显示一个按钮来离开房间。

权限

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操作系统申请麦克风权限。

样式

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

import {StyleSheet} from 'react-native';

export default StyleSheet.create({
  max: {
    flex: 1,
    backgroundColor: '#F7F7F7',
  },
  button: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    backgroundColor: '#38373A',
    marginBottom: 16,
  },
  buttonText: {
    color: '#fff',
    textAlign: 'center',
  },
  fullView: {
    flex: 5,
    alignContent: 'center',
    marginHorizontal: 24,
  },
  subHeading: {
    fontSize: 16,
    fontWeight: '700',
    marginBottom: 10,
  },
  waitText: {
    marginTop: 50,
    fontSize: 16,
    fontWeight: '700',
    textAlign: 'center',
  },
  roleText: {
    textAlign: 'center',
    // fontWeight: '700',
    color: '#fbfbfb',
    fontSize: 18,
  },
  spacer: {
    width: '100%',
    padding: '2%',
    marginBottom: 32,
    // borderWidth: 1,
    backgroundColor: '#38373A',
    color: '#fbfbfb',
    // borderColor: '#38373A',
  },
  input: {
    height: 40,
    borderColor: '#38373A',
    borderWidth: 1.5,
    width: '100%',
    alignSelf: 'center',
    padding: 10,
    marginBottom: 10,
  },
  roomsBtn: {
    padding: 8,
    marginBottom: 4,
    backgroundColor: '#38373A',
  },
  roomHead: {fontWeight: 'bold', color: '#fff', fontSize: 16},
  whiteText: {color: '#fff'},
  video: {width: 150, height: 150},
});

下一步是什么?

同样的技术可以用来传达其他信息,比如连接的用户名称、房间描述和房间标题。我们甚至可以使用同样的机制,通过发送一个RTM消息把用户踢出通话,当评估时,调用远程用户设备上的离开频道方法。

结论

您已经看到了我们如何利用Agora RTM SDK来分享信息和动态创建视频聊天室。您可以查阅Agora React Native API参考来了解可以帮助您快速为下一个实时参与应用程序添加更多功能的方法。

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

image

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