使用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; i<finalStr.length; i++) {
        NSString *str = [finalStr substringWithRange:NSMakeRange(i, 1)];
//        NSString *file = [NSString stringWithFormat:@"%@.m4a", str];
        NSString *path = [[NSBundle mainBundle] pathForResource:str ofType:@"caf"];
        [finalArr addObject: path];
    }
    return finalArr;
}

-(NSString *)caculateNumber:(NSString *)numstr {
    NSArray *numberchar = @[@"0",@"1",@"2",@"3",@"4",@"5",@"6",@"7",@"8",@"9"];
    NSArray *inunitchar = @[@"",@"十",@"百",@"千"];
    NSArray *unitname   = @[@"",@"萬",@"億"];
    
    NSString *valstr =[NSString stringWithFormat:@"%.2f",numstr.doubleValue] ;
        
    NSString *prefix = @"" ;
    
    // 將金額分為整數部分和小數部分
    NSString *head = [valstr substringToIndex:valstr.length - 2 - 1] ;
    NSString *foot = [valstr substringFromIndex:valstr.length - 2] ;
        
//    if (head.length>8) {
//        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.

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容