iOS推送语音播报(相似支付宝收款提醒)

需求分析xcode

实现相似支付宝微信收款后的语音播报如:支付宝到帐xx元。要求是APP在前台运行、锁屏、杀死进程后都会有语音播报。那想到的解决方案就是利用推送了。微信

功能实现思路分析app

上面说了,要使用推送,也就是APNs,这里我使用了极光推送,接下来就是实现手机接收到通知以后播报语音了,关于这个功能的实如今iOS10之后苹果新增了“推送拓展”UNNotificationServiceExtension,咱们能够在这里操做,在这里我用的是苹果官方的AVSpeechSynthesizerAVSpeechUtterance来将接收到的推送内容转换成语音播报,其中在这里,iOS12.1之后,不容许在UNNotificationServiceExtension中播放语音了,我也查找过不少资料,最终实现了一个比较折中的方法,下面会详细说。ide

功能实现函数

1、极光推送fetch

关于极光推送的证书申请啥的就不讲了,官方文档上写的很清楚了,这里只说将极光推送SDK集成到项目以后了。
一、集成极光推送SDK
在项目中的Podfile文件中添加pod 'JPush',而后pod install,等待pod完成。
二、在AppDelegate.m中编写推送功能代码
(其实极光推送的文档里也有)。
(一、在项目中引入所需头文件:ui

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

(二、设置代理加密

@interface AppDelegate ()<JPUSHRegisterDelegate>

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

- (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 将没法正常工做.
 */

(四、实现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];

(五、在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,才能够正常实现功能。

由于以前没有作过此类功能,也是借鉴了不少大牛的解决方案,每一个借鉴都有带的连接,若是有侵权请联系我删除。目前就总结这么多,有更好的想法但愿能够在评论里一块儿交流。