iOS13微信收款到账语音提醒开发总结

5,680 阅读7分钟

之前承诺过发一篇iOS13下微信收款到账语音提醒的总结文章,一直拖着没有写。一方面是还没有上线,另一方面是后面一直在搞红包项目,现在两个项目都顺利上线,终于可以停下来为大家总结一下具体的方案和开发中遇到的问题。

一、背景

随着苹果爸爸在WWDC2019发布了新的iOS13,两年前的这篇微信iOS收款到账语音提醒开发总结方案已经不再适用,具体的原因是iOS13中(准确的说是使用XCode11编译)苹果不再允许PushKit应用在非voip电话的场景上。在iOS13中,苹果比以往更关注用户的隐私以及设备的电池续航问题,所以对PushKit的能力进行了收拢。如果需要使用PushKit的话则需要接入CallKit的接口,导致收到客户端收到Voip Push时会拉起一个接打电话的全屏界面,有在国区发布过应用的同学应该知道拉起这个界面是不被甲方允许的。这篇文章总结了在iOS13下的语音播报迁移方案以及一些需要注意的问题。目前微信的7.0.10版本已经带上了这部分的特性。

二、技术方案

Notification Service Extension

新的方案是主要是利用了苹果在iOS10中推出的Notification Service Extension(以下简称NSE),当apns的payload上带上"mutable-content"的值为1时,就会进入NSE的代码中。与Voip方案最大的不同之处是,NSE不能唤醒主应用,也不能访问主应用的文件空间,只能在Extension进程中处理相应的逻辑。在NSE中,开发者可以更改通知的内容,利用离线合成或者从后台下载的方式,生成需要播报的内容,通过自定义通知铃声的方式,达到语音播报提醒的目的。NSE方案也是苹果在WWDC2019的Session707上推荐的解决方式。

UNNotificationSound

在NSE中,可以通过给UNNotificationContent中的Sound属性赋值来达到在通知弹出时播放一段自定义音频的目的。

// The sound file to be played for the notification. The sound must be in the Library/Sounds folder of the app's data container or the Library/Sounds folder of an app group data container. If the file is not found in a container, the system will look in the app's bundle.

文档中明确描述了音频文件的存储路径,以及读取的优先级:

  1. 主应用中的Library/Sounds文件夹中
  2. AppGroups共享目录中的Library/Sounds文件夹中
  3. main bundle中

自定义铃声支持的声音格式包括,aiff、wav以及wav格式,铃声的长度必须小于30s,否则系统会播放默认的铃声。

而且由于是通知铃声,声音是默认跟静音开关的,不需跟以前一样再使用判断静音开关的黑魔法(黑魔法在不同机型上偶尔会出现误判的情况)。

AppGroups

由于我们是在NSE中自定义铃声,所以1和3这两个文件路径我们是无法访问的。只能将合成好或者下载到语音音频文件存储到AppGroups下的Library/Sounds文件夹中,需要在Capablities中打开这个AppGroups的能力,即可通过NSFileManagercontainerURLForSecurityApplicationGroupIdentifier:方法访问AppGroups的根目录。

语音合成

微信的收款到账语音依赖了我们自研的强大的离线语音合成库。apns的payload中携带了需要合成的文本内容,通过离线语音合成库生成wav音频文件后,将文件写到AppGroups的Library/Sounds文件夹下,最后更改UNNotificationSound属性即可使通知播报一段自定义的收款到账语音。

如果一些小型的企业本身不具备有离线合成的能力(看了下市面上的几个比较厉害的离线合成服务都是需要收费的),则可以采用在线合成再通过http下载的方式,讯飞和微信都有提供免费的服务。这个方案的缺点是依赖后台和当前的网络环境,有可能会导致消息播报不及时的问题。如果出现30s内都无法现在成功,需要在serviceExtensionTimeWillExpire方法中进行处理,最好的兜底方案是播放一段默认的语音。

三、开发过程中遇到的问题

消息播放队列

NSE方案有个问题是:当客户端短时间内收到多条播报通知时,后面的通知会顶掉前面的通知,导致前面的通知播报不完整,这种情况对于商家来说是比较困扰的。所以需要增加一个消息队列,将所有需要播报的通知都添加到队列中,当前面的消息播放完毕后,再播放后面的消息。音频的播放时间可以让后台通过payload推送,如果是自己的合成的wav可以通过播放时间 =(音频大小 - 音频头)/ (采样频率 * 采样精度 * 通道数)进行计算。

多线程问题

要注意的是,NSE的代码逻辑并不是在主线程执行的。苹果这样的设计是非常合理的,一方面避免了开发者在NSE由于代码设计失误导致前台的其他应用界面卡住的问题,另一方面是主工程此时已被挂起或者已被kill掉,本来也不应该给主线程的执行时间给到NSE。

所以我们在处理上面提到的消息播放队列,以及涉及到文件读写的逻辑上,需要给相应的代码逻辑加锁,否则会出现多线程问题。

消息去重

由于支付的消息相较于普通消息对可达性与实时性的要求更高,所以当初设计的时候使用了双通道来降低Voip的偶现的丢消息和延迟的问题。之前的Voip方案是客户端会收到两条消息一样的Voip消息,通过记录payload中的单号来对消息进行去重。但是在NSE中,客户端是无法做到主动去重的,根本原因是NSE的设计理念只是为了修改NotificationContent的内容,而不能阻止通知弹出,这一点可以从超时处理方法的文档中看出:

If your didReceive(_:withContentHandler:)method takes to long to execute its completion block, the system calls this method on a separate thread to give you one last chance to execute the block. Use this method to execute the block as quickly as possible. Doing so might mean providing some fallback content. For example, if your extension is still downloading an image file with the intent of attaching it to the notification’s content, you might update the notification’s alert text to indicate that an image is being downloaded. If you fail to execute the completion block from the didReceive(_:withContentHandler:) method in time, the system displays the notification’s original content.

如果你30s内没调用handler方法,并且没有实现serviceExtensionTimeWillExpire方法,那么系统会帮你主动推送后台推给客户端的原内容。

这里的解决方案是让后台,让双通道触发的apns消息在requestheader上带上同样的apns-collapse-id,后面的通知就会覆盖前面的通知。但是这里还有个问题就是虽然用户看到的是一条消息,但是声音还是会播两次。这里就可以通过记录已播放的消息单号,后面再重现重复的单号就讲sound设置为一段空白的音频就ok了。

三、总结

现在回头看,NSE是比Voip更优雅的解决方案,完成迁移后,总体代码量也比Voip方案少了不少,那当初为什么没有选择这个方案呢?这里其实也有它的历史原因:一方面是NSE是iOS10以后才出现的新Extension,做第一版方案时iOS10刚发布,对其了解程度不够深入。另一方面,微信当时并不具备离线合成语音的能力的,只能通过Cgi去拉取在线合成语音,而微信的Extension当时也不具备Cgi请求的能力。切换到NSE方案后,最好的一个体验是语音播报与静音开关能完美契合,令人诟病的消息延迟问题也有所改善。

四、相关资料

Advances in App Background Execution - 2019

UNNotificationServiceExtension