用Agora开发实时音视频直播React Native App

从主播、采访到现场音乐表演,实时音视频直播在广泛的用途上越来越受欢迎。一旦有一些用户与观众进行实时互动,你就会发现无限的可能性。

有一个简单的方法可以利用Agora React Native SDK来完成音频直播。在本教程中,我们将通过利用Agora音频SDK来构建一个可以拥有多个主播并承载成千上万用户的音频直播App。在深入研究代码之前,我们将介绍应用程序的结构、设置和执行。点击这里可以查看GitHub开源示例代码

我们将使用 Agora RTC SDK for React Native 来完成示例。在写这篇文章的时候,我使用的是v3.2.2。

创建一个Agora账户

注册声网开发者账户 并登录到后台。

image
Selecting the Project Management tab in the Agora Console.

导航到 "项目管理 "选项卡下的 "项目列表 "选项卡,并通过单击蓝色的 "创建 "按钮创建一个项目。

创建项目并获得App ID。(当提示使用App ID+证书时,请选择App ID Only。)App ID将在您开发应用程序时用于授权请求,而无需生成Token。

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

例子的结构

这就是应用程序的结构。

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

运行应用程序

你需要安装最新版本的Node.js和NPM。

  • 确保你已经拥有Agora账户,设置了一个项目,并生成了一个App ID(如上所述)。

  • GitHub示例项目主分支下载并解压ZIP文件。

  • 运行 npm install 来安装解压目录中的App依赖项。

  • 导航到 ./App.tsx ,在状态声明中输入 App ID 作为 appId: YourAppIdHere .

  • 如果你是为iOS构建,打开终端,执行 cd ios && pod install

  • 连接设备,并运行 npx react-native run-android / npx react-native run-ios 来启动应用程序。给它几分钟的时间来构建应用程序并安装到你的设备上。

  • 一旦你在移动设备(或模拟器)上看到主屏幕,点击设备上的开始通话按钮。

就是这样,现在你应该已经在两个设备之间建立了音频直播。

该应用使用 channel-x 作为频道名称。

在我们深入研究代码之前,让我们先把一些基础知识讲清楚。

  • 我们使用Agora RTC (实时音视频) SDK来连接到一个频道并加入一个音频通话.
  • 可以有多个用户对一个频道进行直播。所有的用户作为该频道的听众,都可以收听主播的声音。
  • 听众可以动态切换到主播角色。
  • Agora RTC SDK为每个用户使用唯一的ID(UID)。为了将这些UID与用户名关联起来,我们将使用Agora RTM(实时消息)SDK向通话中的其他人发送用户名。我们将在下面讨论它是如何完成的。

让我们来看看代码是如何工作的:

App.tsx

App.tsx将是进入应用程序的入口。我们的所有代码都会在这个文件中。当你打开应用程序时,会有一个用户名字段,里面有三个按钮:加入通话,结束通话,以及在主播和观众之间切换我们的用户角色。

