iOS数据驱动IglistKit及StackedSectionController弃用替代

3,935 阅读10分钟

IglistKit介绍

  虽然在iOS开发中有很多很好用的列表控件,性能和API都很好用,对于简单无变化或者变化较为简单的列表cell是可以满足开发需求的,但是对于复杂的列表,就会出现不足,常见的reloadData时的闪烁和performBatchUpdates时手动维护updater的较大难度和易crash,由此出现了针对复杂列表的三方库IglistKit,它是 Instagram 的一个数据驱动的 UICollectionView 框架,为了构建快速和可扩展的列表。另外,它有助于你在 app 结束对于大量视图控制器的使用。

image.png

v2-0a91d4a3d6e7674136de4f9f0f786bb2_1440w.jpeg
  从iglist的结构图中可以看出,其中引入了adapter,它可以被所属的控制器所持有,同时对于传入的不同的data描述成不同的sectionController,同一个sectionController又可以描述成相同的cell或者不同的cell,adapter是变化(新增、删除、更新)开始的地方,sectionController就是变化适配的地方,它的强大之处在于其中的diff算法,本篇暂不讨论其中的算法实现和差异更新,侧重如何使用和注意事项!

IglistKit基本使用(StackedSectionController)

image.png   让我们来看一个例子,来源于juejin.cn/post/684490… 如上图所示,对于一个列表的一个cell可以拆分为由「红框(usernfo)」和「绿框(userContent)」堆积而成,在iglistKit~3.4.0可以通过StackedSectionController实现,每一个部分都会对应一个sectionController,每一个sectionController又对应了一个自己的cell和model,个人信息和评论分别由自己的sectionController和cell显示,步骤如下:

1、布局collectinView和Adapter

  在普通的ViewController中创建showObjects用来存需要展示的数据,创建collectionView和adapter,注意在实现ListAdapterDataSource协议时,返回需要显示的数据源、sectionController和空态,由上图可知,我们需要两个sectionController(info和content)

import UIKit
import IGListKit

class FirstViewController: UIViewController {
    //显示的数据
    var showObjects:[ListDiffable] = [ListDiffable]()
    //collectionView
    var collectionView: UICollectionView = {
        let flow = UICollectionViewFlowLayout()
        let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flow)
        return collectionView
    }()

    //adapter
    lazy var adapter:ListAdapter = {
        let adapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self)
        return adapter
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        adapter.collectionView = collectionView
        adapter.dataSource = self
        collectionView.frame = view.bounds
        collectionView.showsVerticalScrollIndicator = false
        collectionView.showsHorizontalScrollIndicator = false
        do {
            //从data1中拿到解析成Feed模型的数据源数组
            let data = try JsonTool.decode([Feed].self, jsonfileName: "data1")
            self.showObjects.append(contentsOf: data)
            adapter.performUpdates(animated: true, completion: nil)
        } catch {
            print("decode failure")
        }
    }
}

extension FirstViewController:ListAdapterDataSource {
    //collectionView中要显示的数据
    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
        return showObjects
    }
    
    //数据对应的sectionController,注意这里使用的是ListStackedSectionController用栈的方式压入了UserInfoSectionController和UserContentSectionController
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        let stack = ListStackedSectionController(sectionControllers: [UserInfoSectionController(),UserContentSectionController()])
        stack.inset = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0)
        return stack
    }

    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }
}

2、布局UserInfoSectionController

创建UserInfoSectionController继承自ListSectionController,实现它的三个方法

class UserInfoSectionController: ListSectionController {
    //包含用户信息(头像和姓名)的object
    var obj: Feed!
    //数据模型
    lazy var userInfoModel: UserInfoCellModel = {
        let userInfoModel = UserInfoCellModel(avatar: URL(string: obj.avatar), userName: obj.userName)
        return userInfoModel
    }()

    override func numberOfItems() -> Int {
        return 1
    }
    
    override func sizeForItem(at index: Int) -> CGSize {
        let width = collectionContext?.containerSize(for: self).width
        return CGSize(width:width!, height: 60);
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        //这里返回的就是UserInfoCell
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: UserInfoCell.cellIdentifier, bundle: nil, for: self, at: index) as? UserInfoCell else {fatalError()}
        cell.bindViewModel(UserInfoCellModel as Any)
        return cell
    }
}

