TableView 優化之資料模型優化

鏡畫者發表於2017-12-06

每次寫 TableView 都是又愛又恨,程式碼感覺總是很像,但細節不同又借鑑不了。究其原因就是程式碼沒有真正規範和模組化。在參考了幾篇文章後,我總結了一個正規化,可以很大程度上對 TableView 的編寫做到規範化。本文不僅是對 TableView 的總結,同時也是對協議、列舉等型別的使用探討。

參考文章如下:

本文重點從資料模型角度進行優化,TableView 的資料模型分為三種情況:動態型別 cell(Dynamic Prototypes)、靜態型別 cell(Static Cells)、動靜態混合型別。

先看看優化後的總體模型結構圖:

TableView 優化方案

優化的關鍵在於配合協議和列舉對模型的合理設計,下面我們先看看動態型別:

動態型別 TableView 優化

我們接著上次的示例工程進行改寫(上次教程 參見這裡 ,Github 示例工程地址

在上次的示例工程中我們有一個展示書單的簡單列表,下面我們將新增以下功能:

1.從豆瓣獲取我收藏的書籍

2.列表分為 3 個 Sectinon 展示:想看、在看、已看的書

3.在列表中交替展示兩種型別的 Cell(即異構型別資料模型):書籍名稱、書籍評分

4.書籍評分的詳情頁中,將包含動靜態混合資料。

最終效果如下:

bookList

現在開始編碼環節:功能1、2

  • 功能 1 需要我們發起網路請求,並解析返回資料為指定模型。這裡我們使用 URLSession 傳送請求,我們先新增兩個協議:

?NetworkProtocol.swift:在 send 方法中我們使用泛型約束,這樣比直接使用 Request 協議作為引數型別更高效。

 /// 網路請求傳送協議
protocol Client {
    var host: String { get }
    func send<T: Request>(_ r: T, handler: @escaping (Data?) -> Void)
}

/// 網路請求內容協議
protocol Request {
    var path: String { get }
    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }
}
複製程式碼

新增兩個列舉型別:

?Enums.swift

/// Http 請求方法
public enum HTTPMethod: String {
    case GET
    case POST
}

/// 主機型別
///
/// - doubanAPI: 豆瓣 API
enum HostType: String {
    case doubanAPI = "https://api.douban.com"
}


複製程式碼

再新增對應協議的請求模型:

?URLSessionClient.swift:在這裡我們不解析返回的 Data 型別資料,後面交給資料模型來完成。

/// 網路客戶端模型
struct URLSessionClient: Client {
    let host: String
    
    func send<T: Request>(_ requestInfo: T, handler: @escaping (Data?) -> Void) {
        let url = URL(string: host.appending(requestInfo.path))!
        var request = URLRequest(url: url)
        request.httpMethod = requestInfo.method.rawValue
        
        let task = URLSession.shared.dataTask(with: request) {
            data, _, error in
            if let data = data {
                DispatchQueue.main.async { handler(data) }
            } else {
                DispatchQueue.main.async { handler(nil) }
            }
        }
        task.resume()
    }
}
複製程式碼

?BookRequest.swift:在這裡我們將請求得到的 Data 型別資料通過 Swift 4.0 提供的 JSONDecoder 進行解析,這比以前解析 json 的方式優雅太多了,因此接下來我們的資料模型都將要支援 Codable 協議,以使用此便利。程式碼中的 BookCollections 資料模型將在後面建立。

/// 書籍查詢的網路請求模型
struct BookRequest: Request {
    let userName: String // 使用者名稱
    let status: String // 閱讀狀態:想讀:wish 在讀:reading 讀過:read
    let start: Int // 起始編號
    let count: Int // 每次查詢最大數量
    
