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

用声网Agora RTC(Real-time Messaging,云信令)SDK 在 React Native 上创建视频聊天应用非常简单。声网Agora RTM SDK 可以让多位用户通过同一频道名加入视频聊天室进行交流。

假设你正在创建一个社交视频聊天应用,你想让你的用户创建一个允许其他用户浏览、加入和说话的聊天室,可以使用后端服务器来处理这些请求,并向其他用户更新创建好的聊天室的信息,但是,这样做不仅需要编写后端代码,还需要托管服务器。

本教程将介绍一种使用声网Agora RTM SDK 实现这个目标的方法,我们将使用用户发送的消息来创建和更新动态视频聊天室,并且使用前端代码。

这样做的好处在于,当你不想搭建后端服务器时,可以用消息向其他用户更新聊天室状态。此方法也很容易扩展到完全的聊天室管理,以及管理员同意/拒绝用户、让用户静音、将用户从聊天室移除等功能。

在本文的示例中,我们用的是用于 React Native 框架的 Agora RTC SDKAgora RTM SDK ,具体版本是 RTC SDK v3.2.2 和 RTM SDK v1.2.2-alpha.3。

项目概述

  • 我们有一个名为“lobby”的 RTM 聊天室。当有人创建新聊天室或聊天室的成员发生变化时,我们会用“lobby”向用户发信号。

  • 具体操作是,我们会让视频聊天室中的高级成员向其他成员发送消息。最早进入聊天室的成员就是高级成员(详见下文)。

  • 我们会以 ‘roomName:memberCount’ 的形式发送消息,便于其他用户处理,把聊天室名称和成员数量存储在他们的应用状态中,然后用此形式渲染一个聊天室列表,列表中包含了聊天室成员的数量。

  • 获得聊天室列表后,我们就可以通过使用 RTC SDK 加入聊天室。另外,还需监听用户加入/离开聊天室的状态,为其他用户更新成员数量。为控制管理成本,只有高级成员才可以这样操作。

  • 为其他用户更新聊天室信息还需考虑两种情况:第一、当有新用户加入大厅时,每个频道中的高级成员会向该用户发送点对点消息。第二、当一个频道中的成员数量更新时,我们会给所有连接到大厅的用户发送频道消息,更新他们的聊天室列表。

创建一个声网Agora 账户

点击这里免费注册声网Agora 账户,登入后台。

image
网站上的 Project Management(项目管理)选项卡

找到 “Project Management” 选项卡下的 “Project List”选项卡,点击蓝色的“Create”按钮,创建一个项目。(当提示使用 App ID 和证书时,只选择 App ID)。App ID 可以在开发应用时对你的请求进行授权,不需要生成令牌,所以把 App ID 复制保存起来,方便以后使用。

注意: 本指南没有执行令牌验证,建议在生产环境中运行的所有 RTE 应用都采用令牌验证。如果想了解更多关于声网 Agora 平台基于令牌进行验证的信息,可以查看文档 校验用户权限

下载源码

你可以直接跳转到代码,代码是开放源码,可以在 GitHub 上找到。如果想自己动手尝试,请先查看其中的 readme 文件了解运行应用所需步骤。


在安卓模拟器上运行该应用的截图

示例的结构

下面是我们正在搭建的应用的结构:

.
├── 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};
    }
    ...

我们先编写输入声明,然后为应用状态定义一个接口,包含以下内容:

  • appId:声网Agora App ID
  • token:为加入频道而生成的令牌
  • inCall:存储我们是否在一个活跃的视频聊天室的布尔值
  • inLobby:存储我们是否在大厅的布尔值
  • input:创建新房间时存储输入的字符串
  • peerIdsRTC:存储视频聊天室中其他用户的 RTC UID 的数组
  • seniors: 存储在我们之前加入视频聊天室的 RTM 成员的数组
  • myUsername:登录 RTM 的本地用户名称
  • rooms:存储聊天室名称及成员数的字典
              ...
              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 函数。

在构造函数中设置状态变量,并申请在安卓设备上录制音频的权限。(我们使用下面 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 渲染已连接用户的视频,这些已连接用户以 UID 存储在 peerIds 数组中。我们还会显示一个用来离开聊天室的按钮。

Permission.ts

    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)
        }
    }

我们正在导出一个辅助函数,这个辅助函数可以向安卓操作系统请求麦克风权限。

Style.ts

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

原文作者:Ekaansh Arora
原文链接:Dynamic Channels for Video Chat Using Agora RTM on React Native