iOS推送語音播報(類似支付寶收款提醒)

需求分析

實現類似支付寶微信收款後的語音播報如:支付寶到賬xx元。要求是APP在前臺運行、鎖屏、殺死進程後都會有語音播報。那想到的解決方案就是利用推送了。

功能實現思路分析

上面說了,要使用推送,也就是APNs,這裏我使用了極光推送,接下來就是實現手機接收到通知之後播報語音了,關於這個功能的實現在iOS10以後蘋果新增了“推送拓展”UNNotificationServiceExtension,我們可以在這裏操作,在這裏我用的是蘋果官方的AVSpeechSynthesizerAVSpeechUtterance來將接收到的推送內容轉換成語音播報,其中在這裏,iOS12.1以後,不允許在UNNotificationServiceExtension中播放語音了,我也查找過很多資料,最終實現了一個比較折中的方法,下面會詳細說。

功能實現

一、極光推送

關於極光推送的證書申請啥的就不講了,官方文檔上寫的很清楚了,這裏只說將極光推送SDK集成到項目之後了。
1、集成極光推送SDK
在項目中的Podfile文件中添加pod 'JPush',然後pod install,等待pod完成。
2、在AppDelegate.m中編寫推送功能代碼
(其實極光推送的文檔裏也有)。
(1、在項目中引入所需頭文件:

// 引入 JPush 功能所需頭文件
#import "JPUSHService.h"
// iOS10 註冊 APNs 所需頭文件
#ifdef NSFoundationVersionNumber_iOS_9_x_Max
#import <UserNotifications/UserNotifications.h>
#endif

(2、設置代理

@interface AppDelegate ()<JPUSHRegisterDelegate>

(3、在- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法中配置推送相關配置
初始化APNs:

- (void)initAPNS {
    //Required
    //notice: 3.0.0 及以後版本註冊可以這樣寫,也可以繼續用之前的註冊方式
    JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
    if (@available(iOS 12.0, *)) {
        entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound|UNAuthorizationOptionProvidesAppNotificationSettings;
        //應用內顯示通知設置的按鈕
    } else {
        entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
    }
    if ([[UIDevice currentDevice].systemVersion floatValue] >= 8.0) {
        // 可以添加自定義 categories
        // NSSet<UNNotificationCategory *> *categories for iOS10 or later
        // NSSet<UIUserNotificationCategory *> *categories for iOS8 and iOS9
    }
    [JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
}

初始化JPUSH:

#pragma mark 初始化jpush
- (void)initJpushWithOptions:(NSDictionary *)launchOptions {
    // Optional
    // 獲取 IDFA
    // 如需使用 IDFA 功能請添加此代碼並在初始化方法的 advertisingIdentifier 參數中填寫對應值
//    NSString *advertisingId = [[[ASIdentifierManager sharedManager] advertisingIdentifier] UUIDString];
    
    // Required
    // init Push
    // notice: 2.1.5 版本的 SDK 新增的註冊方法,改成可上報 IDFA,如果沒有使用 IDFA 直接傳 nil
    // 如需繼續使用 pushConfig.plist 文件聲明 appKey 等配置內容,請依舊使用 [JPUSHService setupWithOption:launchOptions] 方式初始化。
    
    NSString *appKey = @"你申請的推送AppKey";
    
    [JPUSHService setupWithOption:launchOptions appKey:appKey
                          channel:@"0"
                 apsForProduction:NO
            advertisingIdentifier:nil];
}
/*!
 * @abstract 啓動SDK
 *
 * @param launchingOption 啓動參數.
 * @param appKey 一個JPush 應用必須的,唯一的標識. 請參考 JPush 相關說明文檔來獲取這個標識.
 * @param channel 發佈渠道. 可選.
 * @param isProduction 是否生產環境. 如果爲開發狀態,設置爲 NO; 如果爲生產狀態,應改爲 YES.
 *                     App 證書環境取決於profile provision的配置,此處建議與證書環境保持一致.
 * @param advertisingIdentifier 廣告標識符(IDFA) 如果不需要使用IDFA,傳nil.
 *
 * @discussion 提供SDK啓動必須的參數, 來啓動 SDK.
 * 此接口必須在 App 啓動時調用, 否則 JPush SDK 將無法正常工作.
 */

(4、實現APNs的代理方法:

#pragma mark 註冊 APNs 成功並上報 DeviceToken
- (void)application:(UIApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    
    /// Required - 註冊 DeviceToken
    
    NSString *token = [[[[deviceToken description] stringByReplacingOccurrencesOfString:@"<" withString:@""] stringByReplacingOccurrencesOfString:@">" withString:@""] stringByReplacingOccurrencesOfString:@" " withString:@""];
    NSLog(@"device token is %@", token);
    
    [JPUSHService registerDeviceToken:deviceToken];
}
#pragma mark 實現註冊 APNs 失敗接口
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
    //Optional
    NSLog(@"did Fail To Register For Remote Notifications With Error: %@", error);
}
#pragma mark 添加處理 APNs 通知回調方法
//這個方法是用來出來在收到推送通知,並且在還沒有展示出通知具體內容時調用的,可以在這裏處理一些邏輯,比如說APP在活躍狀態中設置不出現彈框和badge,只有聲音提示,或者說APP在Active狀態下直接跳轉制定界面。
// iOS 10 Support 
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger))completionHandler {
    // Required
    NSDictionary * userInfo = notification.request.content.userInfo;
    if([notification.request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        [JPUSHService handleRemoteNotification:userInfo];
    }
    //驗證別名
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSString *userID = [userDefaults objectForKey:prefix_userId];
    NSString *localAlias = [NSString stringWithFormat:@"shop_id_%@",userID];
    
    [JPUSHService getAlias:^(NSInteger iResCode, NSString *iAlias, NSInteger seq) {
        NSLog(@"極光推送請求到的別名:iResCode=%ld,iAlias=%@,seq=%ld", iResCode, iAlias, seq);
        if ([localAlias isEqualToString:iAlias]) {
            if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive) {
                //活躍狀態
//                completionHandler(UNNotificationPresentationOptionBadge); // 需要執行這個方法,選擇是否提醒用戶,有 Badge、Sound、Alert 三種類型可以選擇設置
                //重置角標
                [[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
                [JPUSHService resetBadge];
                
                if ([[UIDevice currentDevice].systemVersion doubleValue] >= 12.1) {
                    //如果是iOS12.1 有語音提示
                    completionHandler(UNNotificationPresentationOptionSound);
                }
                    
            } else {
                completionHandler(UNNotificationPresentationOptionBadge|UNNotificationPresentationOptionSound|UNNotificationPresentationOptionAlert); // 需要執行這個方法,選擇是否提醒用戶,有 Badge、Sound、Alert 三種類型可以選擇設置
            }
        }
    } seq:1];
}

//在iOS10 及以上系統,收到通知後,點擊通知框,進行的邏輯頁面跳轉(比如:跳轉到指定頁面)
// iOS 10 Support
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)())completionHandler {
    // Required
    NSDictionary * userInfo = response.notification.request.content.userInfo;
    if([response.notification.request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        [JPUSHService handleRemoteNotification:userInfo];
    }
    //設置角標
    [[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
    [JPUSHService resetBadge];
    
    //驗證別名
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSString *userID = [userDefaults objectForKey:prefix_userId];
    NSString *localAlias = [NSString stringWithFormat:@"shop_id_%@",userID];
    
    [JPUSHService getAlias:^(NSInteger iResCode, NSString *iAlias, NSInteger seq) {
        NSLog(@"極光推送請求到的別名ssss:iResCode=%ld,iAlias=%@,seq=%ld", iResCode, iAlias, seq);
        //驗證別名成功
        if ([localAlias isEqualToString:iAlias]) {
           //點擊跳轉頁面
        }
    } seq:1];
    
    completionHandler();  // 系統要求執行這個方法
}

//系統版本小於10.0 跳轉制定頁面
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
    // Required, iOS 7 Support
    [JPUSHService handleRemoteNotification:userInfo];
    //設置角標
    [[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
    [JPUSHService resetBadge];
    
    //驗證別名
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSString *userID = [userDefaults objectForKey:prefix_userId];
    NSString *localAlias = [NSString stringWithFormat:@"shop_id_%@",userID];
    
    [JPUSHService getAlias:^(NSInteger iResCode, NSString *iAlias, NSInteger seq) {
        NSLog(@"極光推送請求到的別名ssss:iResCode=%ld,iAlias=%@,seq=%ld", iResCode, iAlias, seq);
        //驗證別名成功
        if ([localAlias isEqualToString:iAlias]) {
           //跳轉指定頁面
        }
    } seq:1];
    
    completionHandler(UIBackgroundFetchResultNewData);
}

:另外,我們是根據別名來進行推送的,別名是用戶名,所以需要在登錄的時候需要註冊別名

[JPUSHService setAlias:alias completion:^(NSInteger iResCode, NSString *iAlias, NSInteger seq) {
                  NSLog(@"極光推送設置別名:iResCode = %ld, alias = %@, seq = %ld", iResCode,iAlias, seq);
              } seq:1];

在註銷登錄的時候註銷推送別名

[JPUSHService deleteAlias:^(NSInteger iResCode, NSString *iAlias, NSInteger seq) {
        NSLog(@"極光推送清除別名:iResCode = %ld, alias = %@, seq = %ld", iResCode,iAlias, seq);
    } seq:1];

(5、在UNNotificationServiceExtension推送拓展中操作
這個UNNotificationServiceExtension使用xcode自帶模板進行創建,創建出來是一個新的target,具體流程可以參考這個博客https://blog.csdn.net/BUG_delete/article/details/80408661
在新建的UNNotificationServiceExtension中我使用蘋果自帶的AVSpeechSynthesizerAVSpeechSynthesisVoiceAVSpeechUtterance來實現語音播報,當然也可以使用訊飛或者百度等第三方SDK來實現。
文件中默認實現了方法

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
  self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];

//用來展示通知彈框
self.contentHandler(self.bestAttemptContent);
}

這個方法用來接收通知推送,我們可以在這裏面進行處理。
首先說在iOS12.1之前語音播報方法:

#pragma mark iOS12.1以下 播放語音
- (void)playApsVoice {
    //內容是通知信息攜帶的數據
    NSDictionary *info = self.bestAttemptContent.userInfo;
    
    NSDictionary *contentDic = [info objectForKey:@"aps"];
    //播放語音
    [self playVoiceWithContent:contentDic[@"alert"]];
}

- (void)playVoiceWithContent:(NSString *)content {
    
    AVSpeechSynthesizer * synthsizer = [[AVSpeechSynthesizer alloc] init];
    synthsizer.delegate = self;
    AVSpeechUtterance * utterance = [[AVSpeechUtterance alloc] initWithString:content];//需要轉換的文本
    utterance.voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];//國家語言
    utterance.rate = 0.5f;//聲速
    utterance.volume = 1;
    [synthsizer speakUtterance:utterance];
}

//新增語音播放代理 語音播放完成的代理函數中添加播完彈框功能
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {
    self.contentHandler(self.bestAttemptContent);
}

接下來對這個.m文件的每個函數逐一分析:
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {}這個函數是通知拓展類的最爲核心的函數了,你可以理解爲這個就是接受到蘋果APNs通知的一個hock函數,每次當推送一條通知過來,都會執行到這個函數體內,所以說我們的語音播報邏輯也是在這個函數中進行處理的。
- (void)playApsVoice{}
這個函數就是用來獲取通知信息攜帶的需要播放的語音的字段做處理進行播放。
- (void)playVoiceWithContent:(NSString *)content {}
用來播放語音
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {}
之所以能夠實現當同時又多條通知同時推送,我們還能一條條串行逐條播放,主要的功能就是這個函數,這個是AVSpeechSynthesizer類的代理函數,就是一段語音播放完成後執行這個函數,每次當一條語音播放完成,都會被此函數勾住,我們在函數體內實現我們的處理邏輯。
- (void)serviceExtensionTimeWillExpire {}
這個函數時拓展類自帶的函數,這個函數時當拓展類被系統終止之前,調用這個函數。被強行彈框。

蘋果通知的通知欄問題

在蘋果通知中,當來一條通知時,我們的手機會叮一下,然後手機通知欄彈出通知。這裏大家注意下,其實這個叮一下出來的通知欄也是有生命週期的。從通知欄被彈出來,到通知欄最終被收起,其實中間蘋果給了限制時間,大概就6秒左右的時長。
說到6秒左右的時長,對於那些多條通知同時到達,需要串行來逐一播報,但是很多小夥伴們會遇到這樣一個問題:就是當同時來了多條通知,總是隻能播報2-3條,然後就語音中斷了,後面的通知不會播報了,遇到這些問題的小夥伴們有沒有注意到,其實只能播報2-3條,這個時間差其實就是6秒左右,也就是通知欄的生命週期時長。
出現上面的問題的原因就是:當第一條通知來了,彈出通知欄,然後開始播報第一條語音,第一條播報完了,開始播報第二天語音,可能當第二天語音播報到一半了,但是這個時候,通知欄週期的時間到了,這時通知欄就會收起,注意:,當通知欄收起時,擴展類裏面的代碼就會終止執行,導致後面的語音播報終端。
上面說到當通知欄收起時,擴展類的代碼會終止執行,這裏又引出了另一個注意點:就是我們創建的這個擴展類也是有生命週期的,並且這個生命週期和通知欄的生命週期他們是有依賴關係的。即:當通知欄收起時,擴展類就會被系統終止,擴展內裏面的代碼也會終止執行,只有當下一個通知欄彈出來,擴展類就恢復功能。
上面說到通知欄的出現和收起能夠影響到擴展類的功能,那我們是不是控制好通知欄的顯示和隱藏,就能解決多條串行問題尼?
是的,我們只要控制好通知欄,就可以解決上面的棘手問題,那麼問題又來了,我們怎麼才能控制通知欄的顯示和隱藏尼?感覺我們平時使用蘋果的推送,從來沒有關心過處理通知欄的顯示與隱藏,感覺從來沒有這樣用過,是的,對應普通的需求,我們確實不需要關係通知欄顯示隱藏,感覺這些蘋果系統自己已經處理好了,通知來了就顯示通知欄,等5秒左右,週期結束就隱藏通知欄。
其實啊,在擴展類裏面中,蘋果已經給我們指出瞭如何控制通知欄的顯示和隱藏,核心就是這行代碼:self.contentHandler(self.bestAttemptContent);,當我們調用到這行代碼,就是用來彈出通知欄的,通知欄的隱藏不需要我們來控制了,因爲5秒左右的生命週期結束後,它會自動隱藏。
是不是對這樣代碼既熟悉有陌生啊,熟悉是因爲你的擴展類文件中確實有這行代碼,陌生是因爲你之前從來都沒有用過這行代碼,不知道行代碼是用來幹啥的。
好了,既然self.contentHandler(self.bestAttemptContent); 這行核心代碼引用出來了,我們就回到最開始的問題,在沒有做任何處理時,爲什麼當同時來多條通知是,語音播報就不能逐一播報尼,其實就是因爲當每一條通知到達都會執行這個函數- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {},有沒有發現,這個函數體裏面 默認就是 執行了 self.contentHandler(self.bestAttemptContent); 這行代碼。
假設 一次性同時來了 10條 通知,就會一次性調用了 10次 didReceiveNotificationRequest這個函數, 也就 執行了 10次 self.contentHandler(self.bestAttemptContent), 按照上面的說法,同時執行10次,不就是同時彈出10次的 通知欄嗎,這裏我調試時發現,當同時來10條通知時,通知欄並沒有同時彈出來10次,可能只彈出來1-2次。也就只能在這1-2次的時間長度中進行語音播報了。
上面解釋這麼多,那麼我們到底該如何做尼,細心的同學發現了,我們上面 貼出來的 .m 代碼中,我們新增了一個 AVSpeechSynthesizer 類的代理函數,就是語音播報完成的函數,我們將 呼出通知欄的代碼 self.contentHandler(self.bestAttemptContent); 添加到這個代理函數中。意思就是:當第一條語音播放完成了,這時我們呼出通知欄顯示播放的內容(通知欄的週期時間大概6秒左右),正好這時可以播放第二條語音,等第二條語音播放完成了,呼出第二個通知的通知欄,繼續播放第三天語音,以此類推。
看到這裏,想必大家應該都理解了爲啥之前總是語音播報中斷的問題。
還有一個很重要的函數:- (void)serviceExtensionTimeWillExpire{},我們上面只是提了下,具體他具體有什麼功能尼?
我們發現serviceExtensionTimeWillExpire函數中,也調用了 self.contentHandler(self.bestAttemptContent) 這行代碼,它爲啥也要調用這行代碼尼?
這是因爲:當我們在接受通知的鉤子函數中(didReceiveNotificationRequest)沒有調用self.contentHandler(self.bestAttemptContent) 這行代碼,這時就會出現一個現象:就是通知收到了,但是沒有通知欄出現,這時蘋果就不允許了。蘋果規定,當一條通知達到後,如果在30秒內,還沒有呼出通知欄,我就係統強制調用self.contentHandler(self.bestAttemptContent) 來呼出通知欄。 這時想必大家都知道 serviceExtensionTimeWillExpire 函數的用途了吧
此段解釋源自:https://blog.csdn.net/qq_23414675/article/details/82751049

關於iOS12.1及以上系統推送語音播報失效的問題:
官方給出的說明,之前給出這個拓展推送主要是爲了豐富推送的UI樣式,推送信息加密之類的,結果卻被用做推送語音播報,所以就發了這個聲明,在12.1之後,在這個推送擴展裏面AVAudioPlayer就失效了。
解決方法:這裏我的處理可能不是最理想的解決方法,因爲我在iOS12.1及以上採用了播放固定錄製好的語音,並不能靈活播放推送消息了。
既然我們可以修改推送內容的title、subtitle和body,那麼由此類推,同樣的話,我們也可以修改推送的sound
在推送拓展target中拖入音頻文件:然後進行如下設置:

self.bestAttemptContent.sound = [UNNotificationSound soundNamed:@"shoukuanAuido.wav"];
        self.contentHandler(self.bestAttemptContent);
注意:

在項目target-Capabilities-Background Modes中要記得勾選Background fetchRemote notifications 這樣設置纔可以正常接收推送。並且在設置推送的時候,一定要帶上這個字段:"mutable -content" ,只有將該字段設置爲1,纔可以正常實現功能。

因爲之前沒有做過此類功能,也是借鑑了很多大牛的解決方案,每個借鑑都有帶的鏈接,如果有侵權請聯繫我刪除。目前就總結這麼多,有更好的想法希望可以在評論裏一起交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章