TableViewSections 更优雅和高效的实现复杂列表

6,610 阅读7分钟

笔者文笔有限,写的不是很清楚,使用中遇到问题可私信或加入cocoapod-sled交流群提出疑问

TableViewSections 目标是简化复杂列表的开发,让代码结构更清晰、易用维护,提高多人协作效率,同时提高模块(Secton)级复用性,当有足够多的通用模块时可以支持页面一定的动态性。

TableViewSections 非常简单的框架,很容易上手,学习成本低

模块: 这里指 tableView 的 indexPath.section 中所有 cell 集合

前言

当开发一个相对复杂的列表页面,如何实现呢?

可能要考虑以下的问题:假设决定使用UITableView

  1. 分工问题:如果工期比较紧张,可能要多人合作才能完成
  • 模块如何划分?页面框架和单独模块的边界怎么清晰界定?
  • 单个模块代码怎么调试?
  • 合并代码时可能遇到的冲突?
  1. 基于UITableView的实现方案
  • 设置 tabView.dataSourcetabView.delegate

    需要一个DataSource(ViewModel 或 ViewController)

    • 处理页面大部分逻辑,代码量大?迭代维护困难?
  • 区分不同的 Section 和 Cell

    使用枚举、二维数组或其他方式

    • 数据源动态更新如何处理?
    • 迭代维护是否方便?
  • 网络请求和数据缓存如何处理

    每个模块的请求接口独立(有一个或多个接口) 整个页面可能还有一个框架接口定义页面可用模块及展示顺序(有一定的动态性)

    • 模块复用时,接口能否快速复用?
  • 可交互的模块如何处理:比如某个模块可关闭,关闭后不再展示
  1. 代码复用问题
  • 如果一个模块及其逻辑在多个地方使用,怎么能够快速复用,避免代码拷贝
  1. 迭代维护成本
  • 对模块的增、删、改能否快速实现,减少无关代码干扰,缩小修改影响范围
    • 修改、增加模块,是否需要修改多个地方?
    • 删除模块,能否快速删除,不遗留大量冗余代码?
  1. 跨组件复用
    • 工程组件化粒度较小时,代码能否方便的跨组件复用?

下面向大家推荐我的实现方案 TableViewSections

Github地址:github.com/git17997950…

TableViewSections

采用分层解耦的思想,可以很好的解决上面提到的问题

  1. 降低代码复杂度,增加代码的可维护性 
  2. 提高代码聚合度,增加代码的可复用性 
  3. 通过组合的方式,增加代码的可扩展性,提高多人协作的编码体验和效率

如何分层?

强化 Section,给他创建一个实体,承载更多的逻辑。

抽象出 SectionType 类型负责管理自己的数据处理、视图状态、用户交互、业务逻辑等。DataSource 那一层只需要负责 Section 的组装和页面框架相关的逻辑。

从:TableView -> DataSource -> Cells

变为:TableView -> DataSource -> Sections -> Cells

框架类型介绍

BaseTableViewModel

作为UITableView的 DataSource 和 Delegate,实现了部分代理方法,并转发到 Section。

简单页面可直接使用,真以为 TableView 和 Section 的粘合剂。复杂页面可以子类化,按需实现更多的代理方法,记得转发到 Section。

还提供了一些其他特性:

  • Number of rows cache:避免数据源瞬时变更引起的崩溃
  • 便利的刷新和删除方法,谨慎使用 TableViewModelReloadable.swift
  • 上下文,方便的获取 tableView viewController 和自定义附加信息

TableViewModelReloadable.swift

BaseTableViewModel的扩展,提供一些便利的刷新方法

EstimatedTableViewModel

BaseTableViewModel 的子类,实现并转发了以下方法

func tableView(UITableViewestimatedHeightForRowAt: IndexPath) -> CGFloat

TableViewSectionType

一个 protocol,对 Section 的抽象,负责处理 BaseTableViewModel 转发的代理方法,并处理 Section 自己的业务逻辑。

TableViewSectionsContext

上下文,可在 Section 内方便的获取 tableView viewController 和自定义附加信息

实践

介绍完了 TableViewSections 的设计思想和实现方案,下面我们看看如何使用

定义一个Section

Cell自动注册:juejin.cn/post/719500…

// 定义一个组合类型,SectionLoaderType 实现请查看补充
typealias PlanTableViewSectionType = TableViewSectionType & SectionLoaderType

// 定义一个 Section 实体类
class PlanNoticeSection: PlanTableViewSectionType {
    // 业务 - 组合类型
    let type: PlanType
    // 业务 - 数据模型
    var model: PlanNoticeModel?
    
    // 框架 - 上下文,需要获取上下文,就定义下面这个存储属性,框架内部自动赋值
    var context: TableViewSectionsContext?
    // 框架 - 控制Section是否显示
    var section_isDisplay: Bool = false
    
    init(type: PlanType) { self.type = type }
    
    // 使用Cell自动注册逻辑,可不用实现这个方法
    // https://juejin.cn/post/7195005912653856827
//    static func register(for tableView: UITableView) {
//        // 注册用到的Cell,外部需要调用这个方法进行注册
//    }
    
    // SectionLoaderType 声明的方法,按需加载缓存
    func loadCache() {
        // 按需加载缓存数据
    }
    
    // SectionLoaderType 声明的方法,网络请求
    func loadData(callback: @escaping ErrorTask) {
        PlanAPI.otherApi(params: ["type": type.rawValue]).reqeust { [weak self] in
            defer { callback(nil) }
            guard let self = self else { return }
            self.section_isDisplay = true
            self.model = PlanNoticeModel(demoImageNamed: "plan_(self.type.rawValue)_notice")
        }
    }
    