3、布局UserInfoCell

自定义一个UICollectionViewCell用xib描述,并且遵守ListBindable协议,实现bindViewModel协议方法

class UserInfoCell: UICollectionViewCell {
    @IBOutlet weak var avaterImageView: UIImageView!
    @IBOutlet weak var showAndHiddenBtn: UIButton!
    @IBOutlet weak var nameLabel: UILabel!
    
    override func awakeFromNib() {
        super.awakeFromNib()
        self.avaterImageView.layer.cornerRadius = 20
        self.backgroundColor = UIColor.yellow
    }
}

extension UserInfoCell: ListBindable {
    func bindViewModel(_ viewModel: Any) {
        guard let viewModel = viewModel as? UserInfoCellModel else { return }
        avaterImageView.backgroundColor = UIColor.cyan
        nameLabel.text = viewModel.userName
    }
}

4、布局UserInfoCellModel

class UserInfoCellModel {
    //头像
    var avatar: URL?
    //昵称
    var userName: String = ""
    //初始化构造函数
    init(avatar: URL?, userName:String){
        self.avatar = avatar
        self.userName = userName
    }
}

extension UserInfoCellModel: ListDiffable {
    func diffIdentifier() -> NSObjectProtocol {
        return "UserInfo" as NSObjectProtocol
    }

    func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
        if self === object {
            return true
        }
        guard let obj = object as? UserInfoCellModel else { return false}
        return userName == obj.userName
    }
}

5、布局UserContentSectionController

class UserContentSectionController: ListSectionController {
    var obj: Feed!
    var expand: Bool = false
    //数据模型
    lazy var userContentModel: UserContentCellModel = {
        let userContentModel = UserContentCellModel(contentStr: obj.content ?? "")
        return userContentModel
    }()

    override func numberOfItems() -> Int {
        if obj.content?.isEmpty ?? true {
            return 0
        }
        return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        guard let content = obj.content else { return CGSize.zero }
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        let height: CGFloat = expand ? UserContentCell.lineHeight() : UserContentCell.height(for: content, limitWidth: width)
        return CGSize(width: width, height: height + 5)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: UserContentCell.cellIdentifier, bundle: nil, for: self, at: index) as? UserContentCell else { fatalError() }
        cell.bindViewModel(userContentCellModel as Any)
        return cell
    }
    
    override func didUpdate(to object: Any) {
        self.obj = object as? Feed
    }
   
    override func didSelectItem(at index: Int) {
        expand.toggle()
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.6, options: [], animations: {
            self.collectionContext?.invalidateLayout(for: self, completion: nil)
        }, completion: nil)
    }
}

6、布局UserContentCell

class UserContentCell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!
    override func awakeFromNib() {
        super.awakeFromNib()
    }

   // 计算content收起时的高度
   static func lineHeight() -> CGFloat {
        return UIFont.systemFont(ofSize: 16).lineHeight
    }
   // 计算content展开时的高度
   static func height(for text: NSString,limitwidth: CGFloat) -> CGFloat {
        let font = UIFont.systemFont(ofSize: 16)
        let size: CGSize = CGSize(width: limitwidth - 20, height: CGFloat.greatestFiniteMagnitude)
        let rect = text.boundingRect(with: size, options: [.usesFontLeading,.usesLineFragmentOrigin], attributes: [NSAttributedString.Key.font:font], context: nil)
        return ceil(rect.height)
    }
}

extension UserContentCell: ListBindable {
    func bindViewModel(_ viewModel: Any) {
        guard let vm = viewModel as? UserContentCellModel else { return }
        self.label.text = vm
    }
}

7、布局UserContentCellModel

class UserContentCellModel {
    //内容
    var content: String = ""
    init(contentStr: String) {
        self.content = contentStr
    }
}

