一、问题
需求是:在一个列表中有下载按钮、工具按钮、开关,点击下载按钮后,左边位置换成进度圆环,并不断更新进度,此时用户可以点击工具按钮,进行操作。
问题:在UITableView不断刷新时,删除的行可能错位,例如明明选择第四行删除,reload后发现删除的是第7行。
当前写法如下
VC:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SomeCell") as! SomeCell
let model = modelArray[indexPath.row]
cell.setModel(model)
cell.btnDownloadClickBlock = { [weak self] in
//Do something
}
cell.btnToolClickBlock = { [weak self] type in
//Do something
}
cell.switchChangedBlock = { [weak self] isOn in
//Do something
}
return cell
}
Cell:
class SomeCell: UITableViewCell {
//MARK: - UI声明
@IBOutlet weak var lblName: UILabel!
@IBOutlet weak var btnDownload: UIButton!
@IBOutlet weak var btnTool: UIButton!
@IBOutlet weak var switchVisible: UISwitch!
//MARK: - UI事件
@IBAction func btnDownload_Click(_ sender: Any) {
btnDownloadClickBlock?()
}
@IBAction func btnTool_Click(_ sender: Any) {
let items: [ToolType] = [.delete]
let selectView = PopoverSelectView(style: .iconAndText, items: items) { [weak self] result in
self?.btnToolClickBlock?(result)
PopoverManager.shared.hide()
}
PopoverManager.shared.show(selectView, fromView: btnTool)
}
@IBAction func visibleValue_Changed(_ sender: Any) {
switchChangedBlock?(switchVisible.isOn)
}
//MARK: - 公开变量
/// 用户点击下载的回调
var btnDownloadClickBlock: VoidClosure? = nil
/// 用户选择工具的回调
var btnToolClickBlock: ((ToolType) -> Void)? = nil
/// 用户切换开关的回调
var switchChangedBlock: BoolClosure? = nil
//MARK: - 公开方法
/// 设置数据源
func setModel(_ model: SomeModel) {
lblName.text = model.name
switchVisible.isOn = model.visible
btnDownload.isHidden = !model.canDownload
}
}
二、探究
2.1 第一次尝试
由于测试人员反馈的是删除功能错位,所以在删除的回调里把id和行号打出来,果然有时不正确,但是id和行号是匹配的。
cell.btnToolClickBlock = { [weak self] type in
print(model.id)
print(indexPath.row)
//Do something
}
那么把model传入cell,cell回调时将model一起返回来就ok了。这也是网上很多人给出的解决方案。
1.增加私有变量
private var model: SomeModel? = nil
2.设置数据源时给私有变量赋值
/// 设置数据源
func setModel(_ model: SomeModel) {
self.model = model
lblName.text = model.name
switchVisible.isOn = model.visible
btnDownload.isHidden = !model.canDownload
}
3.修改回调参数
var btnToolClickBlock: ((ToolType, SomeModel?) -> Void)? = nil
4.返回model
@IBAction func btnTool_Click(_ sender: Any) {
let items: [ToolType] = [.delete]
let selectView = PopoverSelectView(style: .iconAndText, items: items) { [weak self] result in
self?.btnToolClickBlock?(result, self?.model)
PopoverManager.shared.hide()
}
PopoverManager.shared.show(selectView, fromView: btnTool)
}
5.在VC中不用捕获的model,使用返回的model
cell.btnToolClickBlock = { [weak self] (type, returnModel) in
guard let returnModel = returnModel else {
return
}
print(model.id)
print(returnModel.id)
guard model.id == returnModel.id else {
return
}
//Do something
}
此时如果错位就return了,不会误删除,可是原来的也没有被正确的删除。
分析:网上采用此解决方案的主要是图片的异步加载,当请求完成后给UIImageView设置图片时,该行已经被重用了,导致图片错位,此时return可以解决该问题。当然,SDWebImage的sd_setImageWithURL没有这个问题,重新设置后sd会取消之前的请求。
2.2 第二次尝试
忽然想到,为什么下载按钮和开关没有错位,只有工具按钮弹窗后返回的删除事件错位了呢。于是我在这里打出了id。第一次是正确的,第二次是错误的!!!
@IBAction func btnTool_Click(_ sender: Any) {
guard let oldModel = model else {
return
}
//这里的id是正确的行
print(oldModel.id)
let items: [ToolType] = [.delete]
let selectView = PopoverSelectView(style: .iconAndText, items: items) { [weak self] result in
guard let newModel = self?.model else {
return
}
//这里的id是错误的行
print(newModel.id)
self?.btnToolClickBlock?(result, self?.model)
PopoverManager.shared.hide()
}
PopoverManager.shared.show(selectView, fromView: btnTool)
}
试着将弹出选择框的代码放在VC中,一切都正确了。
三、最终代码
VC:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SomeCell") as! SomeCell
let model = modelArray[indexPath.row]
cell.setModel(model)
cell.btnDownloadClickBlock = { [weak self] in
//Do something
}
cell.btnToolClickBlock = { [weak self] btnTool in
let items: [ToolType] = [.delete]
let selectView = PopoverSelectView(style: .iconAndText, items: items) { [weak self] result in
//这里直接用捕获的model,是正确的model,不需要cell再传model出来
self?.selectTool(model: model, type: result)
PopoverManager.shared.hide()
}
PopoverManager.shared.show(selectView, fromView: btnTool)
}
cell.switchChangedBlock = { [weak self] isOn in
//Do something
}
return cell
}
Cell:
class SomeCell: UITableViewCell {
//MARK: - UI声明
@IBOutlet weak var lblName: UILabel!
@IBOutlet weak var btnDownload: UIButton!
@IBOutlet weak var btnTool: UIButton!
@IBOutlet weak var switchVisible: UISwitch!
//MARK: - UI事件
@IBAction func btnDownload_Click(_ sender: Any) {
btnDownloadClickBlock?()
}
@IBAction func btnTool_Click(_ sender: Any) {
btnToolClickBlock?(btnTool)
}
@IBAction func visibleValue_Changed(_ sender: Any) {
switchChangedBlock?(switchVisible.isOn)
}
//MARK: - 公开变量
/// 用户点击下载的回调
var btnDownloadClickBlock: VoidClosure? = nil
/// 用户选择工具的回调
var btnToolClickBlock: ((UIButton) -> Void)? = nil
/// 用户切换开关的回调
var switchChangedBlock: BoolClosure? = nil
//MARK: - 公开方法
/// 设置数据源
func setModel(_ model: SomeModel) {
lblName.text = model.name
switchVisible.isOn = model.visible
btnDownload.isHidden = !model.canDownload
}
}
四、分析
为了了解该问题的原因,做了以下两个测试。
4.1 异步延迟
该现象应该是由于更新下载进度时不断刷新TableView(因为可以同时下载很多数据,不会只刷新一行),这时许多Cell被重用了,导致改变了block和cell一一对应的关系。
只要是异步延迟都可能会出现该情况,例如把indexPath传入cell,在cell中仅做一个延迟。
@IBAction func btnTool_Click(_ sender: Any) {
//这里打出的行号是正确的
print(indexPath.row)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: { [weak self] in
//这里打出的行号是错误的
print(indexPath.row)
})
}
如果在VC中进行异步延迟操作,是没有问题的。
cell.btnToolClickBlock = { [weak self] btnTool in
//这里打出的行号是正确的
print(indexPath.row)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: { [weak self] in
//这里打出的行号也是正确的
print(indexPath.row)
})
}
4.2 delegate与block
既然是block的问题,那么delegate有没有问题呢?
Delegate:
protocol SomeCellDelegate {
func btnToolClick(indexPath: IndexPath?)
}
Cell:
@IBAction func btnTool_Click(_ sender: Any) {
//打出来的行号是正确的
print(indexPath.row)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: { [weak self] in
self?.delegate?.btnToolClick?(indexPath: self?.indexPath)
})
}
VC:
func btnToolClick(indexPath: IndexPath?) {
guard let indexPath = indexPath else {
return
}
//打出来的行号是错误的
print(indexPath.row)
}
证明delegate和block一样,都会出现匹配不上的问题。
4.3 总结
综上,无论是delegate还是block,在UITableViewCell刷新时,若在cell内部做了异步的操作,都可能导致VC收到的数据和用户操作的数据不匹配。
应该把异步操作回调到上层处理(ViewController),不要把业务代码放到Cell(View)里。