iOS — 快速上手 Instagram/IGListKit 框架 (官方Demo教程文档翻译整理)

Instagram/IGListKit 快速上手 (官方文档翻译)

IGListKit是Instagram推出的新的UICollectionView框架,使用数据驱动,旨在创造一个更快更灵活的列表控件。

项目地址:https://github.com/Instagram/IGListKit
作者演讲: 大规模重构——重写 Instagram Feed 的经验之谈

官方文档地址: https://instagram.github.io/IGListKit/
官方原文地址: https://instagram.github.io/IGListKit/modeling-and-binding.html

相关优秀文章推荐:
但江的思考-IGListKit
比 UICollectionView更好用的IGListKit教程 — 通过模拟实现NASA的简单需求来更好的了解IGListKit

翻译水平有限,在不影响原文意思的前提下适当做了修改, 有错误请在留言区指正
译者微信: MTMwMjAwOTkzNjY=


前言(译者注):

译者目前属于初级iOS开发者,难免有许多翻译或点评不恰当的地方,欢迎指正。
Q4项目重构阶段认识了这个框架,2017年被评为33个必须了解的iOS开源库之一。最开始阅读了文章头部推荐的作者演讲,他阐述重写Feed的原因是技术债务(Technical Debt),例如大家在刷Ins的时间线的时候,除了帖子之外,大家还会看到优秀创作者推荐等信息。因为这些信息不包含在FeedItem这个模型里面,所以做起来会比较难以实现。

通过Diff算法来对比Model是否有变化,来实现界面的局部更新。

本文只是片面的翻译官方提供的Demo文档,暂时没有深入研究各种原理。在文章头部提供了一个模拟实现NASA业务需求的Demo,可以帮助你更好的理解IGListKit的妙处。


Demo — 实现Ins帖子内容

创建Model与绑定

本文将通过一个实际的例子来教大家如何使用IGListKit。
通过本文你将学会:

  • 一个顶级Model和多个ViewModel组合的设计规范
  • 使用 ListBindingSectionController 对Cell进行更新
  • Cell和Controller之间的响应事件和代理的处理
  • 使用本地变化的数据来更新UI

开始

我们可以跟着下面的例子一起做。首先下载官方提供的测试项目:rnystrom/IGListKit-Binding-Guide。 此项目已经通过CocoaPods集成了IGListKit,所以我们可以直接打开:ModelingAndBinding-Starter/ModelingAndBinding.xcworkspace

此项目的目的是实现类似Instagram帖子详情页面,所以我们可以大致思考一下它的数据模型:

  • 顶部Cell现实用户名和发布时间标签
  • 中部Cell包含一个通过URL加载的图片
  • 图片下方显示点赞数,同时我们也会加入交互功能,当用户点击此按钮时,点赞数将增加
  • 底部为评论列表,显示不确定数量的评论,主要包括用户名和评论内容

切记,IGListKit实现的是一个Model对应一个Section Controller。 上述所有的Cell都与一个顶级Model相关联。我们创建一个Post模型,其中包含所有cell需要的数据。

常见的错误是,对每一个单独的cell创建一个Model和一个Section Controller。在本例子中,我们将要创建一个非常复杂的顶级Model,它将包括用户,图片,评论,点赞等模型所需要的数据。


创建Model

在打开的项目中创建Post.swift

import IGListKit

final class Post: ListDiffable {

  // 1
  let username: String
  let timestamp: String
  let imageURL: URL
  let likes: Int
  let comments: [Comment]

  // 2
  init(username: String, timestamp: String, imageURL: URL, likes: Int, comments: [Comment]) {
    self.username = username
    self.timestamp = timestamp
    self.imageURL = imageURL
    self.likes = likes
    self.comments = comments
  }

}
  • 最好的做法是所有的属性全部使用let声明,这样就不会被再次修改。上文代码会被提示 “找不到Comment模型”,这里可以忽略。

上文代码已经遵循了 ListDiffable 协议,接下来在Post中实现它。

// MARK: ListDiffable

func diffIdentifier() -> NSObjectProtocol {
  // 1
  return (username + timestamp) as NSObjectProtocol
}

// 2
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
  return true
}

1.为每一篇文章派生一个唯一的标识符,因为一篇帖子不可能发帖人用户名和发帖时间都相同,所以我们通过这两个属性来生成标识符;
2. 使用 ListBindingSectionController 的一个核心要求是,如果两个Model具有相同的diffIdentifier,那么他们必定相同,以便于Section Controller可以比较这些View Model

译者注: 关于此处算法的由来,可以阅读文章头部 ‘作者演讲’,或参考文章:Instagram/IGListKit实践谈

View Models

创建Comment.swift文件,参考下列需求完成代码:

  • String类型的username
  • String类型的text
  • 实现ListDiffable

参考代码:

import IGListKit 

final class Comment: ListDiffable { 
    let username: String 
    let text: String 
    
