iOS 模仿支付宝支付到账推送,播报钱数

4,239 阅读14分钟

最近申请了支付宝的二维码收钱码,其中支付宝有这么一个功能,就是,别人扫描你的二维码给你转账之后,收到钱会有一条语音推送,”支付宝到账 1000万“之类的推送消息,不管你的支付宝app有没有被杀死。

只要你的远程推送开着,并且支付宝的"二维码收钱到账语音提醒",都打开着,就可以收到。

打开方式:支付宝点击右上角设置-通用-新消息通知,打开到账提醒即可。

image.png
image.png

对支付宝进行相关测试:

1、iOS 10以下的设备收到钱之后不管App是杀死还是压入后台状态都会播报”支付宝到账一笔”一句固定的语音
2、iOS 10以下的设备收到钱之后不管App是杀死还是压入后台状态,并且设备处于静音状态,只是会有一个推送弹框和手机振动
3、iOS 10以上的设备,收到钱之后,不管APP是杀死还是压入后台状态,不管是静音还是非静音状态,在收到转账的时候,会播报”支付宝到账 * 元”
4、iOS 10以上的设备,在收到语音播报的时候,按音量键是可以调节音量大小的

实现以上功能注意的点:

iOS 10以上和iOS10以下设备实现方式不一样iOS 10以上需要考虑的因素,设备是否被杀死状态,静音非静音状态,音量是否可以调节,是否可以播报随机对应的钱数
并且别人给你转多少钱就会播报到账多少钱。

iOS 10 之前系统实现方案:

能保证设备在杀死或者压入后台的状况下收到信息,应该是远程推送的功劳了。
iOS 10 之前系统,可以借助远程推送定制铃声的功能来实现,只要在本地添加一段提前录制好的语音,并且在推送内容的时候将sound字段,修改成语音的名称即可

下面来看一下iOS10以上系统的实现播报自定义语音的实现方式:

实现以上功能需借助iOS 10的 NotificationServiceExtension
首先了解下常规的远程推送逻辑

image.png
image.png

iOS 10以后添加上NotificationServiceExtension之后的推送流程

image.png
image.png

可以查看相关文档了解: NotificationServiceExtension

NotificationServiceExtension这个东西有啥作用呢?

主要是能够在展示推送内容之前先获取到相关的推送信息,可以更改或者替换相关的推送内容。

怎么实现呢?
不用自己创建UNNotificationServiceExtension类,使用Xcode提供的模板直接选择就可以。如果你的app收到远程推送的话就会加载扩展并且调用didReceiveNotificationRequest:withContentHandler:,而做这些的前提就是,远程推送的那一套配置<证书之类的>得做好,还有就是推送的字典中包含mutable-content并且它的值是1;

image.png
image.png


iOS 10系统之后远程推送播报语音的实现思路:

设备在收到远程推送的时候,进入Service Extension,将远程推送的信息拦截下来,将需要播报的文字信息通过相关方式翻译成语音,进行播报,播报完毕之后再展示弹框信息。

UNNotificationServiceExtension的功能就是可以拦截到远程推送的信息,然后当调用self.contentHandler(self.bestAttemptContent); 之后就会进行弹框显示了,如果进行了弹框显示,那么UNNotificationServiceExtension的使命意味着结束了。

当拦截到信息,到弹框最多有30秒的时间进行操作。
Your extension has a limited amount of time (no more than 30 seconds) to modify the content and execute the contentHandler
block. If you do not execute that block in a timely manner, the system calls your extension’s serviceExtensionTimeWillExpire method to give you one last chance to execute the block. If you do not, the system presents the notification’s original content to the user.

image.png
image.png

大致思路定下之后,下一步操作,怎么把文字翻译成语音进行播报

1、使用科大讯飞以及类似的三方库,将远程推送过来的文字,直接使用三方库播放出来
2、使用 AVSpeechSynthesisVoice 相关类将远程推送过来的文字直接转化成语音,进行播报
3、如果播报的是钱数的话,可以在本地将相关可能播报的语音片段录制好,然后根据推送过来的内容标识,对语音片段进行拼接,然后进行播放

如果对Notification Servivice Extension不是很熟悉的,建议先了解一下
iOS10 推送extension之 Service Extension你玩过了吗?


在介绍相关方式之前,先介绍一个测试工具
SmartPush

使用方式也很简单,运行起来,输入相关的推送数据和token,并且选择对应的推送证书,点击推送即可

