一、前言
iOS15之后,不允许推送消息没有 body 值,所以iOS15之前循环发送本地通知来实现后台播放的语音消息的方式将不再可用。
Tips: 循环发送本地通知来播放语音消息也有个弊病,就是每播放一个声音手机就会震动一下,体验不好。比如“微信到账11元”,手机就会震动4次。而且推送消息横幅只能在声音播放完成后,才会弹出来。
所以我们将采用新的方式在 iOS15上实现后台播放语音消息,这种方式不会有震动多次的情况,而且声音是和推送消息一起出来的。
二、创建NotificationServiceExtension Target
- File->New->Target,选择
Notification Service Extension
点击“下一步”,输入 Target 名称比如“ WeiXinNotificationService”
Tips: 除了Product Name 和 Language,其他各项都会自动跟随主项目,不需要修改。
三、创建Target 应用 id 及推送
打开 Apple 开发者后台,选择Identifiers
,创建一个App ID,并勾选Push Notifications
(配置推送证书的过程在此不再赘述,但必须要配置)
四、创建App Groups
打开Identifiers
, 右侧下拉列表中选择App Groups
,
点击
Register an App Group
,
注册页面会默认选中“App Groups”,直接点击
Continue
。
在
Description
中输入描述,比如“xx App Group”,
在
Identifier
中输入唯一 id,格式一般是 group.主项目bundle ID,比如“group.com.xx.xx”
点击
Continue
,
确认信息无误后,点击
Register
,稍等几秒,就可以看到创建成功了。如图:
五、Apple开发后台配置 App Groups
-
主项目配置 App Groups
打开主项目 App ID,勾选App Groups
,并点击Configure
在弹出的页面
App Group Assignment
中选择刚才创建的 App Group,然后点击Continue
。
配置成功后,如图:
点击
Save
,会弹出一个提示框,提醒你所以此 App ID 相关的证书需要重新配置。点击Confirm
。
-
Notification Service Extension Target 配置 App Groups
步骤同主项目App ID 配置 App Groups。
六、Xcode 配置 App Groups
-
主项目 Target 配置
在"Targets"中选择主项目Target,点击+ Capability
,在App Groups
上双击,如图:
此时,在
Signing & Capabilities
中会多出一个 App Groups
区域,并显示出自己刚才加的 App Groups 的 id,勾上选中即可,如图:
-
Notification Service Extension Target 配置
步骤同主项目App ID 配置 App Groups。
七、重新生成开发和生产证书
现在Xcode 中会有如下错误提示,则说明需要重新生成开发和生产的证书,因为App ID 中配置了 App Groups。
生成证书过程不再赘述。
八、声音文件处理
需要准备几段音频,因为我们需要播放的是“微信到账11元”,所以第一段就是“微信到账”,然后就是0-9,点、十、百、千、万、元,可通过在线文字转音频网站处理。
把这些声音文件放在主项目中的任意位置就可以。
Tips:我使用的声音文件的格式是.caf
。
九、将推送消息文字转换为声音数组
/// 获取的金额中每个音频文件的地址数组,numStr是实际的金额,比如15.4。
-(NSArray *)getMusicFileArrayWithNum:(NSString *)numStr
{
NSString *finalStr = [self caculateNumber:numStr];
//前部分字段例如:***到账 user_payment是项目自定义的音乐文件
NSString *path = [[NSBundle mainBundle] pathForResource:@"user_payment" ofType:@"caf"];
NSMutableArray *finalArr = [[NSMutableArray alloc] initWithObjects:path, nil];
for (int i=0; i8) {
// return nil ;//只支持到千万,抱歉哈
// }
// 处理整数部分
if([head isEqualToString:@"0"]) {
prefix = @"0" ;
}
else {
NSMutableArray *ch = [[NSMutableArray alloc]init] ;
for (int i = 0; i < head.length; i++) {
NSString * str = [NSString stringWithFormat:@"%x",[head characterAtIndex:i]-'0'] ;
[ch addObject:str] ;
}
int zeronum = 0 ;
for (int i = 0; i < ch.count; i++) {
NSInteger index = (ch.count-1 - i)%4 ; //取段内位置
NSInteger indexloc = (ch.count-1 - i)/4 ; //取段位置
if ([[ch objectAtIndex:i]isEqualToString:@"0"]) {
zeronum ++ ;
}
else {
if (zeronum != 0) {
if (index != 3) {
prefix=[prefix stringByAppendingString:@"零"];
}
zeronum = 0;
}
if (ch.count >i) {
NSInteger numIndex = [[ch objectAtIndex:i]intValue];
if (numberchar.count >numIndex) {
prefix = [prefix stringByAppendingString:[numberchar objectAtIndex:numIndex]] ;
}
}
if (inunitchar.count >index) {
prefix = [prefix stringByAppendingString:[inunitchar objectAtIndex:index]] ;
}
}
if (index == 0 && zeronum < 4) {
if (unitname.count >indexloc) {
prefix = [prefix stringByAppendingString:[unitname objectAtIndex:indexloc]] ;
}
}
}
}
//1十开头的改为十
if([prefix hasPrefix:@"1十"]) {
prefix = [prefix stringByReplacingOccurrencesOfString:@"1十" withString:@"十"] ;
}
//处理小数部分
if([foot isEqualToString:@"00"]) {
prefix = [prefix stringByAppendingString:@"元"] ;
}
else {
prefix = [prefix stringByAppendingString:[NSString stringWithFormat:@"点%@元", foot]] ;
}
return prefix ;
}
十、本地合成语音并存入App Groups
///在AppGroup中合并音频
- (void)mergeAVAssetWithSourceURLs:(NSArray *)sourceURLsArr completed:(void (^)(NSString * soundName,NSURL * soundsFileURL)) completed{
//创建音频轨道,并获取多个音频素材的轨道
AVMutableComposition *composition = [AVMutableComposition composition];
//音频插入的开始时间,用于记录每次添加音频文件的开始时间
__block CMTime beginTime = kCMTimeZero;
[sourceURLsArr enumerateObjectsUsingBlock:^(id _Nonnull audioFileURL, NSUInteger idx, BOOL * _Nonnull stop) {
//获取音频素材
AVURLAsset *audioAsset1 = [AVURLAsset assetWithURL:[NSURL fileURLWithPath:audioFileURL]];
//音频轨道
AVMutableCompositionTrack *audioTrack1 = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:0];
//获取音频素材轨道
AVAssetTrack *audioAssetTrack1 = [[audioAsset1 tracksWithMediaType:AVMediaTypeAudio] firstObject];
//音频合并- 插入音轨文件
[audioTrack1 insertTimeRange:CMTimeRangeMake(kCMTimeZero, audioAsset1.duration) ofTrack:audioAssetTrack1 atTime:beginTime error:nil];
// 记录尾部时间
beginTime = CMTimeAdd(beginTime, audioAsset1.duration);
}];
//用动态日期会占用空间
// NSDateFormatter *formater = [[NSDateFormatter alloc] init];
// [formater setDateFormat:@"yyyy-MM-dd-HH:mm:ss-SSS"];
// NSString * timeFromDateStr = [formater stringFromDate:[NSDate date]];
// NSString *outPutFilePath = [NSHomeDirectory() stringByAppendingFormat:@"/tmp/sound-%@.mp4", timeFromDateStr];
NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier: @“你添加的 App Groups 的 id”];
// NSURL * soundsURL = [groupURL URLByAppendingPathComponent:@"/Library/Sounds/" isDirectory:YES];
//建立文件夹
NSURL * soundsURL = [groupURL URLByAppendingPathComponent:@"Library/" isDirectory:YES];
if (![[NSFileManager defaultManager] contentsOfDirectoryAtPath:soundsURL.path error:nil]) {
[[NSFileManager defaultManager] createDirectoryAtPath:soundsURL.path withIntermediateDirectories:YES attributes:nil error:nil];
}
//建立文件夹
NSURL * soundsURL2 = [groupURL URLByAppendingPathComponent:@"Library/Sounds/" isDirectory:YES];
if (![[NSFileManager defaultManager] contentsOfDirectoryAtPath:soundsURL2.path error:nil]) {
[[NSFileManager defaultManager] createDirectoryAtPath:soundsURL2.path withIntermediateDirectories:YES attributes:nil error:nil];
}
// 新建文件名,如果存在就删除旧的
NSString * soundName = [NSString stringWithFormat:@"sound.m4a"];
NSString *outPutFilePath = [NSString stringWithFormat:@"Library/Sounds/%@", soundName];
NSURL * soundsFileURL = [groupURL URLByAppendingPathComponent:outPutFilePath isDirectory:NO];
// NSString * filePath = soundsURL.absoluteString;
if ([[NSFileManager defaultManager] fileExistsAtPath:soundsFileURL.path]) {
[[NSFileManager defaultManager] removeItemAtPath:soundsFileURL.path error:nil];
}
//导出合并后的音频文件
//音频文件目前只找到支持m4a 类型的
AVAssetExportSession *session = [[AVAssetExportSession alloc]initWithAsset:composition presetName:AVAssetExportPresetAppleM4A];
// 音频文件输出
session.outputURL = soundsFileURL;
session.outputFileType = AVFileTypeAppleM4A; //与上述的`present`相对应
session.shouldOptimizeForNetworkUse = YES; //优化网络
[session exportAsynchronouslyWithCompletionHandler:^{
if (session.status == AVAssetExportSessionStatusCompleted) {
NSLog(@"合并成功----%@", outPutFilePath);
if (completed) {
completed(soundName,soundsFileURL);
}
} else {
// 其他情况, 具体请看这里`AVAssetExportSessionStatus`.
NSLog(@"合并失败----%ld", (long)session.status);
if (completed) {
completed(@"", nil);
}
}
}];
}
十一、替换通知音效为合成语音
在`NotificationService`类中进行处理
if #available(iOSApplicationExtension 15.0, *) {
let soundArray = XSAudioManager.sharedInstance().getMusicFileArray(withNum: amountNum)
XSAudioManager.sharedInstance().mergeAVAsset(withSourceURLs: soundArray) { soundName, soundFileUrl in
if soundName.count == 0 {
self.contentHandler!(bestAttemptContent)
return
}
bestAttemptContent.interruptionLevel = .timeSensitive
bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
self.contentHandler!(bestAttemptContent)
}
}
十二、NotificationService 全部代码
//
// NotificationService.swift
// xxxServiceExtension
//
// Created by xxx on 2021/6/30.
//
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
if let userInfo = bestAttemptContent.userInfo as? [String: Any] {
self.playBackgroundSound(userInfo: userInfo, bestAttemptContent: bestAttemptContent)
}
}
}
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)
}
}
}
extension NotificationService {
private func playBackgroundSound(userInfo: [String: Any], bestAttemptContent: UNMutableNotificationContent) {
if let type = userInfo["type"] as? String, type == "payment" {
//amount:金额/元
if let aps = userInfo["aps"] as? NSDictionary {
if let alert = aps["alert"] as? NSDictionary {
if let message = alert["subtitle"] as? NSString {
let moneyMsg = message.replacingOccurrences(of: ",", with: "") as NSString
// moneyMsg 的值是“微信到账15元”,“微信到账”这4个字是固定的。
//极光推送调试附加字段与aps字段同级
let msg2 = moneyMsg.substring(from: 4) as NSString
let amountNum = msg2.substring(to: msg2.length - 1)
// iOS 15以后使用 app group
if #available(iOSApplicationExtension 15.0, *) {
let soundArray = IFAudioManager.sharedInstance().getMusicFileArray(withNum: amountNum)
IFAudioManager.sharedInstance().mergeAVAsset(withSourceURLs: soundArray) { soundName, soundFileUrl in
if soundName.count == 0 {
self.contentHandler!(bestAttemptContent)
return
}
// 这是 iOS15后新加的属性
bestAttemptContent.interruptionLevel = .timeSensitive
bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
self.contentHandler!(bestAttemptContent)
}
} else {
// iOS 15之前的系统,继续使用循环发送本地通知的方式
let soundArray = IFAudioManager.sharedInstance().getMusicArray(withNum: amountNum)
IFAudioManager.sharedInstance().pushLocalNotification(toApp: 0, with: soundArray) {
self.contentHandler!(bestAttemptContent)
}
}
}
}
}
}
}
}
十三、示例:
十四、提示:
- 极光推送,需要把字段
mutable-content
设置为 true。 - 主项目和NSE Target 都需要配置推送证书。
- 主项目和NSE Target 都需要配置App Groups。
- Xcode 中需要在主项目的
Background Modes
中勾选Audio, AirPlay, and Picture in Picture
Have fun.