extension UserContentModel: ListDiffable {
    func diffIdentifier() -> NSObjectProtocol {
        return "UserContentCellModel" as NSObjectProtocol
    }
    
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
        if self === object {
            return true
        }
        guard let userContent = object as? UserContentCellModel else {return false}
        return content == userContent.content
    }
}

  至此使用StackedSectionController就已经实现了滑动列表的显示功能了,其实UserContentCellModel完全没要存在,因为它接收的就是一个string,在UserContentCell中的bindViewModel中可以直接改用guard let vm = viewModel as? String else { return }在UserContentSectionController中也就不需要UserContentCellModel模型了,cell.bindViewModel(userContentCellModel as Any)也可以直接改为cell.bindViewModel(obj.content as Any),为了对比说明和后面的ListBindingSectionController说明,暂时添加了,到此为止我相信你已经get到了iglist的基本使用了,注意点有:

  • StackedSectionController(section1, section2,....)中是有顺序要求的,section1会先于section2去展示
  • sectionController需要定义一个模型属性在方法didUpdate(to object: Any)中转化接收objects(for listAdapter: ListAdapter) -> [ListDiffable]传过来的模型数据数组的每一项
  • 如需使用模型数据中的某几个字段,可以进行自己封装成对应的cell数据模型,明确cell模型的标识(diffIdentifier)和更新条件(isEqual),并且在外部进行绑定cell.bindViewModel()

IglistKit基本使用(ListBindingSectionController)

image.png   在iglistKit~4.0.0中已经弃用了StackedSectionController,头文件中也没有对应的初始化方法了,弃用的相关🔗github.com/Instagram/I…
  对于StackedSectionController来说,它的diff是section级别的,一个cell对应一个section,每个sectionController的 numberOfItem() -> Int 保持默认值1,以此实现section到cell的1:1关系,来模拟cell级别的更新。其实很多场景下对于多cell一个section的场景,只需要维护cell的diff,不需要繁琐配置一个cell对应一个section,这个时候就可以使用ListBindingSectionController来实现。
  如上图所示是同样的需求,这时候我只需要创建一个sectionController继承ListBindingSectionController并且满足ListBindingSectionControllerDataSource协议,在对应的协议方法中维护需要的model和展示对应model的cell即可,为了后面说明相关的update、insert和remove刷新,在这里的section和cell也添加了对应的操作和逻辑代码。

布局UserInfoViewModel和userContntViewModel

  info和content的model几乎没有什么变化,这里就不重复啰嗦了

布局SectionController

class FirstSectionViewController: ListBindingSectionController<Feed> {
    var expand: Bool = true
    var updateHandler: ((_ feed: UserInfoViewModel) -> Void)?
    var deleteAvatarHandler: ((_ feed: UserInfoViewModel) -> Void)?
    var insertHandler: ((_ feed: UserInfoViewModel) -> Void)?
    
    override init() {
       super.init()
       dataSource = self
    }
}

