需求背景:实现iOS app没有启动时。在收到转账时实现语音播报收款金额
一、背景
在WWDC2019发布了新的iOS13,苹果不再允许PushKit应用在非voip电话的场景上。这篇文章总结了在iOS13下的语音播报迁移方案以及一些需要注意的问题。
二、技术方案
Notification Service Extension
新的方案是主要是利用了苹果在iOS10中推出的Notification Service Extension(以下简称NSE),当apns的payload上带上"mutable-content"的值为1时,就会进入NSE的代码中。在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.
文档中明确描述了音频文件的存储路径,以及读取的优先级:
- 主应用中的Library/Sounds文件夹中
- AppGroups共享目录中的Library/Sounds文件夹中
- main bundle中
自定义铃声支持的声音格式包括,aiff、wav以及wav格式,铃声的长度必须小于30s,否则系统会播放默认的铃声。
AppGroups
由于我们是在NSE中自定义铃声,所以1和3这两个文件路径我们是无法访问的。只能将合成好或者下载到语音音频文件存储到AppGroups下的Library/Sounds文件夹中
,需要在Capablities
中打开这个AppGroups的能力,即可通过NSFileManager
的containerURLForSecurityApplicationGroupIdentifier:
方法访问AppGroups的根目录。
四、实现代码如下
NotificationService代码如下
swift
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
var isSound:Bool = false
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
let monery = OPBApnsHelper.shared.getMusicArray(with: "168.07")
let name = OPBApnsHelper.shared.mergeVoice(musicArry: monery)
let sound = UNNotificationSound(named: UNNotificationSoundName(name))
bestAttemptContent?.sound = sound
if let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
把用到的短音频合并成一个音频文件
js
/// 合并音频文件
/// - Parameters:
/// - musicArry: ["1","百","零","6"]
/// - completed: 合成后的文件名称
func mergeVoiceAudiFileName(musicArry:[String],completed:((String?)->Void)?) {
clear(targetPath)
let composition = AVMutableComposition()
var beginTime = CMTime.zero
for audioFileName in musicArry {
if let audioFilePath = Bundle.main.path(forResource: audioFileName, ofType: "mp3") {
guard let audioAsset = AVURLAsset(url: URL(fileURLWithPath: audioFilePath)) as AVURLAsset?,
let audioAssetTrack = audioAsset.tracks(withMediaType: AVMediaType.audio).first else {
continue
}
let audioTrack = composition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID: kCMPersistentTrackID_Invalid)
do {
try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: audioAsset.duration), of: audioAssetTrack, at: beginTime)
beginTime = CMTimeAdd(beginTime, audioAsset.duration)
} catch {
print("Failed to insert audio track: \(error)")
return
}
}
}
if !FileManager.default.fileExists(atPath: targetPath) {
do {
try FileManager.default.createDirectory(atPath: targetPath, withIntermediateDirectories: true, attributes: nil)
} catch {
NSLog("创建Sounds文件失败 \(targetPath)")
}
}
let fileName = "\(now()).m4a"
let fileUrl = URL(string: "file://\(targetPath)\(fileName)")
guard let url = fileUrl else { return }
let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A)
let outPutFilePath = url
session?.outputURL = outPutFilePath
session?.outputFileType = AVFileType.m4a
session?.shouldOptimizeForNetworkUse = true
session?.exportAsynchronously {
if session?.status == AVAssetExportSession.Status.completed {
print("合并成功----\(outPutFilePath)")
completed?(fileName)
} else {
print("合并失败")
completed?(nil)
}
}
}
把文字转换为对应的短音频文件名称
swift
func getMusicArray(with numStr: String) -> [String] {
guard let finalStr = makeMusicFrom(numStr) else {
return []
}
// 前部分字段例如:***到账 user_payment是项目自定义的音乐文件
var finalArr = ["user_payment"]
for char in finalStr {
finalArr.append(String(char))
}
return finalArr
}
func makeMusicFrom(_ numstr: String) -> String? {
let numberchar = ["0","1","2","3","4","5","6","7","8","9"]
let inunitchar = ["","十","百","千"]
let unitname = ["","万","亿"]
let valstr = String(format: "%.2f", Double(numstr) ?? 0.00)
var prefix = ""
let head = String(valstr.prefix(valstr.count - 2 - 1))
let foot = String(valstr.suffix(2))
if head == "0" {
prefix = "0"
} else {
var ch = [String]()
for char in head {
ch.append(String(format: "%x", char.asciiValue! - UInt8(ascii: "0")))
}
var zeronum = 0
for i in 0..<ch.count {
let index = (ch.count - 1 - i) % 4
let indexloc = (ch.count - 1 - i) / 4
if ch[i] == "0" {
zeronum += 1
} else {
if zeronum != 0 {
if index != 3 {
prefix += "零"
}
zeronum = 0
}
if ch.count > i {
if let numIndex = Int(ch[i]), numIndex < numberchar.count {
prefix += numberchar[numIndex]
}
}
if inunitchar.count > index {
prefix += inunitchar[index]
}
}
if index == 0 && zeronum < 4 {
if unitname.count > indexloc {
prefix += unitname[indexloc]
}
}
}
}
if prefix.hasPrefix("1十") {
prefix = prefix.replacingOccurrences(of: "1十", with: "十")
}
if foot == "00" {
prefix += "元"
} else {
prefix += String(format: "点%@元", foot)
}
return prefix
}
五、在开发过程中遇见的问题总结如下 xcode:15.1 macos:14.0
- 每次只能播放一段语音,不要试图用for循环、或者递归, 去播放多段
- 不要试图把短的语音文件从
bundle
直接读取,进行播放(在AppGroups中已经提醒
) - 在设置UNNotificationSound(named: UNNotificationSoundName(name))中的name字段是在
Library/Sounds
文件夹中的文件名字
- 把金额转换成对应的短语音的文件名称类型数组,然后在把用到的文件内容合并,保存到
Library/Sounds
文件夹中