如何在使用SceneDelegate的项目中实现小窗需求?

1,410 阅读6分钟

前言

之前做过一个直播间的小窗需求,在用户进入到其它页面的时候,依然可以观看直播。而诸如bilibili的视频,微信的视频号,网易云音乐的广场等手机端视频小窗,在iOS 14发布之后,都使用了Apple官方的画中画功能来实现小窗播放。

Untitled.png

具体表现如上,可以具备很多Apple提供的画中画的功能,注意该小窗可以在应用内,也可以在应用外:

  • 双击小窗:改变尺寸,变大变小
  • 拖动小窗:改变小窗的位置
  • 向左或右边缘拖动:隐藏小窗
  • 点击左上角关闭:关闭小窗
  • 点击右上角回归:返回App并全屏观看

说了这么多优点,那么缺点我想显而易见了,必须使用Apple提供的****AVPlayerViewController** 或者**AVPictureInPictureController** 这两种系统控制器来实现小窗需求,那么不可避免的会造成可定制性就会比较低!所以在BILIBILI的最新版本(7.1.2)中它们没有在应用内使用Apple的画中画功能,只是应用外使用了,那么应用内如果不使用Apple的画中画特性,那么如何实现小窗播放呢?

答案显而易见:UIWindow

AppDelegate和SceneDelegate的关联

其实在提到这个UIWindow的创建的时候,有必要去提一提SceneDelegate出来之后的一些变化。在iOS 13之前的App,AppDelegate是App主要的入口,并且是App的各种不同状态切换处理的地方。但是在iOS 13之后,原来AppDelegate的职责就被划分为AppDelegate和SceneDelegate共同承担了,主要的原因是要满足iPad-OS中支持的多窗口的特性。

那么现在它们的职责分别是什么呢?

AppDelegate

职责

依然是整个应用的入口,负责整个App级别的生命周期以及启动设置。

方法

在iOS 13之后,目前AppDelegate默认会有三个方法,分别如下:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
  • 此方法用于整个应用的启动,以及初始化的设置。
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
  • 当一个新的Scene被创建时该方法被调用,在启动时并不会调用该方法。
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>)
  • 当用户从多窗口中移除该Scene或者使用程序销毁该Scene时,该方法被调用。

其它的关于整个App生命周期的方法,以及定位,推送等相关方法这里不做赘述。

SceneDelegate

职责

原来window的概念现在被scene所取代,一个App可以有很多个不同的Scene,而Scene现在作为App的用户界面和内容的管理,同时一个Scene上又可以有很多的UIWindow(本质上UIWindow是UIView)。所以SceneDelegate的职责是管理App中的UI的生命周期(也就是管理Scene的生命周期)。

关于Scene的理解如果接触过Unity游戏开发应该会很容易,在游戏中不同的关卡其实就是不同的场景(Scene),而同一个场景中可以许多不同的窗口视图(UIWindow)。而切换不同的关卡,就是不同场景的切换。所以如果一个App如果要承载业务上许多不同端的功能(如管理端,消费端),其实可以使用不同的Scene来进行这个切换。

方法

整体来说SceneDelegate和iOS 13以前的AppDelegate的方法含义类似,一看就知道是关于各种状态管理的。不过这里管理的是某个Scene的状态。

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
  • 这个方法将创建新的UIWindow,设置Root ViewController,并且使得这个window为keyWindow并展示。
func sceneDidBecomeActive(_ scene: UIScene)
  • 当Scene从一个inactive的状态转变为active的状态时该方法被调用。
func sceneWillEnterForeground(_ scene: UIScene) 
  • 当这个Scene从后台转移到前台时,该方法被调用。使用该方法恢复一些在进入后台时的改变。
func sceneDidEnterBackground(_ scene: UIScene)
  • 当这个Scene从前台进入后台时,该方法被调用。使用该方法保存数据,释放共享资源,以及存储scene特有的状态信息等等
func sceneDidDisconnect(_ scene: UIScene)
  • 当该Scene被系统释放时,此方法被调用。在进入后台后不久,或者这个session被discarded之后,此方法被调用。释放和该Scene相关联的资源,在下次连接的时候,Scene将会被重建。

如何使用UIWindow实现小窗?

讲了这么一大堆废话,其实主要是梳理在iOS 13.0之后,Apple对于App Delegate的职责分离,那么接下来进入正题,如果我们要实现小窗,在这种职责分离的场景下,我们需要做什么?

基于AppDelegate

什么叫基于AppDelegate呢?就是说还是之前那套Window的概念,而不是新的Scene的概念,那么这种情况下,我们就应该将SceneDelegate移除,如何移除呢?很简单,分三步:

  1. 删除项目info.plist文件中的Application Scene Manifest的配置数据。
  2. 删除AppDelegate中关于Scene的代理方法
  3. 删除SceneDelegate类

最后需要在AppDelegate中添加UIWindow 属性,然后进行我们熟悉的UIWindow的初始化流程:

class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame:UIScreen.main.bounds)
        self.window!.backgroundColor = UIColor.white

        //设置root
        let rootVC = ViewController()
        self.window!.rootViewController = rootVC
        self.window!.makeKeyAndVisible()

        return true
    }
}

OK,这是AppDelegate我们熟悉的初始化,那么如果需要添加小窗呢?很简单,我们创建一个UIWindow即可,只需要设置isHidden为false即可。

func setupSmallWindow() -> UIWindow {
    let smallWindow = UIWndow.init(frame: CGRect.init(x: UIScreen.main.bounds.width - 98 - 10, y: UIScreen.main.bounds.height - 176 - (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0) - 10, width: 98, height: 176))
    smallWindow.rootViewController = UIViewController()
    smallWindow.isHidden = false
    return smallWindow
}

当然如果需要添加一些特性,比如拖动手势,比如双击的交互等等,这个后续基于当前UIWindow进行封装即可。同时要注意的是,在这种上下文中,UIWindow初始化时必须要设置rootViewController属性。

基于SceneDelegate

基于SceneDelegate就是说,又要想使用多窗口的特性,又想在某个Scene上提供小窗功能,这个其实就是Scene上关联多个UIWindows的实例。这个怎么做呢?它和之前初始化UIWindow不同了,现在初始化UIWindow是需要指定Scene的。

所以具体来说我们需要做两步操作:

  1. SceneDelegate的启动方法中创建承载UIWindow的Scene
  2. 创建小窗Window,一定要管理Scene
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    windowScene.title = "main"

    window = UIWindow.init(windowScene: windowScene)
    window?.rootViewController = ViewController.init()
    window?.makeKeyAndVisible()

    setupNewWindow()
}

// 创建新的小窗
func setupNewWindow() {
    let scenes = UIApplication.shared.connectedScenes
    for scene in scenes {
        if scene.title == "main" {
            newWindow = UIWindow.init(frame: CGRect.init(x: 0, y: 0, width: 100, height: 100))
	    newWindow?.backgroundColor = UIColor.systemBlue
	    newWindow?.windowScene = (scene as? UIWindowScene)
	    newWindow?.isHidden = false
        }
   }
}

这里有三个点需要注意:

  1. 通过title 属性来区分不同的scene
  2. 创建UIWindow的时候,需要指定windowScene
  3. 一定要设置UIWindow的isHidden 属性,将其设置为false

在scene的场景下,如果不设置为false的话,那么这个小窗是不会显示的。也就是说初始化的UIWindow其实是默认隐藏的。

参考

1、Understanding Scene Delegate & App Delegate

2、iOS13 Scene Delegate详解