    var path: String {
        return "/v2/book/user/\(userName)/collections?status=\(status)&start=\(start)&count=\(count)"
    }
    let method: HTTPMethod = .GET
    let parameter: [String: Any] = [:]
    
    
    func send(handler: @escaping (BookCollections?) -> Void) {
        URLSessionClient(host: HostType.doubanAPI.rawValue).send(self) { data in
            guard let data = data else { return }
            if let bookCollections = try? JSONDecoder().decode(BookCollections.self, from: data) {
                handler(bookCollections)
            } else {
                handler(nil)
                print("JSON parse failed")
            }
            
        }
    }
}
複製程式碼
  • 現在我們來建立解析後的資料模型,以下 4 個資料模型與豆瓣 API 返回的 Json 資料結構是一致的(只保留需要的屬性),因此只要原樣照抄即可,注意這幾個結構是逐漸往下巢狀的,與 API 的巢狀結構一致,這是 Codable 協議中最美好的地方。

?DataModel.swift

typealias DataModel = BookCollections

struct BookCollections: Codable {
    var total: Int = 0
    var collections: [MyBook] = []
}

struct MyBook: Codable {
    var status: String = ""
    var book: Book
}

struct Book: Codable {
    var title: String = ""
    var subtitle: String = ""
    var author: [String] = []
    var publisher: String = ""
    var isbn13: String?
    var price: String = ""
    var pubdate: String = ""
    var summary: String = ""
    var rating: BookRating
    var id: String = ""
}

struct BookRating: Codable {
    var average: String = ""
    var numRaters: Int = 0
}
複製程式碼

以上幾個模型也可以稱為 API 模型,真正應用到表格中還需要做轉換,為了保持儘量解耦,我們這裡設計模型時儘量保持作用分離。接下來我們設計的資料模型是與表格的需求一一對應的,但是為了達到這樣的對應,還需要一個轉換層,這裡我們使用 ViewModel 來承擔模型轉換的責任。

  • 還是先從協議定義開始,這一組協議定義的是表格最主要的資料需求,即所有 cell 顯示的資料,和 section 顯示的資訊。

?TableDataModelProtocol.swift

/// TableView 動態型別資料模型協議。
/// 包含所有 Cell 資料的二維陣列(第一層對應 section,第二層對應 section 下的 cell),
/// 以及 Section 資料陣列
protocol TableViewDynamicCellDataModel {
    associatedtype TableDataModelType: CellModelType
    associatedtype SectionItem: TableViewSection
    var dynamicTableDataModel: [[TableDataModelType]] { get set }
    var sectionsDataModel: [SectionItem] { get set }
}

/// TableView section 資訊結構體模型協議,包含 section 標題資訊等。
protocol TableViewSection {
    var headerTitle: String? { get }
    var footerTitle: String? { get }
}

/// Cell 統一資料模型協議
protocol CellModelType {
    // 此為包裝協議,便於在其他協議中使用,可以為空。
}
複製程式碼

?CellDataModelProtocol.swift

/// 書籍列表中的書名類 Cell 資料協議
protocol BookInfoCellProtocol {
    var identifier: CellIdentifierType { get }
    var title: String { get }
    var authors: String { get }
    var publisher: String { get }
    var isbn13: String { get }
    var price: String { get }
    var pubdate: String { get }
    var summary: String { get }
}
複製程式碼

為了讓 Cell 支援多種資料型別,我們使用列舉 BookListCellModelType 將異構型別變為同構型別。

對於將異構變為同構,除了列舉還可以使用協議,將多個型別遵從同一個協議,但是使用時為了區分不同型別程式碼還是不夠優雅。列舉的好處是使用 switch 語句時可以利用編譯器檢查,但是列舉最大的缺點就是提取值時有點繁瑣,後面將會看到。

然而最不建議的就是使用 AnyAnyObject

?Enums.swiftCellModelType 是包裝協議,協議本身是空的,只是為了涵蓋所有的 Cell 資料型別列舉。

/// 書籍列表 Cell 使用的資料模型型別
///
/// - bookInfo: 圖書基本資訊
enum BookListCellModelType: CellModelType {
    case bookInfo(BookInfoCellModel)
    // 後續將方便擴充套件多個資料模型
}
複製程式碼
  • 接下里我們完成協議要求的資料模型:

?CellModel.swift:書籍列表中的 Cell 由於是可複用的,因此我們需要把 identifier 通過列舉的形式標明。

