使用 React Native 在一个下午内构建一个移动音频聊天应用程序

2025-06-10

使用 React Native 在一个下午内构建一个移动音频聊天应用程序

在 Daily,我们的主要工作之一是使用 API支持纯音频应用。最近,我们听到越来越多关于如何帮助应用用户避免Zoom 疲劳(即整天坐在视频会议中感到精疲力竭)的讨论。

纯音频应用是解决这个问题的绝佳方案,因为它们通常需要的认知资源较少。对于大型通话或移动设备通话来说,它们也是一个不错的选择,因为它们对 CPU 的要求通常较低。(所以你你的设备都不需要费心思考了。😉)

为了帮助我们的客户支持他们的纯音频用例,我们在今年早些时候推出了一个音频入门套件(又名 Party Line),其中包括React (web)iOSAndroidReact Native中的演示应用程序。

在今天的教程中,我们将深入探讨React Native 版本的 Party Line 的工作原理。

在本教程结束时,您将了解如何使用我们的库和 Daily 的可定制呼叫对象构建类似Clubhouse的 Daily 音频应用程序react-native-daily-js


本教程适合哪些人?

为了充分利用本教程,一些 React Native 的基础知识将非常有用。如果您以前从未使用过 React Native,但熟悉 React 和 React Hooks,那么您应该能够顺利完成本教程。

注意:React 和 React Native 代码非常相似,但也存在一些差异,因此我们会尽力解释这些差异!

本地设置

要在本地运行 Party Line 应用,请按照Github 仓库 README中的说明进行操作。iOS 和 Android 版的说明均包含在内,具体取决于您主要喜欢在哪个操作系统上测试 React Native 应用。


功能集和积压

Party Line 应用程序视图

让我们首先描述一下将包含(和不包含)哪些音频通话功能。

Party Line 将包含两种观点:

  1. 主屏幕包含用于加入或创建音频通话的表单
  2. 加入通话后的通话中视图

让我们回顾一下一些基本功能:

  • 在主屏幕上,本地用户可以在表单中填写自己的姓名,并指定房间代码或留空。如果留空,Party Line 会在表单提交后自动创建新房间并加入。
  • 在 Party Line 中创建的每个房间将在 10 分钟后过期。过期时间是在通过Daily REST API创建房间时设置的,我们为此添加了一些功能,以避免演示房间长期存在。不过,您可以在房间设置中根据自己的使用情况进行调整。
  • 加入房间后,房间代码即可与任何人共享。使用一个应用创建的房间可与我们任何其他 Party Line 应用(iOS、Android、React/Web 或 React Native)兼容。

我们将允许三种不同类型的参与者:主持人、演讲者、听众。

参与者类型处理如下:

  • 房间创建者是主持人
  • 管理员在用户界面中以姓名首字母旁边的星号来表示
  • 主持人可以将听众提升为发言者,将发言者提升为听众,并将任何人提升为主持人
  • 听众可以举手(或放下手)来表示他们想发言
  • 发言者和主持人可以静音/取消静音自己,但只能静音其他人
  • 当主持人离开通话且没有其他主持人在场时,所有人的通话都会结束

主持人正在更新本地音频设置

在限制方面,我们不会:

  • 使用任何外部帐户管理或身份验证
  • 拥有一个数据库,尽管我们建议使用数据库来处理生产级应用程序的参与者类型(❗)
  • 除了无服务器函数之外,还有一个调用 Daily REST API 的后端
  • 提供可加入的房间列表;参与者需要知道他们想加入的房间的代码。不过,添加这个功能会很棒😉

我们将在下面介绍其中大部分的工作原理,或者分享现有资源的链接,以帮助我们了解我们没有时间讨论的内容。


组件结构

在深入研究代码之前,让我们先规划一下组件要使用的结构。

组件结构

在这里,我们将App组件作为顶级父组件。它将渲染Header包含应用标题和信息的组件。此外,它还会根据应用状态,有条件地渲染处理InCall每日语音通话的组件,或者包含PreJoinRoom用于加入每日语音通话的表单的组件

我们的InCall组件最为复杂,因为它负责处理我们的日常呼叫。

InCall包含以下组件:

  • 一个Counter组件,显示通话剩余时间
  • ACopyLinkBox复制并分享房间代码
  • ATray控制本地麦克风、举手或离开通话
  • 每个参与者对应的组件Participant。它渲染:
    • 参与者 UI,每个参与者都由一个包含其姓名首字母的方框和一个“显示更多”菜单按钮表示,该按钮会Menu在特定条件下渲染组件。(更多内容见下文)
    • DailyMenuView组件用于提供通话参与者的音频。注意:在 React 项目中,您只需渲染一个<audio>元素即可。

CallProvider.jsx:此操作的核心

