浅谈iOSFeed流布局之IGListKit.
前言
翻墙的小伙伴们都有接触到过Instagram这个APP吧,我们国内的小红书最开始就是仿照他的布局和设计风格。可惜不久前小红书被苹果下架了。紧接着,微博孵化了一个绿洲的APP,整个风格完全就是Instagram的翻版,连图片放大缩小,评论等一模一样的交互。今天,我就带来给大家揭秘Instagram的布局架构————IGListKit。
初见社交feed流
在初次接触社交类型的需求时候,看到YYKit作者写的微博demo里面,列表滑动还能保持50到60的FPS,但是也有稍许卡顿。而造成卡顿的原因就和CPU资源消耗相关了。
- 对象创建
通常大型的项目中我们很少会用到XIB或者Storyboard 创建,因为他消耗的资源比直接用代码创建对象大很多,所以在这个层级上我们尽量用代码去撸,然后如果对象不涉及UI操作,我们尽量放到后台线程中去创建。 - 对象调整
UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。 - 布局计算
这个就是整个项目优化最大的特点了。我们一般在网络请求下来,给model赋值的时候,就提前计算好视图布局的高度和约束。减少频繁的计算和调整可以让滑动更流畅。 - 文本的计算
在社交的feed布局中文本往往需要正则去匹配,我们会有@,#,https等类型,需要颜色高亮或者文本高度的计算。YYText对于文本的处理有强大的功能。
初见IGListKit
在我们构建传统的feed流的时候,布局可能存在各种组合情况,纯文字,纯图片,纯视频,文字+图片,文字+视频+评论,一张图片,3张图片,等等。或者过段时间再迭代加上一个点赞分享,或者关注,大V动图。。。这个时候我们用传统的tableview就会有些吃力了,而且是牵一发而动全身了。
IGListKit是Instagram出的一个基于UICollectionView的数据驱动UI框架,目前在github上有10k+ star,被充分利用在Instagram App上。
它不是由 Swift 开发的一个库,依然使用了 Objective-C 为主要语言开发。我想是因为 Instagram 作为已经七年以上的 App,也就是一定是一个 Objective-C 项目。它运作良好,没有特殊原因没必要用 Swift 重写。所以它的核心 UI 组件库 IGListKit 依然是 Objective-C 也是正常的。
虽然 IGListKit 不是 Swift 写的,但是你不得不忽视 Swift 在这个库中的存在感,列举一二:
- IGListKit 对 Swift 项目提供完整支持,简单来说,nullable/nonnull、genercis 的采用会让你用它时感受不到背后是 Objective-C 还是 Swift 在提供支持;
- IGListKit 的 demo 是 100% Swift 写的;
- IGListKit 的文档的示例代码也是 100% Swift 代码。
综上,IGListKit 是一个很典型的使用 Objective-C 开发的,但却是个偏向使用 Swift 语言开发者的一个 UI 组件库。
IGListKit 初体验
首先要介绍几个概念:
-
ListAdapter
适配器,它将collectionview的dataSource和delegate统一了起来,负责collectionView数据的提供、UI的更新以及各种代理事件的回调。
-
ListSectionController
一个 section controller是一个抽象UICollectionView的section的controller对象,指定一个数据对象,它负责配置和管理 CollectionView 中的一个 section 中的 cell。这个概念类似于一个用于配置一个 view 的 view-model:数据对象就是 view-model,而 cell 则是 view,section controller 则是二者之间的粘合剂。
-
ListDiffable
简单来说这个算法就是计算tableView或者collectionView前后数据变化增删改移关系的一个算法,时间复杂度是O(n),算是IGListKit的特色特点之一。其实这个算法单独拿出来不只可以计算collectionView模型,稍加改造,也适用于其他模型或者文件的变化,使用的是Paul Heckel 的A technique for isolating differences between files 的算法,这份paper是收费。不过这并不妨碍我们直接看源码,我们可以看一下IGListDiff.mm文件,该算法使用C++来编写。主要是通过hashtable和新旧的两个数组结构:
运用IGListKit 仿绿洲(因为小红书下架了)简单demo
下面是写好的简单首页布局
1. ViewCell的分解
- UserInfoCell :负责用户的头像,昵称,发布时间等--->对应的UserInfoCellModel
- ContentCell :负责用户发布的内容--->对应的ContentCellModel
- PhotosCell :负责用户发布的图片--->对应的PhotosCellModel
- CommentCell:负责用户发布的评论--->对应的CommentCellModel
下面是UserInfoCell对应的示例代码:
class UserInfoCell: UICollectionViewCell {
lazy var avatar:UIImageView = {
let avatar = UIImageView.init()
avatar.layer.cornerRadius = 20.0
avatar.layer.masksToBounds = true
avatar.isUserInteractionEnabled = true
avatar.image = UIImage.init(named: "avaterplaceholder")
return avatar
}()
lazy var nameLab:UILabel = {
let Lab = UILabel.init()
Lab.textAlignment = NSTextAlignment.left
Lab.font = UIFont.boldSystemFont(ofSize: 15)
Lab.textColor = ColorTitle
return Lab
}()
lazy var TimeLab:UILabel = {
let Lab = UILabel.init()
Lab.textAlignment = NSTextAlignment.left
Lab.font = UIFont.pingFangTextFont(size: 10)
Lab.textColor = ColorDarkGrayTextColor
return Lab
}()
var viewModel: UserInfoCellModel?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = ColorBackGround
setupUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK:- UI
private func setupUI(){
self.contentView.addSubviews([avatar, nameLab, TimeLab])
avatar.snp.makeConstraints { (make) in
make.left.equalTo(15)
make.top.equalTo(15)
make.width.height.equalTo(UserInfoCellModel.avatarSize.width)
}
nameLab.snp.makeConstraints { (make) in
make.top.equalTo(15)
make.left.equalTo(avatar.snp.right).offset(10)
make.right.equalTo(-100)
make.height.equalTo(20)
}
TimeLab.snp.makeConstraints { (make) in
make.top.equalTo(nameLab.snp.bottom)
make.left.equalTo(avatar.snp.right).offset(10)
make.right.equalTo(-100)
make.height.equalTo(20)
}
}
}
extension UserInfoCell: ListBindable {
func bindViewModel(_ viewModel: Any) {
guard let viewModel = viewModel as? UserInfoCellModel else { return }
self.viewModel = viewModel
avatar.kf.setImage(urlString: viewModel.avatar)
nameLab.text = viewModel.userName
TimeLab.text = viewModel.times
}
}
2. ViewModel的实现
要使用IGListKit,我们的ViewModel必须遵守ListDiffable协定:
func diffIdentifier() -> NSObjectProtocol { return identifier as NSObjectProtocol }
用于区别定义项目
func isEqual(toDiffableObject object: ListDiffable?) -> Bool { if self === object { return true } guard let obj = object as? CommentCellModel else { return false } return self.identifier == obj.identifier }
用于区分两者是否为同一个Model
下面是UserInfoCellModel对应的示例代码:
class UserInfoCellModel: ListDiffable{
var avatar: String?
var userName: String?
var times: String?
let identifier: String
var size: CGSize = CGSize.init(width: ScreenW, height: 61)
static var avatarSize: CGSize = CGSize(width: 36, height: 36)
init(model: homeModel) {
identifier = model.user?.nickname ?? "---"
self.avatar = model.user?.image
self.userName = model.user?.nickname
self.times = Date.timeStampChangeDate(model.note_list.first?.time ?? 0)
}
func diffIdentifier() -> NSObjectProtocol {
return identifier as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
if self === object {
return true
}
guard let obj = object as? UserInfoCellModel else { return false }
return self.identifier == obj.identifier
}
}
3. Controller的实现
我们需要生成UICollectionView,ListAdapter和ListAdapterUpdater。
ListAdapterUpdater 负责 row 和 section 的更新,而 ListAdapter 负责 CollectionView。
let collectionView: UICollectionView = {
let flow = UICollectionViewFlowLayout()
let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flow)
collectionView.backgroundColor = ColorBackGround
return collectionView
}()
lazy var adapter: ListAdapter = {
let adapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self)
adapter.collectionView = collectionView
adapter.dataSource = self
return adapter
}()
4. 使用 ListBindingSectionController
接下来我们通过使用 ListBindingSectionController 实现 Model 和 Cell 的绑定。
此 Controller 获取一个FeedViewModel, 向数据源请求 ViewModel 数组,获取到 ViewModels 之后将它们绑定到 Cell 上。
以下是FeedViewModel的示例代码:
class FeedViewModel: ListDiffable {
var viewModels: [Any]
let identifier: String
init(model: homeModel) {
identifier = model.track_id ?? "---"
var sections: [Any] = []
sections.append(UserInfoCellModel.init(model: model))
sections.append(ContentCellModel.init(model: model))
sections.append(PhotosCellModel.init(model: model))
model.comment_list.forEach { (model) in
sections.append(CommentCellModel.init(model: model))
}
viewModels = sections
}
func diffIdentifier() -> NSObjectProtocol {
return identifier as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
if self === object {
return true
}
guard let obj = object as? FeedViewModel else { return false }
return self.identifier == obj.identifier
}
}
在FeedListSectionController实现代理和数据
以下是FeedListSectionController的示例代码:
class FeedListSectionController: ListBindingSectionController<ListDiffable>,ListBindingSectionControllerDataSource,ListBindingSectionControllerSelectionDelegate {
override init() {
super.init()
dataSource = self
selectionDelegate = self
}
override func didUpdate(to object: Any) {
super.didUpdate(to: object)
}
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, didSelectItemAt index: Int, viewModel: Any) {
}
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, viewModelsFor object: Any) -> [ListDiffable] {
guard let data = object as? FeedViewModel else { return [] }
return data.viewModels as! [ListDiffable]
}
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell & ListBindable {
switch viewModel {
case is UserInfoCellModel:
guard let cell = collectionContext?.dequeueReusableCell(of: UserInfoCell.self, withReuseIdentifier: nil, for: self, at: index) as? UserInfoCell else { fatalError() }
return cell
case is ContentCellModel:
guard let cell = collectionContext?.dequeueReusableCell(of: ContentCell.self, withReuseIdentifier: nil, for: self, at: index) as? ContentCell else { fatalError() }
return cell
case is PhotosCellModel:
guard let cell = collectionContext?.dequeueReusableCell(of: PhotosCell.self, withReuseIdentifier: nil, for: self, at: index) as? PhotosCell else { fatalError() }
return cell
case is CommentCellModel:
guard let cell = collectionContext?.dequeueReusableCell(of: CommentCell.self, withReuseIdentifier: nil, for: self, at: index) as? CommentCell else { fatalError() }
return cell
default:
fatalError()
}
}
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, sizeForViewModel viewModel: Any, at index: Int) -> CGSize
{
switch viewModel {
case is UserInfoCellModel:
return (viewModel as! UserInfoCellModel).size
case is ContentCellModel:
return (viewModel as! ContentCellModel).size
case is PhotosCellModel:
return (viewModel as! PhotosCellModel).size
case is CommentCellModel:
return (viewModel as! CommentCellModel).size
default:
return CGSize.zero
}
}
}
-
当然在点赞的值的变化上,我们可以直接在上一个值的基础上 + 1,然后在delegate里调用 update(animated:,completion:blush:API, 刷新 cell。
-
ListBindingSectionController 是 IGListKit 最强大的功能之一,因为它进一步鼓励你设计小型、可组合的 Models、Views 和 Controllers。
-
我们可以使用 Section Controller 来处理任何交互,以及各种变化 (例如点赞数改变),就像普通控制器一样。