extension FirstSectionViewController: ListBindingSectionControllerDataSource {
    /*
     相当于数据源,只是这里对数据源做了model的区分,实际上装的是一个区分了info和content的模型数组
     就像这样[infomodel, contentmodel]
    */
    func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, viewModelsFor object: Any) -> [ListDiffable] {
        guard let feedViewModel = object as? Feed else {return []}
        var viewModels = [ListDiffable]()
        if !feedViewModel.userName.isEmpty {
            viewModels.append(UserInfoViewModel(name: feedViewModel.userName, avatar: feedViewModel.avatar, feedID: feedViewModel.feedId))
        }
        if let contentStr = feedViewModel.content, !contentStr.isEmpty {
            viewModels.append(userContntViewModel(uerContent: contentStr))
        }
        return viewModels
    }

    //这里就会根据不同的model差异化创建不同的cell进行展示对应的modle数据
    func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell & ListBindable {
        switch viewModel {
        case is UserInfoViewModel:
            guard let infoModel = viewModel as? UserInfoViewModel else {fatalError()}
            let cell = collectionContext?.dequeueReusableCell(withNibName: UserInfoCell.cellIdentifier, bundle: nil, for: self, at: index) as! UserInfoCell
            
            //content展开和收起
            cell.onclickArrow = {[weak self] cell in
                guard let self = self else { return }
                self.expand.toggle()
                UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.6, options: [], animations: {
                    self.collectionContext?.invalidateLayout(for: self, completion: nil)
                }, completion: nil)
            }
            //delete
            cell.deleteAction = {[weak self] deleteCell in
                guard let self = self else { return }
                self.deleteAvatarHandler?(infoModel)
            }
            //update
            cell.updateAction = {[weak self] updateCell in
                guard let self = self else { return }
                self.updateHandler?(infoModel)
            }
            //insert
            cell.addAction = {[weak self] insertCell in
                guard let self = self else { return }
                self.insertHandler?(infoModel)
            }
            return cell
            
        case is userContntViewModel:
            let cell = collectionContext?.dequeueReusableCell(withNibName: UserContentInfo.cellIdentifier, bundle: nil, for: self, at: index) as! UserContentInfo
            return cell
        default:
            return collectionContext?.dequeueReusableCell(of: EmptyCollectionViewCell.self, for: self, at: index) as! EmptyCollectionViewCell
        }
    }

    //确定section的size
    func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, sizeForViewModel viewModel: Any, at index: Int) -> CGSize {
        let width = collectionContext?.containerSize(for: self).width
        switch viewModel {
        case is UserInfoViewModel:
            return CGSize(width: width!, height: 60)
        case is userContntViewModel:
            if let contentModel = viewModel as? userContntViewModel, let content = contentModel.content {
                let height: CGFloat = expand ? UserContentInfo.lineHeight() : UserContentInfo.height(for: content, limitWidth: width!)
                return CGSize(width: width!, height: height + 5)
            }
        default:
            return CGSize(width: 0.0, height: 0.0)
        }
        return CGSize(width: 0.0, height: 0.0)
    }
}

布局infoCell和contentCell

  infoCell和contentCell也和之前的一致,唯一变化的是在infoCell中多添加了一些展开收起content、添加、删除的操作,如下:

var onclickArrow: ((UserInfoCell) -> Void)?
    var deleteAction: ((UserInfoCell) -> Void)?
    var updateAction: ((UserInfoCell) -> Void)?
    var addAction: ((UserInfoCell) -> Void)?
    
    override func awakeFromNib() {
        super.awakeFromNib()
        self.avaterImageView.layer.cornerRadius = 20
        self.backgroundColor = UIColor.yellow
        showAndHiddenBtn.isSelected = false
        showAndHiddenBtn.setTitle("展开", for: .normal)
        showAndHiddenBtn.setTitle("收起", for: .selected)
    }

    @IBAction func click(_ sender: Any) {
        onclickArrow?(self)
        showAndHiddenBtn.isSelected = !showAndHiddenBtn.isSelected
    }
    @IBAction func deleteAction(_ sender: Any) {
        deleteAction?(self)
    }
    @IBAction func updateAction(_ sender: Any) {
        updateAction?(self)
    }
    @IBAction func addAction(_ sender: Any) {
        addAction?(self)
    }

  至此利用ListBindingSectionController展示滚动列表数据的也完成了,需要注意的点是我们可以很方便的根据自己想要展示的不同模块数据,从初始的data中根据不同模块灵活划分不同的model,在ListBindingSectionControllerDataSource协议方法中通过不同的model去创建不同的cell,展示不同的cell,他们两者的区别也很明显

  • sectionContoller中的section和cell是一对一的,bindingSectionController中的section和cell是一对多
  • sectionController中的section需要手动创建一个属性去接收数据模型,并且在协议方法cellForItem(at index: Int) -> UICollectionViewCell中手动去将cell与model进行绑定,bindingSectionController在协议方法中是根据不同的model去创建展示不同的cell,会自动绑定,但是和sectionController一样,在cell中的ListBindable协议bindViewModel(_ viewModel: Any)需要对应好model
  • 代码的灵活度而言,bindingSectionController不需要反复创建多个section了,将原本多个sectionController的返回的不同size和cell逻辑全部集中到了一个section中协议方法中,很好地减少了代码的冗余程度

IglistKit刷新(更新、增加、删除)

