一、目标功能
- App收到语音电话推送消息,设备持续震动、响铃
- 响铃期间通知栏显示推送消息内容
- 点击应用图标或通知栏应用进入前台,停止响铃
- 超时未响应:停止响铃;通知内容显示“未接听通话”
- 推送期间对方挂断电话,停止响铃、通知栏提示
二、功能难点
- 苹果APNS推送虽然能指定推送声音文件等功能,却并不能持续的震动、响铃;而Notification Service Extension可以让你对收到的APNS推送有30s的时间进行处理。
- 使用Extension处理推送完成前,推送栏没有内容;此时可以发送一条本地推送显示:“收到语音电话”;这样又出现了新的问题:Extension超时会将远程推送显示到通知栏,导致通知栏显示两条推送消息。
- 监听到应用进入前台激活时需要停止Extension的响铃,而Extension与主应用并不在同一个进程,想要通信的话需要借助AppGroup功能
- Extension处理推送期间,如果收到新的推送,须等待上一条推送处理完成才会开始处理,那么挂断推送怎么才可以在响铃期间停止呢?
三、实现功能
1.Notification Service Extension
创建Extension的步骤不在此篇进行详细讲解,有疑问的话可以具体查一查。Extension创建好后,项目中会多出一个文件夹,文件夹名为扩展创建时的名字(后续称呼为Notification),文件夹下有一个NotificationService文件,里面已经有一个NotificationService类和两个方法
didReceiveNotificationRequest:withContentHandler
:收到推送消息触发
serviceExtensionTimeWillExpire
:推送消息处理超时触发
运行Extension: 切换项目Target,Run一下,然后选择主应用即可;由于我是Flutter写的应用,Debug模式下热更新的原因选择主应用Run的时候并不能跑起来,此时只需要编辑主应用和Extension的scheme重新Run
2.持续震动、响铃
需要提前将音频文件的引用拖入Notification文件夹
var soundID: SystemSoundID = 0
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void {
...
startAudioWork()
...
}
// 开始播放
private func startAudioWork() {
let audioPath = Bundle.main.path(forResource: "音频文件名", ofType: "mp3")
let fileUrl = URL(string: audioPath ?? "")
// 创建响铃任务
AudioServicesCreateSystemSoundID(fileUrl! as CFURL, &soundID)
// 播放震动、响铃
AudioServicesPlayAlertSound(soundID)
// 监听响铃完成状态
AudioServicesAddSystemSoundCompletion(soundID, nil, nil, {sound, clientData in
// 音频文件一次播放完成,再次播放
AudioServicesPlayAlertSound(sound)
}, nil)
}
// 停止播放
private func stopAudioWork() {
AudioServicesRemoveSystemSoundCompletion(soundID)
AudioServicesDisposeSystemSoundID(soundID)
}
3.响铃时通知栏显示内容
由于在NotificationServiceExtension处理完成前,表示该通知还在处理,故:此时通知栏不会有该条推送的内容;那么在响铃的同时就要显示推送内容的话,我们只好手动加一条本地通知,为了避免推送处理超时通知栏同时存在一条本地推送和一条远程推送,我们需要将本地推送的ID设置成远程推送的ID
,开始我打算在超时回调处直接删除本地推送,很可惜实际并不能成功,经查阅苹果文档删除本地推送的方法是异步
的,在serviceExtensionTimeWillExpire
方法中调用删除通知的方法后,方法还没有执行完成,推送扩展进程就已经挂了(真坑啊,当时一度怀疑方法没调对)
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void {
self.contentHandler = contentHandler
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
startAudioWork()
sendLocalNotification(identifier: request.identifier, body: bestAttemptContent?.body)
...
}
// 本地推送
private func sendLocalNotification(identifier: String, body: String?) {
// 推送id和推送的内容都使用远程APNS的
let content = UNMutableNotificationContent()
content.body = body ?? ""
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
4.推送超时处理
override func serviceExtensionTimeWillExpire() {
stopAudioWork()
if let handler = self.contentHandler, let content = self.bestAttemptContent {
content.body = "[未接听通话]"
// 推送处理完成
handler(content)
}
}
5.应用激活停止响铃
由于主应用与扩展属于两个进程,苹果的沙盒机制使这两个进程不能直接的进行常规通信,而AppGroup可以开辟一块可以共享的内存,让两个进程进行数据的读取,从而达到通信的目的(AppGroup的创建请自行查阅)。需要注意的是主程序、扩展程序target都需要创建AppGroup,且字段名相同
。
// AppDelegate 应用进入前台
func applicationDidBecomeActive(_ application: UIApplication) {
// 通过AppGroupID创建UserDefaults
let userDefaults = UserDefaults(suiteName: "group.bundleID")
// 更新AppGroup数据(1:停止响铃)
userDefaults?.set(1, forKey: "VoiceKey")
// 移除通知栏消息
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
}
var soundID: SystemSoundID = 0
let appGroup = "group.boundleID"
let key = "VoiceKey"
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
let userDefaults = UserDefaults(suiteName: appGroup)
// 通知扩展收到推送消息
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void {
self.contentHandler = contentHandler
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
// 播放震动、响铃
startAudioWork()
// 发送本地通知
sendLocalNotification(identifier: request.identifier, body: bestAttemptContent?.body)
// 更新AppGroup正在响铃
userDefaults?.set(0, forKey: key)
}
// 开始播放
private func startAudioWork() {
let audioPath = Bundle.main.path(forResource: "音频文件名", ofType: "mp3")
let fileUrl = URL(string: audioPath ?? "")
// 创建响铃任务
AudioServicesCreateSystemSoundID(fileUrl! as CFURL, &soundID)
// 播放震动、响铃
AudioServicesPlayAlertSound(soundID)
// 监听响铃完成状态
let selfPointer = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
AudioServicesAddSystemSoundCompletion(soundID, nil, nil, {sound, clientData in
guard let pointer = clientData else { return }
let selfP = unsafeBitCast(pointer, NotificationService.self)
let value = selfP.userDefaults?.integer(forKey: key) ?? 0
if value == 1 {
// app进入前台,停止响铃
selfP.stopAudioWork()
// 推送处理完毕
if let handler = selfP.contentHandler, let content = selfP.bestAttemptContent {
handler(content)
}
} else {
AudioServicesPlayAlertSound(sound)
}
}, selfPointer)
}
}
6.挂断推送停止响铃
实现思路:
- Notification Service Extension的特性,进入Extension的推送消息只能一条接一条的处理,所以挂断推送不能让其进入Extension。
- app挂掉的情况想收到通话挂断消息又只能通过APNS,那么只要后端推送挂断消息的时候
aps
结构中的mutable-content = 0
,该条推送就不会进入Extension消息队列。 - 挂断推送是为了通知Extension结束响铃并不用显示到通知栏,所以在发送挂断推送时还应该加上
content-available = 1
(静默推送)。 - 那么问题又来了,收到挂断推送消息,如何让Extension立马结束呢?
- 经过不懈的查找资料,原来Extension与主程序还可以通过
CFNotificationCenter
通信。
// AppDelegate:
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
// msg_type和rid是app与后台约定的字段,分别表示:消息类型、通话id
let msgType: Int = userInfo["msg_type"] as? Int ?? 0
let rid: String = userInfo["rid"] as? String ?? ""
if msgType == 22 {
// 挂断推送,AppGroup添加已挂断电话的id
let userDefaults = UserDefaults(suiteName: "group.com.xxx.xxx")
var ridArray = userDefaults?.stringArray(forKey: "CancelCall") ?? []
ridArray.append(rid)
userDefaults?.set(ridArray, forKey: "CancelCall")
userDefaults?.synchronize()
// 通知Extension
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("CancelCall" as CFString), nil, nil, true)
}
completionHandler(.noData)
}
let cancelKey = "CancelCall"
// 通知扩展收到推送消息
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void {
...
registerObserver()
}
// 注册进程通知
func registerObserver() {
let notification = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(notification, observer, { center, pointer, name, _, userInfo in
guard let observer = pointer else { return }
let mySelf = Unmanaged<NotificationService>.fromOpaque(observer).takeUnretainedValue()
if mySelf.checkCancelCall() {
mySelf.stopAudioWork()
mySelf.CallBackNotication(body: "[电话已挂断]")
}
}, cancelKey as CFString, nil, .deliverImmediately)
}
// 检查当前电话是否已被取消
func checkCancelCall() {
let rid = bestAttemptContent?.userInfo["rid"] as? String ?? ""
let ridArray = userDefaults?.stringArray(forKey: cancelKey) ?? []
return ridArray.contains(rid)
}
// 回执处理完毕的推送
func callBackNotication(body: String?) {
if let handler = self.contentHandler, let content = self.bestAttemptContent {
content.body = body ?? ""
// 推送处理完毕
handler(content)
}
}
deinit {
let observer = Unmanaged.passUnretained(self).toOpaque()
let name = CFNotificationName(cancelKey as CFString)
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), observer, name, nil)
}
到这里挂断推送消息的处理就已经实现了,回看一下前面app激活时停止响铃的代码,其实也可以通过CFNotificationCenter
来实现。
结语
至此也就基本完整的实现了语音电话推送响铃需求,由于之前我没有接触过扩展开发,在功能研究过程中坑是一个接一个的踩,过程比较辛酸,如果本篇对你有帮助或触动的话,顺手一个小赞鼓励一下。