搜狐技术产品

iOS推送播放语音播报更新

Image

接上篇如何让iOS推送播放语音,之前的结论是iOS如果需要送审商店只能播放本地的mp3文件,这里更新一下:

更新

语音的播放,最终调用的方法是,而这个方法官方文档注释如下:
// 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.
    public convenience init(named name: UNNotificationSoundName)
注释里说,语音文件会从这三个地方查找:
  1. APP 的文件夹
  2. APP和 Extension共享Group的文件夹
  3. App bundle
而之前文章里介绍的,就是属于第三种情况,直接放在中的情况。这种情况的局限性在于,每次有新增或者变更,都需要变更同步到项目,然后APP发版用户更新后才能生效。
这种太麻烦了,有没有可能,不用更新版本,并且能直接增加新的语音种类,本篇介绍的就是这种。

实现

不更新版本,增加新的语音种类,就需要考虑,是否能在线下载?看上面的播放方法语音文件的查找目录,考虑是否可以通过在线下载语音文件到 APP 的文件夹 或者 APP和 Extension共享Group的文件夹下。
首先考虑第一种情况,如果想要下载到APP的文件夹下,要怎么做呢?直接在推送时配置下载链接是否可行?
笔者尝试的是,在的target中,获取到配置的语音文件链接,然后下载,存储到文件夹下,下载成功后,再去播放。
验证后发现不可行,因为此时的目录不是APP的目录,而是推送Target的appex的目录,而这个目录不在语音文件的查找范围内,所以这种不可行?那如何下载到APP 的目录下呢?

下载到APP的Library/Sounds

笔者想到有两种可能方案:
  1. 推送时配置下载链接,在APP处理推送方法的地方,进行下载
  2. 单独接口配置下载链接,APP打开时调用,提前下载
首先方案一,APP 处理推送方法是在的之后,而语音播报是在时,即,下载在播报之后,这种情况下,第一次的语音是播报不出来的;而且 APP 不打开的情况下,是否允许下载,是否能下载成功都未知,所以不可取。
再来看方案二,方案二的实现是一定没有问题的,通过单独的配置接口,下发语音下载链接,下载到 APP 的文件夹下,然后推送时,只需要保证播放的名字和文件夹下名字一致即可。只不过,这个方案需要新增一个配置接口,而且需要提前下发配置链接,以保证用户提前下载成功,才能在真正推送时播放对应的文件。
Ps: 如果采用方案二,是可以连语音下载链接都可以省掉,只需要告诉 APP 要播放的内容就可以,APP 内部把要播放的内容转为通过TTS语音库转为语音文件,并存储到下即可。

APP和Extension共享Group的Library/Sounds文件夹

如果不想新增配置接口,能不能直接在推送时下载呢?
答案是可以的,通过下载到APP和 Extension共享Group的文件夹这种方案,可以实现推送时下载并播放。具体步骤如下:
  1. 创建 APP 和 共享的 Group
  2. 在方法中,获取下载链接,并下载
  3. 下载成功后,存储到Group的文件夹下
  4. 存储成功后,播放
