记 RN 项目中接入 VoIP 语音通话

编辑于2019年05月23日

最近一个 RN 项目中需要接入 VoIP 语音通话功能,虽然在学校的时候学过了 Java,做过一个 Android 小项目,但是后面就完全没有接触过 Android 开发了,对 Java 的了解也停留在基础和一点 Spring Boot 上,而且,iOS 开发是完全没有接触过的,也就学校上课学了点 C、C++。一开始是十分抗拒的,不过想到编程总归是相通的,边做边学也能搞定,于是就开始尝试去对接,最后在经过差不多 4 天的努力下总算的完成了。

由于对原生开发的不熟悉,项目中还是遇到了一些坑的,所以想把这些经历记录下来。此外,完全不懂原生开发实在是不利于做 RN 项目,所以最近打算稍微补一下 iOS,至少让我能看懂别人的代码(想干的事情又变多了,有点担心贪多吃不下😌)。

说明

从功能和 UI 上来说,基本上做成和微信语音通话一样的,支持主叫、被叫、挂断、静音、扬声器以及后台通话。

因为不懂原生开发,所以 UI 希望是能通过 JS 代码实现,然后功能就调用封装好的 SDK。在研究了 SDK 文档以及 Demo 代码之后,基本上我们要做的功能 SDK 都能提供,并且只是简单的函数调函即可,iOS 的后台通话以及来电监听 SDK 也提供了,那我要做的其实就只是写好页面、封装好原生模块然后调用就好了。

Android

Android 部分的接入相对来说还是很容易的,毕竟 java 语言我是熟悉的并且了解 Android 开发的一些基础。

RN 中的 Android 原生模块是一个继承了 ReactContextBaseJavaModule 的类,覆盖父类的 getName 方法返回模块的名字,然后通过 @ReactMethod 注解导出方法给 js 层使用,方法的返回类型必须为void。如果需要返回结果给 js,可以通过传入 Callback 的回调函数形式或者使用 Promise 对象。通过覆盖 getConstants 方法,可以导出常量给 js 使用。SDK 中通话状态的变化,可以通过 RCTDeviceEventEmitter 发送事件给 js 层,然后 js 层进行处理。封装好的原生模块差不多长下面这样:

public class SipModule extends ReactContextBaseJavaModule {
    private DeviceEventManagerModule.RCTDeviceEventEmitter getEventEmitter() {
        return this.getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
    }

    public SipModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @Override
    public String getName() {
        return "SipModule";
    }

    @ReactMethod
    public void registerSip(String account, String password, String addr, String port) {
        YephoneDevice.registerSip(account, password, addr + ":" + port);
        YephoneDevice.setInCallActivity(MainActivity.class);
        final DeviceEventManagerModule.RCTDeviceEventEmitter emitter = this.getEventEmitter();
        YephoneDevice.setAccountState(new YephoneManager.YephoneAccountStateChangedListener() {
            @Override
            public void state(int state, String account, String message) {
                Log.i("sip", "state:" + message);
                emitter.emit("sipStateChange", state);
            }
        });
    }
    
    ...
}

原生模块封装好以后,添加一个 使用了 ReactPackage 接口的 Package 类注册该模块,然后在 MainApplication.java 文件的 getPackages 方法中添加该 Package 即可。

在 JS 中,通过 NativeModules.SipModule 即可访问到添加的原生模块,通过 DeviceEventEmitter 可以注册 Android 原生端发来的事件(iOS 中稍不一样)。

遇到的问题

问题一:SDK 中要求调用 setInCallActivity 函数指定当有来电时应该显示的页面,设置好后 SDK 会在有来电时激活 App 并跳转到该页面,但是 RN 中只有一个 MainActivicy,如果添加新的 Activity,通话页面就无法使用 js 来写了。

这里我们希望的是在收到来电后 SDK 发出通知而不是直接进行页面跳转(这确实做得太多了),但是在于开发商沟通后得到的结果是暂不支持。后来,在与做 Android 开发的小伙伴沟通后得知Acvicity 支持多种启动模式,其中如果设置为 singleTop 模式,在启动该 Acvicity 时不会创建新的。这样一来,我们只需要调用 setInCallActivity 将来电的页面设置为 MainAcvicity 即可,并且当发生调转时会调用 onNewIntent 生命周期函数,我们可以在这里发出事件通知 js 有新的来电。

问题二:电话接通后没有声音。

在完成了主叫和被叫的逻辑之后发现通话时没有声音,起初以为是设置的编码方式不对出了问题,但是我使用的是和 Demo 中一样的编码,并且后面测试发现 Demo 通话时也没有声音。原本打算联系 SDK 开发商解决,不过后面突然想到在使用的过程中没有提示请求麦克风权限,猜测是不是与这有关。于是,在核实了没有麦克风权限之后,手动打开权限再进行测试便可以正常通话了。最终,在 js 中添加了 Android 权限申请的代码,解决了该问题。