/// 書籍列表的書籍名稱類 cell 的資料結構體
struct BookInfoCellModel: BookInfoCellProtocol {
    var identifier = CellIdentifierType.bookInfoCell
    var title: String = ""
    var authors: String = ""
    var publisher: String = ""
    var isbn13: String = ""
    var price: String = ""
    var pubdate: String = ""
    var summary: String = ""
}

/// 列舉表格中包含的所有動態 cell 識別符號
public enum CellIdentifierType: String {
    case bookInfoCell
}
複製程式碼

?SectionModel.swift:這裡的 cellTypecellCount 是為靜態表格預留的。

/// TableView 中的 section 資料結構體
struct SectionModel: TableViewSection {
    var headerTitle: String?
    var footerTitle: String?
    var cellType: CellType
    var cellCount: Int
    
    init(headerTitle: String?,
         footerTitle: String?,
         cellType: CellType = .dynamicCell,
         cellCount: Int = 0)
    {
        self.headerTitle = headerTitle
        self.footerTitle = footerTitle
        self.cellType = cellType
        self.cellCount = cellCount
    }
}

/// cell 型別
///
/// - staticCell: 靜態
/// - dynamicCell: 動態
public enum CellType: String {
    case staticCell
    case dynamicCell
}
複製程式碼
  • 資料模型有了,接下來要實現 VM 了,VM 要做的主要事情就是轉換,將 API 模型 -> 表格資料模型。

?TableViewViewModel.swiftDataModel 是 API 解析後的模型,[[BookListCellModelType]] 是表格需要的資料模型,使用二維陣列的形式是因為在 DataSource 代理方法中使用起來非常方便直接。

getTableDataModel 方法是用來進行資料結構包裝的。getBookInfo 方法是真正進行資料細節上的轉換的,如果有欄位對映的變動在這裡修改就可以了。

struct TableViewViewModel {
    
    /// 構造表格統一的資料模型
    ///
    /// - Parameter model: 原始資料模型
    /// - Returns: 表格資料模型
    static func getTableDataModel(model: DataModel) -> [[BookListCellModelType]] {
        var bookWishToRead: [BookListCellModelType] = []
        var bookReading: [BookListCellModelType] = []
        var bookRead: [BookListCellModelType] = []
        for myBook in model.collections {
            guard let status = BookReadingStatus(rawValue: myBook.status) else {
                return []
            }
            let bookInfo = getBookInfo(model: myBook.book)
            switch status {
            case .wish:
                bookWishToRead.append(bookInfo)
            case .reading:
                bookReading.append(bookInfo)
            case .read:
                bookRead.append(bookInfo)
            case .all:
                break
            }
        }
        return [bookWishToRead, bookReading, bookRead]
    }
	
    /// 獲取 BookInfoCellModel 資料模型
    ///
    /// - Parameter model: 原始資料子模型
    /// - Returns: 統一的 cell 資料模型
    static func getBookInfo(model: Book) -> BookListCellModelType {
        var cellModel = BookInfoCellModel()
        cellModel.title = model.title
        cellModel.authors = model.author.reduce("", { $0 == "" ? $1 : $0 + "、" + $1 })
        cellModel.publisher = model.publisher
        cellModel.isbn13 = model.isbn13 ?? ""
        cellModel.price = model.price
        cellModel.pubdate = model.pubdate
        cellModel.summary = model.summary
        return BookListCellModelType.bookInfo(cellModel)
    }
}

/// 書籍閱讀狀態
public enum BookReadingStatus: String {
    case wish
    case reading
    case read
    case all = ""
}
複製程式碼
  • 好了,有了上面的模型,我們就可以呼叫 API 獲取資料了。

?MainTableViewController.swiftloadData() 方法載入完資料後會自動重新整理表格

	/// 資料來源物件
    var dynamicTableDataModel: [[BookListCellModelType]] = [] {
        didSet {
            tableView.reloadData()
        }
    }

	override func viewDidLoad() {
        super.viewDidLoad()
        loadData() // 載入資料
		// ...
    }

	/// 載入初始資料
    func loadData() {
        let request = BookRequest(userName: "pmtao", status: "", start: 0, count: 40)
        request.send { data in
            guard let dataModel = data else { return }
            let tableDataModel = TableViewViewModel.getTableDataModel(model: dataModel)
            self.dynamicTableDataModel = tableDataModel
        }
    }
