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

相关推荐
黄尚圈圈30 分钟前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水1 小时前
简洁之道 - React Hook Form
前端
正小安4 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch5 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光5 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   5 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   5 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web5 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常5 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇6 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器