相关文章推荐
有腹肌的机器猫  ·  React / ...·  6 天前    · 
性感的饼干  ·  React.lazy()与Suspense: ...·  6 天前    · 
坚韧的酸菜鱼  ·  VueCLi build ...·  1 年前    · 
玩滑板的啄木鸟  ·  Oops!!! - 简书·  1 年前    · 
长情的小熊猫  ·  Oracle ...·  1 年前    · 
业务需要开发基于RN的IM客户端,已经实现了播放、录制功能,现在需要新增对蓝牙耳机、有线耳机的支持,两者都需要用到原生APi的能力。当前RN采用的模块没有合适的方法去处理,本文从当前实现逻辑分析,到Android原生原理,最后封装原生API实现RN功能。

当前RN实现播放录音方式

RN播放 :react-native-sound —— 基于AudioManager,播放时通过setSpeackPhoneOn(true),去setMode(MODE_IN_COMMUNICATION),开启MODE_IN_COMMUNICATION,然后开启扬声器,避免回声消除。
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
  //turn speaker on
  @ReactMethod
  public void setSpeakerphoneOn(final Double key, final Boolean speaker) {
    MediaPlayer player = this.playerPool.get(key);
    if (player != null) {
      AudioManager audioManager = (AudioManager)this.context.getSystemService(this.context.AUDIO_SERVICE);
      if(speaker){
        audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
      }else{
        audioManager.setMode(AudioManager.MODE_NORMAL);
      audioManager.setSpeakerphoneOn(speaker);

通过sound模块封装顺序播放控制PlayRecord.ts进行播放

import Sound from 'react-native-sound';
type ISetCurrentFilepath = {(filepath: string): void};
Sound.setCategory('Voice')
export class PlayRecord {
  BaseUrl: string;
  sound = {} as Sound;
  playList = [] as string[];
  isPlay = false as boolean;
  setCurrentFilepath: ISetCurrentFilepath;
  constructor(setCurrentFilepath: ISetCurrentFilepath, baseUrl: string) {
    this.setCurrentFilepath = setCurrentFilepath;
    this.BaseUrl = baseUrl;
  push(filepath: string) {
    console.log('this.isPlay', this.isPlay);
    if (this.isPlay) {
      this.playList.push(filepath);
    } else {
      this.play(filepath);
  closePlay() {
    console.log('closePlay', this.isPlay);
    if (this.isPlay) {
      this.isPlay = false;
      this.setCurrentFilepath('');
      this.sound.release();
  clickPlay(filepath: string) {
    console.log('clickPlay,this.isPlay:', this.isPlay);
    if (this.isPlay) {
      this.sound.release();
      this.isPlay = false;
      this.setCurrentFilepath('');
      this.playList = [];
    this.play(filepath);
  play(filepath: string,sourceType: 'url'|'local' = 'url') {
    let url = this.BaseUrl + filepath;
    if(sourceType === 'local'){
      url = filepath
    this.sound = new Sound(url, Sound.CACHES, async e => {
      console.log('callback duration :>> ', this.sound.getDuration());
      if (e) {
        console.log(`${url} play fail, error:${e}`);
      // await this.sound.setSpeakerphoneOn(true);    通过播放采用切换到通话模式(默认听筒),并打开扬声器
      this.isPlay = true;
      this.setCurrentFilepath(filepath);
      this.sound.play(() => {
        console.log(`${url}play success,speed:${this.sound.getSpeed()}`);
        this.setCurrentFilepath('');
        this.isPlay = false;
        this.sound.release();
        if (this.playList.length) {
          return new Promise((resolve, reject) => {
            this.play(this.playList.shift() as string);

RN录制:react-native-audio-record —— 基于AudioRecord实现,通过audioSource控制录音类型,当前使用的VOICE_COMMUNICATION

new AudioRecord(
        audioSource,  
        sampleRateInHz, 
        channelConfig, 
        audioFormat, 
        recordingBufferSize

使用方式:

const options = {
  sampleRate: 16000, // default 44100
  channels: 1, // 1 or 2, default 1->AudioFormat.CHANNEL_IN_MONO   2->AudioFormat.CHANNEL_IN_STEREO
  bitsPerSample: 16, // 8 or 16, default 16
  //android only (see below) https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION
  //7 -> AudioSource#VOICE_COMMUNICATION
  //1 -> MIC
  audioSource: 7,
  wavFile: 'test.wav', // default 'audio.wav'
const ar = useRef(AudioRecord);
ar.current.init(options);
ar.current.start()

当前实现方式主要通过以下2点实现:

1播放是AudioManager类采用VOICE_COMMUNICATION模式,从听筒进行播放,再打开扬声器进行播放,实现回声消除。

2.录音采用VOICE_COMMUNICATION模式

当前正常进行录音播放没有问题,但是如果遇到耳机、蓝牙等收听播放播放时,播放录音都有问题。蓝牙(无法播放录音)、有线耳机(无法播放录音)

Android实现蓝牙播放录音方式

直接使用蓝牙耳机不管通过安卓原生MediaRecord还是AudioRecord录制都无法实现录音,因为蓝牙的默认链路为单向播放传输。
蓝牙耳机有两种链路,A2DP及SCO。android的api表明:A2DP是一种单向的高品质音频数据传输链路,通常用于播放立体声音乐;而SCO则是一种双向的音频数据的传输链路,该链路只支持8K及16K单声道的音频数据,只能用于普通语音的传输,若用于播放音乐那就只能呵呵了。两者的主要区别是:A2DP只能播放,默认是打开的,而SCO既能录音也能播放,默认是关闭的。
既然要录音肯定要打开sco啦,因此识别前调用上面的代码就可以通过蓝牙耳机录音了,录完记得要关闭。

原生打开蓝牙Sco方式:

private AudioManager mAudioManager = null;
mAudioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);
mAudioManager.setBluetoothScoOn(true);
# https://developer.android.com/reference/android/media/AudioManager#startBluetoothSco()
mAudioManager.startBluetoothSco();

原生实现蓝牙播放录音

至此我们知道通过SCO模式,可以使蓝牙进入录音状态,至于无法播放的原因主要有2个

1.因为react-native-sound模块播放的时候调用了打开扬声器,导致音频从扬声器上进行播放。

2.录音时Audiorecord采用VOICE_COMMUNICATION,蓝牙耳麦离嘴巴远录音效果不好,在使用蓝牙时采用MIC(1)或者Default模式

原生实现主要控制2点,1.判断当前是否有蓝牙连接 2.蓝牙插入与断开检测

1:通过BluetoothAdapter类实现蓝牙状态信息获取

* 通过这个来获取是否有耳机连接 *
@return * -1 无蓝牙耳机连接 * -2 蓝牙没开 * >0 已连接 public int getHeadSetStatus() { BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); // 蓝牙耳机 if (bluetoothAdapter == null) { // 若蓝牙耳机无连接 return -1; } else if (bluetoothAdapter.isEnabled()) { int a2dp = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.A2DP); // 可操控蓝牙设备,如带播放暂停功能的蓝牙耳机 int headset = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET); // 蓝牙头戴式耳机,支持语音输入输出 int health = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEALTH); // 蓝牙穿戴式设备 // 查看是否蓝牙是否连接到三种设备的一种,以此来判断是否处于连接状态还是打开并没有连接的状态 int flag = -1; if (a2dp == BluetoothProfile.STATE_CONNECTED) { flag = a2dp; } else if (headset == BluetoothProfile.STATE_CONNECTED) { flag = headset; } else if (health == BluetoothProfile.STATE_CONNECTED) { flag = health; // 说明连接上了三种设备的一种 if (flag != -1) { return flag; return -2;
     * 停止SCO
     * @param promise
    @ReactMethod
    public void stopBluetoothSco(Promise promise) {
        mAudioManager.setBluetoothScoOn(false);
        mAudioManager.stopBluetoothSco();
        mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
        mAudioManager.setSpeakerphoneOn(true);
        promise.resolve(true);
     * 开启SCO
     * @param promise
    @ReactMethod
    public void startBluetoothSco(Promise promise) {
        mAudioManager.setBluetoothScoOn(true);
        mAudioManager.startBluetoothSco();
        mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
        mAudioManager.setSpeakerphoneOn(false);
        promise.resolve(true);
    // 注册广播
    private void registerBluetoothDeviceReceiver() {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED);
        intentFilter.setPriority(Integer.MAX_VALUE);//设置优先级
        blueToothtReceiver = new BlueToothtReceiver();//注册
        mReactContext.registerReceiver(blueToothtReceiver, intentFilter);
    public class  BlueToothtReceiver extends BroadcastReceiver{
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();if(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED.equals(action)){//蓝牙连接成功
                int currentState= intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, -1);//当前状态
                if(currentState == BluetoothA2dp.STATE_CONNECTED) {//连接成功
                    // connectPromise.resolve("连接成功");
                    sendEvent("connectSucceeded", "");
                if(currentState == BluetoothA2dp.STATE_DISCONNECTED){//断开连接
                    sendEvent("connectDisconnect", "");
                    // connectPromise.reject("连接失败");
    // 发送事件到js
    public static void sendEvent(String eventName, @Nullable WritableMap params) {
        A2dpModule.reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);

原生实现耳机播放录音

这一个可以参考RN Incall的原生代码实现,与蓝牙类似、获取是否存在耳机、已经监听耳机插入拔出,进行对应的切换控制

这里我尝试后,发现ACTION_HEADSET_PLUG事件一直注册不上,注册了一获取action,app打开后就会闪退,希望有Android大咖能指出错误,谢谢

public class HeadsetModule extends ReactContextBaseJavaModule {
  private BroadcastReceiver wiredHeadsetReceiver;
  private boolean hasWiredHeadset = false;
  private static final String ACTION_HEADSET_PLUG = (android.os.Build.VERSION.SDK_INT >= 21)
      ? AudioManager.ACTION_HEADSET_PLUG
      : Intent.ACTION_HEADSET_PLUG;
  public static ReactApplicationContext reactContext;
  public HeadsetModule(ReactApplicationContext context) {
    super(context);
    reactContext = context;
    // registerBluetoothDeviceReceiver();
  @Override
  public String getName() {
    return "Headset";
  // 注册广播
  private void registerBluetoothDeviceReceiver() {
    IntentFilter filter = new IntentFilter(ACTION_HEADSET_PLUG);
    wiredHeadsetReceiver = new WiredHeadsetReceiver();// 注册
    reactContext.registerReceiver(wiredHeadsetReceiver, filter);
  public class WiredHeadsetReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
      if (ACTION_HEADSET_PLUG.equals(intent.getAction())) {
        hasWiredHeadset = intent.getIntExtra("state", 0) == 1;
        String deviceName = intent.getStringExtra("name");
        if (deviceName == null) {
          deviceName = "";
        WritableMap data = Arguments.createMap();
        data.putBoolean("isPlugged", (intent.getIntExtra("state", 0) == 1) ? true : false);
        data.putBoolean("hasMic", (intent.getIntExtra("microphone", 0) == 1) ? true : false);
        data.putString("deviceName", deviceName);
        sendEvent("WiredHeadset", data);
      } else {
        hasWiredHeadset = false;
public static void sendEvent(String eventName, String params) {
    reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);

RN通过原生代码实现对应设备播放录音

通过原生实现了基础能力,接下来只要在RN中调用进行切换状态即可,下面是RN切换要点(暂不考虑耳机、蓝牙同时使用情况)

// 初始化蓝牙监听器、自动切换播放录音模式 (async () => { let bluetoothStatus = await A2dp.getBluetoothStatus(); if(bluetoothStatus > 0){ dispatch(playRecordDeviceChange('bluetooth')) }else{ dispatch(playRecordDeviceChange('local_machine')) A2dp.on('connectSucceeded', async () => { console.log('蓝牙连接成功'); dispatch(playRecordDeviceChange('bluetooth')) A2dp.on('connectDisconnect', async () => { console.log('蓝牙断开连接'); dispatch(playRecordDeviceChange('local_machine')) })(); return () => { if(playRecordDevice == 'bluetooth'){ A2dp.stopBluetoothSco(); console.log("关闭蓝牙SCO(app关闭)"); }, []) useEffect(() => { // 初始化权限 // 初始化录音模块 AudioRecord const options = { sampleRate: 16000, // default 44100 channels: 1, // 1 or 2, default 1 bitsPerSample: 16, // 8 or 16, default 16 //7 -> AudioSource#VOICE_COMMUNICATION //1 -> MIC audioSource: 7, // android only (see below) https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION wavFile: 'test.wav', // default 'audio.wav' (async ()
=> { if(!hasPermission.current) { console.log("检查权限",hasPermission.current) hasPermission.current = await checkPermission(); if(hasPermission.current){ console.log("权限获取成功",hasPermission.current) if(playRecordDevice){ if(playRecordDevice == 'local_machine'){ await A2dp.stopBluetoothSco(); console.log("关闭蓝牙SCO(playRecordDevice变化)"); }else if(playRecordDevice == 'bluetooth'){ options.audioSource = 1 await A2dp.startBluetoothSco(); console.log("打开蓝牙SCO(playRecordDevice变化)"); dispatch(initAudioRecord(options)); console.log("重新初始化录音模块 AudioRecord",options); let mode = await A2dp.getAudioManagerMode() console.log('AudioManager mode :>> ', mode); }else{ dispatch(initAudioRecord(options)); }else{ console.log("权限获取失败"); Alert.alert("权限错误","软件无法使用,获取权限失败") })(); return () => {} }, [playRecordDevice])

参考资料/代码:

https://dandelioncloud.cn/article/details/1518425101983899650

microCloudCode/react-native-a2dp: reactNative连接a2dp和开启sco (github.com)

react-native-webrtc/react-native-incall-manager: Handling media-routes/sensors/events during a audio/video chat on React Native (github.com)