[譯]Swift 中的通用資料來源

Swants發表於2019-02-26

Swift 中的通用資料來源

在我開發的絕大多數 iOS app 中, tableView 和 collectionView 絕對是最常用的 UI 元件。鑑於設定一個 tableView 或 collectionView 需要大量樣板程式碼,我最近花了些時間找到一個比較好的方法,去避免一遍又一遍地重複同樣的程式碼。我的主要工作是對必需的樣板程式碼進行抽取封裝。隨著時間的推移,很多其他開發者也解決了這個問題。並且隨著 Swift 的最新進展出現了很多有趣的解決方案。

本篇文章裡,我將介紹在我 APP 裡已經使用了一段時間的解決方案,這個方案讓我在設定 collectionView 的時候減少了大量的樣板程式碼。

TableView vs CollectionView

有些人可能會問 為什麼單討論 collectionView 而不提 tableView 呢?

在最近的幾個月裡,我在之前可以使用 tableView 的地方都使用成了 collectionView 。它們到目前為止表現良好!這一做法幫助我不用去區分這兩個 幾乎完全 相似但並不完全相同的集合概念。接下來則是讓我做出這一決定的根本原因:

  • 任何 tableView 都可以用單列的 collectionView 進行實現/重構。
  • tableView 在大螢幕上(如:iPad )表現的不是特別好。

需要說明的是,我沒有建議你把程式碼庫裡所有的 tableView 都用 collectionView 重新實現。我建議的是,當你需要新增一個展示列表的新功能時,你應該考慮下使用 collectionView 來代替 tableView 。尤其是在你開發一個 Universal APP 時,因為 collectionView 將讓你的 APP 在所有尺寸螢幕上動態調整佈局變得更簡單。

Swift 泛型與有效抽取的探索

我一直是泛型程式設計的擁躉,所以你能想象的到當蘋果宣佈在 Swift 中引進泛型時,我是多麼的興奮。但是泛型和協議結合有時並不合作的那麼和諧。這時 Swift 2.x 中關於 關聯型別 的介紹讓使用泛型協議變得更加簡單,越來越多的開發者開始去嘗試使用它們。

我打算展示的程式碼抽取是基於對泛型使用的嘗試,尤其是泛型協議。這樣的程式碼抽取能夠讓我對設定 collectionView 所需的樣板程式碼進行封裝,從而減少設定資料來源所需的程式碼,甚至在一些簡單的使用場景兩行程式碼就足夠了。

我想說明下我所建立的不是通解。我做的程式碼封裝針對於解決一些特定使用場景。對於這些場景來說,使用抽取封裝後的程式碼效果非常好。對於一些複雜的使用場景,可能就需要新增額外的程式碼了。我把抽取工作主要放在了 collectionView 最常用的功能。如果需要的話,你可以封裝更多的功能,但是對於我的特定場景來說,這並不是必需的。

作為本篇文章的目的,我將會展示一部分抽取程式碼來概括使用 collectionView 時常用的功能。這將是你瞭解使用泛型,尤其是泛型協議能夠來做什麼的一個好的機會。

Collection View Cell 抽取

首先,我實現 collectionView 通常都是先建立展示資料的 cell 。處理 collectionView 的 cell 時通常需要:

  • 重用 cell
  • 配置 cell

為了簡化上面的工作,我寫了兩個協議:

  • ReusableCell
  • ConfigurableCell

讓我們詳細地看一下這兩個抽取後程式碼吧。

ReusableCell

這個 ReusableCell 協議需要你定義一個 重用識別符號 ,這個標誌符將在重用 cell 的時候被用到。在我的 APP 裡,我總是圖方便把 cell 的重用識別符號設定為和 cell 的類名一樣。因此,很容易通過建立一個協議擴充套件來抽取出,讓 reuseIdentifier 返回一個帶有類名稱的字串:

public protocol ReusableCell {
    static var reuseIdentifier: String { get }
}