複製程式碼
  • 現在寫起資料來源代理方法來別提多簡單:

?MainTableViewController+DataSource.swift:下面程式碼幾乎可以無痛移植到另一個 TabelView 中,在程式碼註釋的地方改個名即可。這樣的 DataSource 是不是清爽多了,這樣的程式碼寫一遍基本就可以不用理了。其中有個 configureCell 方法,我們統統移到自定義 Cell 類檔案中實現,讓 Cell 完成自己的配置,餵給 Cell 的資料都是不用轉換的,多虧了協議的功勞(BookInfoCellProtocol)。

	override func numberOfSections(in tableView: UITableView) -> Int {
        return dynamicTableDataModel.count
    }

	override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        return dynamicTableDataModel[section].count
    }

	override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath) -> UITableViewCell 
	{
        let section = indexPath.section
        let row = indexPath.row
        let model = dynamicTableDataModel[section][row]
        
        switch model {
        case let .bookInfo(bookInfo): // bookInfo 移植改名
            let identifier = bookInfo.identifier.rawValue // bookInfo 移植改名
            let cell = tableView.dequeueReusableCell(
                withIdentifier: identifier, 
                for: indexPath) as! BookInfoCell // BookInfoCell 移植改名
            cell.configureCell(model: bookInfo) // bookInfo 移植改名
            return cell
        }
    }
複製程式碼

接下來實現功能3

  • 展示兩種型別的 Cell(即異構型別資料模型):書籍名稱、書籍評分,擴充套件前面的模型即可:

?Enums.swift:增加評分資訊的 Cell 資料型別。

/// 書籍列表 Cell 使用的資料模型型別
///
/// - bookInfo: 圖書基本資訊
/// - bookRating: 圖書評分資訊
enum BookListCellModelType: CellModelType {
    case bookInfo(BookInfoCellModel)
    case bookRating(BookRatingCellModel)
}
複製程式碼

?CellDataModelProtocol.swift:增加評分資訊的 Cell 資料協議。

/// 書籍列表中的評分類 Cell 資料協議
protocol BookRatingCellProtocol {
    var identifier: CellIdentifierType { get }
    var average: String { get }
    var numRaters: String { get }
    var id: String { get }
    var title: String { get }
}
複製程式碼

?CellModel.swift:增加評分類 cell 的資料結構體

/// 書籍列表的書籍評分類 cell 的資料結構體
struct BookRatingCellModel: BookRatingCellProtocol {
    var identifier = CellIdentifierType.bookRatingCell
    var average: String = ""
    var numRaters: String = ""
    var id: String = ""
    var title: String = ""
}
複製程式碼

?TableViewViewModel.swift:VM 中再增加下資料模型轉換方法:

	static func getTableDataModel(model: DataModel) -> [[BookListCellModelType]] {
        ...
        let bookRating = getBookRating(model: myBook.book)
        switch status {
          case .wish:
          bookWishToRead.append(bookInfo)
          bookWishToRead.append(bookRating) // 增加的資料型別
          case .reading:
          bookReading.append(bookInfo)
          bookReading.append(bookRating) // 增加的資料型別
          case .read:
          bookRead.append(bookInfo)
          bookRead.append(bookRating) // 增加的資料型別
          case .all:
          break
        }
      ...
    }

	/// 獲取 BookRatingCellModel 資料模型
    ///
    /// - Parameter model: 原始資料子模型
    /// - Returns: 統一的 cell 資料模型
    static func getBookRating(model: Book) -> BookListCellModelType {
        var cellModel = BookRatingCellModel()
        cellModel.average = "評分:" + model.rating.average
        cellModel.numRaters = "評價人數:" + String(model.rating.numRaters)
        cellModel.id = model.id
        cellModel.title = model.title
        return BookListCellModelType.bookRating(cellModel)
    }
複製程式碼

最後一步,DataSource 微調一下:

?MainTableViewController+DataSource.swift:新增一個 case 即可,編譯器還會自動提示你,使用列舉封裝異構資料型別是不是很爽?.

