使用NotificationServiceExtension + App Groups实现iOS15离线语音播报推送消息

一、前言

iOS15之后,不允许推送消息没有 body 值,所以iOS15之前循环发送本地通知来实现后台播放的语音消息的方式将不再可用。

Tips: 循环发送本地通知来播放语音消息也有个弊病,就是每播放一个声音手机就会震动一下,体验不好。比如“微信到账11元”,手机就会震动4次。而且推送消息横幅只能在声音播放完成后,才会弹出来。

所以我们将采用新的方式在 iOS15上实现后台播放语音消息,这种方式不会有震动多次的情况,而且声音是和推送消息一起出来的。

二、创建NotificationServiceExtension Target

  • File->New->Target,选择Notification Service Extension
    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

image.png

在弹出的页面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.

你可能感兴趣的:(使用NotificationServiceExtension + App Groups实现iOS15离线语音播报推送消息)