IOS后台响铃,通过UNNotificationServiceExtension实现方案
UNNotificationServiceExtension介绍
UNNotificationServiceExtension是iOS中的一种扩展类型,用于处理远程通知的内容和资源。它可以在收到通知后对通知进行修改、添加自定义内容,以及下载和管理相关资源。
UNNotificationServiceExtension的主要作用是提供一个后台执行的环境,用于对通知进行处理和修改。它可以通过实现UNNotificationServiceExtension类来创建一个扩展,然后在Info.plist文件中将其配置为通知的服务扩展。
使用UNNotificationServiceExtension可以提供以下功能:
-
修改通知内容:可以在收到通知时修改通知的标题、子标题、正文或其他相关信息。这样可以根据应用的需求对通知内容进行个性化定制。
-
添加自定义内容:可以添加自定义的图像、声音或其他资源到通知中,以提供更多的交互和信息展示方式。
-
下载和管理资源:可以通过扩展来下载和管理通知中需要使用的资源,例如图片、音频文件等。这样可以避免在主应用中进行资源下载的延迟,并提供更好的用户体验。
-
执行后台任务:UNNotificationServiceExtension运行在一个后台进程中,因此可以执行一些耗时的操作,如网络请求、数据处理等。这可以避免在主应用中执行这些任务而影响用户界面的流畅性。
总之,UNNotificationServiceExtension提供了一种灵活、可定制的方式来处理和修改远程通知。它可以帮助开发者提升通知的交互性和用户体验,并提供更多的个性化定制选项。
- Xcode创建UNNotificationServiceExtension target
- 通过NotificationService的didReceive方法处理指定的通知内容
- 使用UserDefaults(suiteName: self.groupName)做共享数据传递
创建UNNotificationServiceExtension target
点击Xcode-File-New-Target,弹出如下选项,进行过滤选择
这里我们选择Notification Service Extension
到这里点击Finish就创建完成了,Xcode将会自动生成必要的代码
NotificationService就是用来拦截通知的,收到通知时,将会触发didReceive方法
调试判断通知扩展服务生效,只需要看弹出的通知是否增加了"[modified]"内容
实现思路
通话请求是一种通知类型,挂断是另外一种通知类型。在收到通话请求时,会有至多30秒钟时间可以拉活应用,我们需要在这段时间内持续响铃+震动。
开始响铃
收到音视频通话请求通知时,检查APP是否处在前台,处在前台时,由主程序自己实现响铃震动,这里只处理APP不在前台的情况,进行响铃和震动
停止响铃
30秒钟处理时间到了之后,系统将会主动调用serviceExtensionTimeWillExpire超时 方法,我们也可以在超时之前自己主动去停止响铃震动。在这里,每秒检查一次通知是否应该被激活,如果用户打开了主程序,需要在主程序中,将共享内存中的callKeyEnterToForeground参数修改为true,如此,NotificationService将能够检查到用户APP被打开,而停止自己的响铃震动,之后是否需要响铃震动,由主程序自己处理。
当收到停止响铃的通知时,将keyStartCallTime重置为0,如此在震动轮循检查状态时,也能够检查到已经收到了挂断通知,从而终止响铃震动逻辑
以下是完整代码
UNNotificationServiceExtension中,需要和主程序进行一些数据交换,这里通过共享分组的方式实现,以此来了解主程序的状态
import UserNotifications
import AudioToolbox
import AVFAudio
import UIKit
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private let keyStartCallTime = "keyStartCallTime"
private let groupName = "group.xxp.ring.callNotification"
private let callKeyEnterToForeground = "enterToForground"
//最长显示通知时长设置为30秒
let maxNotifyTime = 30
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
// Modify the notification content here...
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
if let contType = (request.content.userInfo["info"] as? Dictionary<String , Any>)?["type"] as? Int32{
print("当前消息类型:\(contType)")
if contType == 1 {
//音视频通话请求
if isAppInForground() || isCallNotifyActive() {
//APP在前台时或者通话已经被激活了内部处理自动处理
contentHandler(request.content)
return
}
//通话邀请
bestAttemptContent.title = "\(bestAttemptContent.title)"
bestAttemptContent.interruptionLevel = .timeSensitive
//创建声音
var soundID: SystemSoundID = SystemSoundID(clamping: 999)
// 播放声音
if let url = Bundle.main.url(forResource: "ring", withExtension: "mp3"){
AudioServicesCreateSystemSoundID(url as CFURL, &soundID)
AudioServicesAddSystemSoundCompletion(soundID, nil, nil, { sourceId, unsafeMutableRawPointer in
}, nil)
AudioServicesPlaySystemSound(soundID)
AudioServicesPlaySystemSoundWithCompletion(soundID) {
if self.isCallNotifyActive(){
print("没有结束,继续播放")
AudioServicesPlaySystemSound(soundID)
}
}
} else {
print("没有找到铃声")
}
contentHandler(bestAttemptContent)
//记录开始时间
self.setCallStartTime()
//持续震动逻辑
while self.isCallNotifyActive(){
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
sleep(1)
}
self.setCallStartTime(clean: true)
AudioServicesDisposeSystemSoundID(soundID)
}
else if contType == 2 {
//通话结束
print("通话结束重置任务")
setCallStartTime(clean: true)
contentHandler(request.content)
} else {
contentHandler(request.content)
}
} else {
contentHandler(request.content)
}
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
//通话通知是否仍处于激活状态
func isCallNotifyActive() -> Bool{
return Int64(Date.now.timeIntervalSince1970) - getStartCallTime() < maxNotifyTime && !self.isAppInForground()
}
func getStartCallTime() -> Int64 {
UserDefaults(suiteName: self.groupName)?.object(forKey: keyStartCallTime) as? Int64 ?? 0
}
func setCallStartTime(clean : Bool = false) {
if clean {
UserDefaults(suiteName: self.groupName)?.removeObject(forKey: keyStartCallTime)
} else {
let startTime : Int64 = Int64(Date.now.timeIntervalSince1970)
UserDefaults(suiteName: self.groupName)?.set(startTime, forKey: keyStartCallTime)
}
}
//APP是否在前台
func isAppInForground() -> Bool{
UserDefaults(suiteName: self.groupName)?.bool(forKey: callKeyEnterToForeground) ?? false
}
}
无法拦截通知处理问题排查
-
检查拦截服务的Minimum Deployments版本,必须要小于你运行手机或者模拟器的版本,它默认指定的版本可能是最新版本,修改版本号即可
-
推送时,依赖于ios本身的APNS代理,需要在推送内容中设置mutable-content字段,1表示开启,0表示关闭,只有设置了1才会被UNNotificationServiceExtension所拦截,参考Apple官方文档说明
通知服务应用扩展标志。如果值为1,系统会在传递之前将通知传递给您的通知服务应用扩展。使用您的扩展程序修改通知的内容。
- 检查是否创建了多个Notification Service Extension,测试发现只有第一个Notification Service Extension会生效,其余Notification Service Extension将不会被使用
其它问题
-
测试下来UNNotificationServiceExtension只有第一个会生效,后续的UNNotificationServiceExtension都不会被调用
-
铃声文件,放在同级目录下即可
-
对于多通知下发处理,测试发现当上一个通知没有被处理之前,下一个通知抵达后将会排队,不会直接回调didReceive方法,直到上一个contentHandler(UNNotificationContent)被调用。以至于在呼叫响铃以及挂断停止响铃上难以下手。但是在测试下发现以下方式是可用的
先执行contentHandler(UNNotificationContent)回调,让UNNotificationServiceExtension不至于无法处理下一个通知,然后在当前线程上进行阻塞,这可以有效阻止主程序再次被挂起,导致响铃或者震动被中断,两个通知下发后,他们使用的似乎不是同一个对象,原理暂时不清楚,有知道的可以告知