    init(username: String, text: String) { 
        self.username = username 
        self.text = text 
    } 
    
    // MARK: ListDiffable 
    func diffIdentifier() -> NSObjectProtocol { 
        return (username + text) as NSObjectProtocol 
    } 
    
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool { 
        return true 
    }
}
 

关于 "diffIdentifier"方法的解释: 根据定义,对象实际上和标识符已经一一对应。在检测两个对象是否相等时,我们可以直接检测diffIdentifier

在上述Post模型中用到了Comment,每一条帖子的评论数是不一样的,对于每一条评论,我们都用一个Cell来展示。

不过,可能有一点新的概念。就是即使使用了ListBindingSectionController,我们仍然需要为ImageCell,ActionCell,UserCell创建Model。

每一个绑定的Section Controller 其实就想一个小型的IGListKit。Section Controller包含一个数组,里面是所有的View Model,然后把他们装配到指定的Cell里面。现在为每一个Cell创建Model

创建UserViewModel.swift:

import IGListKit

final class UserViewModel: ListDiffable {

  let username: String
  let timestamp: String

  init(username: String, timestamp: String) {
    self.username = username
    self.timestamp = timestamp
  }

  // MARK: ListDiffable

  func diffIdentifier() -> NSObjectProtocol {
    // 1
    return "user" as NSObjectProtocol
  }

  func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    // 2
    guard let object = object as? UserViewModel else  { return false }
    return username == object.username
    && timestamp == object.timestamp
  }

}

因为每一个帖子只有一个UserViewModel,所以我们可以硬编码一个标识符,这强调只使用一个此类Model和Cell

  • 为ViewModel实现编写一个好的等式方法是很重要的。任何时候发生变化,迫使模型不相等时,Cell就会被刷新。

参考UserViewModel,实现其他两个ViewModel

import IGListKit 
final class ImageViewModel: ListDiffable { 
    let url: URL 
    init(url: URL) { 
        self.url = url 
    } 
    
    // MARK: ListDiffable 
    func diffIdentifier() -> NSObjectProtocol { 
        return "image" as NSObjectProtocol 
    } 
    
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool { 
        guard let object = object as? ImageViewModel else { return false } 
        return url == object.url 
    } 
} 


final class ActionViewModel: ListDiffable { 
    let likes: Int 
    
    init(likes: Int) { 
        self.likes = likes 
    } 
    
    // MARK: ListDiffable 
    
    func diffIdentifier() -> NSObjectProtocol { 
        return "action" as NSObjectProtocol 
    } 
    
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool { 
        guard let object = object as? ActionViewModel else { return false } 
        return likes == object.likes 
    } 
} 

使用 ListBindingSectionController

现在我们有了如下ViewModel,这些都能从一篇帖子中找到:

  • UserViewModel
  • ImageViewModel
  • ActionViewModel
  • Comment

接下来我们通过使用 ListBindingSectionController 实现Model和Cell的绑定。

此Controller获取一个顶级Model (Post), 向数据源请求ViewModel数组,获取到ViewModels之后将它们绑定到Cell上。

创建 PostSectionController.swift

final class PostSectionController: ListBindingSectionController<Post>,
ListBindingSectionControllerDataSource {

  override init() {
    super.init()
    dataSource = self
  }

}

上文代码可以看到我们继承 ListBindingSectionController , 这表明PostSectionController接收Post模型。

接下来的三个方法将实现data source 协议:

  • 返回一个数组,数组中包括Post模型用到的所有ViewModel
  • 返回ViewModel的size
  • 给定ViewModel返回Cell

首先关注Post和ViewModels的转换

// MARK: ListBindingSectionControllerDataSource

func sectionController(
  _ sectionController: ListBindingSectionController<ListDiffable>,
  viewModelsFor object: Any
  ) -> [ListDiffable] {
    // 1
    guard let object = object as? Post else { fatalError() }
    // 2
    let results: [ListDiffable] = [
      UserViewModel(username: object.username, timestamp: object.timestamp),
      ImageViewModel(url: object.imageURL),
      ActionViewModel(likes: object.likes)
    ]
    // 3
    return results + object.comments
}

通过将Post模型分解为较小的模型来创建ViewModel数组;
添加必须的API返回每一个ViewModel的Size

func sectionController(
  _ sectionController: ListBindingSectionController<ListDiffable>,
  sizeForViewModel viewModel: Any,
  at index: Int
  ) -> CGSize {
  // 1
  guard let width = collectionContext?.containerSize.width else { fatalError() }
  // 2
  let height: CGFloat
  switch viewModel {
  case is ImageViewModel: height = 250
  case is Comment: height = 35
  // 3
  default: height = 55
  }
  return CGSize(width: width, height: height)
}