iOS

iOS 部分的接入相较于 Android 中就稍微麻烦了一点,首先是 Objective-C 语法不熟悉,然后也不太会 Xcode 的使用,我甚至都不知道该如何导入 SDK。

由于不太懂,所以我只好照着 SDK 文档中说的进行操作。首先将 SDK 目录复制到 iOS 项目目录下,然后在 Xcode 中右键项目名选择 Add File To...,选择刚刚复制过来的文件夹。然后在 Build Phases->Link Binary With Libraries 中添加 SDK 需要的依赖,接着在 Capabilities 中启用 Background Modes 以支持后台通话,最后再 Build Settings 中关闭了 Bitcode(因为 SDK 不支持)。其中 Bitcode 我了解到是编辑器编译过程中的一种中间码,先将 C、OC 等高级编程语言转换成 Bitcode,然后在将 Bitcode 转换成不同 CPU 架构上的汇编或机器码。

根据文档添加好 SDK 之后,我先尝试编译了一下,然后编译时却报错了,如下图所示。

ios_build_fail

从错误日志上可以看出是因为有重复的 Symbol 导致的,参考这篇博客使用拆分库然后删除对应的 .o 文件解决了该问题。

原生模块

编译通过之后,就需要添加 iOS 原生模块了。一个 iOS 模板就是一个使用了 RCTBridgeModule 的 Objective-C 类。为了实现RCTBridgeModule,类中需要包含 RCT_EXPORT_MODULE() 宏。这个宏也可以添加一个参数用来指定在 Javascript 中访问这个模块的名字。如果不指定,默认就会使用这个类的名字。通过 RCT_EXPORT_METHOD() 宏可以声明要给 Javascript 导出的方法。

// SipModule.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import "YeCallEventDelegate.h"

@interface SipModule : RCTEventEmitter <RCTBridgeModule, YeCallEventDelegate>
@property (nonatomic,strong) NSString* callID;
@end

// SipModule.m
#import "SipModule.h"
#import "YePhoneManager.h"
#import <Foundation/Foundation.h>
#import <React/RCTLog.h>

@implementation SipModule

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(registerSip:(NSString*)sipAccount sipAccPwd:(NSString*)sipAccPwd host:(NSString*)host port:(NSString*)port){
  NSString * sipProxy = [[NSString alloc] initWithFormat:@"%@:%@", host, port];
  [[YePhoneManager instance] registed:sipAccount sipAccPwd:sipAccPwd sipServerAddr:host sipProxy:sipProxy];
}

RCT_EXPORT_METHOD(answer){
  [[YePhoneManager instance] acceptCall:_callID];
}

...

多线程

参考官网文档,原生模块可以指定自己想在哪个队列中被执行。如果模块需要调用一些必须在主线程才能使用的API,那应当这样指定:

- (dispatch_queue_t)methodQueue
{
  return dispatch_get_main_queue();
}

类似的,如果一个操作需要花费很长时间,原生模块不应该阻塞住,而是应当声明一个用于执行操作的独立队列:

- (dispatch_queue_t)methodQueue
{
  return dispatch_queue_create("com.facebook.React.AsyncLocalStorageQueue", DISPATCH_QUEUE_SERIAL);
}

给 Javascript 发送事件

通过继承 RCTEventEmitter,实现 suppportEvents 方法并调用 self sendEventWithName:

- (NSArray<NSString *> *)supportedEvents{
  return @[@"sipStateChange", @"sipOutRing", @"sipCallStart", @"sipCallFail", @"sipCallEnd", @"sipCallIn"];
}

- (NSString*)registrationOk:(NSString*)registrationOk{
  [self sendEventWithName:@"sipStateChange" body:@1];
  return registrationOk;
}

JavaScript代码可以创建一个包含对应模块的 NativeEventEmitter 实例来订阅这些事件:

import { NativeEventEmitter, DeviceEventEmitter, NativeModules } from 'react-native'

import { IS_ANDROID } from '../../utils/device';

const { SipModule } = NativeModules

export const sipEventEmitter = IS_ANDROID ? DeviceEventEmitter : new NativeEventEmitter(SipModule)

export default SipModule

总结

React Native 项目开发过程中,如果需要接入原生模块或者原生 UI 组件,对于前端开发者来说确实不太友好,但是也并不是完全不能解决。不过,如果能具备基本的原生开发知识,做起来也会更加事半功倍,能想到更好的解决方案。