需求布景:完成iOS app没有启动时。在收到转账时完成语音播报收款金额

一、布景

在WWDC2019发布了新的iOS13,苹果不再允许PushKit应用在非voip电话的场景上。这篇文章总结了在iOS13下的语音播报迁移计划以及一些需要注意的问题。

二、技术计划

Notification Service Extension

iOS完成收钱时播映语音提示总结

新的计划是主要是利用了苹果在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代码如下
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)
    }
  }
}
把用到的短音频兼并成一个音频文件
/// 兼并音频文件
  /// - 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)
      }
    }
  }

把文字转换为对应的短音频文件名称

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