前言
之前做过一个直播间的小窗需求,在用户进入到其它页面的时候,依然可以观看直播。而诸如bilibili的视频,微信的视频号,网易云音乐的广场等手机端视频小窗,在iOS 14发布之后,都使用了Apple官方的画中画功能来实现小窗播放。
具体表现如上,可以具备很多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移除,如何移除呢?很简单,分三步:
- 删除项目info.plist文件中的Application Scene Manifest的配置数据。
- 删除AppDelegate中关于Scene的代理方法
- 删除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的。
所以具体来说我们需要做两步操作:
- 在SceneDelegate的启动方法中创建承载UIWindow的Scene
- 创建小窗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
}
}
}
复制代码
这里有三个点需要注意:
- 通过
title
属性来区分不同的scene - 创建UIWindow的时候,需要指定windowScene
- 一定要设置UIWindow的
isHidden
属性,将其设置为false
在scene的场景下,如果不设置为false的话,那么这个小窗是不会显示的。也就是说初始化的UIWindow其实是默认隐藏的。