override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath) -> UITableViewCell 
{
        let section = indexPath.section
        let row = indexPath.row
        let model = dynamicTableDataModel[section][row]
        
        switch model {
        case let .bookInfo(bookInfo):
            let identifier = bookInfo.identifier.rawValue
            let cell = tableView.dequeueReusableCell(
                withIdentifier: identifier, for: indexPath) as! BookInfoCell
            cell.configureCell(model: bookInfo)
            return cell
        // 新增資料型別部分:
        case let .bookRating(bookRating):
            let identifier = bookRating.identifier.rawValue
            let cell = tableView.dequeueReusableCell(
                withIdentifier: identifier, for: indexPath) as! BookRatingCell
            cell.configureCell(model: bookRating)
            return cell
        }
    }
複製程式碼

靜態型別 TableView 優化

我們改造一下書籍詳情頁

詳情頁是純靜態型別表格,用類似的方式封裝協議和模型,更是簡潔到無以復加。

?CellDataModelProtocol.swift:增加詳情頁 Cell 的資料模型協議

/// 圖書詳情類 Cell 資料協議
protocol BookDetailCellProtocol {
    var title: String { get }
    var authors: String { get }
    var publisher: String { get }
}
複製程式碼

?CellModel.swift:增加詳情頁 Cell 的資料模型

/// 書籍詳情類 cell 的資料結構體
struct BookDetailCellModel: BookDetailCellProtocol {
    var title: String = ""
    var authors: String = ""
    var publisher: String = ""
}
複製程式碼

?TableViewViewModel.swift:增加書籍詳情的模型轉換

	/// 獲取 BookDetailCellModel 資料模型
    ///
    /// - Parameter model: BookInfoCellModel 資料模型
    /// - Returns: BookDetailCellModel 資料模型
    static func getBookDetail(model: BookInfoCellModel) -> BookDetailCellModel {
        var cellModel = BookDetailCellModel()
        cellModel.title = model.title
        cellModel.authors = model.authors
        cellModel.publisher = model.publisher
        return cellModel
    }
複製程式碼

?TableDataModelProtocol.swift:增加靜態型別 TableView 資料模型協議

/// TableView 靜態型別資料模型協議。
/// 包含 Cell 結構體資料、 Section 資料陣列
protocol TableViewStaticCellDataModel {
    associatedtype StaticCellDataModel
    associatedtype SectionItem: TableViewSection
    var staticTableDataModel: StaticCellDataModel { get set }
    var sectionsDataModel: [SectionItem] { get set }
}
複製程式碼

?DetailTableViewController.swift:最後整合一下,一共 40 行,是不是很簡潔?.

import UIKit
class DetailTableViewController: UITableViewController, TableViewStaticCellDataModel {
    // MARK: 1.--@IBOutlet屬性定義-----------?
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var authorsLabel: UILabel!
    @IBOutlet weak var publisherLabel: UILabel!
    // MARK: 2.--例項屬性定義----------------?
    var staticTableDataModel = BookDetailCellModel()
    var sectionsDataModel: [SectionModel] = []
    
    // MARK: 3.--檢視生命週期----------------?
    override func viewDidLoad() {
        super.viewDidLoad()
        setSectionDataModel() // 設定 section 資料模型
        configureCell(model: self.staticTableDataModel) // 配置 Cell 顯示內容
    }
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
  
    // MARK: 4.--處理主邏輯-----------------?
    /// 設定 section 資料模型
    func setSectionDataModel() {
        sectionsDataModel = [SectionModel(headerTitle: nil, footerTitle: nil, cellCount: 3)]
    }
    /// 配置靜態 Cell 顯示內容
    func configureCell<T: BookDetailCellProtocol>(model: T) {
        nameLabel?.text = model.title
        authorsLabel?.text = model.authors
        publisherLabel?.text = model.publisher
    }
    // MARK: 5.--資料來源方法------------------?
    override func numberOfSections(in tableView: UITableView) -> Int {
        return sectionsDataModel.count
    }
    override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        return sectionsDataModel[section].cellCount
    }
}
複製程式碼

動靜態混合型別 TableView 優化

最後一個骨頭:功能4

