IGListKit 的演化

【转载APPCODAIGListKit 的演化:一起來看 Instagram 如何逐步解決 App 問題!】

IGListKit + MVVM 是 Instagram 對於 iOS UICollectionView UI 與數據解耦的解決方案,IGListKit 的設計理念是以數據驅動,來解決不同 Team 之間的需求,包含不同的數據與不一樣的 Layout。

原始 UICollectionView 再用戶日益增長的 Instagram 有甚麼問題

Instagram 是一款照片與影片的社交平台,目前月活躍使用者已超過 10 億。隨著 Instagram 的成長,因為越來越多的業務性質,Instagram 也需要更多更複雜的 Cell Layout。

好的架構與解決方案通常都不是一開始就做好的,而是隨著產品的成長與用戶的大規模提升,架構逐漸演進而成的。

原生 UICollectionView 的理念

  1. 可高度客製化
  2. 商業邏輯與 UI Code 解耦合
  3. Cell Reuse 資源可重複利用(我們知道創建 View 的開銷非常大)
    下列是模仿 ig 首頁貼文形式的 Layout 畫面:
instagram-layout

我們可以看到畫面中紅框代表 Cell 元件,目前為止我們有兩種 Cell ,上面負責顯示使用者訊息,下面負責顯示貼文。

讓我們來撰寫程式碼吧!首先,要決定有多少個 Section:

override func numberOfSections(in collectionView: UICollectionView) -> Int {
    return postData.count
}

回應對應的 UICollectionViewCell:

  • UserInfoViewCell:負責顯示用戶大頭貼、名稱、與更多(對應 View Model – PostViewModel)
  • UserImageViewCell:負責顯示用戶貼文圖片(對應 View Model – PostImageViewModel)

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let section = indexPath.section

    // 判斷資料模型種類
    // 貼文類
    if let postViewModel = postData[section] as? PostViewModel {

        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! UserInfoViewCell
        cell.updateWith(object: postViewModel)

        return cell

    } else if let postImageViewModel = postData[section] as? PostImageViewModel {

        // 主要照片類別
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: imageCellId, for: indexPath) as! UserImageViewCell
        cell.updateWith(object: postImageViewModel)

        return cell
    }

    let cell = UICollectionViewCell()
    return cell
}
然後,我們要依照不同的 Cell 設定不同的高度:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

    if postData[indexPath.section] is PostViewModel {
        return CGSize(width: view.frame.width, height: 50)
    } else if postData[indexPath.section] is PostImageViewModel {
        return CGSize(width: view.frame.width, height: 400)
    }

    return CGSize.zero
}

當資料是用戶資訊時,我們設定高度為 50;而當資料是貼文照片時,我們設定高度為 400。

當然,完整原始碼位於最上發方說明的位置可以參考 CollectionNormalController 這是一般 CollectionView 的實現過程。

接下來讓我們情境模擬一下,因業務需求我們需要增加下列推薦關注的元件

layout-2

那我們需要增加哪些 Code 呢?

UserFocusViewCell:負責顯示推薦關注(對應 View Model – FocusViewModel)
讓我們增加判斷回應的 Cell:

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    //...以上省略

else if let focusViewModel = postData[section] as? FocusViewModel {

        // 關注類
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: focusCellId, for: indexPath) as! UserFocusViewCell
        cell.updateWith(object: focusViewModel)

        return cell
    }

   //...以下省略
}
看起來修改的幅度並不多,但是這裡出了幾個問題!
  1. 多人合作時,大家需要在同樣的 Function 反覆添加與修改邏輯
  2. Controller 越來越臃腫
  3. 因業務需求新增 UI 需要修改多個團隊的代碼,職責分權不明確
  4. 可讀性及可維護性下降
  5. 業務邏輯與設計需求耦合
  6. 難以增加 A/B Test 代碼
因這些業務與成長需求,ig 不得不另外尋找解決方案,而 IGListKit 就是透過這些需求演化而成的。
IGListKit 的理念
  • 1.增加一層 SectionController,拆分商業邏輯與 UI
  • 2.提升 Code 的可重用度
  • 3.高性能更新畫面機制 (O(n))