public extension ReusableCell {
    static var reuseIdentifier: String {
        return String(describing: self)
    }
}複製程式碼

ConfigurableCell

這個 ConfigurableCell 協議需要你實現一個方法,這個方法將使用特定型別的例項配置 cell ,而這個例項被定義成了一個泛型型別 T:

public protocol ConfigurableCell: ReusableCell {
    associatedtype T

    func configure(_ item: T, at indexPath: IndexPath)
}複製程式碼

這個 ConfigurableCell 協議將會在載入 cell 內容的時候被呼叫。接下來我會詳細介紹一些細節,現在我就強調下一些地方:

  1. ConfigurableCell 繼承 ReusableCell

  2. 繫結型別的使用( 繫結型別 T )將 ConfigurableCell 定義為泛型協議。

資料來源的抽取: CollectionDataProvider

現在,讓我們把目光收回,再回想下設定 collection view 都需要做些什麼。為了讓 collection view 展示內容,我們需要遵循 UICollectionViewDataSource 協議。那麼最先要做的常常是確定下來這些:

  • 需要幾組:numberOfSections(in:)
  • 每組需要幾行:collectionView(_:numberOfItemsInSection:)
  • cell 的內容怎麼載入 :collectionView(_:cellForItemAt:)

將上述代理方法實現,會確保我們能夠對指定 collectionView 的 cell 進行展示 。而對於我來說,這裡是非常適合進行程式碼抽取的地方。

為了抽取和封裝上述步驟,我建立了以下泛型協議:

public protocol CollectionDataProvider {
    associatedtype T

    func numberOfSections() -> Int
    func numberOfItems(in section: Int) -> Int
    func item(at indexPath: IndexPath) -> T?

    func updateItem(at indexPath: IndexPath, value: T)
}複製程式碼

這個協議前三個方法是:

  • numberOfSections()
  • numberOfItems(in:)
  • item(at:)

他們指明瞭遵循 UICollectionViewDataSource 協議需要實現的代理方法列表。基於我有過一些當使用者互動後需要更新資料來源的使用場景,我在最後又加了一個 (updateItem(at:, value:)) 方法。這個方法允許你在需要的時候更新底層資料。到這裡,在 CollectionDataProvider 定義的方法滿足了遵循 UICollectionViewDataSource 協議時需要實現的常用功能。

封裝樣板: CollectionDataSource

通過上面的抽取,現在可以開始實現一個基類,這個基類將被封裝為 collectionView 建立資料來源所需的常用樣板。這就是最神奇地方!這個類的主要作用就是利用特定的 CollectionDataProviderUICollectionViewCell 來滿足遵循 UICollectionViewDataSource 協議所需要實現的方法。

這是這個類的定義:

open class CollectionDataSource<Provider: CollectionDataProvider, Cell: UICollectionViewCell>:
    NSObject,
    UICollectionViewDataSource,
    UICollectionViewDelegate,
    where Cell: ConfigurableCell, Provider.T == Cell.T
{ [...] }複製程式碼