功能 4 要實現一個動靜態混合的表格,這個話題也是 TabelView 改造中很常見的一個話題。我嘗試了幾種方案,總想用點黑科技偷點懶,實驗完發現還是得用常規思路。現總結如下:

  • 先用靜態表格進行設計,動態資料部分只需預留一個 Cell 即可(純佔位,無需做任何設定)。
  • 新建 UITableViewCell 子類和配套的 xib 檔案,新增 Identifier,這個用來複用動態 cell 部分。
  • 在資料來源中判斷 cell 型別(靜態、動態),並返回相應的 cell。

開動吧。

?Enums.swift:增加點型別

/// 列舉表格中包含的所有動態 cell 識別符號
public enum CellIdentifierType: String {
    case bookInfoCell
    case bookRatingCell
    case bookReviewTitleCell
}

/// Cell nib 檔案型別
public enum CellNibType: String {
    case BookReviewListTableViewCell
}

/// 書籍評論列表頁的評分項 Cell 使用的資料模型型別
enum BookReviewCellModelType: CellModelType {
    case bookReviewList(BookReviewListCellModel)
}
複製程式碼

?TableDataModelProtocol.swift:建立一個混合版資料模型協議,其實就是合併了動態、靜態的資料型別

/// TableView 動態、靜態混合型別資料模型協議。
/// 包含動態 Cell 二維陣列模型、靜態 Cell 結構體資料、Section 資料陣列、動態 Cell 的複用資訊。
protocol TableViewMixedCellDataModel {
    associatedtype TableDataModelType: CellModelType
    associatedtype StaticCellDataModel
    associatedtype SectionItem: TableViewSection
    var dynamicTableDataModel: [[TableDataModelType]] { get set }
    var staticTableDataModel: StaticCellDataModel { get set }
    var sectionsDataModel: [SectionItem] { get set }
    var cellNibs: [(CellNibType, CellIdentifierType)] { get set }
}
複製程式碼

?CellDataModelProtocol.swift:Cell 的資料模型協議分為動態、靜態兩部分。

/// 圖書評論摘要列表資料協議
protocol BookReviewListCellProtocol {
    var identifier: CellIdentifierType { get }
    var title: String { get }
    var rate: String { get }
    var link: String { get }
}

/// 圖書評論標題資料協議
protocol BookReviewHeadCellProtocol {
    var title: String { get }
    var rate: String { get }
}
複製程式碼

?BookReviewRequest.swift:評論資料需要單獨發起網路請求,新增一種請求模型即可。

struct BookReviewRequest: Request {
    let bookID: String // 書籍 ID
    let start: Int // 起始編號
    let count: Int // 每次查詢最大數量
    
    var path: String {
        return "/v2/book/\(bookID)/reviews?start=\(start)&count=\(count)"
    }
    let method: HTTPMethod = .GET
    let parameter: [String: Any] = [:]
    
    func send(handler: @escaping (BookReview?) -> Void) {
        URLSessionClient(host: HostType.doubanAPI.rawValue).send(self) { data in
            guard let data = data else { return }
            if let bookReviews = try? JSONDecoder().decode(BookReview.self, from: data) {
                handler(bookReviews)
            } else {
                handler(nil)
                print("JSON parse failed")
            }
            
        }
    }
}
複製程式碼

?DataModel.swift:API 模型也相應新增。

struct BookReview: Codable {
    var reviews: [Review] = []
    
    struct Review: Codable {
        var rating: Score
        var title: String = ""
        var alt: String = "" // 評論頁連結
    }
    
    struct Score: Codable {
        var value: String = ""
    }
}
複製程式碼

?CellModel.swift:實現 cell 資料協議所需的模型,也按動態、靜態區分開。

/// 書籍評論詳情的摘要列表類 cell 的資料結構體
struct BookReviewListCellModel: BookReviewListCellProtocol {
    var identifier = CellIdentifierType.bookReviewTitleCell
    var title: String = ""
    var rate: String = ""
    var link: String = ""
}

/// 書籍評論詳情的評論標題類 cell 的資料結構體
struct BookReviewHeadCellModel: BookReviewHeadCellProtocol {
    var id: String = ""
    var title: String = ""
    var rate: String = ""
}
複製程式碼