image.png
image.png


正式进入主题

推送的内容是:

{
  "aps":{
    "alert":{
      "title":"iOS 10 title",
      "subtitle":"iOS 10 subtitle",世上只有妈妈好,有妈的孩子像块宝。投进妈妈的怀抱,幸福哪里找。没妈的孩子像根草。大河向东流,天上的星星参北斗,嘿呀,咿儿呀,嘿  嘿  咿儿呀"
    },
    "my-attachment":"http://img01.taopic.com/160317/240440-16031FU23937.jpg",
    "mutable-content":1,
    "category":"myNotificationCategory1",
    "badge":3

  }
}

1、使用科大讯飞以及类似的三方库,将远程推送过来的文字,直接使用三方库播放出来

这个流量用多了应该收费,具体可查看科大讯飞官网

将推送过来的文字转化成语音播放,然后在播放完毕的回调中执行self.contentHandler(self.bestAttemptContent);

@interface NotificationService ()<IFlySpeechSynthesizerDelegate,AVAudioPlayerDelegate,AVSpeechSynthesizerDelegate>
{
    AVSpeechSynthesizer *synthesizer;
}
@property (nonatomic, strong) IFlySpeechSynthesizer *iFlySpeechSynthesizer;

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@property (nonatomic, strong)AVAudioPlayer *myPlayer;

@property (nonatomic, strong) NSString *filePath;

// AVSpeechSynthesisVoice 播放完毕之后的回调block
@property (nonatomic, copy)PlayVoiceBlock finshBlock;

// 科大讯飞播放完毕之后的block回调
@property (nonatomic, copy)PlayVoiceBlock kedaFinshBlock;

// 语音合成完毕之后,使用 AVAudioPlayer 播放
@property (nonatomic, copy)PlayVoiceBlock aVAudioPlayerFinshBlock;

@end

@implementation NotificationService

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

    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
    __weak __typeof(self)weakSelf = self;

    /**************************************************************************/


    // 方式1,直接使用科大讯飞播放,成功,但是刚开始的时候可能需要几秒的准备播放时间
    [self playVoiceKeDaXunFeiWithMessage:self.bestAttemptContent.body withBlock:^{
        weakSelf.contentHandler(weakSelf.bestAttemptContent);
    }];
}
#pragma mark- 使用科大讯飞播放语音
- (void)playVoiceKeDaXunFeiWithMessage:(NSString *)message withBlock:(PlayVoiceBlock)finshBlock
{
    if (finshBlock) {
        self.kedaFinshBlock = finshBlock;
    }

    //创建语音配置,appid必须要传入,仅执行一次则可
    NSString *initString = [[NSString alloc] initWithFormat:@"appid=%@",@"59db7ce2"];

    //所有服务启动前,需要确保执行createUtility
    [IFlySpeechUtility createUtility:initString];

    /******************************************************/
    //获取语音合成单例
    _iFlySpeechSynthesizer = [IFlySpeechSynthesizer sharedInstance];
    //设置协议委托对象
    _iFlySpeechSynthesizer.delegate = self;
    //设置合成参数
    //设置在线工作方式
    [_iFlySpeechSynthesizer setParameter:[IFlySpeechConstant TYPE_CLOUD]
                                  forKey:[IFlySpeechConstant ENGINE_TYPE]];
    //设置音量,取值范围 0~100
    [_iFlySpeechSynthesizer setParameter:@"50"
                                  forKey: [IFlySpeechConstant VOLUME]];
    //发音人,默认为”xiaoyan”,可以设置的参数列表可参考“合成发音人列表”
    [_iFlySpeechSynthesizer setParameter:@" xiaoyan "
                                  forKey: [IFlySpeechConstant VOICE_NAME]];
    //保存合成文件名,如不再需要,设置为nil或者为空表示取消,默认目录位于library/cache下
    [_iFlySpeechSynthesizer setParameter:@" tts.pcm"
                                  forKey: [IFlySpeechConstant TTS_AUDIO_PATH]];
    //启动合成会话
    [_iFlySpeechSynthesizer startSpeaking:message];

}

//IFlySpeechSynthesizerDelegate协议实现
//合成结束
- (void) onCompleted:(IFlySpeechError *) error {

    NSLog(@"合成结束 error ===== %@",error);
    self.kedaFinshBlock();
}