为了使我们的逻辑井然有序且(大部分)集中在一处,我们使用了React Context API来存储全局应用状态。我们的App组件将其内容包装在CallProvider组件(我们的上下文)中,这意味着我们应用的所有内容都可以访问调用上下文中的数据集。

// App.jsx
function App() {
   return (
       <CallProvider>
          <AppContent />
       </CallProvider>
   );
}
Enter fullscreen mode Exit fullscreen mode

注意:Context API 适用于任何 React 应用(不仅仅是 React Native)。事实上,我们在这个应用的 Web 版本中就是这么做的!

现在,让我们花点时间了解一下发生了什么CallProvider。(我们无法在这里涵盖所有细节,因此如果您有任何问题,请告诉我们。)

我们在中定义了几个动作(即方法)CallProvider

从我们的应用程序状态开始,让我们看看我们将初始化和导出哪些值以在整个应用程序中使用。

// CallProvider.jsx
export const CallProvider = ({children}) => {
 const [view, setView] = useState(PREJOIN); // pre-join | in-call
 const [callFrame, setCallFrame] = useState(null);
 const [participants, setParticipants] = useState([]);
 const [room, setRoom] = useState(null);
 const [error, setError] = useState(null);
 const [roomExp, setRoomExp] = useState(null);
 const [activeSpeakerId, setActiveSpeakerId] = useState(null);
 const [updateParticipants, setUpdateParticipants] = useState(null);
 
return (
   <CallContext.Provider
     value={{
       getAccountType,
       changeAccountType,
       handleMute,
       handleUnmute,
       displayName,
       joinRoom,
       leaveCall,
       endCall,
       removeFromCall,
       raiseHand,
       lowerHand,
       activeSpeakerId,
       error,
       participants,
       room,
       roomExp,
       view,
     }}>
     {children}
   </CallContext.Provider>
 );
};
Enter fullscreen mode Exit fullscreen mode

如何使用sendAppMessage

在此演示中,我们通过在每个参与者的用户名末尾附加一个字符串来管理参与者类型(主持人、发言者或听众),该字符串不会显示在 UI 中(例如${username}_MOD对于主持人)。

❗注意:对于生产级应用,我们建议构建一个用于管理参与者类型的后端。当前解决方案旨在将代码保留在客户端以用于演示目的。

话虽如此,让我们看看参与者类型管理是如何运作的。

每当主持人更新另一个参与者的帐户类型时,该更新将通过每日方法传达给其他参与者sendAppMessage

所有参与者都将通过事件监听器收到该应用程序消息app-message,该消息添加于CallProvider
callFrame.on('app-message', handleAppMessage);