?TableViewViewModel.swift:VM 中再把 API 模型轉換到 cell 資料模型。

	/// 獲取 BookReviewListCellModel 資料模型
    ///
    /// - Parameter model: BookReview 資料模型
    /// - Returns: 書籍評論頁需要的評論列表模型
    static func getBookReviewList(model: BookReview) -> [[BookReviewCellModelType]] {
        var cellModel: [BookReviewCellModelType] = []
        for review in model.reviews {
            var bookReviewListCellModel = BookReviewListCellModel()
            bookReviewListCellModel.title = review.title
            bookReviewListCellModel.rate = "評分:" + review.rating.value
            bookReviewListCellModel.link = review.alt
            // 轉換為 enum 型別
            let model = BookReviewCellModelType.bookReviewList(bookReviewListCellModel)
            cellModel.append(model)
        }
        return [[], cellModel]
    }
    
    /// 獲取 BookReviewHeadCellModel 資料模型
    ///
    /// - Parameter model: Book 資料模型
    /// - Returns: 書籍評論頁需要的標題資訊
    static func getBookReviewHead(model: BookRatingCellModel) -> BookReviewHeadCellModel {
        var cellModel = BookReviewHeadCellModel()
        cellModel.id = model.id
        cellModel.title = model.title
        cellModel.rate = model.average
        return cellModel
    }
複製程式碼

?ReviewTableViewController.swift:最後的整合,靜態部分的 Cell 直接把要設定的控制元件建立 IBOutlet,用資料模型對映一下就好。

class ReviewTableViewController: UITableViewController, TableViewMixedCellDataModel {
    // MARK: 1.--@IBOutlet屬性定義-----------?
    @IBOutlet weak var bookNameLabel: UILabel!
    @IBOutlet weak var rateLabel: UILabel!
    
    // MARK: 2.--例項屬性定義----------------?
    /// 資料來源物件
    var dynamicTableDataModel: [[BookReviewCellModelType]] = [] {
        didSet {
            if shouldReloadTable {
                setSectionDataModel()
                tableView.reloadData()
            }
        }
    }
    var staticTableDataModel = BookReviewHeadCellModel()
    var sectionsDataModel: [SectionModel] = []
    var cellNibs: [(CellNibType, CellIdentifierType)] =
        [(.BookReviewListTableViewCell, .bookReviewTitleCell)]
    
    /// 有資料更新時是否允許重新整理表格
    var shouldReloadTable: Bool = false
複製程式碼

再進行一些初始化設定,注意:在 viewDidLoad 方法中就已經可以對靜態 cell 通過 IBOutlet 進行配置了。

    // MARK: 3.--檢視生命週期----------------?	
    override func viewDidLoad() {
        super.viewDidLoad()
        loadData() // 載入資料
        setSectionDataModel() // 設定 section 資料模型
        configureStaticCell(model: staticTableDataModel) // 配置 Cell 顯示內容
        setupView() // 檢視初始化
    }

    // MARK: 4.--處理主邏輯-----------------?
	/// 載入初始資料
    func loadData() {
        let request = BookReviewRequest(bookID: staticTableDataModel.id,
                                        start: 0,
                                        count: 3)
        request.send { data in
            guard let dataModel = data else { return }
            let tableDataModel = TableViewViewModel.getBookReviewList(model: dataModel)
            self.shouldReloadTable = true
            self.dynamicTableDataModel = tableDataModel
        }
    }

    /// 設定 section 資料模型
    func setSectionDataModel() {
        let section1 = SectionModel(
            headerTitle: "書籍",
            footerTitle: nil,
            cellType: .staticCell,
            cellCount: 2)
        
        var section2CellCount = 0
        if dynamicTableDataModel.count > 0 {
            section2CellCount = dynamicTableDataModel[1].count
        }
        let section2 = SectionModel(
            headerTitle: "精選評論",
            footerTitle: nil,
            cellType: .dynamicCell,
            cellCount: section2CellCount)
        sectionsDataModel = [section1, section2]
    }

    /// 配置靜態 Cell 顯示內容
    func configureStaticCell<T: BookReviewHeadCellProtocol>(model: T) {
        bookNameLabel?.text = model.title
        rateLabel?.text = model.rate
    }

