React Native Agora v3からv4の変更点

現在はReact Nativeでビデオ通信を用いたアプリを開発しています。

初期の開発は[React Native WebRTC](https://github.com/react-native-webrtc/react-native-webrtc)を使用していましたが、現在はドキュメントの充実と将来的な開発の安定性を考えて[React Native Agora](https://github.com/AgoraIO-Extensions/react-native-agora)を使用しています。

導入時期はまだv3系でしたが、node_modulesをアップデートした際にv4系に上がっていました。

修正する箇所が何箇所かあったので、まずは調査を兼ねてミニマムなものを作りました。

## 作ったもの

以下の要件のシンプルなものを作成しました。

1. スクリーンをロードした際に自分のカメラの映像を表示
2. 「JOIN」をタップするとserverへuidTokenを作成をリクエストし、チャンネルへJOINする
3. 両方のデバイスがJOINすると相手の画面に映像が小窓で表示される
4. 「LEAVE」をタップすると映像が切れる

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54502/39b239a5-07fb-5862-ec0d-b6715eaa9b3f.png" width="320" />

## コードの変更点

### engineの作成

v3は`createWithContext`でPromiseが返ってきましたが、v4ではその辺りは意識しなくてよくなっています。

```ts
// v3
const engine = await RtcEngine.createWithContext(new RtcEngineContext(appId))

// v4
const engine = createAgoraRtcEngine()
engine.initialize({ appId, channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting })
```

### Eventの作成

イベント名は「on + イベント名」に変更されています。

```ts
// v3
engine.addListener('JoinChannelSuccess', joinSuccessHandler)
engine.addListener('Error', (e) => console.info(e))
engine.addListener('UserJoined', userJoinedHandler)
engine.addListener('UserOffline', userOfflineHandler)
engine.addListener('LeaveChannel', leaveChannelHandler)

// v4
engine.addListener('onJoinChannelSuccess', joinSuccessHandler)
engine.addListener('onError', (e) => console.info(e))
engine.addListener('onUserJoined', userJoinedHandler)
engine.addListener('onUserOffline', userOfflineHandler)
engine.addListener('onLeaveChannel', leaveChannelHandler)
```

### joinChannelの引数

v3の第3引数が`optionalInfo`ということですが、型を見ると"optionalInfo (Optional) Reserved for future use." となっており、おそらく使われないままv4に移行したのかなと思います。

v4ではv3で第4引数だったuidが第3引数になっており、第4引数では`ChannelMediaOptions`を指定するようになっています。

```ts
// v3
engine.joinChannel(token, 'CHANNEL_ID', null, uid)

// v4
engine.joinChannel(token, 'CHANNEL_ID', uid, {
    clientRoleType: ClientRoleType.ClientRoleBroadcaster,
  }
)
```

### RtcLocalViewとRtcRemoteView

v3で使用していたRtcLocalViewとRtcRemoteViewはなくなりました。
RtcLocalViewがなくなったため、canvasの`{ uid }`で誰の映像かを判定しているのかと思います。ちょっとドキュメントでは見つけられなかったのですが、どうやら0を指定すると自分のカメラの映像がプレビューされるっぽいです。

```tsx
// v3
const memberView = useMemo(() => {
  return members.map((uid, key) => {
    return (
      <RtcRemoteView.SurfaceView
        key={key}
        style={styles.memberView}
        uid={uid}
        zOrderMediaOverlay={true}
      />
    )
  })
}, [members])

return (
  <View>
    <RtcLocalView.SurfaceView style={styles.localView} />
    {memberView}
  </View>
)

// v4 
const memberView = useMemo(() => {
  return members.map((uid, key) => {
    return (
      <RtcSurfaceView
        key={key}
        style={styles.memberView} 
        zOrderMediaOverlay={true}
        canvas={{ uid }}
      />
    )
  })
}, [members])

return (
  <View>
    <RtcSurfaceView canvas={{ uid: 0 }} style={styles.localView} />
    {memberView}
  </View>
)
```

## v4のテストに使用したコード

tokenの生成にserver.jsを使用しています。

### MainScreen.tsx

```tsx
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { StyleSheet, Text, Button, View } from 'react-native'
import { createAgoraRtcEngine, RtcSurfaceView, ChannelProfileType, ClientRoleType } from 'react-native-agora'
import type { IRtcEngine, RtcConnection, UserOfflineReasonType, RtcStats } from 'react-native-agora'
import { agora } from '../../constants'

export default function MainScreen() {
  const [engine, setEngine] = useState<IRtcEngine>()
  const [uids, setUids] = useState<number[]>([])
  const [id] = useState<number>(() => Math.floor(Math.random() * 1000))

  useEffect(() => {
    const mount = async () => {
      if (engine) return
      const rtcEngine = createAgoraRtcEngine()
      rtcEngine.initialize({ appId: agora.appId, channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting })

      rtcEngine.addListener('onJoinChannelSuccess', joinSuccessHandler)
      rtcEngine.addListener('onError', (e) => console.info(e))
      rtcEngine.addListener('onUserJoined', userJoinedHandler)
      rtcEngine.addListener('onUserOffline', userOfflineHandler)
      rtcEngine.addListener('onLeaveChannel', leaveChannelHandler)
      rtcEngine.enableVideo()
      rtcEngine.disableAudio()
      rtcEngine.startPreview()

      setEngine(rtcEngine)
    }
    mount()
  }, [engine])

  const joinSuccessHandler = (connection: RtcConnection, elapsed: number) => {
    console.info('JoinChannelSuccess', connection, elapsed)
  }
  const userJoinedHandler = (connection: RtcConnection, remoteUid: number, elapsed: number) => {
    console.info('UserJoined', connection, remoteUid, elapsed)
    setUids((items) => [...items, remoteUid])
  }
  const userOfflineHandler = (connection: RtcConnection, remoteUid: number, reason: UserOfflineReasonType) => {
    console.info('UserOffline', connection, remoteUid, reason)
    setUids((items) => items.filter((item) => item !== remoteUid))
  }
  const leaveChannelHandler = (connection: RtcConnection, stats: RtcStats) => {
    console.info('LeaveChannel', connection, stats)
    setUids([])
  }

  const _handleJoinChannel = useCallback(async () => {
    if (!engine) return
    const res = await fetch('http://xxxxxxxxxxxxx/token/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ id, channel: agora.channelId })
    })    
      .then((res) => res.json())
      .catch((error) => {
        console.error('エラー', error)
      })
    engine.joinChannel(
      res.uidToken, agora.channelId, id, {
        clientRoleType: ClientRoleType.ClientRoleBroadcaster,
      }
    )
  }, [engine])

  const _handleLeaveChannel = useCallback(async () => {
    if (!engine) return
    engine.leaveChannel()
  }, [engine])

  const members = useMemo(() => {
    if (!uids.length) return
    return uids.map((uid, key) => {
      return (
        <RtcSurfaceView
          key={key}
          style={styles.remote} 
          zOrderMediaOverlay={uid !== 0}
          canvas={{ uid }}
        />
      )
    })
  }, [uids])

  return (
    <View style={styles.container}>
      <View>
        <Text style={styles.title}>MainScreen #{id}</Text>
        <Button
          onPress={_handleJoinChannel}
          title={'JOIN'}
        />
        <Button
          onPress={_handleLeaveChannel}
          title={'LEAVE'}
        />
      </View>
      <View>
        <RtcSurfaceView canvas={{ uid: 0 }} style={styles.local} />
        {members}
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
  },
  local: {
    width: '100%',
    height: '100%'
  },
  remote: {
    position: 'absolute',
    top: 20,
    right: 20,
    width: 120,
    height: 180
  },
})
```

### server.js

```ts
const express = require("express")
const { RtcTokenBuilder, RtcRole } = require('agora-access-token')
const constants = {
  appId: "xxx",
  appCertificate: "xxx"
}

const app = express()
const PORT = 3000

app.use(express.json())
app.use(express.urlencoded({ extended: true }))

app.post("/token/create", (req, res) => {
  const channelName = req.body.channel
  const uid = req.body.id
   
  const expirationTimeInSeconds = 36000
  const currentTimestamp = Math.floor(Date.now() / 1000)
  const privilegeExpiredTs = currentTimestamp + expirationTimeInSeconds
  const uidToken = RtcTokenBuilder.buildTokenWithUid(
    constants.appId,
    constants.appCertificate,
    channelName,
    uid,
    RtcRole.PUBLISHER,
    privilegeExpiredTs
  )
  return res.status(200).json({
    uidToken,
  })
})

app.listen(PORT, () => {
  console.log(`Example app listening on port ${PORT}`)
})
```

## 気になったところ

### switchCamera()がロード時にうまく動かない

おそらくv3の時はいろいろな処理がPromiseの非同期処理が完了してから、switchCameraを呼んでいたので切り替えれたのかと思います。
今回の場合は特にPromiseが返ってくるものがない為、`startPreview()`する前にdeleyした方がいいのかもしれません。（未実施）

そもそもですが「使用するカメラを指定させて欲しい」「現状のカメラの状態を確認する値が欲しい」というのがあり、それができればこの問題も根本的に解決できるとは思います。

## まとめ
個人的にはv3に不満があったわけではありませんが、これからの開発とメンテのことを考えコードもまだ小さいこともありv4に移行していく予定です。

React Nativeを用いたAgoraの事例がまだまだ少ないので、今後もっと増えていくといいなーと思っています。