使用swift懒加载需要注意的陷阱
title: 使用swift懒加载需要注意的陷阱 date: 2021-12-27 17:00:10 tags:
修改老代码后,发现UITableView会在创建cell时闪退,原因是在调用dequeueReusableCell(withIdentifier:)
创建cell时返回了nil。但是检查代码,确认在viewDidLoad
注册了这个cell,按道理不应该返回nil。后面分析才发现,由于lazy var
不是线程安全的,在碰到viewDidLoad的某个特殊调用时机时就会出现这个问题,而且代码可能在大部分场景正常运行,然后出现一些看起来莫名其妙的bug!
样例
我把问题代码简化后如下: ```obj-c class TestTableViewController: UIViewController { /// 使用懒加载创建tableView lazy var tableView: UITableView = { print("start init testLabel, isViewLoaded (self.isViewLoaded)") let tableView = UITableView.init(frame: self.view.bounds) print("created tableView (tableView)") tableView.delegate = self tableView.dataSource = self return tableView }()
override func viewDidLoad() {
super.viewDidLoad()
print(#function)
view.addSubview(tableView)
print(#function, "tableView \(tableView) register cell")
// 注册cell
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
}
extension TestTableViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 10 }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = self.tableView.dequeueReusableCell(withIdentifier: "cell")!
cell.textLabel?.text = "\(indexPath.row)"
return cell
}
}
// 调用方式如下
@IBAction func showTestTableViewVC(_ sender: Any) {
let testVC = TestTableViewController.init()
// 引起问题的关键代码
testVC.tableView.isScrollEnabled = false
self.navigationController?.pushViewController(testVC, animated: true)
}
``
如果你已经一眼就看出了问题所在,那么就没有必要看下去了。如果你没有看出来,也不要着急,这个问题确实挺隐蔽的。上述代码运行后,会出现报错:
TestTableViewController.swift:29: Fatal error: Unexpectedly found nil while unwrapping an Optional value`。那么这个问题是怎么产生的类?
问题是怎么产生的?
首先我们要清楚两个知识点:
1. lazy var
懒加载不是线程安全的
2. 在UIViewController
中,成员变量view
没有初始化及viewDidLoad
方法被调用之前,只要调用了成员变量view
,就会立即初始化view
并调用viewDidLoad
方法。
第二点有点隐蔽,例如在
viewDidLoad
方法调用之前调用self.view.bounds
就会触发。
上述代码运行后的Log输出如下:
在调用let testVC = TestTableViewController.init()
初始化控制器后,我们立即调用了testVC.tableView.isScrollEnabled = false
,这个时候会进入tableView
的懒加载部分:
``obj-c
lazy var tableView: UITableView = {
print("start init testLabel, isViewLoaded \(self.isViewLoaded)")
// 注意,这里调用了self.view,会导致
viewDidLoad`被提前调用!
let tableView = UITableView.init(frame: self.view.bounds)
print("created tableView (tableView)")
tableView.delegate = self
tableView.dataSource = self
return tableView
}()
override func viewDidLoad() { super.viewDidLoad() print(#function) view.addSubview(tableView)
print(#function, "tableView \(tableView) register cell")
// 注册cell
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
``
我们先定义这次要创建的
tableView为
A。这部分懒加载代码由于错误的调用了
self.view,导致
self.view初始化和
viewDidLoad方法被提前调用,此时成员变量
tableView还没有被初始化完成,而
viewDidLoad方法中又调用了
tableView,由于
lazy不是线程安全的,所以又递归进入了上述初始化
tableView的逻辑,这个时候
self.view已经被创建了,所以会初始化完成,我们定义这次创建的
tableView为
B,这个时候控制器持有的
tableView对象是
B,它会在
viewDidLoad方法的这次调用中注册cell。
上述逻辑跑完后,
A才紧随其后完成创建,并替换
B成为控制器的新成员变量,而且由于
viewDidLoad已经被调用过了,在
self.navigationController?.pushViewController(testVC, animated: true)方法调用后,
viewDidLoad不会再被调用,所以
A`是没有注册cell的。
运行到这时,控制器持有了A
,而控制器的view
通过addSubview
持有了它的子视图B
,图示如下:
其中B
对象在viewDidLoad
方法中注册了cell
,而A
对象并没有注册,所以在代理方法中创建cell
时返回了nil
,导致了crash
。如果对这部分不理解,可以多看几遍代码和日志,理顺下调用流程。
crash位置代码如下:
obj-c
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// self.tableView是对象A,它并没有注册cell。
// 代理方法传递过来的tableView是对象B,它注册了cell,直接使用它则不会crash
let cell = self.tableView.dequeueReusableCell(withIdentifier: "cell")!
cell.textLabel?.text = "\(indexPath.row)"
return cell
}
而这个问题的隐蔽性在于存在两个UITableView
对象,如果在代理方法中不使用self.tableView
而是使用代理方法传递过来的tableView
,那么程序不会crash,而且显示正常。而后续会不会出现奇奇怪怪的问题,就完全看你的运气了。
当然这个问题埋的隐蔽性并不止于此,当外部不调用tableView
属性时,例如不像样例代码那样调用testVC.tableView.isScrollEnabled = false
,那么在viewDidLoad
方法中会正常执行tableView
的初始化,一切都是正常的。但是一旦哪位同事在外部调用了一次,那么潘多拉魔盒就打开了~
解决方案
要解决这种问题,需要我们有良好的编码规范。首先,要强化lazy
不是线程安全的概念,在懒加载中只做这个变量初始化的事情,尽量避免其它变量及逻辑的混入。在UIViewController
及其子类的懒加载逻辑中,避免对view
的调用。我看很多人喜欢在懒加载逻辑中调用view.addSubView()
或view.bounds
,这是不太对的,因为在isViewLoaded
为false
的情况下,对view
的调用就代表着viewDidLoad
方法的提前调用,这让程序的逻辑变得有些混乱,除非你能保证在viewDidLoad
之后调用这个属性。
其次,在编码过程中,要注意权限的控制,设计合适的接口,这样对使用者更友好,也能规避很多异常场景,当然这对开发者的要求较高,需要平常多加修炼和积累了。
关于OC
另外需要注意的是,OC
的懒加载也有同样的问题。但是OC
可以优化写法避免出现这个问题,而Swift
不行。
关键代码如下: ``` - (UITableView *)tableView { if (!_tableView) { // 第一种用法:这样调用会出现异常 // _tableView = [[UITableView alloc] initWithFrame:self.view.bounds]; // 第二种用法:这样是正常的 _tableView = [[UITableView alloc] init]; _tableView.frame = self.view.bounds;
_tableView.delegate = self;
_tableView.dataSource = self;
}
return _tableView;
}
``
上述代码中的第二种用法不会出现问题,是由于在
_tableView.frame = self.view.bounds;这行代码才引入的
self.view,此时
_tableView`
已经有值,后续代码不会执行。
虽然没有问题,但是不推荐这样使用,因为它还是引起了viewDidLoad
的提前执行。
参考资料
我的博客:星的天空