背景#
以前見たことがある人が画中画を使って時分秒のカウントを実現していたので、ついでに保存しておいたが、ずっと見ることができなかった。最近『每日英语听力』を使用していると、突然画中画を使って聴力文の表示を実現していることに気づき、興味が湧いたので、どのように実現されているのか研究してみることにした。ついでに画中画で時分秒のカウントを実現する方法も研究してみたい —— 特定のプラットフォームで毎日固定の時間に購入が始まるとき、iPhone が秒単位でカウントダウンを表示してくれたら、いつクリックするのが適切かがわかるので、毎回 1 分前からクリックし続けて何も手に入らないということがなくなるのだが。。。
実現#
画中画は一般的に動画を浮かせて再生するために使用されるが、どうやって画中画で動画ではなくカスタムインターフェースを再生させるのか?以下の 5 つのステップに分けて具体的に見ていこう:
- 画中画機能を実現するために、どのスイッチを設定し、どのメソッドを実装する必要があるか;
- 基本的なシステムプレーヤーを使用する際の画中画の実現;
- カスタムプレーヤーを使用する際の画中画機能の実現にはどのような設定が必要で、どのような違いがあるか;
- 画中画を通じて時分秒のカウント機能を実現する方法;
- 『每日英语听力』が画中画を通じて英語の聴力文を再生する際にどのように実現しているのか?
APP 支持画中画功能#
APP が画中画機能をサポートするにはどうすればよいか?まず、App がBackgroundModes
をサポートするように設定し、次にBackgroundModes
の中からAudio, Airplay, and Picture in Picture
にチェックを入れる必要がある。
操作は以下の通り:


次にAVAudioSession
を設定する必要があり、AppDelegate.Swift
のapplication(_:didFinishLaunchingWithOptions:)
メソッドに以下のコードを設定する:
AVFoundation
をインポートAVAudioSession
がバックグラウンド再生をサポートするように設定
// AVFoundationをインポート
import AVFoundation
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 設定コードを追加
do {
// AVAudioSession.Category.playbackを設定後、サイレントモードやAPPがバックグラウンドに入ったり、ロック画面に入ったりしても再生を続けることができる。
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: AVAudioSession.Mode.moviePlayback)
} catch {
print(error)
}
return true
}
使用系统播放器时画中画的实现#
システムプレーヤーAVPlayerViewController
を使用してプレーヤーの画中画を実現するには、まずAVKit
をインポートし、再生するリソースを取得し、次にAVPlayerViewController
を使用して再生する。コードは以下の通り:
import AVKit
/// 再生するリソースを取得
fileprivate func playerResource() -> AVQueuePlayer? {
guard let videoURL = Bundle.main.url(forResource: "suancaidegang", withExtension: "mp4") else {
return nil
}
let item = AVPlayerItem(url: videoURL)
let player = AVQueuePlayer(playerItem: item)
player.actionAtItemEnd = .pause
return player
}
@IBAction func systemPlayerAction(_ sender: Any) {
guard let player = playerResource() else {
return
}
let avPlayerVC = AVPlayerViewController()
avPlayerVC.player = player
present(avPlayerVC, animated: true) {
player.play()
}
}
ここで注意が必要なのは、必ず実機でなければ画中画の効果を見ることができないということだ。シミュレーターでは動作しない。実行後、AVPlayerViewController
が直接画中画の再生をサポートしているのがわかる;画中画に入ると、以前の全画面の再生インターフェースが自動的に閉じる;画中画から再生インターフェースに戻ると、画中画は閉じるが、以前の再生インターフェースも再び開かれない。効果は以下の通り:

ここで明らかに、画中画から以前の再生インターフェースに戻れないのは問題があるので、少し修正して、画中画に入るときに全画面の再生インターフェースを閉じないように設定できるようにし、画中画から戻るときに正常に戻れるかどうかを確認する。ここでAVPlayerViewControllerDelegate
のメソッド playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool
を使用して、画中画に入るときに現在のインターフェースを閉じるかどうかを制御できる。
// このメソッドにavPlayerVC.delegate = selfを追加
@IBAction func systemPlayerAction(_ sender: Any) {
guard let player = playerResource() else {
return
}
let avPlayerVC = AVPlayerViewController()
avPlayerVC.delegate = self
avPlayerVC.player = player
present(avPlayerVC, animated: true) {
player.play()
}
}
// AVPlayerViewControllerDelegateを設定
extension ViewController: AVPlayerViewControllerDelegate {
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
// falseを返すと、画中画に入るときに再生インターフェースは閉じない
// trueを返すと、画中画に入るときに再生インターフェースは自動的に閉じる。デフォルトはtrue
return false
}
}
実行してデバッグすると、画中画に入るときに再生インターフェースが閉じず、This video is playing in picture in picture
と表示され、閉じるボタンがないことがわかる;画中画から戻ると、再生インターフェースが引き続き再生される。効果は以下の通り:

比較すると、処理前は画中画に入ると再生インターフェースが消え、画中画の入るボタンをクリックしても再生インターフェースに戻れない。左側の画像のような効果。処理後の効果は右側の画像のように、画中画に入った後、全画面再生インターフェースに戻れる。


しかし、上記の効果も期待通りではない。通常、画中画モードに入るのは、ページの他の内容を操作し続けるためであり、上記の設定では画中画から戻ると再生を続けることができるが、ページの他の内容を操作することを妨げてしまうため、やはり修正が必要である。期待される効果は、画中画インターフェースに入ると、現在の再生インターフェースが消え、画中画から戻ると再生インターフェースに入れるようにする。以下でその実現方法を見ていこう:
AVPlayerViewControllerDelegate
には別のメソッドplayerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void)
があり、画中画から戻るときにこのメソッドがトリガーされるので、行うべきことは、このメソッドがトリガーされたときに再生動画インターフェースを再び呼び起こすことだ。コードは以下の通り:
extension ViewController: AVPlayerViewControllerDelegate {
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
// ここをtrueを返すように変更し、画中画に入るときに再生インターフェースを閉じる
return true
}
func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
restore(playerVC: playerViewController, completionHandler: completionHandler)
}
}
fileprivate func restore(playerVC: UIViewController, completionHandler: @escaping (Bool) -> Void) {
if let presentedVC = presentedViewController {
// 現在再生中のインターフェースがまだ存在することを示す
// まずインターフェースを閉じてから、再生インターフェースを表示する
presentedVC.dismiss(animated: false) { [weak self] in
self?.present(playerVC, animated: false) {
completionHandler(true)
}
}
} else {
// 直接再生インターフェースを表示する
present(playerVC, animated: false) {
completionHandler(true)
}
}
}
実行して効果を確認すると、画中画インターフェースに入ると現在の再生インターフェースが消え、画中画から戻ると再生インターフェースに入れることがわかる。完璧なデモは以下の通り:

比較すると、処理前は画中画に入ると再生インターフェースが消え、画中画の入るボタンをクリックしても再生インターフェースに戻れない。左側の画像のような効果。処理後の効果は右側の画像のように、画中画に入った後、全画面再生インターフェースに戻れる。



自定义播放器时画中画的实现#
カスタムプレーヤーは、システムのAVPlayerViewController
を使用する場合と比較して、カスタムプレーヤーインターフェースでクリックして画中画再生を呼び起こし、画中画のデリゲートメソッドAVPictureInPictureControllerDelegate
を実装する必要がある。画中画のデリゲートメソッドでは、画中画から戻るときのロジックを処理する必要がある。特に注意すべき点は、画中画に入ると再生インターフェースが消えるように設定すると、現在の再生インターフェースが解放され、再生インターフェース上の画中画再生も消えてしまうため、特別な処理が必要で、グローバルなを宣言して保存する。参考にPicture in Picture Across All Platforms。
コードは以下の通り:
protocol CustomPlayerVCDelegate: AnyObject {
func playerViewController(
_ playerViewController: MWCustomPlayerVC,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
completionHandler: @escaping (Bool) -> Void
)
}
private var activeCustomPlayerVCs = Set<MWCustomPlayerVC>()
class MWCustomPlayerVC: UIViewController {
// MARK: - properties
private var pictureInPictureVC: AVPictureInPictureController?
weak var delegate: CustomPlayerVCDelegate?
var autoDismissAtPip: Bool = false // 画中画に入るとき、現在の再生ページを自動的に閉じるかどうか
var enterPipBtn: CustomPlayerCircularButtonView?
override func viewDidLoad() {
super.viewDidLoad()
// ビューの読み込み後に追加のセットアップを行う。
view.backgroundColor = .black
setupPictureInPictureVC()
setupEnterPipBtn()
}
fileprivate func setupPictureInPictureVC() {
guard let playerLayer = playerLayer else {
return
}
pictureInPictureVC = AVPictureInPictureController(playerLayer: playerLayer)
pictureInPictureVC?.delegate = self
}
fileprivate func setupEnterPipBtn() {
enterPipBtn = CustomPlayerCircularButtonView(symbolName: "pip.enter", height: 50.0)
enterPipBtn?.addTarget(self, action: #selector(handleEnterPipAction), for: [.primaryActionTriggered, .touchUpInside])
view.addSubview(enterPipBtn!)
enterPipBtn?.snp.makeConstraints { make in
make.right.equalToSuperview().inset(10.0)
make.centerY.equalTo(self.view.snp.centerY)
make.width.height.equalTo(50.0)
}
}
// 画中画インターフェースを呼び起こす
@objc
fileprivate func handleEnterPipAction() {
pictureInPictureVC?.startPictureInPicture()
}
}
extension MWCustomPlayerVC: AVPictureInPictureControllerDelegate {
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
// 画中画再生のデリゲートメソッド
activeCustomPlayerVCs.insert(self)
enterPipBtn?.isHidden = true
}
// 画中画が開始された後、現在の再生インターフェースは消えるか
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if autoDismissAtPip {
dismiss(animated: true)
}
}
// 画中画の開始に失敗した
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
activeCustomPlayerVCs.remove(self)
enterPipBtn?.isHidden = false
}
// 画中画から戻るデリゲートメソッド
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
delegate?.playerViewController(self, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler: completionHandler)
}
}
そして、外部で使用する場所で呼び出し、画中画の閉じるコールバックデリゲートメソッドを処理する。以下の通り:
@IBAction func customPlayerAction(_ sender: Any) {
guard let player = playerResource() else {
return
}
let playerVC = MWCustomPlayerVC()
playerVC.modalPresentationStyle = .fullScreen
playerVC.delegate = self
playerVC.player = player
playerVC.autoDismissAtPip = true
present(playerVC, animated: true) {
player.play()
}
}
extension ViewController: CustomPlayerVCDelegate {
func playerViewController(
_ playerViewController: MWCustomPlayerVC,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
completionHandler: @escaping (Bool) -> Void) {
restore(playerVC: playerViewController, completionHandler: completionHandler)
}
}
実行後の効果は以下の通りで、カスタムプレーヤーが処理された後、システムのプレーヤーと同じ画中画効果を持つことがわかる:

次のステップに進む前に、皆さんに考えてもらいたいのは、上記の画中画の例を通じて、画中画がどのように使用されるかがわかったということだ。もしあなたが画中画のカウントを実現するなら、どのように実現するか、どのような方法があるか?
- 筆者が考えた方法は、画中画が動画を再生するので、ビューを動画に変換できるのではないか?そして再生動画の方法を使って、ビューの内容を再生することができるのではないか?
- その後、筆者は他の資料を調べて、さらにトリッキーな考え方があることを発見した。画中画が APP 内でポップアップするので、画中画のウィンドウを取得できるのではないか?ウィンドウを取得した後、直接ウィンドウにビューを追加することで表示できるのではないか?
以下でこれら 2 つの方法がどちらも実行可能かを検証していく。
まず、画中画のカウントを実現するために、方法 1 が実行可能かを検証する;『每日英语听力』の文の表示を通じて、方法 2 が実行可能かを検証する。
時分秒计时画中画的实现#
ここでは方法 1 を使用し、ビューを動画に変換し、再生動画の方法を使ってビューの内容を再生することでカウントダウンタイマーを実現する。問題は、ビューを動画に変換する方法はどうするか?
直接の変換方法は見つからないが、参考にUIPiPViewを見つけると、以下の手順があることがわかる:
view
をCMSampleBuffer
に変換する(参考:https://soranoba.net/programming/uiview-to-cmsamplebuffer)initWithSampleBufferDisplayLayer
メソッドを使用してAVSampleBufferDisplayLayer
を初期化し、AVPictureInPictureController.ContentSource
を作成する。- その後、
AVPictureInPictureController.ContentSource
を使用してAVPictureInPictureController
を初期化する。 - 最後に
AVSampleBufferDisplayLayer
を使用してCMSampleBuffer
を表示し、 - 最終的にビューを
AVPictureInPictureController
に表示する。
ここで注意すべき問題は、上記のview
をCMSampleBuffer
に変換し、CMSampleBuffer
をAVPictureInPictureController
に表示するプロセスは単一のビューであり、どのようにスムーズな動画再生にするか?タイマーを定義して絶えず更新する必要があるが、その更新のタイミングはどのくらいが適切か?肉眼でカクつきが見えないのが適切であり、UIPiPView
では 0.1/60 秒の使用を推奨している。
ここでは再封装を繰り返さず、直接UIPiPViewを使用し、タイマーを作成する必要がある。表示するビューはUIPipView
に追加されることに注意。
コードは以下の通り:
import UIKit
import UIPiPView
import SnapKit
class MWFullTimerVC: MWBaseVC {
// MARK: - properties
private let pipView = UIPiPView()
private let timeLabel = UILabel()
private let dateFormatStr = "yyyy-MM-dd HH:mm:ss"
private var timer: Timer?
// MARK: - view life cycle
override func viewDidLoad() {
super.viewDidLoad()
// ビューの読み込み後に追加のセットアップを行う。
view.backgroundColor = UIColor.black
setupPipView()
setupTimeLabel()
createDisplayLink()
}
fileprivate func setupPipView() {
let width = UIScreen.main.bounds.width
pipView.frame = CGRect(x: 10.0, y: 0, width: width - 20.0, height: 50.0)
view.addSubview(pipView)
pipView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(10.0)
make.top.equalToSuperview().inset(100.0)
make.height.equalTo(50.0)
}
}
fileprivate func setupTimeLabel() {
timeLabel.font = UIFont.boldSystemFont(ofSize: 16.0)
timeLabel.textColor = UIColor.white
timeLabel.backgroundColor = UIColor.orange
timeLabel.textAlignment = .center
pipView.addSubview(timeLabel)
timeLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.top.equalToSuperview()
make.height.equalToSuperview()
}
}
func createDisplayLink() {
timer = Timer(timeInterval: 0.1/60, repeats: true, block: { [weak self] _ in
self?.refresh()
})
RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)
timer?.fire()
}
// MARK: - init
// MARK: - utils
func reloadTime() {
let date = Date()
let formatter = DateFormatter()
formatter.dateFormat = dateFormatStr
self.timeLabel.text = formatter.string(from: date)
}
// MARK: - action
func refresh() {
reloadTime()
}
override func handleEnterPipAction() {
super.handleEnterPipAction()
if pipView.isPictureInPictureActive() {
pipView.stopPictureInPicture()
} else {
pipView.startPictureInPicture(withRefreshInterval: 0.1/60.0)
}
}
// MARK: - other
}
実行後のデバッグ効果は以下の通り:

上記の方法が実行可能であり、画中画のサイズは自分で定義でき、空白の動画ファイルを内蔵する必要がないことがわかる。
《每日英语听力》画中画的实现#
ここでは画中画のウィンドウを取得し、ウィンドウを取得した後、直接ウィンドウにカスタムテキスト再生ビューを追加することで、画中画にカスタムビューを表示する方法を検証する。
画中画が表示される直前のデリゲートメソッドpictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)
の中で、最上位のウィンドウを取得し、そのウィンドウにカスタムテキスト再生ビューを追加する。テキスト再生ビューは、2 秒ごとに次の文を再生するように設定する。
最終的な全体コードは以下の通り:
class MWPipWindowVC: UIViewController {
func setupTextPlayerView(on targetView: UIView) {
targetView.addSubview(textPlayView)
textPlayView.text = text1
textPlayView.snp.makeConstraints { make in
make.centerY.equalTo(targetView.snp.centerY)
make.leading.trailing.equalToSuperview()
make.height.equalTo(250.0)
}
}
fileprivate func setupTimer() {
timer = Timer(timeInterval: 2.0, repeats: true, block: { [weak self] _ in
self?.handleTimerAction()
})
RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)
timer?.fire()
}
// MARK: - utils
// MARK: - action
func handleTimerAction() {
let dataList = [text1, text2, text3, text4, text5]
count += 1
let index = count % 5
let str = dataList[index]
textPlayView.text = str
}
}
extension MWPipWindowVC: AVPictureInPictureControllerDelegate {
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
activeCustomPlayerVCs.insert(self)
enterPipBtn?.isHidden = true
if let window = UIApplication.shared.windows.first {
setupTextPlayerView(on: window)
}
}
xxx
}
実行してデバッグすると、最終的な効果は以下の通り:

上記の方法が実行可能であることがわかり、画中画に入るときに再生動画のインターフェースが一瞬フラッシュし、画中画のサイズは動画のサイズと一致することがわかる。したがって、この方法を使用する場合は、事前に対応するサイズの空白動画を準備する必要がある。
"ビューを動画に変換して再生する" と "空白動画を再生してから、その上にビューを表示する" の 2 つの実装を比較すると、まず両者ともカスタム画中画表示の効果を実現できることがわかる。しかし、筆者のテストによると、"ビューを動画に変換して再生する" 方法は CPU の使用率が非常に高く、絶えず読み込みと更新を行う必要があるため、"空白動画を再生してから、その上にビューを表示する" 方法は CPU の使用率が低くなるが、空白動画ファイルに依存するため、インストールパッケージのサイズが大きくなる。また、複数のスタイルの画中画効果が必要な場合は、複数の空白動画ファイルが必要になる。
したがって、どちらの方法を使用するかを選択するには、通常、複数の画中画スタイルの必要がない場合は、"空白動画を再生してから、その上にビューを表示する" 方法を選択することをお勧めする。一方、インストールパッケージのサイズに敏感で、ユーザーがカスタム画中画や異なるスタイルの画中画を必要とする場合は、"ビューを動画に変換して再生する" 方法を検討することができる。
総括#