最后,实现API返回每个ViewModel对应的Cell。 官方已经提供了各种Cell,请在 Main.storyboard查看或者参考下文代码

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell {
        let identifier: String
        
        switch viewModel {
        case is ImageViewModel:
            identifier = "image"
        case is Comment:
            identifier = "comment"
        case is UserViewModel:
            identifier = "user"
        default:
            identifier = "action"
        }
        
        guard let cell = collectionContext?.dequeueReusableCellFromStoryboard(withIdentifier: identifier, for: self, at: index) else {
            fatalError()
        }
        
        if let cell = cell as? ActionCell {
            cell.delegate = self
        }
        
        return cell
    }

绑定Model – Cell

现在已经实现了 PostSectionController 创建不同的ViewModel,Size和Cell。Cell通过 ListBindingSectionController 接收ViewModel。

实现 ListBindable,Cell便可以接收ViewModel。

接下来完善每个Cell

完善 ImageCell.swift

import UIKit
import SDWebImage
// 1
import IGListKit

// 2
final class ImageCell: UICollectionViewCell, ListBindable {

  @IBOutlet weak var imageView: UIImageView!

  // MARK: ListBindable

  func bindViewModel(_ viewModel: Any) {
    // 3
    guard let viewModel = viewModel as? ImageViewModel else { return }
    // 4
    imageView.sd_setImage(with: viewModel.url)
  }

}

剩余Cell请自行绑定


在Controller调用

回到ViewController.swift ,在ViewDidload()之后,设置dataSource和collectionView之前,增加以下测试代码。


data.append(Post(
  username: "@janedoe",
  timestamp: "15min",
  imageURL: URL(string: "https://placekitten.com/g/375/250")!,
  likes: 384,
  comments: [
    Comment(username: "@ryan", text: "this is beautiful!"),
    Comment(username: "@jsq", text: "😱"),
    Comment(username: "@caitlin", text: "#blessed"),
  ]
))

最后,修改以下方法,替换Return值

func listAdapter(
  _ listAdapter: ListAdapter,
  sectionControllerFor object: Any
  ) -> ListSectionController {
  return PostSectionController()
}

绑定点击事件

接下来将为ActionCell的❤️(点赞按钮)绑定事件。为此,我们需要处理UIButton的点击,然后将事件转发到PostSectionController

打开ActionCell.swift加入以下代码

protocol ActionCellDelegate: class {
  func didTapHeart(cell: ActionCell)
}

在ActionCell中添加delegate

weak var delegate: ActionCellDelegate? = nil

重写awakeFromNib()添加action

override func awakeFromNib() {
  super.awakeFromNib()
  likeButton.addTarget(self, action: #selector(ActionCell.onHeart), for: .touchUpInside)
}

最后添加action的实现

func onHeart() {
  delegate?.didTapHeart(cell: self)
}

打开PostSectionController.swift,更新cellForViewModel:方法,在guard和return cell 之间添加代码:

if let cell = cell as? ActionCell {
  cell.delegate = self
}

此时编译器会报错,这时候我们在PostSectionController暂时实现协议中的方法。

final class PostSectionController: ListBindingSectionController<Post>,
ListBindingSectionControllerDataSource,
ActionCellDelegate {

//...

// MARK: ActionCellDelegate

func didTapHeart(cell: ActionCell) {
  print("like")
}

运行代码,现在可以实现点击事件了。


局部变化

每当用户点击点赞按钮时,我们需要更新帖子页面的点赞数。然而我们Model的属性都是由let定义的,因为这样更安全。如果一切都是不可变的,那我们如何改变详情页的点赞数呢。

PostSectionController是处理和存储变化的最佳场所,打开PostSectionController.swift 添加以下变量

var localLikes: Int? = nil

回到didTapHeart(cell:),我们把print()修改成具体内容。

func didTapHeart(cell: ActionCell) {
  // 1
  localLikes = (localLikes ?? object?.likes ?? 0) + 1
  // 2
  update(animated: true)
}
  1. localLIkes变量为在上一个值的基础上+1,如果localLike本身是nil则使用Model里的likes值,如果此值也不存在时,使用0作为初始值;
  2. 调用update(animated:,completion:)API, 刷新cell

为了将变化实际发送到Model,我们需要在开始的时候使用localLikes代替原先从服务器获取的likes点赞数,赋值给ActionViewModel

还是在PostSectionController.swift ,回到cellForViewModel: 方法,把ActionViewModel的初始化修改为下面的样子

// 之前的写法,直接把后端api传过来的likes赋值给ViewModel
// ActionViewModel(likes: object.likes)

ActionViewModel(likes: localLikes ?? object.likes)

编译代码,OK,功能实现。


总结

ListBindingSectionController是IGListKit最强大的功能之一,因为它进一步鼓励你设计小型、可组合的Models、Views和Controllers。

我们可以使用Section Controller 来处理任何交互,以及各种变化(例如点赞数改变),就像普通控制器一样。