iPhone上使用画中画(PictureInPicture of iOS14)

画中画(PictureInPicture)在iOS9就已经推出了,不过之前都只能在iPad使用,iPhone要使用画中画就得更新到iOS14才能使用。

Demo:JPPictureInPictureDemo

iPhone上的画中画

前提准备

  • 下载并安装Xcode12(系统并不需要更新到最新);下载地址
  • 如果要用真机测试,就得下载iOS14的描述文件,然后更新并安装iOS14的beat版本即可。下载地址

基本使用

如果对播放器要求不大的可以直接使用AVPlayerViewController,自身就提供画中画功能可直接使用,而自定义的播放器要开启画中画那就使用AVPictureInPictureController,也是很简单易用,并且动画效果内部已经实现,只需要以下3步即可实现:

  1. 使用Xcode12打开工程,首先得开启后台模式:
  2. 初始化播放器的同时创建AVPictureInPictureController

// 1. 判断当前项目是否支持画中画
if AVPictureInPictureController.isPictureInPictureSupported() == true {
    // 2. 开启权限
    do {
        try AVAudioSession.sharedInstance().setCategory(.playback)
        try AVAudioSession.sharedInstance().setActive(true, options: [])
    } catch {
        print("AVAudioSession发生错误")
    }
    // 3. 创建实例         
    pipCtr = AVPictureInPictureController(playerLayer: playerLayer) // playerLayer为播放器的图层,不能为新建的AVPlayerLayer
    pipCtr?.delegate = self // 成为代理
}
  1. 开启/关闭画中画:
if pipCtr.isPictureInPictureActive == true {
    pipCtr.stopPictureInPicture()
} else {
    pipCtr.startPictureInPicture()
}

到目前为止已经可以实现基本的画中画效果了:

全局画中画

不过现在退出控制器后画中画也会跟着关闭,这里参考哔哩哔哩App的画中画,人家是开启画中画的同时退出当前播放器,另外开启新的画中画,会先关闭当前的画中画再打开新的画中画,所以目前的画中画功能还不够完善。

要想退出控制器后还保持着画中画,那就得继续保住播放器的命,或者保住控制器的命也可以,这里简单演示就保住控制器吧,不过在什么时候保住,什么时候释放呢?这时就得从AVPictureInPictureController的代理方法中入手了:


// 即将开启画中画
optional func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 已经开启画中画
optional func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 开启画中画失败
optional func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error)
// 即将关闭画中画
optional func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 已经关闭画中画
optional func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
// 关闭画中画且恢复播放界面
optional func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void)

我的做法是,用一个全局变量来专门来保住控制器性命(毕竟画中画一般也就同时最多存在一个而已):

var playerVC_ : JPPlayerViewController?

class JPPlayerViewController: UIViewController {
    ......
    pipCtr?.delegate = self // 成为代理
}

extension JPPlayerViewController : AVPictureInPictureControllerDelegate {
    // 在即将开启画中画时持有该控制器,接着退出控制器,这样控制器就能苟住
    func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        playerVC_ = self
        navigationController?.popViewController(animated: true)
    }

    // 在确保已经关闭画中画后释放引用,销毁控制器(只要关闭画中画最后都会来到这里,所以个人认为在这里释放比较合适)
    func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        playerVC_ = nil
    }
}

关闭画中画并恢复播放界面

有两种方式:
1. 代码调用stopPictureInPicture()
2. 点击画中画上面的右边按钮


值得注意的是,通过这两种方式的话在关闭画中画前会执行pictureInPictureController(_ pictureInPictureController:, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:)这个代理方法,这是用来恢复播放界面的,只需要在代理方法里面执行一下回调的闭包即可开启恢复动画:

func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController,
                                restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
    completionHandler(true) // 执行回调的闭包
}

不过由于前面的做法是开启画中画的同时退出控制器的,想要恢复播放界面还得重新打开控制器:

var playerVC_ : JPPlayerViewController?

class JPPlayerViewController: UIViewController {
    weak var navCtr : UINavigationController?
    