IGListKit 主要提供了這些功能:
  • 不需要一次次調用 performBatchUpdates(_:, completion:) 或 reloadData()
  • 具有可重複使用的 Cell 和 Components
  • 創建具有多種數據類型的集合
  • 自定義模型的差異行為
  • 只依賴 UICollectionView
  • 可擴充的 API
  • 使用 Objective-C 編寫,並完整支援 Swift
IGListKit 簡單範例使用

為了表示 IGListKit 擴充性與可重用性,我們沿用上面製作好的 UI 與 ViewModel:

  • CollectionIGListKitController 為 IGListKit 使用方式
  • UserInfoViewCell:負責顯示用戶大頭貼、名稱、與更多(對應 View Model – PostViewModel)
  • UserImageViewCell:負責顯示用戶貼文圖片(對應 View Model – PostImageViewModel)
  • UserFocusViewCell:負責顯示推薦關注(對應 View Model – FocusViewModel)
  • PostData:模擬資料來源


ViewModel

要使用 IGListKit,我們的 ViewModel 必須遵守 ListDiffable 協定。我們先了解一下這個協定吧!ListDiffable 必須實現兩個 function:
    1. func diffIdentifier() -> NSObjectProtocol:用於定義辨識項目
    1. func isEqual(toDiffableObject object: ListDiffable?) -> Bool:用於辨識兩者是否為同一個 Model
      現在,我們可以開始實作了!首先,建立一個專屬的辨識協定 PostPageProtocol:
protocol PostPageProtocol: ListDiffable {
   var identifier: UUID { get }
 }

這個協定很簡單,遵守 ListDiffable,並且規定必須實作 identifier 用於資源比較。然後,在我們的 PostViewModel 中加入 headerImage、headerTitle、和 headerRightButtonTitle,用於顯示用戶資訊。

class PostViewModel: PostPageProtocol {

let identifier = UUID.init()
let headerImage: String
let headerTitle: String
let headerRightButtonTitle: String

init(headerImage: String, headerTitle: String , headerRightButtonTitle: String) {
    self.headerImage = headerImage
    self.headerTitle = headerTitle
    self.headerRightButtonTitle = headerRightButtonTitle
}

func diffIdentifier() -> NSObjectProtocol {
    return identifier as NSObjectProtocol
}

func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    guard let object = object as? PostViewModel else {
        return false
    }
    return self.identifier == object.identifier
}
}

再製作兩個 ViewModel PostImageViewModel 和 PostImageViewModel,用於貼文照片與推薦關注。其中 PostImageViewModel 只加入屬性 mainImage,用於顯示照片;而 FocusViewModel 就加入屬性 headerImage、headerTitle、和 headerRightButtonTitle,用於顯示推薦關注的資訊。

class PostImageViewModel: PostPageProtocol {

let identifier = UUID.init()
let mainImage: String

init(mainImage: String) {
    self.mainImage = mainImage
}

func diffIdentifier() -> NSObjectProtocol {
    return identifier as NSObjectProtocol
}

func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    guard let object = object as? PostImageViewModel else {
        return false
    }
    return self.identifier == object.identifier
}
}

class FocusViewModel: PostPageProtocol {

let identifier = UUID.init()
let headerImage: String
let headerTitle: String
let headerRightButtonTitle: String

init(headerImage: String, headerTitle: String , headerRightButtonTitle: String) {
    self.headerImage = headerImage
    self.headerTitle = headerTitle
    self.headerRightButtonTitle = headerRightButtonTitle
}

func diffIdentifier() -> NSObjectProtocol {
    return identifier as NSObjectProtocol
}

func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    guard let object = object as? FocusViewModel else {
        return false
    }
    return self.identifier == object.identifier
}
}

Controller 製作

接下來,我們來製作 Controller 吧!我們先以下列程式碼建立一個 UIViewController,注意不是 UICollectionViewController。

//MARK:- MainViewController
class CollectionIGListKitController: UIViewController {

// 生成 CollectionView
let layout = UICollectionViewFlowLayout()
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)

// 生成 updater 與 adapter
let updater = ListAdapterUpdater()

// 綁定 adapter
lazy var adapter = ListAdapter(updater: updater, viewController: self)

