原文:CallKit Tutorial for iOS
作者:József Vesza
译者:kmyhy
对 VoIP App 开发者来说,iOS 的支持并不友好。尤其是它的通知发送这一块,太糙了。你的 App 允许在后台,你唯一的选择就是使用常规的通知,这也太容易搞丢了。和内置的、丰富的电话 UI 一比,突然你的 App 是如此的不和谐。
幸好,苹果在 iOS 10 中推出了 CallKit,让这一切发生了改变!
在这个教程中,你将通过编写一个 App 领略到 CallKit 的风采:
注意:CallKit 无法在模拟器上运行。为了配合本教程,你必须使用一台装有 iOS 10.2 的 iPhone。
从此处下载本教程的开始项目,然后解压缩。为了在设备上调试项目,你必须对代码进行签名。打开项目文件,在项目导航器中选择 Hotline。
你需要修改 bundle ID。选中项目,在 General 窗口,找到 Identity 一栏。将 bundle ID 修改为其它:
然后,找到 Signing 栏。从下拉框中选择你的开发团队(以我为例,是我自己的个人团队)。确保勾选上 Automatically manage signing。这允许 Xcode 自动创建 App 所用的 provisioning profile。
运行 App 进行测试。
目前 App 还没有什么内容,但你会在开始项目中发现几个源文件。它们大部分用于创建UI,处理用户交互,其中比较值得注意的是这两个类:
CallKit 是一个新框架,用于改善 VoIP 的体验,允许 App 和原生的 Phone UI 紧密集成,你的 App 将能够:
本节中,你将学习 CallKit 的构成。下图显示了几个重要对象:
在使用 CallKit 时,有两个主要的类:CXProvider和 CXCallController。分别介绍如下。
你的 App 使用 CXProvider 来将外部通知报告给系统。通常是外部事件,比如来电。
当有事件发生,CXProvider 会创建一个 call update 来通知系统。什么是 call update?call update 用于封装新的或者改变了的和通话有关的信息。它用 CXCallUpdate 类来描述,这个类暴露了这些属性:呼入者姓名、是否是音频通话还是视频通话。
当系统想通知 App 有收到一个事件时,它会以 CXAction 的形式通知。CXAction 是一个抽象类,表示电话的动作。针对不同 action,CallKit 会提供不同的 CXAction 实现。例如,呼出用 CXStartCallAction 来表示,CXAnswerCallAction 则用于接听呼入。Action 通过唯一的 UUID 来识别,它要么是 fail 要么是 fulfill。
App 通过 CXProviderDelegate 和 CXProvider 打交道,这个协议定义了 CXProvider 的生命周期事件方法,以及来电 Action。
App 使用 CXCallController 来让系统知道用户发起的请求,比如“呼叫”动作。CXProvider 和 CXCallController 的最大不同在于:CXProvider 的工作是通知系统,而 CXCallController 则代表用户向用户发起请求。
CXCallController 在发起请求时使用了事务。事务用 CXTransaction 来表示,它会包含一个或多个 CXAction 实例。CXCallCotroller 将事务发送给系统,如果一切正常,系统会响应对应的 action 给 CXProvider。
理论还不少,但怎样使用它们呢?
下图显示了来电的高度抽象的模型:
第一步是创建 CXProvider 的委托。
回到 Xcode,在项目导航器中,选中 App 文件夹,点击菜单 File\New…,然后选择 iOS\Source\Swift File。名字命名为 ProviderDelegate,然后点 Create。
在文件中添加代码:
import AVFoundation
import CallKit
class ProviderDelegate: NSObject {
// 1.
fileprivate let callManager: CallManager
fileprivate let provider: CXProvider
init(callManager: CallManager) {
self.callManager = callManager
// 2.
provider = CXProvider(configuration: type(of: self).providerConfiguration)
super.init()
// 3.
provider.setDelegate(self, queue: nil)
}
// 4.
static var providerConfiguration: CXProviderConfiguration {
let providerConfiguration = CXProviderConfiguration(localizedName: "Hotline")
providerConfiguration.supportsVideo = true
providerConfiguration.maximumCallsPerCallGroup = 1
providerConfiguration.supportedHandleTypes = [.phoneNumber]
return providerConfiguration
}
}
这段代码解释如下:
在 providerConfiguration 下面,添加一个工具方法:
func reportIncomingCall(uuid: UUID, handle: String, hasVideo: Bool = false, completion: ((NSError?) -> Void)?) {
// 1.
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
// 2.
provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error == nil {
// 3.
let call = Call(uuid: uuid, handle: handle)
self.callManager.add(call: call)
}
// 4.
completion?(error as? NSError)
}
}
这个工具方法允许 App 通过 CXProvider API 来报告一个来电。代码解释如下:
这个方法被其它类所调用,为了模拟来电呼入。
接下来是实现协议方法。仍然在 ProviderDelegate.swift 文件中,声明一个新的扩展,实现 CXProviderDelegate:
extension ProviderDelegate: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
stopAudio()
for call in callManager.calls {
call.end()
}
callManager.removeAllCalls()
}
}
CXProviderDelegate 只实现一个 required 的方法,providerDidReset(_:)。当 CXProvider 被 reset 时,这个方法被调用,这样你的 App 就可以清空所有去电,会到干净的状态。在这个方法中,你会停止所有的呼出音频会话,然后抛弃所有激活的通话。
现在 ProviderDelegate 提供了一个方法去报告来电,让我们来用用它!
在项目导航器中选择 App 文件夹,打开 AppDelegate.swift。在类中添加一个新属性:
lazy var providerDelegate: ProviderDelegate = ProviderDelegate(callManager: self.callManager)
providerDelegate 已经整装待发!在 AppDelegate 中添加如下方法:
func displayIncomingCall(uuid: UUID, handle: String, hasVideo: Bool = false, completion: ((NSError?) -> Void)?) {
providerDelegate.reportIncomingCall(uuid: uuid, handle: handle, hasVideo: hasVideo, completion: completion)
}
这个方法向其它类暴露 providerDelegate 的工具方法。
最后一块拼图是将它和 UI 连接到一起。展开 UI/View Controllers 文件夹,打开 CallsViewController.swift,这是 App 主界面的控制器。找到空的 unwindSegueForNewCall(_:)方法,替换为如下代码:
@IBAction private func unwindForNewCall(_ segue: UIStoryboardSegue) {
// 1.
let newCallController = segue.source as! NewCallViewController
guard let handle = newCallController.handle else { return }
let videoEnabled = newCallController.videoEnabled
// 2.
let backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
DispatchQueue.main.asyncAfter(wallDeadline: DispatchWallTime.now() + 1.5) {
AppDelegate.shared.displayIncomingCall(uuid: UUID(), handle: handle, hasVideo: videoEnabled) { _ in
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
}
}
}
这段代码的大意是:
现在一切就绪,运行 App,进行如下操作:
几秒钟后,你会看到原生的呼入通话 UI:
但是,一旦你要接听电话,你会看到 UI 会仍然停留在下面的状态:
这是因为你还没有实现和接听电话对应的方法。回到 Xcode,打开 ProviderDelegate.swift,在类扩展中添加:
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// 1.
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 2.
configureAudioSession()
// 3.
call.answer()
// 4.
action.fulfill()
}
// 5.
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudio()
}
这段代码大意如下:
运行 App,再次开始呼入一个通话。当你接听时,系统会成功地变成去电状态。
如果你解锁 iPhone,你会看到 iOS 和 App 都会显示出正确的呼出状态。
接听通话会带来一个问题:没有办法结束通话。这个 App 将会支持两种结束通话的方式:从原生的通话界面,或者从 App 中进行结束。
下图显示这两种结束通话的情况:
注意第一步有所不同:当用户从通话界面结束通话(1a)时,系统会自动发送一个 CXEndCallAction 给 CXProvider。但是,如果你想用 Hotline App 来结束通话(1b),那么应该有你来将 CXAction 封装成 CXTransaction,然后请求系统。当系统处理完请求,它会发送 CXEndCallCation 给 CXProvider。
不管哪种方法,你的 App 必须实现相应的 CXProviderDelegate 方法。打开 ProviderDelegate.swift,在类的扩展中添加下列方法:
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
// 1.
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 2.
stopAudio()
// 3.
call.end()
// 4.
action.fulfill()
// 5.
callManager.remove(call: call)
}
还不是太难!代码解释如下:
这只实现了从原生通话界面结束的情况。为了从 App 结束通话,你必须修改 CallManager。在项目导航器的 Call Management 文件夹下,打开 CallManager.swift。
CallManager 需要和 CXCallController 通信,因此需要一个它的引用。添加属性:
private let callController = CXCallController()
在类中添加下列方法:
Now add the following methods to the class:
func end(call: Call) {
// 1.
let endCallAction = CXEndCallAction(call: call.uuid)
// 2.
let transaction = CXTransaction(action: endCallAction)
requestTransaction(transaction)
}
// 3.
private func requestTransaction(_ transaction: CXTransaction) {
callController.request(transaction) { error in
if let error = error {
print("Error requesting transaction: \(error)")
} else {
print("Requested transaction successfully")
}
}
}
代码解释如下:
最后是将代码和 UI 连接起来。打开 CallsViewController.swift,在 tableView(_:cellForRowAt:) 方法下面,添加代码:
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
let call = callManager.calls[indexPath.row]
callManager.end(call: call)
}
当用户在 cell 上使用轻扫-删除手势时,App 会请求 CallManager 结束对应的通话。
运行 App,执行下列操作:
这时,通话结束。无论锁屏还是不锁屏,无论 App 是否在前台,这个 App 都会报告通话。
如果你看过 CXProviderDelegate 的文档,你会注意到 CXProvider 还会执行许多 CXAction,包括静音、群组或者设置呼叫等待(通话保持)。后面一个听起来不错,我们现在就来实现它。
当用户在 cell 上轻扫-删除时,App 会请求 CallManager 去结束对应的通话。
当用户想设置某个通话为“保持”状态,App 会发送一个 CXSetHeldCallAction 给提供者。你的任务就是实现相关的委托方法。打开 ProviderDelegate.swift,在类扩展中添加如下方法:
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 1.
call.state = action.isOnHold ? .held : .active
// 2.
if call.state == .held {
stopAudio()
} else {
startAudio()
}
// 3.
action.fulfill()
}
代码非常简单:
因为这个动作是用户发起的,我们还需要修改 CallManager 类。打开 CallManager.swift,在 end(call:) 方法后添加方法:
func setHeld(call: Call, onHold: Bool) {
let setHeldCallAction = CXSetHeldCallAction(call: call.uuid, onHold: onHold)
let transaction = CXTransaction()
transaction.addAction(setHeldCallAction)
requestTransaction(transaction)
}
这段代码和 end(call:) 非常像。事实上,二者唯一的不同是,后者封装在 transaction 中的是一个 CXSetHeldCallAction 对象。这个 action 包含了通话的 UUID 以及保持状态。
然后将这个方法和 UI 连接起来。打开 CallsViewController.swift,找到 UITableViewDelegate 的扩展处。在这个扩展的 tableView(_:titleForDeleteConfirmationButtonForRowAt:) 方法后面添加下列方法。
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let call = callManager.calls[indexPath.row]
call.state = call.state == .held ? .active : .held
callManager?.setHeld(call: call, onHold: call.state == .held)
tableView.reloadData()
}
当用户在某行上点击,上述代码会改变对应通话的保持状态。
运行 App,开始新的呼入。如果你点击这个通话对应的行,你会注意到状态标签会从 Acitve 变成 On Hold。
最后还有一个用户发起的动作,需要我们实现,那就是呼出。打开 ProviderDelegate.swift ,在 CXProviderDelegate 类扩展中添加方法:
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
let call = Call(uuid: action.callUUID, outgoing: true, handle: action.handle.value)
// 1.
configureAudioSession()
// 2.
call.connectedStateChanged = { [weak self, weak call] in
guard let strongSelf = self, let call = call else { return }
if call.connectedState == .pending {
strongSelf.provider.reportOutgoingCall(with: call.uuid, startedConnectingAt: nil)
} else if call.connectedState == .complete {
strongSelf.provider.reportOutgoingCall(with: call.uuid, connectedAt: nil)
}
}
// 3.
call.start { [weak self, weak call] success in
guard let strongSelf = self, let call = call else { return }
if success {
action.fulfill()
strongSelf.callManager.add(call: call)
} else {
action.fail()
}
}
}
当有呼出请求时,provider 会调用这个方法:
现在 ProviderDelegate 已经能够处理呼出了。接下来是让 App 进行一次呼出通话。
打开 CallManager.swift,添加如下方法:
func startCall(handle: String, videoEnabled: Bool) {
// 1
let handle = CXHandle(type: .phoneNumber, value: handle)
// 2
let startCallAction = CXStartCallAction(call: UUID(), handle: handle)
// 3
startCallAction.isVideo = videoEnabled
let transaction = CXTransaction(action: startCallAction)
requestTransaction(transaction)
}
这个方法将一个 CXStartCallAction 放到 CXTransaction 中,然后向系统发起请求。
一个 CXHandle 对象表示了一次操作,同时指定了操作的类型和值。Hotline App 支持对电话号码进行操作,因此我们在操作中指定了电话号码。
一个 CXStartCallAction 用一个 UUID 和一个操作作为输入。
你可以通过 action 的 isVideo 属性指定通话是音频还是视频。
然后在 UI 中使用新方法。打开 CallsViewController.swift 将 unwindForNewCall(_:) 方法修改为:
@IBAction private func unwindForNewCall(_ segue: UIStoryboardSegue) {
let newCallController = segue.source as! NewCallViewController
guard let handle = newCallController.handle else { return }
let incoming = newCallController.incoming
let videoEnabled = newCallController.videoEnabled
if incoming {
let backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
DispatchQueue.main.asyncAfter(wallDeadline: DispatchWallTime.now() + 1.5) {
AppDelegate.shared.displayIncomingCall(uuid: UUID(), handle: handle, hasVideo: videoEnabled) { _ in
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
}
}
} else {
callManager.startCall(handle: handle, videoEnabled: videoEnabled)
}
}
代码中进行了一些调整:当 incoming 为 false 时,view controller 会请求 CallManager 开始一次呼出通话。
这就是打电话功能了。接下来我们测试一下!运行 App。点击 + 按钮,呼出一次通话,确保你选择了 segmented 控件中的 Outgoing。
你应该能够在列表中看到新的通话。注意状态标签会根据当前通话的不同阶段变化:
你很容易就会想到,Hotline 的用户会收到多个通话。你可以模拟一下,先呼出一次,再呼入一次,然后在呼入进来之前按下 Home 键。这时,App 会显示如下画面:
系统让用户来决定如何处理这种问题。根据用户的选择,它会在一个 CXTransaction 中加入多个 action。例如,如果用户选择结束去电并接听来电,系统会先创建一个 CXEndCallActon,然后是一个CSStartCallAction。两个 action 都放在一个 transaction 中发送给 provider,provider 需要分别进行处理。因此,如果你的 App 能够分别对两个请求进行响应的话,那就不需要再多做什么了!
你可以测试上面说的情况;通话列表会根据你的选择进行显示。App 一次只能处理一个音频会话。如果你选择恢复通话,另一个会自动变成保持通话状态。
通讯录扩展是 CallKit 提供的一个新功能。它允许你的 VoIP App:
当系统收到来电,它会在通讯录中进行陪陪,如果没有找到结果,它会在 App 的扩展通讯录中查找。那就让我们在 Hotline 中添加一个扩展通讯录吧!
返回 Xcode,点击菜单 File\New\Target… 然后选择 Call Directory Extension。Xcode 会自动创建一个新文件 CallDirectoryHandler.swift。在项目导航器中选中它,看一下的内容。
第一个方法是 beginRequest(with:)。这个方法在扩展被初始化时调用。如果发生错误,扩展会告诉宿主 App 取消这次扩展请求(通过调用 cancelRequest(withError:)方法)。另外两个方法用于构建 App 的通讯录。
addBlockingPhoneNumber(to:) 方法用于定义要阻塞的电话号码。修改这个方法为:
private func addBlockingPhoneNumbers(to context: CXCallDirectoryExtensionContext) throws {
let phoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1234 ]
for phoneNumber in phoneNumbers {
context.addBlockingEntry(withNextSequentialPhoneNumber: phoneNumber)
}
}
以指定的号码调用 addBlockingEntry(withNextSequentialPhoneNumber:) 方法,将这个号码添加到黑名单。当某个号码被阻塞,系统电话 provider 不会显示任何来自这个号码的来电。
然后是 addIdentificationPhoneNumbers(to:) 方法。将这个方法修改为:
private func addIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) throws {
let phoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1111 ]
let labels = [ "RW Tutorial Team" ]
for (phoneNumber, label) in zip(phoneNumbers, labels) {
context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label)
}
}
将某个号码和 label 作为参数调用 addIdentificationEntry(withNextSequentialPhoneNumber:label:) 方法将创建一个新的 identification entry。当系统收到这个号码的来电时,电话 UI 上会显示这个 label 给用户。
来测试一下。在设备上运行 App。但是你的扩展并没有被激活。你需要经过以下步骤来激活它:
注意:如果你无法让系统识别出你的扩展,请退出 App 并重新打开。有时候 iOS 在使用你的扩展时会有点问题。
测试来电阻止其实很简单:点开 Hotline,以号码 1234 来进行一次呼入。你会注意到系统不会报告任何来电。事实上,你可以在 ProviderDelegate 的reportIncomingCall(uuid:handle:hasVideo:completion:) 方法中打一个断点,你会看到 reportNewIncomingCall 这句代码甚至会报错。
要测试身份识别,再次运行 Hotline,模拟一次呼入,这次,号码输入 1111。你会看到如下的通话界面:
恭喜!你创建了一个 App,用 CallKit 提供了原生的 VoIP 体验!:]
你可以从这里下载最终完成的项目。
如果你想学习更多关于 CallKit 的内容,请看 WWDC 2016 第 230 讲会议视频。
希望你喜欢这篇 CallKit 教程。有任何建议或问题,请在下面留言。