HolyShit!懶載入執行兩次?
前言:最近遇到了一個棘手的Bug,查詢Bug的過程是心力憔悴。故抽空書寫這篇文章記錄下。
我們從App的頁面載入說起,通常App首頁展現邏輯大概是這樣的:展示載入欄loadingView後請求首頁資料,在資料回撥返回後移除loadingView,回撥成功顯示正確內容,失敗則展示異常佔位圖。但同時存在的問題是,為了讓App首頁能更加快速、優先的展示,通常對於使用者登入或其他操作是與主頁請求是保持非同步請求的,因此當用戶態發生變化或其他狀態改變時需重新重新整理首頁資料。
依照上述流程,但確由此產生了一個棘手的Bug,偶現loadingView在資料成功返回後仍然無法移除。
基礎的程式碼如下:
@implementation MainViewController
- (instancetype)init {
if (self = [super init]) {
// 登入成功通知,重新整理首頁資料
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(loginSucess:)
name:LSureLoginSucessNoti
object:nil];
}
return self;
}
- (void)loginSucess:(NSNotification *)noti {
// 清除資料
// 重新請求
[self loadMainRequestData];
}
- (void)viewDidLoad {
[super viewDidLoad];
// 展示LoadingView
[self showLoadingView];
// 請求主頁資料
[self loadMainRequestData];
}
- (void)loadMainRequestData {
// 模擬網路請求
dispatch_async(dispatch_get_global_queue(0,0),^{
dispatch_async(dispatch_get_main_queue(),^{
// 網路回撥移除LoadingView
[self hideLoadingView];
});
});
}
- (void)showLoadingView {
[self.loadingView set Hidden:NO];
}
- (void)hideLoadingView {
[self.loadingView setHidden:YES];
}
- (UIView *)loadingView {
if (!_loadingView) {
NSLog(@"LoadingView LazyLoad");
_loadingView = [[UIView alloc] initWithFrame:self.view.bounds];
}
return _loadingView;
}
複製程式碼
外部呼叫MainViewController初始化,並模擬在MainViewController初始化或跳轉後觸發登入成功的通知
// 外部跳轉
[self.navigationController pushViewController:self.mainVC animated:YES];
// 模擬登入請求回撥
[[NSNotificationCenter defaultCenter] postNotificationName:LSureLoginSucessNoti
object:nil];
}
- (MainViewController *)mainVC {
if (!_mainVC) {
_mainVC = [[MainViewController alloc] init];
}
return _mainVC;
}
複製程式碼
上述程式碼為簡化模擬版,感興趣的童鞋可以先停下來檢查上述程式碼。
開始的懷疑點線上程方面,確實在多執行緒場景操作UI會多創建出UI物件,但通常在子執行緒建立或修改UI控制元件,XCode會有相應的Log與警告:
Main Thread Checker: UI API called on a background thread: -[UIView initWithFrame:]
複製程式碼
經過排查,可以排除多執行緒的問題。我將上述程式碼再次簡化成如下版本,假設在viewController的viewDidLoad方法中做的為loadingView的顯示操作,init方法中做的只是loadingView的隱藏操作。甚至可以簡化為只是分別在init與viewDidLoad方法呼叫了loadingView的getter方法而已。
- (instancetype)init {
if (self = [super init]) {
NSLog(@"init");
[self loadingView];
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"viewDidLoad");
[self loadingView];
}
- (UIView *)loadingView {
if (!_loadingView) {
NSLog(@"LoadingView LazyLoad");
_loadingView = [[UIView alloc] initWithFrame:self.view.bounds];
}
return _loadingView;
}
複製程式碼
執行結果如下
init
LoadingView LazyLoad
viewDidLoad
LoadingView LazyLoad
複製程式碼
通過Debug和Log打印發現LoadingView懶載入被執行兩次!!!這真是顛覆了我的認知。
但更讓人匪夷所思的是,如果將loadingView的建立形式更改為等同螢幕大小的frame或單純以init的形式建立,就不會出現懶載入被執行兩次的情況!
_loadingView = [[UIView alloc] initWithFrame:[UIScreen mainScreen].bounds];
複製程式碼
列印結果:
init
LoadingView LazyLoad
viewDidLoad
複製程式碼
下面我們來揭開謎底
要解決這個問題,我們先想清楚viewController的生命週期方法的呼叫順序
init->loadView->viewDidLoad->viewWillAppear->viewDidAppear->...
複製程式碼
初始化後加載view,接著檢視載入完成,即將顯示到最終顯示完成。
那什麼時候viewDidLoad會被觸發呢? 答案是當呼叫當前viewController的view getter方法時(即呼叫self.view||[self view])!
我們可以這麼理解,對於viewController而言,檢視均是放置在self.view上的,因此當呼叫了self.view可認為父子檢視載入完成,因此回調了viewDidLoad生命週期方法。通過Debug也可驗證這一點。(猜測viewController的views屬性也是以懶載入的形式存在的)
我們再改寫下上述程式碼loadingView的初始化方法,將loadingView以init形式初始化,然後在loadingView初始化前呼叫下viewController view的getter方法。
- (UIView *)loadingView {
if (!_loadingView) {
NSLog(@"LoadingView LazyLoad");
[self view];
_loadingView = [[UIView alloc] init];
}
return _loadingView;
}
複製程式碼
我們可以通過斷點或者列印來進行觀察,首先執行了viewController的init方法,在init方法中呼叫了loadingView的getter方法,首次呼叫,在這裡識別到**_loadingView不存在,因此進入判斷,在判斷中因為呼叫了[self view],因此接下來會呼叫viewDidLoad方法,在viewDidLoad方法中我們同樣呼叫了loadingView的getter方法。這時又執行到loadingView的getter方法,因為主執行緒中是順序執行的,首次呼叫的loadingView還沒被初始化,所以仍然識別到_loadingView不存在,這時我們會發現if (_loadingView) {}**的判斷已經被執行了兩次。因此列印結果是這樣的:
init
LoadingView LazyLoad
viewDidLoad
LoadingView LazyLoad
複製程式碼
呼叫流程如圖所示
懶載入判斷被執行兩次,而兩次建立互不影響,因此loadingView也被建立了兩次。可以嘗試在init和viewDidLoad呼叫**[self loadingView]後列印_loadingView**的地址,會發現完全是兩個不同地址的物件。
迴歸在最初的案例中,首先將loadingView新增到首頁,當首頁資料請求中未回撥時,使用者登入成功使用者態發生變化傳送通知給主頁重新重新整理資料移除loadingView,但所移除的並不是首頁資料開始載入時新增的loadingView,因此loadingVeiw會一直顯示無法移除,至此找到了問題的根本原因。
文中測試程式碼可點選連結下載: github.com/LSure/LazyL…
總結:問題產生的原因與viewController的生命週期和懶載入的呼叫未知有關,但通常是這種簡單的問題會被我們所疏忽。
慎用懶載入,並不是不建議使用懶載入,而是要注意其使用場景及可能出現的問題。
這個問題也從側面說明了為什麼不要在init方法中呼叫self來訪問屬性,其可能會造成的影響是未知的。另外在dealloc方法也不要呼叫self來訪問屬性,相關內容在之前也寫過一篇文章進行講述,感興趣的可以移步進行檢視:記憶體管理-dealloc方法到底應該怎麼寫?
暫時寫到這裡,在日常開發中,往往疏忽了對基礎知識的掌握,而導致無法預期的問題。寫這篇文章也是為了記錄下來引以為戒。共勉!