通过这种方案,就可以实现在推送时,配置语音文件链接,从而下载并播放。这种方案需要考虑要下载文件的时间和大小,因为超过一定时间后,就会自动回调了;而且文件如果太大,即使下载成功也有可能播放失败。
Ps:这种方案也可以考虑直接推送要播放的内容,然后通过离线语音库合成音频文件,存储到Group的下,然后播放,这里不做详细介绍,感兴趣可以自己实验。
再来考虑一个问题,假如项目里已经有了某些音频文件,要推送消息时,是否会根据项目中有没有决定加不加语音文件链接?当然不是,产品或者运营推送时是不会判断的,他们一定是无脑加;所以,项目中需要判断,判断按步骤判断项目Bundle 中有没有,再判断APP 的下有没有,再判断共享Group的中有没有已下载过,最后才是去下载。
具体代码大致如下:
  1. 首先判断项目Bundle 中有没有音频文件,由于在中获取不到主项目的,所以需要在打开 APP 时,存储已存在的音频文件名字到共享,然后在中通过 Group 获取音频文件名字判断:
    // 存储已存在的音频文件名字到共享 Group, 在 App 打开时调用
    static func updateAppMainBundleMP3FileResources() {
        let path = Bundle.main.bundlePath
        let fileManager = FileManager.default
        do {
            let allContentList = try fileManager.contentsOfDirectory(atPath: path)
            let validContentList = allContentList.filter { $0.hasSuffix(".mp3") }
            print(validContentList)

                    let shareDefaults = UserDefaults(suiteName: shareDefaultsSuiteName())
            shareDefaults?.setValue(validContentList, forKey: kMainAppMp3FileKey)
            shareDefaults?.synchronize()
        } catch {
            print(error)
        }
    }
    // 共享的Group
    static func shareDefaultsSuiteName() -> String {
        let name = "group.com.xxx.pushGroup"
        return name
    }

  2. 判断APP 的下有没有对应音频文件,这一步需要考虑,是否采用了 APP提供单独接口下发语音文件链接,如果没有,则不需要考虑;笔者这里没有,所以不做详细演示。其逻辑大致如下:
  • 获取项目文件夹
  • 获取文件夹下所有音频文件
  • 合并存储音频文件名字到共享 Group 中
  • 判断共享Group的中有没有已下载过:
    fileprivate static let kMainAppMp3FileKey = "kMainAppMp3FileKey"
    static func isVoiceInfoExist(voiceName: String) -> Bool {
        /**
         The /Library/Sounds directory of the app’s container directory.
         The /Library/Sounds directory of one of the app’s shared group container directories.
         The main bundle of the current executable.
         */

        // 判断bundle中有没有
        let soundStr = voiceName + ".mp3"

            let shareDefaults = UserDefaults(suiteName: shareDefaultsSuiteName())
        if let validContentList = shareDefaults?.value(forKey: kMainAppMp3FileKey) as? [String],
            validContentList.contains(soundStr) {
            // 文件存在
            return true
        }

            // 判断 /Library/Sounds 文件夹下有没有
        let fileManager = FileManager.default
        if let soundsDirectoryURL = getLibrarySoundsDir() {
            let filePath = (soundsDirectoryURL as NSString).appendingPathComponent(voiceName + ".mp3")
            print("------", filePath)
            if fileManager.fileExists(atPath: filePath) {
                return true
            }
        }

            // 文件不存在存在
        return false
    }

    // 获取共享 Group 的`Library/Sounds`文件夹
    static func getLibrarySoundsDir() -> String? {
        let fileManager = FileManager.default
        let groupIdentifer = shareDefaultsSuiteName()
        let sharedContainerURL: URL? = fileManager.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifer)
        if let soundsDirectoryURL = sharedContainerURL?.appendingPathComponent("Library/Sounds") {
            let fileExist = fileManager.fileExists(atPath: soundsDirectoryURL.path)
            if !fileExist {
                do {
                    try fileManager.createDirectory(atPath: soundsDirectoryURL.path,
                                                    withIntermediateDirectories: true)
                } catch {
                    print(error)
                }
            }
            return soundsDirectoryURL.path
        }
        return nil
    }

  • 下载音频文件:
    // 下载音频文件
    static func downloadAndSave(url: URL, voiceName: String, handler: @escaping (_ localURL: URL?) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { data, res, error in
            var localURL: URL?
            if let data = data {
                let librarySoundDir = getLibrarySoundsDir()
                let filePath = (librarySoundDir as? NSString)?.appendingPathComponent(voiceName + ".mp3")
                if let urlStr = filePath {
                    let targetUrl = URL(fileURLWithPath: urlStr)
                    do {
                        _ = try data.write(to: targetUrl)
                    } catch {
                        print(error)
                    }
                    print("url------", targetUrl)
                    localURL = targetUrl
                }
            }
            handler(localURL)
        }
        task.resume()
    }
  • 最后统一调用:
    import UserNotifications
    import AVFoundation
    class NotificationServiceUtil {
       func playVoice(with bestAttemptContent: UNMutableNotificationContent, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
           let userInfo = bestAttemptContent.userInfo

                  do {
               try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
               try AVAudioSession.sharedInstance().setActive(true)
           } catch {
               print(error)
           }

                  // 要播放的语音文件名字
           guard let voiceName = userInfo["voiceName"] as? String else {
               contentHandler(bestAttemptContent)
               return
           }

                  // 判断本地是否有语音文件, 有则播放; 没有则下载或者尝试系统语音播放
           let isVoiceExists = NotificationServiceUtil.isVoiceInfoExist(voiceName: voiceName)
           let soundStr = voiceName + ".mp3"
           let soundName = UNNotificationSoundName(soundStr)

           if isVoiceExists {
               bestAttemptContent.sound = UNNotificationSound(named: soundName)
               contentHandler(bestAttemptContent)
           } else {
               // 下载链接
               if let voiceUrlStr = userInfo["voiceUrl"] as? String,
                   let voiceUrlUrl = URL(string: voiceUrlStr) {
                   // 下载
                   NotificationServiceUtil.downloadAndSave(url: voiceUrlUrl, voiceName: voiceName) { localURL in
                       bestAttemptContent.sound = UNNotificationSound(named: soundName)
                       contentHandler(bestAttemptContent)
                   }
               } else {
                   contentHandler(bestAttemptContent)
               }
           }
       }
    }

  • 在中调用:
    import UserNotifications
    class NotificationService: UNNotificationServiceExtension {

       var contentHandler: ((UNNotificationContent) -> Void)?
       var bestAttemptContent: UNMutableNotificationContent?
       fileprivate lazy var util = NotificationServiceUtil()

       override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
           self.contentHandler = contentHandler
           bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

                  if let bestAttemptContent = bestAttemptContent {
               // 播放处理
               util.playVoice(with: bestAttemptContent, withContentHandler: contentHandler)
           }
       }

          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)
           }
       }
    }

  • 总结

    iOS语音播报支持方式总结如下:
    Image
    iOS 语音播报
    iOS 语音播报实现流程如下:

    Image

    Image