Simulator Screen Recording - iPhone 11 - 2022-04-20 at 09.54.24.gif
  我将cell显示内容的变更、添加一行cell、删除一行cell统称为cell的刷新,我们先看git图的效果

展开收起(size变化)

  当content的内容超出一行时,点击「展开」,contentCell会展开铺满,点击「收起」contentCell会收起为初始化状态,需要用一个bool值来记录点击情况,根据bool值来更新size的高度,同时在section级别的回调中执行方法 self.collectionContext?.invalidateLayout(for: self, completion: nil)self.update(animated: true, completion: nil)都可,前者建议配合动画执行

//content展开和收起
    cell.onclickArrow = {[weak self] cell in
        guard let self = self else { return }
        self.expand.toggle()
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.6, options: [], animations: {
            self.collectionContext?.invalidateLayout(for: self, completion: nil)
        }, completion: nil)
//      self.update(animated: true, completion: nil)
    }

更新(内容变化)

  点击「更新」按钮,期望将info信息中的name文字改为"修改后的",我们需要拿到点击的这一行的cell的model,当然这个model是infoModel,然后对model中的name进行调整即可,可以找到对应的section,仅更新对应的section即可,这里update可以在section级别完成,也可以通过section回调给ViewController,使用adapter在ViewController中完成


//update 方式1 section级别
cell.updateAction = {[weak self] updateCell in
    guard let self = self else { return }
    self.collectionContext?.performBatch(animated: true, updates: {(batch) in
        let updateIndex: Int! = self.collectionContext?.index(for: updateCell, sectionController: self)
        guard let updateModel = self.viewModels[updateIndex] as? UserInfoViewModel else { return }
        updateModel.userName = "修改后的1"
        batch.reload(self)
    }, completion:nil)     
 }

***************************************************************************

//update 方式2 section回调给VC,由VC中的adapter完成
cell.updateAction = {[weak self] updateCell in
    guard let self = self else { return }
    self.updateHandler?(infoModel)   
}

//update userName
vc.updateHandler = { [weak self] infoModel in
    guard let self = self else { return }
    for item in self.showObjects {
        guard let feedItem = item as? Feed else { return }
        if feedItem.feedId == infoModel.feedId {
            feedItem.userName = "修改后的2"
            if let sectionController = self.adapter.sectionController(for: item) as? FirstSectionViewController {
               sectionController.update(animated: true, completion: nil)
            }
            break
        }
    }          
}

删除

  点击「删除」期望删除掉info和content,对于删除的操作,在section层可以拿到viewModels,但是它是一个只读属性,不能修改里面的数据源,所以只能回调到ViewController层,去处理数据源然后调用adapter的performUpdates(animated: true, completion: nil)

//delete
vc.deleteAvatarHandler = {[weak self] deleteItem in
    guard let self = self else { return }
    var deleteIndex = -1
    for (index, item) in self.showObjects.enumerated() {
        guard let feedItem = item as? Feed else { return }
        if feedItem.feedId == deleteItem.feedId {
            deleteIndex = index
            break
        }
    }
    if deleteIndex >= 0 {
        self.showObjects.remove(at: deleteIndex)
        self.adapter.performUpdates(animated: true, completion: nil)
    }
}

新增

  点击「新增」期望新增加一条数据包含了info和content,name为"新增的",content为"这个是新增加的cell",和删除一样也是需要对数据源做操作,也是需要回调到ViewController中去处理数据然后调用adapter的performUpdates(animated: true, completion: nil)

//insert 
vc.insertHandler = {[weak self] insertItem in
    guard let self = self else { return }
    var insertIndex = -1
    for (index, item) in self.showObjects.enumerated() {
        guard let feedItem = item as? Feed else { return }
        if feedItem.feedId == insertItem.feedId {
            insertIndex = index
            break
        }
    }
    do {
        if insertIndex >= 0 {
            let data = try JsonTool.decode([Feed].self, jsonfileName: "insertData")
            data[0].feedId += UInt(insertIndex)
            self.showObjects.insert(contentsOf: data, at: insertIndex)
            self.adapter.performUpdates(animated: true, completion: nil)
        }
    } catch {
        print("decode failure")
    }                
}

参考资料: