如何在使用SceneDelegate的項目中實現小窗需求?

語言: CN / TW / HK

前言

之前做過一個直播間的小窗需求,在用户進入到其它頁面的時候,依然可以觀看直播。而諸如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默認會有三個方法,分別如下:

swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool

  • 此方法用於整個應用的啟動,以及初始化的設置。

swift func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration

  • 當一個新的Scene被創建時該方法被調用,在啟動時並不會調用該方法。

swift 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的狀態。

swift func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)

  • 這個方法將創建新的UIWindow,設置Root ViewController,並且使得這個window為keyWindow並展示。

swift func sceneDidBecomeActive(_ scene: UIScene)

  • 當Scene從一個inactive的狀態轉變為active的狀態時該方法被調用。

swift func sceneWillEnterForeground(_ scene: UIScene)

  • 當這個Scene從後台轉移到前台時,該方法被調用。使用該方法恢復一些在進入後台時的改變。

swift func sceneDidEnterBackground(_ scene: UIScene)

  • 當這個Scene從前台進入後台時,該方法被調用。使用該方法保存數據,釋放共享資源,以及存儲scene特有的狀態信息等等

swift 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的初始化流程:

```swift 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即可。

swift 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

```swift 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詳解