iOS实现收钱时播放语音提醒

需求背景:实现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.

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

  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的根目录。

四、实现代码如下

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

  1. 每次只能播放一段语音,不要试图用for循环、或者递归, 去播放多段
  2. 不要试图把短的语音文件从bundle直接读取,进行播放(在AppGroups中已经提醒)
  3. 在设置UNNotificationSound(named: UNNotificationSoundName(name))中的name字段是在Library/Sounds文件夹中的文件名字
  4. 把金额转换成对应的短语音的文件名称类型数组,然后在把用到的文件内容合并,保存到Library/Sounds文件夹中

有洲洲哥必有demo

相关推荐
妙哉73614 分钟前
零基础学安全--HTML
前端·安全·html
咔叽布吉21 分钟前
【前端学习笔记】AJAX、axios、fetch、跨域
前端·笔记·学习
GISer_Jing41 分钟前
Vue3常见Composition API详解(适用Vue2学习进入Vue3学习)
前端·javascript·vue.js
Dragon Wu1 小时前
TailwindCss 总结
前端·css·前端框架
bpmf_fff1 小时前
十、事件类型(鼠标事件、焦点.. 、键盘.. 、文本.. 、滚动..)、事件对象、事件流(事件捕获、事件冒泡、阻止冒泡和默认行为、事件委托)
前端·javascript
泰山小张只吃荷园1 小时前
期末Python复习-输入输出
java·前端·spring boot·python·spring cloud·docker·容器
悦涵仙子2 小时前
vueuse中的useTemplateRefsList
前端·javascript·vue.js
萧萧玉树2 小时前
分布式在线评测系统
前端·c++·后端·负载均衡
haima952 小时前
ubuntu安装chrome无法打开问题
前端·chrome
放逐者-保持本心,方可放逐2 小时前
XSS 与 CSRF 记录
前端·xss·csrf·浏览器安全