这将使用回调方法handleAppMessage,该方法会将用户名上附加的字符串更新为新的帐户类型(例如_LISTENER_SPEAKER

// CallProvider.jsx
 const handleAppMessage = async (evt) => {
     console.log('[APP MESSAGE]', evt);
     try {
       switch (evt.data.msg) {
         case MSG_MAKE_MODERATOR:
           console.log('[LEAVING]');
           await callFrame.leave();
           console.log('[REJOINING AS MOD]');

           let userName = evt?.data?.userName;
           // Remove the raised hand emoji
           if (userName?.includes('')) {
             const split = userName.split('');
             userName = split.length === 2 ? split[1] : split[0];
           }
           joinRoom({
             moderator: true,
             userName,
             name: room?.name,
           });
           break;
         case MSG_MAKE_SPEAKER:
           updateUsername(SPEAKER);
           break;
         case MSG_MAKE_LISTENER:
           updateUsername(LISTENER);
           break;
         case FORCE_EJECT:
           //seeya
           leaveCall();
           break;
       }
     } catch (e) {
       console.error(e);
     }
   };
Enter fullscreen mode Exit fullscreen mode

将听众提升为演讲者

让某人成为主持人稍微复杂一些,因为他们需要使用每日令牌重新加入通话,这将赋予他们所需的所有者权限,以便能够将其他参与者静音。为此,我们会将他们悄悄地踢出通话 ( ),然后立即使用所有者令牌callFrame.leave()以主持人身份重新加入通话

注意:要使参与者成为拥有会议令牌的会议所有者,is_ownertoken 属性必须为true有关更多信息,请参阅我们的令牌配置文档。

当我们介绍下面的具体组件时,我们将回顾其中概述的一些其他具体方法CallProvider


PreJoinRoom 表单

PreJoinRoom组件是一个表单,包含三个输入框(名字、姓氏、加入代码)以及一个提交表单的按钮。只有名字是必填字段;姓氏是可选字段。如果没有提供加入代码,我们会认为用户想要创建一个新的房间并加入。

让我们关注一下提交表单时发生的情况:

// PreJoinRoom.jsx
const PreJoinRoom = ({handleLinkPress}) => {
 const {joinRoom, error} = useCallState();
 const [firstName, setFirstName] = useState('');
 const [lastName, setLastName] = useState('');
 const [roomName, setRoomName] = useState('');
 const [submitting, setSubmitting] = useState(false);
 const [required, setRequired] = useState(false);

 const submitForm = useCallback(
   (e) => {
     e.preventDefault();
     if (!firstName?.trim()) {
       setRequired(true);
       return;
     }
     if (submitting) return;
     setSubmitting(true);
     setRequired(false);

     let userName =
       firstName?.trim() + (lastName?.trim() || '');

     let name = '';
     if (roomName?.trim()?.length) {
       name = roomName;
       /**
        * We track the account type by appending it to the username.
        * This is a quick solution for a demo; not a production-worthy solution!
        */
       userName = `${userName}_${LISTENER}`;
     } else {
       userName = `${userName}_${MOD}`;
     }
     joinRoom({userName, name});
   },
   [firstName, lastName, roomName, joinRoom],
 );
Enter fullscreen mode Exit fullscreen mode

在 中submitForm,我们首先确保名字已填写。如果没有,则更新required状态值,从而阻止表单提交。

接下来,我们通过连接名字和可选的姓氏值来获取本地用户的用户名:

let userName = firstName?.trim() + (lastName?.trim() ?  ${lastName?.trim()} : '');
Enter fullscreen mode Exit fullscreen mode

roomName如果表格中提供了房间代码( ),我们将其分配给我们的变量并更新附加到其中的name用户名。_LISTENER

如果没有房间代码,我们不会设置房间name并将其附加_MOD到用户名中。如上所述,创建房间的人默认为主持人,因此我们会在名称中追踪这一点。

if (roomName?.trim()?.length) {
    name = roomName;

    userName = `${userName}_${LISTENER}`;
} else {
    userName = `${userName}_${MOD}`;
}
Enter fullscreen mode Exit fullscreen mode

一旦我们有了userName可选的房间name,我们就可以调用joinRoom来自的方法CallProvider

const joinRoom = async ({userName, name, moderator}) => {
   if (callFrame) {
     callFrame.leave();
   }

   let roomInfo = {name};
   /**
    * The first person to join will need to create the room first
    */
   if (!name && !moderator) {
     roomInfo = await createRoom();
   }
   setRoom(roomInfo);

   /**
    * When a moderator makes someone else a moderator,
    * they first leave and then rejoin with a token.
    * In that case, we create a token for the new mod here.
    */
   let newToken;
   if (moderator) {
     // create a token for new moderators
     newToken = await createToken(room?.name);
   }
   const call = Daily.createCallObject({videoSource: false});

   const options = {
     // This can be changed to your Daily domain
     url: `https://devrel.daily.co/${roomInfo?.name}`,
     userName,
   };
   if (roomInfo?.token) {
     options.token = roomInfo?.token;
   }
   if (newToken?.token) {
     options.token = newToken.token;
   }

   await call
     .join(options)
     .then(() => {
       setError(false);
       setCallFrame(call);
       call.setLocalAudio(false); 
       setView(INCALL);
     })
     .catch((err) => {
       if (err) {
         setError(err);
       }
     });
 };
Enter fullscreen mode Exit fullscreen mode

joinRoom有以下步骤:

  • 如果你已经在某个房间了,它就会离开当前房间。(这主要是针对那些糟糕透顶、糟糕透顶、代码 bug 非常多的糟糕日子的防御性编程。)
  • createRoom如果没有提供房间名称,它会用我们上面提到的方法创建一个新房间
  • 如果参与者是主持人,则会创建一个令牌。如果他们是第一个加入的人,或者他们在升级后以主持人身份重新加入,则可能会发生这种情况
  • 接下来,我们创建本地 Daily 呼叫对象实例:(const call = Daily.createCallObject({videoSource: false});我们将在下面详细介绍该videoSource属性。)
  • 我们还设置了加入通话之前需要的通话选项(加入的房间 URL、用户名和主持人的可选令牌
const options = {
  url: `https://devrel.daily.co/${roomInfo?.name}`,
  userName,
};
Enter fullscreen mode Exit fullscreen mode
  • 最后,我们加入呼叫并相应地更新我们的本地状态,包括将我们的view值更新为incall
await call
    .join(options)
    .then(() => {
       setError(false);
       setCallFrame(call);
       /**
        * Now mute, so everyone joining is muted by default.
        */
       call.setLocalAudio(false);
       setView(INCALL);
    })
Enter fullscreen mode Exit fullscreen mode

一旦完成,我们将进入我们的InCall组件,因为这种情况App.js

{view === INCALL && <InCall handleLinkPress={handleLinkPress} />}


通话体验:主持人和我们其他人

现在我们知道如何接听电话,让我们关注如何实际使用react-native-daily-js库来使我们的音频正常工作。

InCall组件Participant为通话中的每个参与者渲染一个组件,并根据谁可以发言在 UI 中显示。主持人和发言者显示在顶部,听众显示在底部。

通话中的发言者和听众

让我们看看如何呈现该Speakers部分,其中包括主持人和发言人,即可以取消静音的任何人。

// InCall.jsx
 const mods = useMemo(() => participants?.filter((p) => p?.owner), [
   participants,
   getAccountType,
 ]);

 const speakers = useMemo(
   (p) =>
     participants?.filter((p) => {
        return getAccountType(p?.user_name) === SPEAKER;
   }),
   [participants, getAccountType],
 );
Enter fullscreen mode Exit fullscreen mode

个人参与者的 UI 包括诸如他们的姓名、姓名首字母、如果他们是主持人则为星形表情符号以及根据参与者类型执行某些操作的“更多”菜单等详细信息。

参与者 UI

然而,组件最重要的方面Participant在 UI 中是不可见的:DailyMediaView组件!

// Participant.jsx
import {DailyMediaView} from '@daily-co/react-native-daily-js';

const Participant = ({participant, local, modCount, zIndex}) => {
...

{audioTrack && (
    <DailyMediaView
        id={`audio-${participant.user_id}`}
        videoTrack={null}
        audioTrack={audioTrack}
    />
)}
...

Enter fullscreen mode Exit fullscreen mode

这是一个从你的参与者列表中导入的组件,react-native-daily-js它接受来自参与者列表的音频和/或视频轨道,这些轨道也由 Daily 的 call 对象提供(回想一下callObject.participants():)。由于这是一个纯音频应用,我们将 设置videoTrack为 null,并将audioTrack其设置为每个参与者的音轨:

// Participant.jsx
const audioTrack = useMemo(
   () =>
     participant?.tracks?.audio?.state === 'playable'
       ? participant?.tracks?.audio?.track
       : null,
   [participant?.tracks?.audio?.state],
 );

Enter fullscreen mode Exit fullscreen mode

设置音轨后,您将能够听到参与者的声音。👂

先生,这是 Arby's:让主持人静音发言者

现在我们已经开始播放音频,让我们快速看一下如何使参与者静音。

如上所述,只有使用所有者会议令牌加入的参与者才可以静音其他参与者。(顺便说一句,我们不建议让参与者取消静音其他参与者。这有点侵犯隐私!😬)

为此,我们可以利用 Daily 的updateParticipant方法

CallProvider.jsx
const handleMute = useCallback(
   (p) => {
     if (!callFrame) return;
     console.log('[MUTING]');

     if (p?.user_id === 'local') {
       callFrame.setLocalAudio(false);
     } else {
       callFrame.updateParticipant(p?.session_id, {
         setAudio: false,
       });
     }
     setUpdateParticipants(`unmute-${p?.user_id}-${Date.now()}`);
   },
   [callFrame],
 );
Enter fullscreen mode Exit fullscreen mode

在 中CallProvider,我们handleMute为参与者提供了一种将自己或他人静音的方法。如果他们要将自己静音,则调用setLocalAudio(false)。如果他们要将其他人静音,则调用 ,updateParticipant并将待静音参与者的属性对象session_id和一个等于 的属性对象作为setAudio参数false

你,你,你应该知道

对于纯音频应用,需要注意的一个重要方面是设备权限。由于 Daily 的 React Native 库兼容音频和视频应用,因此除非我们干预,否则它会请求麦克风摄像头权限。

设备权限请求

如果您不解决这个问题,您的应用用户将会看到这两个设备权限请求,这对他们来说可能是一个危险信号🚩。(一个音频应用为什么需要相机权限?🤔)

为了让您的应用程序看起来不那么令人毛骨悚然,您可以videoSource在创建本地调用对象实例时将其设置为 false。

const call = Daily.createCallObject({videoSource: false});

添加这一项细节意味着您的用户只需获得麦克风权限。💫


资源

我们希望这篇 Party Line 应用概述能帮助您更好地了解其底层工作原理。我们无法涵盖所有​​细节,因此请查看以下涵盖相关主题的现有教程/资源:

在我们的下一个 React Native 教程中,我们将重点介绍如何构建视频通话应用程序,敬请期待!

与往常一样,如果您有任何疑问,请告诉我们

鏂囩珷鏉ユ簮锛�https://dev.to/trydaily/build-a-mobile-audio-call-app-in-an-afternoon-with-react-native-3j3g
PREV
2024 年最热门后端框架性能基准比较与排名
NEXT
在 Docker 中设置基本的本地 PHP 开发环境