结果
满足以下两点要求(1)、iOS 10以上的设备,收到推送之后,不管APP是杀死还是压入后台状态,不管是静音还是非静音状态,在收到转账的时候,会播报”到账 * 元”
(2)、iOS 10以上的设备,在收到语音播报的时候,按音量键是可以调节音量大小的

坑点
说明:当前实现的是将push内容中的body播放出来
1、如果你收到推送了但是添加了系统的铃声,也就是你在push的json中添加了"sound":"default"那么就可能会影响推送声音的播放
2、推送有问题

image.png
image.png

3、播放的语音时长最好不要超过30秒

4、如果说你的远程推送还是走的iOS10之前的逻辑,那么请检查一下你的推送的json有没有【"mutable-content":1】


2、使用 AVSpeechSynthesisVoice 相关类将远程推送过来的文字直接转化成语音,进行播报
AVSpeechSynthesisVoice 可查看官方文档

流程:
将推送过来的文字转化成语音播放,然后在播放完毕的回调中执行,和科大讯飞类似,不过是苹果系统相关类

相关代码

@interface NotificationService ()<IFlySpeechSynthesizerDelegate,AVAudioPlayerDelegate,AVSpeechSynthesizerDelegate>
{
    AVSpeechSynthesizer *synthesizer;
}
@property (nonatomic, strong) IFlySpeechSynthesizer *iFlySpeechSynthesizer;

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@property (nonatomic, strong)AVAudioPlayer *myPlayer;

@property (nonatomic, strong) NSString *filePath;

// AVSpeechSynthesisVoice 播放完毕之后的回调block
@property (nonatomic, copy)PlayVoiceBlock finshBlock;

// 科大讯飞播放完毕之后的block回调
@property (nonatomic, copy)PlayVoiceBlock kedaFinshBlock;

// 语音合成完毕之后,使用 AVAudioPlayer 播放
@property (nonatomic, copy)PlayVoiceBlock aVAudioPlayerFinshBlock;

@end

@implementation NotificationService

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

    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
    __weak __typeof(self)weakSelf = self;


    /**************************************************************************/

//  方式4,AVSpeechSynthesisVoice使用系统方法,文字转语音播报,成功
    [self playVoiceWithAVSpeechSynthesisVoiceWithContent:self.bestAttemptContent.body fishBlock:^{
        weakSelf.contentHandler(weakSelf.bestAttemptContent);
    }];

}
#pragma mark- AVSpeechSynthesisVoice文字转语音进行播放,成功
- (void)playVoiceWithAVSpeechSynthesisVoiceWithContent:(NSString *)content fishBlock:(PlayVoiceBlock)finshBlock
{
    if (content.length == 0) {
        return;
    }
    if (finshBlock) {
        self.finshBlock = finshBlock;
    }

    AVAudioSession *session = [AVAudioSession sharedInstance];
    [session setActive:YES error:nil];
    [session setCategory:AVAudioSessionCategoryPlayback error:nil];

    // 创建嗓音,指定嗓音不存在则返回nil
    AVSpeechSynthesisVoice *voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];

    // 创建语音合成器
    AVSpeechSynthesizer *synthesizer = [[AVSpeechSynthesizer alloc] init];
    synthesizer.delegate = self;

    // 实例化发声的对象
    AVSpeechUtterance *utterance = [AVSpeechUtterance speechUtteranceWithString:content];
    utterance.voice = voice;
    utterance.rate = 0.5; // 语速

    // 朗读的内容
    [synthesizer speakUtterance:utterance];
}

- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didStartSpeechUtterance:(AVSpeechUtterance *)utterance
{
    NSLog(@"开始");
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance
{
    self.finshBlock();
    NSLog(@"结束");
}

坑点
说明:当前实现的是将push内容中的body播放出来
1、如果你收到推送了但是添加了系统的铃声,也就是你在push的json中添加了"sound":"default"那么就可能会影响推送声音的播放
2、播放的语音时长最好不要超过30秒
3、如果说你的远程推送还是走的iOS10之前的逻辑,那么请检查一下你的推送的json有没有【"mutable-content":1】
4、如果在手机静音的状态下听不到播报的语音
添加设置

AVAudioSession *session = [AVAudioSession sharedInstance];
[session setActive:YES error:nil];
[session setCategory:AVAudioSessionCategoryPlayback error:nil];

结果
满足以下两点要求
(1)、iOS 10以上的设备,收到推送之后,不管APP是杀死还是压入后台状态,不管是静音还是非静音状态,在收到转账的时候,会播报”到账 * 元”
(2)、iOS 10以上的设备,在收到语音播报的时候,按音量键是可以调节音量大小的


3、如果播报的是钱数的话,可以在本地将相关可能播报的语音片段录制好,然后根据推送过来的内容标识,对语音片段进行拼接,然后进行播放

流程:
如果播报的内容是相对固定的片段组合体,这里那支付宝举例。
比如提前先录好 以下可能播报的内容
*到账、 0、 1、 2、 3、 4、 5、 6、 7、 8、 9、 十、 百、 千、 万、 十万、 百万、 千万、 亿、 元 等等
然后根据推送的内容进行相关语音文件的对应,然后拼接,拼接完毕之后生成一个语音文件,然后进行播放

相关代码:

@interface NotificationService ()<IFlySpeechSynthesizerDelegate,AVAudioPlayerDelegate,AVSpeechSynthesizerDelegate>
{
    AVSpeechSynthesizer *synthesizer;
}
@property (nonatomic, strong) IFlySpeechSynthesizer *iFlySpeechSynthesizer;

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@property (nonatomic, strong)AVAudioPlayer *myPlayer;

@property (nonatomic, strong) NSString *filePath;

// AVSpeechSynthesisVoice 播放完毕之后的回调block
@property (nonatomic, copy)PlayVoiceBlock finshBlock;

// 科大讯飞播放完毕之后的block回调
@property (nonatomic, copy)PlayVoiceBlock kedaFinshBlock;

// 语音合成完毕之后,使用 AVAudioPlayer 播放
@property (nonatomic, copy)PlayVoiceBlock aVAudioPlayerFinshBlock;

@end

@implementation NotificationService

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

    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
    __weak __typeof(self)weakSelf = self;


    /*******************************推荐用法*******************************************/

    // 方法3,语音合成,使用AVAudioPlayer播放,成功
    AVAudioSession *session = [AVAudioSession sharedInstance];
    [session setActive:YES error:nil];
    [session setCategory:AVAudioSessionCategoryPlayback error:nil];

    [self hechengVoiceAVAudioPlayerWithFinshBlock:^{
        weakSelf.contentHandler(weakSelf.bestAttemptContent);
    }];   
}
#pragma mark- 合成音频使用 AVAudioPlayer 播放
- (void)hechengVoiceAVAudioPlayerWithFinshBlock:(PlayVoiceBlock )block
{
    if (block) {
        self.aVAudioPlayerFinshBlock = block;
    }

    /************************合成音频并播放*****************************/

    AVMutableComposition *composition = [AVMutableComposition composition];

    NSArray *fileNameArray = @[@"daozhang",@"1",@"2",@"3",@"4",@"5",@"6"];

    CMTime allTime = kCMTimeZero;

    for (NSInteger i = 0; i < fileNameArray.count; i++) {
        NSString *auidoPath = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"%@",fileNameArray[i]] ofType:@"m4a"];

        AVURLAsset *audioAsset = [AVURLAsset assetWithURL:[NSURL fileURLWithPath:auidoPath]];

        // 音频轨道
        AVMutableCompositionTrack *audioTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:0];
        // 音频素材轨道
        AVAssetTrack *audioAssetTrack = [[audioAsset tracksWithMediaType:AVMediaTypeAudio] firstObject];

        // 音频合并 - 插入音轨文件
        [audioTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, audioAsset.duration) ofTrack:audioAssetTrack atTime:allTime error:nil];

        // 更新当前的位置
        allTime = CMTimeAdd(allTime, audioAsset.duration);

    }

    // 合并后的文件导出 - `presetName`要和之后的`session.outputFileType`相对应。
    AVAssetExportSession *session = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetAppleM4A];
    NSString *outPutFilePath = [[self.filePath stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"xindong.m4a"];

    if ([[NSFileManager defaultManager] fileExistsAtPath:outPutFilePath]) {
        [[NSFileManager defaultManager] removeItemAtPath:outPutFilePath error:nil];
    }

    // 查看当前session支持的fileType类型
    NSLog(@"---%@",[session supportedFileTypes]);
    session.outputURL = [NSURL fileURLWithPath:outPutFilePath];
    session.outputFileType = AVFileTypeAppleM4A; //与上述的`present`相对应
    session.shouldOptimizeForNetworkUse = YES;   //优化网络

    [session exportAsynchronouslyWithCompletionHandler:^{
        if (session.status == AVAssetExportSessionStatusCompleted) {
            NSLog(@"合并成功----%@", outPutFilePath);

            NSURL *url = [NSURL fileURLWithPath:outPutFilePath];

            self.myPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];

            self.myPlayer.delegate = self;
            [self.myPlayer play];


        } else {
            // 其他情况, 具体请看这里`AVAssetExportSessionStatus`.
            // 播放失败
            self.aVAudioPlayerFinshBlock();
        }
    }];

    /************************合成音频并播放*****************************/
}
#pragma mark- AVAudioPlayerDelegate
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag
{
    if (self.aVAudioPlayerFinshBlock) {
        self.aVAudioPlayerFinshBlock();
    }
}

