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=
Blog: https://www.xuzhengke.cn
前言(译者注):
译者目前属于初级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.为每一篇文章派生一个唯一的标识符,因为一篇帖子不可能发帖人用户名和发帖时间都相同,所以我们通过这两个属性来生成标识符;
- 使用 ListBindingSectionController 的一个核心要求是,如果两个Model具有相同的diffIdentifier,那么他们必定相同,以便于Section Controller可以比较这些View Model
译者注: 关于此处算法的由来,可以阅读文章头部 ‘作者演讲’,或参考文章:[Instagram/IGListKit实践谈
](https://www.jianshu.com/p/44bda1421757)
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<Post> , 这表明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)
}
localLIkes变量为在上一个值的基础上+1,如果localLike本身是nil则使用Model里的likes值,如果此值也不存在时,使用0作为初始值;
调用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 来处理任何交互,以及各种变化(例如点赞数改变),就像普通控制器一样。