	/// 檢視初始化相關設定
    func setupView() {
        // 註冊 cell nib 檔案
        for (nib, identifier) in cellNibs {
            let nib = UINib(nibName: nib.rawValue, bundle: nil)
            tableView.register(nib, forCellReuseIdentifier:  identifier.rawValue)
        }
    }
複製程式碼

關鍵的資料來源方法來了:

	// MARK: 8.--資料來源方法------------------?
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return sectionsDataModel.count
    }
    
    override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        return sectionsDataModel[section].cellCount
    }
複製程式碼

我們在 sectionsDataModel 的資料中就已經包含了 Cell 的動靜態型別,因此可以直接拿來判斷。靜態型別的 Cell 通過 super 屬性即可直接獲取, super 其實就是控制器物件本身,從中獲取的 Cell 是從 StoryBoard 中初始化過的例項,這樣獲取可以避免 cellForRowAt 再呼叫自身方法造成死迴圈。動態型別 Cell 直接呼叫 dequeueReusableCell 方法即可,注意要帶 for: indexPath 引數的那個。

	override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath)
        -> UITableViewCell
    {
        let section = indexPath.section
        let row = indexPath.row
        
        if sectionsDataModel[section].cellType == .staticCell {
            let cell = super.tableView(tableView, cellForRowAt: indexPath)
            return cell
        } else {
            let model = dynamicTableDataModel[section][row]
            switch model {
            case let .bookReviewList(bookReviewList):
                let identifier = bookReviewList.identifier.rawValue
                let cell = tableView.dequeueReusableCell(
                    withIdentifier: identifier, for: indexPath) as! BookReviewListTableViewCell
                cell.configureCell(model: bookReviewList)
                return cell
            }
        }
    }
複製程式碼

檢視代理方法中還有一些要補充的,這些方法是由於套用靜態 TableView 來實現動態 cell 效果帶來的副作用,照著寫就行:

	// MARK: 9.--檢視代理方法----------------?
    
    // 複用靜態 cell 時要使用這個代理方法
    override func tableView(_ tableView: UITableView,
                            heightForRowAt indexPath: IndexPath) -> CGFloat
    {
        let section = indexPath.section
        if sectionsDataModel[section].cellType == .staticCell {
            return super.tableView(tableView, heightForRowAt: indexPath)
        } else {
            let prototypeCellIndexPath = IndexPath(row: 0, section: indexPath.section)
            return super.tableView(tableView, heightForRowAt: prototypeCellIndexPath)
        }
    }
    
    // 複用靜態 cell 時要使用這個代理方法
    override func tableView(_ tableView: UITableView,
                            indentationLevelForRowAt indexPath: IndexPath) -> Int
    {
        let section = indexPath.section
        if sectionsDataModel[section].cellType == .staticCell {
            return super.tableView(tableView, indentationLevelForRowAt: indexPath)
        } else {
            // 將 storyBoard 中繪製的原型 cell 的 indentationLevel 賦予其他 cell
            let prototypeCellIndexPath = IndexPath(row: 0, section: indexPath.section)
            return super.tableView(tableView, indentationLevelForRowAt: prototypeCellIndexPath)
        }
    }
    
    // 設定分割槽標題
    override func tableView(_ tableView: UITableView,
                            titleForHeaderInSection section: Int) -> String?
    {
        return sectionsDataModel[section].headerTitle
    }
複製程式碼

優化終於完成了!

真不容易,看到這裡是不是有點暈,其實總結一下,拆分並實現以下模組,編寫 TableView 就可以做到很優雅了,以後基本就可以全套套用了:

  • 定義網路請求協議
  • 定義表格資料模型協議
  • 定義 API 資料模型
  • 定義網路請求模型
  • 定義表格資料模型
  • 定義 Cell 資料需求模型
  • 定義檢視模型
  • 定義 UITableViewCell 子類

完整工程已上傳 Github 工程地址


歡迎訪問 我的個人網站 ,閱讀更多文章。

題圖:Return trip from Hana, Hawaii - Luca Bravo @unsplash

相關文章