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

最近一个 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.png

从错误日志上可以看出是因为有重复的 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 组件,对于前端开发者来说确实不太友好,但是也并不是完全不能解决。不过,如果能具备基本的原生开发知识,做起来也会更加事半功倍,能想到更好的解决方案。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容