它為我們做了很多事:

  1. 這個類有一個公有屬性,讓我們能夠將它擴充套件為指定 CollectionDataProvider 提供正確的實現。
  2. 這是一個泛型的類,所以它需要特定的 Provider (CollectionDataProvider) 和 Cell (UICollectionViewCell) 物件進一步的定義來使用。
  3. 這個類繼承於 NSObject 基類,所以能夠遵循 UICollectionViewDataSourceUICollectionViewDelegate 來進行抽取封裝樣板程式碼。
  4. 這個類在以下場景使用的時候有一些特定限制:
  • UICollectionViewCell 必須遵循 ConfigurableCell 協議。( Cell: ConfigurableCell
  • 特定型別 T 必須和 cell 跟 Provider 的 T 相同 (Provider.T == Cell.T)。

程式碼需要像下面一樣對 CollectionDataSource 進行初始化和設定:

// MARK: - Private Properties
let provider: Provider
let collectionView: UICollectionView

// MARK: - Lifecycle
init(collectionView: UICollectionView, provider: Provider) {
    self.collectionView = collectionView
    self.provider = provider
    super.init()
    setUp()
}

func setUp() {
    collectionView.dataSource = self
    collectionView.delegate = self
}複製程式碼

程式碼是非常簡單的:CollectionDataSource 需要知道它將針對哪個 collectionView 物件,將根據哪個作為資料提供者。這些問題都是通過 init 方法的引數進行傳遞確定的。在初始化的過程中,CollectionDataSource 將自己設定為 UICollectionViewDataSourceUICollectionViewDelegate 的代理物件(在 setUp 方法中)。

現在讓我們看一下 UICollectionViewDataSource 代理的樣板程式碼。

這是程式碼:

// MARK: - UICollectionViewDataSource
public func numberOfSections(in collectionView: UICollectionView) -> Int {
    return provider.numberOfSections()
}

public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return provider.numberOfItems(in: section)
}

open func collectionView(_ collectionView: UICollectionView,
     cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
    guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.reuseIdentifier,
        for: indexPath) as? Cell else {
        return UICollectionViewCell()
    }
    let item = provider.item(at: indexPath)
    if let item = item {
        cell.configure(item, at: indexPath)
    }
    return cell
}複製程式碼

上面的程式碼片段通過 CollectionDataProvider 的一個物件展示了 UICollectionViewDataSource 代理的主要實現,就像之前所說的那樣,它封裝了資料來源實現的所有細節。每個代理都使用指定的 CollectionDataProvider 方法來抽取跟資料來源之間進行互動。

注意 collectionView(_:cellForItemAt:) 方法有一個公開的屬性,這就能夠讓它的任何子類在需要對 cell 內容進行更多定製化的時候進行擴充套件。

現在對 collectionView cell 展示的功能已經做好了,讓我們再為它新增更多的功能吧。

而作為第一個要新增的功能,使用者應該能夠在點選 cell 的時候觸發某些操作。為了實現這個功能,一個簡單的方案就是定義一個簡單的 closure,並對這個 closure 初始化,當使用者點選 cell 的時候執行這個 closure 。

處理 cell 點選的自定義 closure 如下所示:

public typealias CollectionItemSelectionHandlerType = (IndexPath) -> Void複製程式碼

現在,我們能定義個屬性來儲存這個 closure ,當使用者點選這個 cell 的時候就會在 UICollectionViewDelegatecollectionView(_:didSelectItemAt:) 代理方法實現中執行這個初始化好的 closure 。

// MARK: - Delegates
public var collectionItemSelectionHandler: CollectionItemSelectionHandlerType?

// MARK: - UICollectionViewDelegate
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    collectionItemSelectionHandler?(indexPath)
}複製程式碼

作為第二個要新增的功能,我打算在 CollectionDataSource 中對多組組頭和組的一些程式碼樣板進行封裝。這就需要實現 UICollectionViewDataSource 的代理方法 viewForSupplementaryElementOfKind 。為了能夠讓子類自定義的實現 viewForSupplementaryElementOfKind ,這個代理方法需要定義為公開方法,以便讓任何子類能夠對這個方法進行重寫。

open func collectionView(_ collectionView: UICollectionView,
    viewForSupplementaryElementOfKind kind: String,
    at indexPath: IndexPath) -> UICollectionReusableView
{
    return UICollectionReusableView(frame: CGRect.zero)
}複製程式碼

通常來說,這種方式適用於所有的代理方法,當他們需要被子類重寫覆蓋時,這些方法需要定義為公有方法,並在 CollectionDataSource 中實現。

另一種不同的解決方案就是使用一個自定義的 closure ,就像在 (CollectionItemSelectionHandlerType) 方法中處理 cell 點選事件一樣。