    ......

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 保存当前导航控制器
        navCtr = navigationController
        ......
    }
}

extension JPPlayerViewController : AVPictureInPictureControllerDelegate {

    ......

    func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        // 如果当前导航控制器的栈里并没有当前控制器,则重新打开
        if let navCtr = navCtr, navCtr.viewControllers.contains(self) != true {
            playerVC_ = nil // 确定关闭,置空防止后续误判
            navCtr.pushViewController(self, animated: true)
            // 因为push有动画过程,延时一点再恢复,不然动画会恢复到错误位置
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15) {
                completionHandler(true)
            }
            return
        }
        completionHandler(true)
    }
}


如果不是点击画中画的按钮,而是是通过其他途径打开当前画中画的控制器,这时也应该关闭画中画,可以在viewWillAppear里面进行判断并关闭:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    ......
    // 如果打开的控制器是当前画中画的控制器
    if playerVC_ == self {
        pipCtr?.stopPictureInPicture() // 关闭画中画
    }
}

已有画中画的情况下开启新的画中画

画中画一般同时最多存在一个,所以如果要在已有画中画的情况下开启新的画中画,首先确保先完全关闭当前的再去打开新的,防止未知的错误出现,不过关闭画中画是有过程的,我的做法是采用闭包回调:

class JPPlayerViewController: UIViewController {

    ......

    fileprivate var stopPipComplete : (()->())?

    // 自定义一个关闭画中画的函数
    func stopPictureInPicture(_ complete: (()->())?) {
        if let pipCtr = pipCtr, pipCtr.isPictureInPictureActive == true {
            stopPipComplete = complete
            pipCtr.stopPictureInPicture()
        } else {
            stopPipComplete = nil
        }
    }
}

extension JPPlayerViewController : AVPictureInPictureControllerDelegate {

    ......

    func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        print("pictureInPictureControllerDidStopPictureInPicture")
        playerVC_ = nil
        // 执行闭包
        if let complete = stopPipComplete { complete() }
        stopPipComplete = nil
    }
    
    // 多加一个判断条件
    func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        if stopPipComplete == nil,  // 如果关闭全局的画中画,那么stopPipComplete不为nil
           let navCtr = navCtr,
           navCtr.viewControllers.contains(self) != true {
            
            playerVC_ = nil
            navCtr.pushViewController(self, animated: true)
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15) {
                completionHandler(true)
            }
            return
        }
        completionHandler(true)
    }
}

在另一个控制器中开启新的画中画:

func __togglePictureInPicture() {
    guard let pipCtr = pipCtr else { return }
    if pipCtr.isPictureInPictureActive {
        controlView.pipBtn?.isSelected = false
        pipCtr.stopPictureInPicture()
    } else {
        // 如果全局变量有值,说明已经存在一个画中画了,先关闭再打开新的
        if let playerVC = playerVC_ {
            // 如果时间较长可以加个hud
            playerVC.stopPictureInPicture { [weak self] in
                // 关闭hud
                self?.controlView.pipBtn?.isSelected = true
                self?.pipCtr?.startPictureInPicture()
            }
        } else {
            controlView.pipBtn?.isSelected = true
            pipCtr.startPictureInPicture()
        }
    }
}

额外问题

如果是想在打开画中画时才创建AVPictureInPictureController(懒加载),有很大几率会没有反应,这时候应该加个延时再打开:

pipCtr = AVPictureInPictureController(playerLayer: playerLayer)
pipCtr?.delegate = self
guard let pipCtr = pipCtr else { return }
// 初始化的同时开启画中画很有可能会失效,可能是完全没有初始化完毕导致的,最好延时一下再开启
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
    pipCtr.startPictureInPicture()
}

这是因为这个view在创建Controller后的下一次RunLoop循环才会初始化完毕,使用gcd延时执行就可以在往后的循环中拿到这个初始化好的view。


参考:官方文档
Demo:JPPictureInPictureDemo

你可能感兴趣的:(iPhone上使用画中画(PictureInPicture of iOS14))