Smart client team pu Xin
Lists in most iOS apps are implemented through UICollectionView. Although it has powerful performance and a rich AND easy-to-use API, there are still some shortcomings. Common examples are:
- If called when updating the page
reloadData
Will cause the screen flicker; - call
performBatchUpdates(_:completion:)
Handwritten updater is more difficult.
IGListKit was born to solve these problems.
Introduction to the
Take a look at the architecture of IGListKit:
Instead of typing the UICollectionView directly into the datasource, IGListKit builds an Adapter on it and initializes the section Controller for each type of object. The latter is responsible for building and maintaining the corresponding cell. And each section controller is also a section view, it also has supplementary View and decoration View.
use
- First we create an Adapter, usually directly in the corresponding View Controller. We also need to set its data source and updater.
class ViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
lazy var adapter: ListAdapter = {
let updater = ListAdapterUpdater(a)let adapter = ListAdapter(updater: updater,
viewController: self,
workingRangeSize: 1) adapter.collectionView = collectionView
adapter.dataSource = SuperHeroDatasource(a)return adapter
}()
override func viewDidLoad(a) {
super.viewDidLoad()
_ = adapter
}
}
Copy the code
The creation of an Adapter requires three attributes:
- updaterThis object is used to update rows and sections. Usually we’ll just use the default implementation;
- view controller: Is usually the View Controller that holds the adapter. It can be set to another object or even left empty;
- workingRangeSizeA working range represents a section controller of a range that is not currently visible but is near the edge of the screen. Usually used to prepare content, such as downloading images in advance.
- And then we need to build our owndata modelAnd follow
ListDiffable
The agreement.
class SuperHero {
private var identifier: String = UUID().uuidString
private(set) var firstName: String
private(set) var lastName: String
private(set) var superHeroName: String
private(set) var icon: String
init(firstName: String.lastName: String.superHeroName: String.icon: String) {
self.firstName = firstName
self.lastName = lastName
self.superHeroName = superHeroName
self.icon = icon
}
}
extension SuperHero: ListDiffable {
func diffIdentifier(a) -> NSObjectProtocol {
return identifier as NSString
}
func isEqual(toDiffableObject object: ListDiffable?). -> Bool {
guard let object = object as? SuperHero else {
return false
}
return self.identifier = = object.identifier
}
}
Copy the code
A ListDiffable object needs to implement two functions:
-
DiffIdentifier: A unique object that can be used to identify and compare models
-
IsEqual (toDiffableObject:) : Compares two objects
-
Then we will connect the Datamodel to the Adapter. The Adapter is responsible for maintaining the datasource and telling the list how to display them. In IGListKit, the datasource is an object that follows the ListAdapterDataSource protocol.
class SuperHeroDataSource: NSObject.ListAdapterDataSource {
func objects(for listAdapter: ListAdapter)- > [ListDiffable] {
return [SuperHero(firstName: "Peter",
lastName: "Parker",
superHeroName: "SpiderMan",
icon: "🕷"),
SuperHero(firstName: "Bruce",
lastName: "Wayne",
superHeroName: "Batman",
icon: "🦇"),
SuperHero(firstName: "Tony",
lastName: "Stark",
superHeroName: "Ironman",
icon: "🤖"),
SuperHero(firstName: "Bruce",
lastName: "Banner",
superHeroName: "Incredible Hulk",
icon: "🤢")]}func listAdapter(_ listAdapter: ListAdapter.sectionControllerFor object: Any) -> ListSectionController {
return SuperHeroSectionController()}func emptyView(for listAdapter: ListAdapter) -> UIView? {
return nil}}Copy the code
There are three functions involved:
-
Objects (for:) -> [ListDiffable] : Returns an array of models that need to be managed by adapter
-
ListAdapter (_:sectionControllerFor:) -> SectionController: Returns the corresponding sectionController for different model types
-
emptyView(for:) -> UIView? : Empty page that should be displayed when the Model array is empty
-
Finally, the section Controller describes how to display the corresponding cell. Splitting code through section Controllers, where different datamodel types describe different cells, is a huge optimization point compared to UICollectionView.
class SuperHeroSectionController: ListSectionController {
var currentHero: SuperHero?
override func didUpdate(to object: Any) {
guard let superHero = object as? SuperHero else {
return
}
currentHero = superHero
}
override func numberOfItems(a) -> Int {
return 1 // One hero will be represented by one cell
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
let nibName = String(describing: SuperHeroCell.self)
guard let ctx = collectionContext, let hero = currentHero else {
return UICollectionViewCell()}let cell = ctx.dequeueReusableCell(withNibName: nibName,
bundle: nil,
for: self,
at: index)
guard let superHeroCell = cell as? SuperHeroCell else {
return cell
}
superHeroCell.updateWith(superHero: hero)
return superHeroCell
}
override func sizeForItem(at index: Int) -> CGSize {
let width = collectionContext?.containerSize.width ?? 0
return CGSize(width: width, height: 50)}}Copy the code
There are four approaches involved here
- didUpdate(to:)This method is called when the section Controller gets data
- cellForItem(at:) -> UICollectionViewCell: According to index or corresponding data and set the corresponding cell and return
- sizeForItem(at:) -> CGSize: Returns the size of the corresponding cell according to index
- numberOfItem() -> Int: How many cells are displayed in the current section controller
The result is as follows:
The principle of
With an overview of how IGListKit works, let’s take a look at the basics. In one sentence: Insert, remove, Update, and move are calculated by comparing the two datasource data points, so as to notify UICollectionView performBatchUpdates(_: Completion :). This way you can get the performance benefits of differential updates without having to write updater by hand. A detailed implementation of the Diff algorithm can be found in this article.
The core Diff code and update logic are as follows:
practice
The IGListKit code is actually fairly easy to read and understand, but in practice we ran into two problems. Here’s a closer look at what the problem is and how to fix it.
Delta update
First take a look at the live video:
We noticed that when we got back to the home page of the app, the entire list flickered with updates, as if the differential update wasn’t working as expected.
Analysis of the
Before analyzing this problem, let’s first determine the page structure:
The corresponding dataSource is shown below:
func buildItem(studyInfo: StudyInfo? .recommendRoom: StudyRoom? .studyRooms: [StudyRoom]?)- > [HomeListSection] {
return [
.title(Title()),
.personalInfo(PersonalInfo(
studyInfo: studyInfo,
recommendRoom: recommendRoom,
updateGradeHandler: { [weak self] in
if let self = self {
self.loadData()
}
})),
.studyRoomList(StudyRoomList(studyRooms: studyRooms))
]
}
Copy the code
final class PersonalInfo: NSObject {
var studyInfo: StudyInfo?
var recommendRoom: StudyRoom?
var updateGradeHandler: (() -> Void)?
init(studyInfo: StudyInfo? .recommendRoom: StudyRoom? .updateGradeHandler: (() - >Void)?) {
self.studyInfo = studyInfo
self.recommendRoom = recommendRoom
self.updateGradeHandler = updateGradeHandler
super.init()}}extension PersonalInfo: ListDiffable {
func diffIdentifier(a) -> NSObjectProtocol {
"\ [Self.self)" as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?). -> Bool {
guard let personalInfo = object as? PersonalInfo else {
return false
}
let newStudyInfo = personalInfo.studyInfo
let newRecommendRoom = personalInfo.recommendRoom
return ((newStudyInfo = = studyInfo) && (newRecommendRoom = = recommendRoom))
}
}
Copy the code
There seems to be no problem, ListDiffable is implemented correctly. Look again at where Diff is used in IGListKit:
Both fromObjects and toObjects here are one-dimensional arrays, so Diff occurs on PersonalInfo, which is Section level. That is, Diff results are only available on the Section dimension, so updates are also updated by Section, calling reloadSections(_:). And this Section is:
So even though we are only updating the data of the recommendation bits, it will also cause the blue card on the left to be updated because they belong to the same Section.
To solve
Most IGListKit tutorials do not address how to implement cell-level difference updates. Instead, it is recommended that the datasource be flat, i.e. the numberOfItem() -> Int for each section Controller keeps the default value 1. In this way, the 1:1 relationship between sections and cells is implemented to simulate cell-level updates.
There is a problem with this use for our application scenario:
It is quite natural to use two cells for this implementation. But if you choose to use two sections to realize, the default UICollectionViewFlowLayout does not support this layout (the width of the section is the width of the crossAxis by default). We chose to use ListCollectionViewLayout, but this would limit our ability to customize the layout in the future.
Therefore, in this case, we can choose to use ListBindingSectionController for cell level of the ability of delta update. The following is a brief introduction of its use:
The other uses are no different, except for two changes:
- Data Model is being implemented
ListDiffable
The time is different:
func isEqual(toDiffableObject object: IGListDiffable?). -> Bool {
return true
}
Copy the code
- Section Controller inherits
ListBindingSectionController
And implementListBindingSectionControllerDataSource
The agreement. The sample code looks like this:
func sectionController(_ sectionController: IGListBindingSectionController.viewModelsFor object: Any)- > [IGListDiffable] {
guard let month = object as? Month else { return[]}let date = Date(a)let today = Calendar.current.component(.day, from: date)
var viewModels = [IGListDiffable]()
viewModels.append(MonthTitleViewModel(name: month.name))
for day in 1..<(month.days + 1) {
let viewModel = DayViewModel(
day: day,
today: day = = today,
selected: day = = selectedDay,
appointments: month.appointments[day]?.count ?? 0
)
viewModels.append(viewModel)
}
for appointment in month.appointments[selectedDay] ?? [] {
viewModels.append(appointment)
}
return viewModels
}
func sectionController(_ sectionController: IGListBindingSectionController.cellForViewModel viewModel: Any.at index: Int) -> UICollectionViewCell {
let cellClass: AnyClass
if viewModel is DayViewModel {
cellClass = CalendarDayCell.self
} else if viewModel is MonthTitleViewModel {
cellClass = MonthTitleCell.self
} else {
cellClass = LabelCell.self
}
return collectionContext?.dequeueReusableCell(of: cellClass, for: self, at: index) ?? UICollectionViewCell()}func sectionController(_ sectionController: IGListBindingSectionController.sizeForViewModel viewModel: Any.at index: Int) -> CGSize {
guard let width = collectionContext?.containerSize.width else { return .zero }
if viewModel is DayViewModel {
let square = width / 7.0
return CGSize(width: square, height: square)
} else if viewModel is MonthTitleViewModel {
return CGSize(width: width, height: 30.0)}else {
return CGSize(width: width, height: 55.0)}}Copy the code
Here the viewModels themselves are also [ListDiffable], so IGListKit Diff again at this layer to implement cell-level updates.
Used with RxSwift
First look at the following code:
We found that we can’t change an element in an array of structs. This is because struct is immutable by default, and we cannot change its elements in-place. In this case, the common idea is to use a class instead and gain the ability to modify attributes in-place by reference.
This does work, but it causes new problems: For IGListKit, internal calls to IGListDiffExperiment require two array instances to be passed in, and in-place changes cause IGListKit to observe no changes (since the array is the same instance before and after modification).
Analysis of the
IGListKit is a data-driven list, but it is not a bi-bound, responsive list, which requires that any changes we make must be updated to the data source and made aware by adapter. In other words, the entire flow of data must be top-down.
A common approach is:
- Element notifies the View Model of the fields that need to be modified
- The View Model finds the corresponding index based on the element of the notification and records it
- The View Model reconstructs the data source and applies the change to the element corresponding to the index recorded in the previous step
- New data source construction completed, notification
IGListKit
updated
But it was still a hassle, and we wanted to update the data in a more intuitive and efficient way.
To solve
First we need to review IGListKit’s Diff algorithm. We know that diffIdentifier is used as the unique identifier of data, so what is the role of isEqual(toDiffableObject)?
By reading the source code, you can see that move, DELETE, and INSERT can be computed using diffIdentifier, while update can be computed using isEqual(toDiffableObject).
Thus, once we understand how IGListKit Diff updates work, we can invalidate isEqual(toDiffableObject), which returns true by default. In this way, there will be no update in IGListKit’s Diff result, and the update can be handed over to RxSwift. The only thing to notice is that we need to cancel the RxSwift subscription in prepareForReuse. Here is the sample code:
class Model {
let name = "model1"
let field = BehaviorRelay(value: "data")}extension Model: ListDiffable {
func diffIdentifier(a) -> NSObjectProtocol {
name as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?). -> Bool {
return true}}class ModelCell: UICollectionReusableView {
let label = UILabel(a)var bag = DisposeBag(a)func prepareForReuse(a) {
super.prepareForReuse()
bag = DisposeBag()}}extension ModelCell {
func bindViewModel(model: Model) {
model
.field
.subscribe(onNext: { field in
self?.label.text = field
})
.dispose(by: bag)
}
}
Copy the code
It is important to note that the Section Controller, we still need to use ListBindingSectionController to turn on the cell level delta updates.
conclusion
By reading the source code for IGListKit and digging into how it works, we solved two typical problems in practice. Proper use of IGListKit forces clearer organization of business code, good default animation, and superior performance. It’s recommended.