我實現的這個特定方面是軟體工程中的一個典型的權衡,一方面 —— 為 collectionView 設定資料來源的主要細節都被隱藏(被抽取封裝)。另一方面 —— 封裝的樣板程式碼中沒有提供的功能,就會變得不能開箱即用,新增新的功能並不複雜,但是需要像我上面兩個例子那樣,需要實現更多的自定義程式碼。

實現一個具體的 CollectionDataProvider 也就是 ArrayDataProvider

現在樣板程式碼已經設定好了,collectionView 的資料來源由 CollectionDataSource 負責。讓我們通過一個普通的使用案例來看看樣板程式碼用起來有多方便。為了做這個,CollectionDataSource 物件需要提供 CollectionDataProvider 具體的實現。一個覆蓋大多數常見使用案例的基本實現,可以簡單地使用二維陣列來包含展示 collectionView cell 內容的資料 。作為我對資料來源抽象的試驗的一部分,我使這個實現變得更加通用,並且能夠表示:

  • 二維陣列,每一個陣列元素代表 collectionView 一組 cell 的內容。
  • 陣列,表示 collectionView 只有一組 cell 的內容(沒有組頭)。

上面的程式碼實現都包含在泛型類 ArrayDataProvider 中:

public class ArrayDataProvider<T>: CollectionDataProvider {
    // MARK: - Internal Properties
    var items: [[T]] = []

    // MARK: - Lifecycle
    init(array: [[T]]) {
        items = array
    }

    // MARK: - CollectionDataProvider
    public func numberOfSections() -> Int {
        return items.count
    }

    public func numberOfItems(in section: Int) -> Int {
        guard section >= 0 && section < items.count else {
            return 0
        }
        return items[section].count
    }

    public func item(at indexPath: IndexPath) -> T? {
        guard indexPath.section >= 0 &&
            indexPath.section < items.count &&
            indexPath.row >= 0 &&
            indexPath.row < items[indexPath.section].count else
        {
            return items[indexPath.section][indexPath.row]
        }
        return nil
    }

    public func updateItem(at indexPath: IndexPath, value: T) {
        guard indexPath.section >= 0 &&
            indexPath.section < items.count &&
            indexPath.row >= 0 &&
            indexPath.row < items[indexPath.section].count else
        {
            return
        }
        items[indexPath.section][indexPath.row] = value
    }
}複製程式碼

這樣做可以提取訪問資料來源的細節,線性資料結構可以表示 cell 的內容是最常見的使用情況。

封裝到一塊: CollectionArrayDataSource

這樣 CollectionDataProvider 協議就具體實現了,建立一個 CollectionDataSource 子類來實現最常見的簡單的列表資料展示是非常容易的。

讓我們從這個類的定義開始:

open class CollectionArrayDataSource<T, Cell: UICollectionViewCell>: CollectionDataSource<ArrayDataProvider<T>, Cell>
     where Cell: ConfigurableCell, Cell.T == T
 { [...] }複製程式碼

