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

劉光軍_Shine 2019-04-03 10:26:21 993

需求分析

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

功能实现思路分析

上面说了,要使用推送,也就是APNs,这里我使用了极光推送,接下来就是实现手机接收到通知之后播报语音了,关于这个功能的实现在iOS10以后苹果新增了“推送拓展”UNNotificationServiceExtension,我们可以在这里操作,在这里我用的是苹果官方的AVSpeechSynthesizer和AVSpeechUtterance来将接收到的推送内容转换成语音播报,其中在这里,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中我使用苹果自带的AVSpeechSynthesizer、AVSpeechSynthesisVoice和AVSpeechUtterance来实现语音播报,当然也可以使用讯飞或者百度等第三方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 fetch和Remote notifications 这样设置才可以正常接收推送。并且在设置推送的时候,一定要带上这个字段:"mutable -content" ,只有将该字段设置为1,才可以正常实现功能。

因为之前没有做过此类功能,也是借鉴了很多大牛的解决方案,每个借鉴都有带的链接,如果有侵权请联系我删除。目前就总结这么多,有更好的想法希望可以在评论里一起交流。

作者:劉光軍_Shine

链接:https://www.jianshu.com/p/baad93eab877