结果
(1)、iOS 10以上的设备,收到推送之后,不管APP是杀死还是压入后台状态,不管是静音还是非静音状态,在收到转账的时候,会播报”到账 * 元”
(2)、iOS 10以上的设备,在收到语音播报的时候,按音量键是可以调节音量大小的

坑点
(1)、注意上面的坑点
(2)、播放音频的时候有两种播放形式AudioServicesPlayAlertSoundWithCompletionAVAudioPlayer
建议使用AVAudioPlayer,因为AVAudioPlayer能满足不受设备静音不静音的影响,能根据音量调节声音的高低。
AudioServicesPlayAlertSoundWithCompletion局限性比较大,会受静音的影响,只会震动,并且无法调整音量的高低。

合成语音之后如果使用AudioServicesCreateSystemSoundID播放的话,有一定的局限性
http://www.hangge.com/blog/cache/detail_771.html
1,系统声音服务介绍:
系统声音服务提供了一个Api,用于播放不超过30秒的声音。它支持的文件格式有限,具体的说只有CAF、AIF和使用PCM或IMA/ADPCM数据的WAV文件。
但此函数没有提供操作声音和控制音量的功能,因此如果是要为多媒体或游戏创建专门声音,就不要使用系统声音服务。

2,系统声音服务支持如下三种类型:
(1)声音:立刻播放一个简单的声音文件。如果手机静音,则用户什么也听不见。
(2)提醒:播放一个声音文件,如果手机设为静音或震动,则通过震动提醒用户。
(3)震动:震动手机,而不考虑其他设置。

说明:
上面并没有实现 数字转对应音频文件名称数组的过程,直接实现的是合成音频的方法。

Extension的运行生命周期:
iOS对于扩展的支持已经由最初的6类到了如今iOS10的19类(相信随着iOS的发展扩展的覆盖面也会越来越广),当然不同类型的扩展其用途和用法均不尽相同,但是其工作原理和开发方式是类似的。下面列出扩展的几个共同点:

扩展依附于应用而不能单独发布和部署;
扩展和包含扩展的应用(containing app)生命周期是独立的,分别运行在两个不同的进程中;
扩展的运行依赖于宿主应用(或者叫载体应用 host app,而不是containing app)其生命周期由宿主应用确定;
对开发者而言扩展作为一个单独的target而存在;
扩展通常展现在系统UI或者其他应用中,运行应该尽可能的迅速而功能单一;

image.png
image.png

关于断点调试
如果想通过断点调试来了解或者检查推送的流程怎么搞呢?

方法一:

image.png
image.png

然后在相关的target中打断点就可以了,但是貌似在Xcode 9.0 9.1上面选择service Extension不太管用,直接走iOS 10 之前的推送去了,不知道为啥。
image.png
image.png

调试 content Extension

image.png
image.png

方法二:
如果想同时调试各个target怎么办?

将项目运行起来,然后发送一条推送之后,激活Service Extension,如果有需要可以激活content Extension<下拉一下推送条查看就好,前提你的conten Extension可以使用>
然后去选择,这个时候不要stop掉程序,根据下图选择完毕之后,在相关的地方打上断点,再次推送。

image.png
image.png

image.png
image.png

建议使用方法二,各个流程顺序更加直观

最后献上相关的Demo地址,如果你有更好的建议欢迎留言,如有不正,欢迎来喷。

可以直接用我的Demo进行调试,调试的时候注意修改下bundleId,然后用自己的开发者账号配置一下相关的push证书就可以了

image.png
image.png

image.png
image.png