import React, { Component } from 'react';
import {
  Platform,
  ScrollView,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native';
import RtcEngine, { ClientRole, ChannelProfile } 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
 * @property token Token for the channel;
 * @property isHost Boolean value to select between broadcaster and audience
 * @property channelName Channel Name for the current session
 * @property joinSucceed State variable for storing success
 * @property rtcUid local user's UID on joining the RTC channel
 * @property peerIds Array for storing connected peers
 * @property myUsername local user's name to login to RTM
 * @property Array to store usernames mapped to RTC UIDs
 */

interface State {
  appId: string;
  token: string | null;
  isHost: boolean;
  channelName: string;
  joinSucceed: boolean;
  rtcUid: number;
  peerIds: number[];
  myUsername: string;
  usernames: { [uid: string]: string };
}
...

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

  • appId:Agora App ID

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

  • isHost: 在观众和直播之间切换的布尔值。

  • channelName:频道名称

  • joinSucceed存储我们是否成功连接的布尔值。

  • rtcUid: 本地用户加入RTC频道时的UID。

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

  • usernames:将远程用户的RTC UID与他们的用户名关联起来的字典,我们将使用RTM获取该用户名。

  • peerIds:一个数组,用于存储通道中其他用户的UID。

       ...
      export default class App extends Component<null, State> {
        _rtcEngine?: RtcEngine;
        _rtmEngine?: RtmEngine;
    
    constructor(props) {
      super(props);
      this.state = {
        appId: 'YOUR APP ID HERE',
        token: null,
        isHost: true,
        channelName: 'channel-x',
        joinSucceed: false,
        rtcUid: parseInt((new Date().getTime() + '').slice(4, 13), 10),
        peerIds: [],
        myUsername: '',
        usernames: {},
      };
      if (Platform.OS === 'android') {
        // Request required permissions from Android
        requestCameraAndAudioPermission().then(() => {
          console.log('requested!');
        });
      }
    }
    
    componentDidMount() {
      this.initRTC();
      this.initRTM();
    }
    
    componentWillUnmount() {
      this._rtmEngine?.destroyClient();
      this._rtcEngine?.destroy();
    }
    ...
    

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

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

RTC初始化

...
   /**
   * @name initRTC
   * @description Function to initialize the Rtc Engine, attach event listeners and actions
   */
  initRTC = async () => {
    const { appId, isHost } = this.state;
    this._rtcEngine = await RtcEngine.create(appId);
    // await this._rtcEngine.disableVideo();
    await this._rtcEngine.setChannelProfile(ChannelProfile.LiveBroadcasting);
    await this._rtcEngine.setClientRole(
      isHost ? ClientRole.Broadcaster : ClientRole.Audience
    );

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

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

    this._rtcEngine.addListener('UserOffline', (uid, reason) => {
      console.log('UserOffline', uid, reason);
      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({
          joinSucceed: true,
          rtcUid: uid,
        });
      }
    );
  };
...

使用App ID来创建我们的引擎实例。接下来,根据我们的isHost状态变量值,将channelProfile设置为Live Broadcasting和clientRole。

当我们加入频道时,RTC为每个在场的用户和后来加入的新用户触发一个userJoined事件。当用户离开通道时,会触发userOffline事件。我们使用事件监听器来同步我们的peerIds数组。

注意:观众成员不会触发userJoined/userOffline事件。

RTM初始化

使用RTM将我们的用户名发送给通话中的其他用户。这就是如何将我们的用户名与我们的RTC UID关联起来的方法。

  • 当一个用户加入一个频道时,以 UID:Username 的形式向所有频道成员发送 一条消息。

  • 在收到一条频道消息时,所有用户都会将键值对添加到他们的用户名字典中。

  • 当一个新用户加入时,频道上的所有成员都会以相同的模式 UID:Username 向该用户发送一条对等消息 。

  • 在接收到对等消息时,我们也做同样的事情(将键值对添加到字典中)并更新我们的用户名。

     ...
     /**
     * @name initRTM
     * @description Function to initialize the Rtm Engine, attach event listeners and use them to sync usernames
     */
    initRTM = async () => {
      let { appId, usernames, rtcUid } = this.state;
      this._rtmEngine = new RtmEngine();
    
      this._rtmEngine.on('error', (evt) => {
        console.log(evt);
      });
    
      this._rtmEngine.on('channelMessageReceived', (evt) => {
        let { text } = evt;
        let data = text.split(':');
        console.log('cmr', evt);
        if (data[1] === '!leave') {
          let temp = JSON.parse(JSON.stringify(usernames));
          Object.keys(temp).map((k) => {
            if (k === data[0]) delete temp[k];
          });
          this.setState({
            usernames: temp,
          });
        } else {
          this.setState({
            usernames: { ...usernames, [data[0]]: data[1] },
          });
        }
      });
    
      this._rtmEngine.on('messageReceived', (evt) => {
        let { text } = evt;
        let data = text.split(':');
        console.log('pm', evt);
        this.setState({
          usernames: { ...usernames, [data[0]]: data[1] },
        });
      });
    
      this._rtmEngine.on('channelMemberJoined', (evt) => {
        console.log('!spm', this.state.myUsername);
        this._rtmEngine?.sendMessageToPeer({
          peerId: evt.uid,
          text: rtcUid + ':' + this.state.myUsername,
          offline: false,
        });
      });
    
      await this._rtmEngine.createClient(appId).catch((e) => console.log(e));
    };
    ...
    

按照计划,我们在 channelMessageReceived (向频道广播消息)、 messageReceived (对等消息)和 channelMemberJoined 事件上附加带有函数的事件监听器来填充和更新用户名。我们还使用相同的App ID在引擎上创建一个客户端。

按钮的功能

...
   /**
   * @name toggleRole
   * @description Function to toggle the roll between broadcaster and audience
   */
  toggleRole = async () => {
    this._rtcEngine?.setClientRole(
      !this.state.isHost ? ClientRole.Broadcaster : ClientRole.Audience
    );
    this.setState((ps) => {
      return { isHost: !ps.isHost };
    });
  };

  /**
   * @name startCall
   * @description Function to start the call
   */
  startCall = async () => {
    let { myUsername, token, channelName, rtcUid } = this.state;
    if (myUsername) {
      // Join RTC Channel using null token and channel name
      await this._rtcEngine?.joinChannel(token, channelName, null, rtcUid);
      // Login & Join RTM Channel
      await this._rtmEngine
        ?.login({ uid: myUsername })
        .catch((e) => console.log(e));
      await this._rtmEngine
        ?.joinChannel(channelName)
        .catch((e) => console.log(e));
      await this._rtmEngine
        ?.sendMessageByChannelId(channelName, rtcUid + ':' + myUsername)
        .catch((e) => console.log(e));
    }
  };

  /**
   * @name endCall
   * @description Function to end the call
   */
  endCall = async () => {
    let { channelName, rtcUid } = this.state;
    await this._rtcEngine?.leaveChannel();
    await this._rtmEngine
      ?.sendMessageByChannelId(channelName, rtcUid + ':!leave')
      .catch((e) => console.log(e));
    this.setState({ peerIds: [], joinSucceed: false, usernames: {} });
    await this._rtmEngine?.logout().catch((e) => console.log(e));
  };
...

toggleRole 函数更新状态并根据状态调用具有正确参数的 setClientRole 函数。

startCall 函数检查是否有用户名被输入,然后加入RTC通道。它也会登录到RTM,加入频道,并为用户名发送频道消息,就像我们之前讨论的那样。

endCall 函数离开RTC通道,发送一条消息从我们的远程用户字典中删除用户名,然后离开并退出RTM。

渲染用户界面

...
  render() {
    const { joinSucceed, isHost, channelName, myUsername } = this.state;
    return (
      <View style={styles.max}>
        <View style={styles.spacer}>
          <Text style={styles.roleText}>
            You're{' '}
            <Text style={styles.roleTextBold}>
              {isHost ? 'a broadcaster' : 'the audience'}
            </Text>
          </Text>
          <Text style={styles.roleText}>
            {joinSucceed
              ? "You're connected to " + channelName
              : "You're disconnected - start call"}
          </Text>
        </View>
        {this._renderUsers()}
        {joinSucceed ? (
          <></>
        ) : (
          <>
            <TextInput
              style={styles.input}
              placeholder={'Name'}
              onChangeText={(t) => {
                this.setState({ myUsername: t });
              }}
              value={myUsername}
            />
            {!myUsername ? (
              <Text style={styles.errorText}>Name can't be blank</Text>
            ) : null}
          </>
        )}
        <View style={styles.buttonHolder}>
          <TouchableOpacity onPress={this.toggleRole} style={styles.button}>
            <Text style={styles.buttonText}> Toggle Role </Text>
          </TouchableOpacity>
          <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>
      </View>
    );
  }

  _renderUsers = () => {
    const { joinSucceed, peerIds, isHost, usernames, myUsername } = this.state;

    return joinSucceed ? (
      <View style={styles.fullView}>
        <Text style={styles.subHeading}>Broadcaster List</Text>
        {isHost ? <Text>{myUsername}</Text> : <></>}
        <ScrollView>
          {peerIds.map((value, index) => {
            return <Text key={index}>{usernames[value + '']}</Text>;
          })}
        </ScrollView>
        <Text style={styles.subHeading}>Audience List</Text>
        {!isHost ? <Text>{myUsername}</Text> : <></>}
        <ScrollView>
          {Object.keys(usernames).map((key, index) => {
            return (
              <Text key={index}>
                {peerIds.includes(parseInt(key, 10)) ? null : usernames[key]}
              </Text>
            );
          })}
        </ScrollView>
      </View>
    ) : null;
  };
}
...

我们定义了渲染函数,用于显示开始和结束调用的按钮以及切换角色。我们定义了一个函数 _renderUsers 用于渲染所有直播和观众成员的列表。

权限

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 { StyleSheet } from 'react-native';

export default StyleSheet.create({
  max: {
    flex: 1,
    // marginVertical: 40,
    backgroundColor: '#F7F7F7',
  },
  buttonHolder: {
    alignItems: 'center',
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'space-evenly',
  },
  button: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    backgroundColor: '#38373A',
    // borderRadius: 24,
  },
  buttonRed: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    // backgroundColor: '#F4061D',
    borderRadius: 24,
  },
  buttonGreen: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    // backgroundColor: '#09DF18',
    borderRadius: 24,
  },
  buttonText: {
    color: '#fff',
  },
  fullView: {
    flex: 5,
    alignContent: 'center',
    marginHorizontal: 24,
  },
  centerText: {
    textAlign: 'center',
  },
  subHeading: {
    fontSize: 16,
    fontWeight: '700',
  },
  remote: {
    width: 150,
    height: 150,
    marginHorizontal: 2.5,
  },
  noUserText: {
    paddingHorizontal: 10,
    paddingVertical: 5,
    color: '#0093E9',
  },
  roleText: {
    textAlign: 'center',
    // fontWeight: '700',
    color: '#fbfbfb',
    fontSize: 18,
  },
  roleTextBold: {
    textAlign: 'center',
    fontSize: 18,
    fontWeight: '700',
  },
  roleTextRed: {
    textAlign: 'center',
    fontSize: 18,
    // color: '#F4061D',
  },
  spacer: {
    width: '100%',
    padding: '2%',
    marginBottom: 32,
    // borderWidth: 1,
    backgroundColor: '#38373A',
    color:'#fbfbfb',
    // borderColor: '#38373A',
  },
  input: {
    height: 40,
    borderColor: '#38373A',
    borderWidth: 1.5,
    width: '90%',
    alignSelf: 'center',
    padding: 10,
  },
  errorText: { textAlign: 'center', margin: 5, color: '#38373A' },
});

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

结论

这就是构建一个实时音视频直播App的简单方法。你可以参考声网React Native API参考中的方法,这些方法可以帮助你快速添加功能,如静音麦克风、设置音频配置文件、音频混合等。

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

image

推荐阅读
相关专栏
SDK 教程
167 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。