override func viewDidLoad() {
    super.viewDidLoad()

    // 定義 adapter 的 dataSource 與 collectionView
    adapter.dataSource = self
    adapter.collectionView = collectionView

    view.addSubview(collectionView)
    collectionView.fillToSuperview()

    collectionView.backgroundColor = .white
}
}

我們首先生成一個 UICollectionView,再生成 ListAdapterUpdater 與 ListAdapter。ListAdapterUpdater 負責 row 與 section 的更新,而 ListAdapter 負責控制 CollectionView。

ListAdapterDataSource

因為我們的 adapter.dataSource 是指定 CollectionIGListKitController,所以必須實作 ListAdapterDataSource。

//MARK:- ListAdapterDataSource
extension CollectionIGListKitController: ListAdapterDataSource {
// 資料來源
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
    return PostData.postData
}

// 返回合適的 ListSectionController
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {

    if object is PostViewModel {
        return UserInfoViewController()
    } else if object is PostImageViewModel {
        return UserImageViewController()
    } else if object is FocusViewModel {
        return FocusViewModelViewController()
    }

    return ListSectionController()
}

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

返回合適的 ListSectionController,就如同一開始我們返回適合的 Cell 一樣。

所以,我們必須實作這三個 SectionController。這個步驟並不複雜,首先先定義了他們本身需要的 Model。sizeForItem 定義了這個 Cell 需要的大小,而 cellForItem 則定義了要返回哪一個 UICollectionViewCell。

//MARK:- UserInfoView
class UserInfoViewController: ListSectionController {

var currentUserInfo: PostViewModel?

override func didUpdate(to object: Any) {
    guard let userInfo = object as? PostViewModel else {
        return
    }
    currentUserInfo = userInfo
}

override func numberOfItems() -> Int {
    return 1
}

override func sizeForItem(at index: Int) -> CGSize {
    return CGSize(width: collectionContext!.containerSize.width, height: 50)
}

override func cellForItem(at index: Int) -> UICollectionViewCell {
    let cell = collectionContext!.dequeueReusableCell(of: UserInfoViewCell.self, for: self, at: index) as! UserInfoViewCell

    if let currentUserInfo = currentUserInfo {
        cell.updateWith(object: currentUserInfo)
    }

    return cell
}
}

其他几个也类似这么写 。

我們使用 IGListKit 後解決了甚麼問題

iglistkit

其實就是一開始 ig 團隊遇到的問題:

  1. 不同業務團隊只需負責自己的業務邏輯;
  2. 商業邏輯分離至 ListSectionController,解決越來越臃腫的 Controller,細分成許多 Child Controller。

總結

隨著業務與用戶規模的成長,App 一定會遇到許多複雜問題,而我們一定要透過架構的演進來解決新的問題。能跟著產品成長的團隊,才是好團隊!今天的範例說明了 IG 團隊在業務增長上所誕生的 IGListKit

IGListKit 使很多團隊可以專注在自己業務邏輯面的開發,並且讓職責更加明確,大大提高可擴展性、易讀性、與易維護性,同時也為 A/B Test 做好準備。

好的架構並不是一蹴而就,更多的是演化打磨與取捨。架構如此,人的成長也如此。感謝你的閱讀,亦強烈建議你搭配範例閱讀 IGListKitArchetype

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • 程序員創業白皮書 作者:Paul Graham Paul Graham是程序員,專欄作家。他在1995年創建了第一...
    刘立山John阅读 1,847评论 0 20
  • 【杯子技巧】 和對方的交情還屬於曖昧不清的階段,正確掌握和對方的距離感,是很困難的事。 最可怕的是,你覺得兩人的感...
    77733261dbff阅读 641评论 0 0
  • 一入简书深似海,不知不觉我已写了十六万字了,但能拿出手的却没有。哎!估计我属于典型的自嗨型吧。 今天晨读材料里分享...
    捡到蜜罐的熊阅读 244评论 2 2
  • 人正是心存执念,才成为自己。若是随波逐流,不执着于自己信仰的东西,就永远不知道自己的需求。不知道自己的需求,身体就...
    Sissi杨小黑阅读 135评论 0 0
  • ■蛟腾村:我望见了什么 ■六月飞雪 苍天 蓝蓝的 我若生活中的一片树叶 随风飘零 不止我一个 还是一...
    向前的冰山来客15669阅读 145评论 0 5