這個宣告定義了很多事情:

  1. 這個類有一個公有的屬性,因為它最終將被擴充套件為 UICollectionView 物件的資料來源物件。
  2. 這是一個繼承 UICollectionViewCell 的泛型類,需要被特定的型別 T 進一步定義才能正確展示 cell 和 cell 的內容。

  3. 這個類擴充套件了 CollectionDataSource 來提供進一步的特定行為。

  4. 特定型別 T 將被表示,它將通過一個 ArrayDataProvider < T > 物件來訪問 cell 內容。

  5. 這個類在 closure 中的定義表明有些特定的約束:

  • UICollectionViewCell 必須遵循 ConfigurableCell 協議。( Cell: ConfigurableCell
  • cell 中的特定型別 T 必須跟 Provider 的 T 相同 (Provider.T == Cell.T) 。

類的實現非常簡單:

// MARK: - Lifecycle
public convenience init(collectionView: UICollectionView, array: [T]) {
   self.init(collectionView: collectionView, array: [array])
}

public init(collectionView: UICollectionView, array: [[T]]) {
   let provider = ArrayDataProvider(array: array)
   super.init(collectionView: collectionView, provider: provider)
}

// MARK: - Public Methods
public func item(at indexPath: IndexPath) -> T? {
   return provider.item(at: indexPath)
}

public func updateItem(at indexPath: IndexPath, value: T) {
   provider.updateItem(at: indexPath, value: value)
}複製程式碼

它只是提供了一些初始化方法和與互動方法,這些方法使我們能夠讓資料提供者與資料來源透明地進行讀取和寫入操作。

建立一個基本的 CollectionView

可以將 CollectionArrayDataSource 基類擴充套件,為任何可以用二維陣列展示的 collection view 建立一個特定的資料來源。

class PhotosDataSource: CollectionArrayDataSource<PhotoViewModel, PhotoCell> {}複製程式碼

宣告比較簡單:

  1. 繼承於 CollectionArrayDataSource
  2. 這個類表示 PhotoViewModel 作為特定型別 T 將會展示 cell 內容,可通過 ArrayDataProvider < PhotoViewModel > 物件訪問,PhotoCell 將作為 UICollectionViewCell 展示。

請注意,PhotoCell 必須遵守 ConfigurableCell 協議,並且能夠通過 PhotoViewModel 例項初始化它的屬性。

建立一個 PhotosDataSource 物件是非常簡單的。只需要傳遞過去將要展示的 collectionView 和由展示每個 cell 內容的 PhotoViewModel 元素組成的陣列:

let dataSource = PhotosDataSource(collectionView: collectionView, array: viewModels)複製程式碼

collectionView 引數通常是 storyboard 上的 collectionView 通過 outlet 指向獲取到的。

所有的就完成了!兩行程式碼就可以設定一個基本的 collectionView 資料來源。

設定帶有組標題和組的 CollectionView

對於更高階和複雜的用例,你可以簡單在 GitHub repo 上檢視 TaskList 。內容已經很長了,本文就不再不介紹示例的更多細節。我將在下一篇 “Collection View with Headers and Sections” 文章裡進行深入地探討。在這個說明中,如果存在一個話題對你來說很有意思,請不要猶豫讓我知道,這樣我就可以優先考慮下一步寫什麼。為了和我聯絡,請在這篇文章下方留言或發郵件給我: andrea.prearo@gmail.com

結論

在這篇文章中,我介紹了一些我做的抽取封裝,以簡化使用泛型資料來源的 collectionView 。所提出的實現都是基於我在構建 iOS app 時遇到的重複程式碼的場景。一些更高階的的功能可能需要進一步的自定義。我相信,繼續優化所得到的程式碼抽取,或者構建新的程式碼抽取,來簡化處理不同的 collectionView 模式都是可能的。但這已經超出了這篇文章的範圍。

所有的通用資料來源程式碼和示例工程都在 GitHub 並且是遵守 MIT 協議的。你可以直接使用和修改它們。歡迎所有的反饋意見和建議的貢獻,並非常感謝你這麼做。如果你有足夠的興趣,我將很樂意新增所需的配置,使程式碼與Cocoapods和Carthage一起使用,並允許使用這種依賴關係管理工具匯入通用資料來源。或者,這可能是一個很好的起點去為這個專案做出貢獻。


額外連結

披露宣告:這些意見是作者的意見。 除非在文章中額外宣告,否則 Capital One 版權不屬於任何所提及的公司,也不屬於任何上述公司。 使用或顯示的所有商標和其他智慧財產權均為其各自所有者的所有權。 本文版權為 ©2017 Capital One

更多關於 API、開源、社群活動或開發文化的資訊,請訪問我們的一站式開發網站 developer.capitalone.com


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章