    // 实现 BaseTableViewModel 转发的代理方法
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return model == nil ? 0 : 1
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Cell 自动注册 https://juejin.cn/post/7195005912653856827
        let cell = tableView.ns.dequeueCell(PlanNoticeCell.self, for: indexPath)
        model.run(cell.update(with:))
        return cell
    }
    
    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat? {
        return 768
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // TODO: 跳转
        let vc = DemoDetailViewController()
        vc.navigationItem.title = "通知详情"
        context?.viewController?.navigationController?.pushViewController(vc, animated: true)
    }
}

定义 BaseTableViewModel 子类(按需)

// Demo使用自适应高度,所以继承自 EstimatedTableViewModel
class PlanType1TableVM: EstimatedTableViewModel {
    // 网络请求逻辑
    func loadData(callback: @escaping ErrorTask) {
        // 请求页面框架
        PlanAPI.someApi.reqeust { [weak self] in
            // 可以根据框架数据动态组装section
            let sections: [PlanTableViewSectionType] = [
                ClosableSection(),
                PlanType1HeaderSection(),
                PlanType1IntroduceSection(),
                PlanNoticeSection(type: .type1), // section 复用
                PlanType1GrowthSection(),
                PlanProfitRatioSection(type: .type1), // section 复用
                PlanType1NewPlanSection(),
                PlanHoldingSection(type: .type1), // section 复用
                PlanManagerSection(type: .type1), // section 复用
                PlanQASection(type: .type1), // section 复用
                PlanTopCommentsSection(type: .type1), // section 复用
            ]
            
            // 请求 sction 的数据
            self?.loadSections(sections, callBack: callback)
        }
    }
    
    // Demo采用所有Section数据加载完毕在渲染的策略,也可以分开渲染
    // - 使用 section_isDisplay 控制 Section 是不展示: sections(数据源) -> displayedSections(实际展示的,section_isDisplay == true)
    // - 前几个 Section 使用占位或缓存,提高视觉渲染速度
    private func loadSections(_ sections: [PlanTableViewSectionType], callBack: @escaping ErrorTask) {
        let group = DispatchGroup()
        var err: Error?
        
        sections.forEach { (sec) in
            group.enter()
            sec.loadData { (error) in
                group.leave()
                if error != nil { err = error }
            }
        }
        
        group.notify(queue: .main) { [weak self] in
            self?.updateSections(sections)
            self?.reloadTableView()
            callBack(err)
        }
    }
}

定义ViewController和TableView

class PlanType1ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        // 页面状态 - 加载中
        tableView.ps.item = PSLabelItem.empty(text: "加载中...")
            .config { item in
                item.layoutOffset = CGPoint(x: 0, y: -150)
            }
        viewModel.loadData { [weak tableView] error in
            // 页面状态 - 移除
            tableView?.ps.item = nil
        }
    }
    
    let viewModel = PlanType1TableVM()
    
    lazy var tableView: UITableView = {
        let tableView = UITableView(frame: .zero, style: .grouped)
        // viewModel 关联 tableView,内部把自己设置为 tableView 的 DataSource 和 Delegate
        viewModel.associateTableView(tableView, viewController: self, additional: nil)
        tableView.tableHeaderView = UIView()
        tableView.tableFooterView = UIView()
        tableView.separatorStyle = .none
        tableView.sectionHeaderHeight = 12
        tableView.sectionFooterHeight = .leastNonzeroMagnitude
        
        // 这里应该注册用到的Cell,使用Cell自动注册可以省略
        // https://juejin.cn/post/7195005912653856827
        // PlanNoticeSection.register(for: tableView)
        
        return tableView
    }()

}

小结

这样我们就使用 TableViewSections 完成了列表的创建,更多实现细节可查看Demo

从上面的示例代码可以看到,代码编写习惯和直接使用 UITableView 基本没有区别,只是中间多了一层 SectonType,把业务逻辑做了分割和聚合。

笔者精力有限,只提供了简单的使用示例,TableViewSections 是一个简单且扩展性比较强的框架,更多使用方式等待你的发掘。

更多使用场景

分页加载

有的页面最底部的模块可以分页无限加载,TableViewSectionType 可以相关的逻辑,不过要记得把分页的 Section 放到最底部

模块动态显示隐藏

  1. 可关闭的模块,关闭后不再展示
  2. 请求到数据后才能确定是否展示
  3. 根据条件动态的显示和隐藏
// 可关闭的模块,关闭后不再展示
class ClosableSection: PlanTableViewSectionType {
    
    // 控制 Section 是否展示
    var section_isDisplay: Bool = true
    
    // 定义为存储属性,框架内部自动设置
    var context: TableViewSectionsContext?
    
    // 省略部分代码 ...
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.ns.dequeueCell(ClosableCell.self.self, for: indexPath)
        // 点击关闭按钮
        // section_isDisplay 赋值维 false
        // 调用 `reloadTableViewAndDisplayedSections` 刷新数据源
        cell.onTapCloseButtonClosure = { [weak self] in
            guard let self = self else { return }
            self.section_isDisplay = false
            self.context?.viewModel?.reloadTableViewAndDisplayedSections()
        }
        return cell
    }
    
    // 省略部分代码 ...
}

补充

SectionLoaderType

typealias ErrorTask = OneParamTask<Error?>

protocol SectionLoaderType {
    func loadCache()
    func loadData(callback: @escaping ErrorTask)
}

extension SectionLoaderType {
    func loadCache() { }
    
    func loadData(callback: @escaping ErrorTask) {
        callback(nil)
    }
}

其它

喜欢就star❤️一下吧

推荐一个Cocoapods组件二进制化插件 cocoapods-sled,即插即用,快速提高编译效率。