iOS 生命週期的缺失和錯亂
不知道大家有沒有考慮過一個很奇怪的情況,就是 View Controller 的生命週期沒有被呼叫,或者是呼叫順序錯亂?其實這在實際操作中經常發生,override 的時候一不小心就忘記呼叫 super 了,或者明明是 override viewWillAppear(),卻呼叫成了 super.viewWillDisappear()。甚至,一不小心,呼叫了兩次…
override func viewWillAppear(_ animated: Bool) {
super.viewWillDisappear(animated)
// 寫成這樣會被罵死嗎 =。=
}
複製程式碼
那麼這究竟會發生導致什麼問題呢?
我們先簡單寫一個 demo 方便我們提問(demo 地址:LifeCycleDemo,非常簡單,自己寫一個也行)。就是一個用 Storyboard 新建了一個 ViewController,然後可以跳轉到另一個 ViewController。
然後,我們在 ViewController 的每一個生命週期被呼叫時都列印一下生命週期的名字,就是下面這樣:
好,有了這個 Demo 之後,我們依照這個 Demo 來討論下面幾個問題:
1. 如果缺少 loadView() 方法會怎麼樣?
override func loadView() {
// super.loadView()
print("loadView")
}
複製程式碼
答案:黑屏
這道題很假單,如果沒有 loadView,那就沒有載入 view,就是黑屏。
Apple 文件中說,loadView 不能被手動呼叫,View Controller 會自動在其 View 第一次被獲取、並且還是 nil 的時候呼叫(可以理解為 View 是懶載入的)。如果你要 override 這個方法,那麼必須要將你自己的 view hierarchy 中的 root View 設定給 View Controller 的 View 屬性。並且這個 View 不能與其他 View Controller 共享,也不能再呼叫 super 方法了。
2. 如果在 loadView() 之前呼叫 view 會怎麼樣?
override func loadView() {
print(self.view)
super.loadView()
}
複製程式碼
答案:infinite stack trace
可以看出,[UIViewController view] 和 ViewController.loadView 迴圈呼叫了。這是因為在 loadView 之前,view 並沒有被建立,而由於 view 是懶載入的,此時呼叫 self.view 會觸發 loadView,由此導致了迴圈引用。
另外,如果我們想要重寫 loadView,正確的方式應該類似於這樣:
override func loadView() {
let myView = MyView()
view = myView
}
複製程式碼
實際上,重寫 loadView 能達到一些意想不到的效果,推薦一篇文章:重寫 loadView() 方法使 Swift 檢視程式碼更加簡潔
3. 如果在 viewWillAppear() 時候手動呼叫 loadView() 會怎麼樣?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadView()
}
複製程式碼
答案:ViewController 的 view 被替換
表面上看起來沒有任何變化,ViewController 還是能完整地顯示出來。但是這個時候如果我們點選 "Presented Controller" 這個按鈕,想要跳轉到下一個頁面,會發現沒有響應。同時會發現 Console 中有下面的輸出:
Warning: Attempt to present <LifeCycleDemo.PresentedViewController: 0x7fe4f601def0> on <LifeCycleDemo.ViewController: 0x7fe4f6212e50> whose view is not in the window hierarchy!
複製程式碼
很明顯的,由於我們在手動呼叫了 loadView 方法,導致 ViewController 中本來的 view 新建了兩次。新的 view 替換了原來的 view,導致新 view 的檢視層級出錯了,於是在進行 present 操作的時候就發生了上述錯誤。
為了驗證一下,我們可以在呼叫 loadView() 之前和之後分別 print(self.view!)
,會發現 ViewController 的 view 確實被替換掉了,結果如下:
loadView
viewDidLoad
<UIView: 0x7fef58c089d0; frame = (0 0; 375 812); autoresize = W+H; layer = <CALayer: 0x60000272b280>>
loadView
<UIView: 0x7fef58c1c220; frame = (0 0; 375 812); autoresize = W+H; layer = <CALayer: 0x60000272ba80>>
viewWillAppear
複製程式碼
同時我們發現一個有趣的現象,之後的生命週期沒有被打印出來了(並不是我沒有複製貼上上來!)。可以合理推斷 viewDidAppear 等實際上監聽的還是第一個 view 的變化,而由於第一個 view 被換掉之後,之後的生命週期沒有被觸發,所以也不會列印之後的生命週期。
4. 如果在 viewDidLoad() 時候手動呼叫 loadView() 會怎麼樣?
override func viewDidLoad() {
super.viewDidLoad()
loadView()
}
複製程式碼
答案:view 被替換但是可以正常跳轉
loadView
<UIView: 0x7ff917519350; frame = (0 0; 375 812); autoresize = W+H; layer = <CALayer: 0x600000e8bd80>>
loadView
<UIView: 0x7ff91a407a50; frame = (0 0; 375 812); autoresize = W+H; layer = <CALayer: 0x600000ef1120>>
viewDidLoad
viewWillAppear
viewSafeAreaInsetsDidChange
viewWillLayoutSubviews
viewDidLayoutSubviews
viewDidAppear
複製程式碼
我們輸出生命週期之後,發現手動呼叫 loadView 之後 view 確實被替換了。但是為什麼這一次,之後的生命週期就被正常打印出來了,並且再跳轉的時候也可以正常跳轉呢?
可以推測,底層在將 view 加入到檢視層級,並且開始監聽 viewWillAppear 等生命週期的時機,是在 viewDidLoad 之後,viewWillAppear 之前的。所以如果在 view 被加入檢視層級之前將其替換掉,並不影響它被加入檢視層級之中,於是也就可以正常跳轉了。
5. 如錯誤呼叫 viewWillAppear 等方法會怎麼樣?
override func viewDidAppear(_ animated: Bool) {
super.viewDidDisappear(animated) // 呼叫錯了!
}
override func viewDidDisappear(_ animated: Bool) {
// super.viewDidDisappear(animated) 忘記呼叫了
}
複製程式碼
答案:繼承時可能有問題
根據程式碼註釋描述可以知道,實際上這些方法並沒有實際上做什麼事情,只是在特定的時間節點,起到一個通知的作用。所以在我們的 demo 裡,錯誤呼叫、不呼叫不會有什麼實質上的錯誤。但是由於我們在複雜的專案中會有非常複雜的繼承關係,如果中間有一個地方錯了,那麼很可能影響繼承關係中的其他 ViewController。所以還是應該嚴格準確地呼叫 super 方法。
那麼,如何來保證正確地呼叫 super 方法呢?在 Objective-C 中,可以使用 __attribute__((objc_requires_super));
或者 NS_REQUIRES_SUPER
屬性(實際功效都是相同的),比如新建一個 BaseViewController 作為所有類的基類,然後這樣寫:
// Objective-C 保證呼叫 super 方法
@interface BaseViewController : UIViewController
- (void)viewDidLoad __attribute__((objc_requires_super));
- (void)viewWillAppear:(BOOL)animated NS_REQUIRES_SUPER;
@end
複製程式碼
(參考答案:Stack Overflow - nhgrif's answer)
如果是 swift 呢?目前 swift 沒有上面這種程式碼層面的解決辦法,只能藉助 SwiftLint 進行靜態檢查。按照官方文件引入 SwiftLint 後,在 yml 檔案中加入下面的描述即可強制檢查,override 的時候是否呼叫響應方法的 super(這也可以用於檢查自定義的 class):
// Swift 保證呼叫 super 方法
overridden_super_call:
severity: error
included:
- "*"
- viewDidLoad()
- viewWillAppear()
- viewDidAppear()
- viewWillDisappear()
- viewDidDisappear()
複製程式碼
6. 最後兩個小問題
小問題1:在當前螢幕上加一個全屏的 window,會觸發下面的 ViewController 的 viewWillAppear 等方法嗎?
答案:不會,這些方法只關注在同一個 view hierarchy 下的變化。同理,鎖屏後進入,後臺進前臺等都不會觸發。
小問題2:如何判定一個 ViewController 是否可見?
答案:Stack Overflow - progrmr's answer
可以使用 view.window
方法來判斷,但是需要注意加上 isViewLoaded
,來防止在 ViewController 的 view 沒有被初始化過的時候被呼叫,而觸發它的懶載入。
if (viewController.isViewLoaded && viewController.view.window) {
// viewController is visible
}
複製程式碼
另外,在 iOS 9+,也可以使用下面這個更加簡潔的方式:
if viewController.viewIfLoaded?.window != nil {
// viewController is visible
}
複製程式碼
(本文 Github 連結:RickeyBoy - iOS 生命週期的缺失和錯亂)