В мире iOS-разработки, коллекции – это основной инструмент для представления данных в упорядоченной форме, таких как списки, таблицы, галереи фотографий или медиа-файлов. Они широко используются в различных сервисах, от социальных сетей до медицинских приложений. Однако, для создания эффективных и высокопроизводительных систем, необходимо иметь гибкое и масштабируемое решение для управления данными в коллекциях. Именно здесь на сцену выходят универсальные датасорсы.
Универсальные датасорсы предоставляют разработчикам API для доступа к данным в коллекциях без привязки к конкретному источнику и типу данных. Они обеспечивают абстракцию, которая дает разработчикам возможность работать с данными в коллекциях независимо от их происхождения или формата хранения. Это позволяет легко изменять источник данных, добавлять новые функции и поддерживать разные типы данных в приложении.
Datasource - хранилище данных для коллекции. Там находятся методы для управления данными и предоставления ячеек коллекции.
Обычно, чтобы отобразить какую-то ячейку в коллекции необходимо сделать несколько действий:
Создать ячейку
Зарегистрировать её в коллекции
Загрузить конкретную ячейку и сконфигурировать её контент
Этот процесс может быть достаточно рутинным и требует написания дублирующегося кода. Универсальные датасорсы позволяют избежать части этих проблем, и далее мы разберем как именного можно этого достичь.
В данной статье рассмотрим:
стандартные подходы к регистрации и конфигурации ячеек в коллекции
альтернативный подход с использованием ViewRegistration
пример реализации универсального датасорса (на базе Diffable Datasource)
преимущества, особенности, сложности и недостатки подхода
Регистрация и конфигурация ячеек
Стандартный (типичный) подход
Предположим что у нас есть ViewModel. Она представляет из себя enum, где у каждого кейса есть associated value в виде реальной вью модели, с помощью которой мы будем настраивать ячейку.
С таком подходе мы сначала регистрируем ячейки в коллекции, далее в методе collectionView(_:cellForItemAt:) делаем dequeue ячейки и кастим к определенному типу с последующей конфигурацией.
Как это выглядит в коде:
// Вью модель ячейки
enum ViewModel {
case tile(TileCellViewModel)
case simple(SimpleCellViewModel)
case alert(AlertCellViewModel)
}
// Регистрация ячеек в коллекцию
collectionView.register(TileCell.self, forCellWithReuseIdentifier: String(describing: TileCell.self))
collectionView.register(SimpleCell.self, forCellWithReuseIdentifier: String(describing: SimpleCell.self))
collectionView.register(AlertCell.self, forCellWithReuseIdentifier: String(describing: AlertCell.self))
// Кастинг к определенному типу ячейки через switch
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let viewModel = viewModels[indexPath.item]
switch viewModel {
case .tile(let item):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
String(describing: TileCell.self), for: indexPath) as! TileCell
// Конфигурация ячейки
return cell
case .simple(let item):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
String(describing: SimpleCell.self), for: indexPath) as! SimpleCell
// Конфигурация ячейки
return cell
case .alert(let item):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
String(describing: AlertCell.self), for: indexPath) as! AlertCell
// Конфигурация ячейки
return cell
}
}
В чем минус такого подхода? Гипотетически, если у нас будет 100 вью моделей, то метод collectionView(_:cellForItemAt:) может разрастись до невероятных размеров. С другой стороны здесь есть строгая типизация - одной вью модели соответствует одна ячейка.
Более элегантный подход
У нас есть какая-то базовая ячейка с методом конфигурации, в который передаётся вью модель по протоколу, от неё наследуются все другие ячейки. В них переопределяется метод конфигурации ячейки с кастингом к определенному типу вью модели. Далее ячейки регистрируются в коллекции по данному типу. После чего в методе collectionView(_:cellForItemAt:) делаем dequeue ячейки по типу вью модели, кастим к базовому типу ячейки и вызываем метод конфигурации, в который передаём вью модель.
Как это выглядит в коде:
// Базовая ячейка с методом конфигурации
class BaseCell: UICollectionViewCell {
func configure(viewModel: BaseViewModelProtocol) {
// override
fatalError()
}
}
// Регистрация ячеек в коллекцию
collectionView.register(TileCell.self, forCellWithReuseIdentifier: String(describing: TileCellViewModel.self))
collectionView.register(SimpleCell.self, forCellWithReuseIdentifier: String(describing: SimpleCellViewModel.self))
collectionView.register(AlertCell.self, forCellWithReuseIdentifier: String(describing: AlertCellViewModel.self))
// Dequeue ячеек по типу вью модели с кастингом к базовой ячейке и последующей конфигурацией
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let viewModel = viewModels[indexPath.item]
let identifier = String(describing: type(of: viewModel))
if let cell = collectionView.dequeueReusableCell(withIdentifier: identifier) as?
BaseCell {
cell.configure(viewModel: viewModel)
return cell
}
assertionFailure(“Cell is not registered”)
return BaseCell()
}
// Конфигурация по вью модели внутри ячейки
override func configure(viewModel: BaseViewModelProtocol) {
guard let viewModel = viewModel as? TileCellViewModel else {
return
}
// Конфигурация ячейки
}
Альтернативный подход с использованием ViewRegistration
В данном случае нам не требуется создание базовой ячейки, от которой будут наследоваться все остальные. Мы создаём требующиеся нам ячейки, регистрируем их через датасорс с единовременной настройкой их конфигурации. Сохраняем конфигурации ячеек в хранилище датасорса. После чего в методе collectionView(_:cellForItemAt:) происходит dequeue ячейки и настройка её контента с применением конфигурации из хранилища.
Как это выглядит в коде:
// Регистрация ячейки через датасорс, настройка её конфигурации
dataSource.registerCell(
itemType: TileCellViewModel.self,
cellType: TileCell.self,
configuration: { cell, indexPath, item in
cell.configure(with: item)
}
)
Что происходит в методе registerCell? Сначала формируется ключ регистрации ячейки, при инициализации в него передаётся уникальный идентификатор (тип вью модели), категория вью элемента (cell, supplementaryView или decorationView) и тип элемента в случае с supplementaryView.
private struct RegistrationKey: Hashable {
let id: ObjectIdentifier
let category: UICollectionView.ElementCategory
let elementKind: String?
}
Далее формируется ViewRegistration, в который передаётся описанная ранее настройка контента ячейки, и всё это сохраняется в хранилище по ранее созданному RegistrationKey. Последний штрих - регистрация ячейки в коллекции.
func registerCell<Item, Cell>(
itemType: Item.Type,
cellType: Cell.Type,
configuration: @escaping (Cell, IndexPath, Item) -> Void) where Cell:
UICollectionViewCell {
let key = RegistrationKey(id: .init(itemType), category: .cell, elementKind: nil)
let registration = ViewRegistration(configuration: configuration)
registrations.value[key] = registration
collectionView?.register(
registration.viewClass,
forCellWithReuseIdentifier: registration.reuseIdentifier
)
}
Далее в методе collectionView(_:cellForItemAt:) мы получаем нужную нам вью модель по IndexPath, определяем её тип, формируем RegistrationKey и вытаскиваем по нему сохраненную конфигурацию из хранилища.
let itemType = type(of: itemRepresentation(item))
let key = RegistrationKey(
id: ObjectIdentifier(itemType),
category: .cell,
elementKind: nil
)
guard let registration = registrations.value[key] else {
assertionFailure("No registration for item of type: \(itemType)")
return UICollectionViewCell()
}
После того как нам удалось достать из хранилища нужную регистрацию мы делаем dequeue ячейки по идентификатору из регистрации и вызываем метод настройки ячейки.
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: registration.reuseIdentifier,
for: indexPath
)
registration.configure(cell, indexPath: indexPath, item: item)
return cell
Здесь следует обратить внимание на замыкание itemRepresentation, с помощью которого мы определяли тип вью модели. Оно задаётся при инициализации датасорса.
private(set) lazy var dataSource = UniversalDiffableDataSource<AnyHashable>(
collectionView: collectionView,
itemRepresentation: { $0.base }
)
AnyHashable здесь - это тип вью моделей. По своей сути AnyHashable является контейнером для Hashable объектов, скрывающий тип обернутого значения. В данном случае AnyHashable позволяет нам хранить в датасорсе любые вью модели, которые соответствуют констрейнту Hashable. А itemRepresentation - это правило "распаковки" контейнера, с помощью которого мы получаем нужный нам тип вью модели.
Подробнее с AnyHashable можно ознакомиться в документации Apple.
Сведем у минимуму количество кода при использовании данного подхода
Метод регистрации ячейки в датасорс можно сделать очень простым, чтобы сократить количество кода.
Вместо подобного:
dataSource.registerCell(
itemType: TileCellViewModel.self,
cellType: TileCell.self,
configuration: { cell, indexPath, item in
cell.configure(with: item)
}
)
можно реализовать метод registerCellType(:), в который необходимо будет пробрасывть только тип ячейки:
dataSource.registerCellType(SimpleCell.self)
для этого необходимо реализовать следующий протокол у ячейки:
protocol ConfigurableView: UIView {
associatedtype ViewModel
func configure(with viewModel: ViewModel)
}
и добавить пару новых generic-методов в наш датасорс:
func registerCell<Item, Cell>(itemType: Item.Type, cellType: Cell.Type) where Cell: UICollectionViewCell, Cell: ConfigurableView, Cell.ViewModel == Item {
registerCell(itemType: itemType, cellType: cellType) { cell, _, item in
cell.configure(with: item)
}
}
func registerCellType<Cell>(_ cellType: Cell.Type) where Cell: UICollectionViewCell, Cell: ConfigurableView {
registerCell(itemType: Cell.ViewModel.self, cellType: cellType) { cell, _, item in
cell.configure(with: item)
}
}
Пример реализации универсального датасорса
С подробным примером реализации универсального датасорса на основе UICollectionViewDiffableDataSource с таким подходом можно ознакомиться по ссылке на GitHub.
CellRegistration в iOS 14
В iOS 14 Apple добавили свою функциональность аналогичную описанной ранее - CellRegistration. С ней мы можем заранее создать конфигурацию ячейки и в методе collectionView(_:cellForItemAt:) получить готовую настроенную этой конфигурацией ячейку.
let simpleConfig = UICollectionView.CellRegistration<TileCell, String> { (cell, indexPath, model) in
cell.title.text = model
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let model = "Cell \(indexPath.row)"
return collectionView.dequeueConfiguredReusableCell(
using: simpleConfig,
for: indexPath,
item: model
)
}
Минус такого подхода заключается в том, что CellRegistration есть только для коллекций. Если вы планируете использовать таблицы, то такой вариант вам не подойдет. Более того для регистрации supplementaryView нужно использовать отдельный SupplementaryRegistration.
Подробнее с данным механизмом можно ознакомиться в документации Apple.
Достоинства использования универсальных датасорсов с ViewRegistration
Нет переборов, как в случае с типичным подходом, где использовался enum для вью модели.
Нет кастов. Не нужно явно кастить к определенному типу ячейки или типу вью модели.
Нет необходимости создавать базовые ячейки и "пустые" протоколы для вью моделей, как в случае со вторым подходом.
Более безопасно. Есть четкая связь модели и вью (ячейки).
Универсальность и повышение переиспользуемости кода. Можно создать один универсальный датасорс, который будет использоваться в разных частях приложения или даже в разных проектах. Это позволит экономить время и усилия на написание одного и того же кода для разных коллекций.
Недостатки подхода
Существенных недостатков мной обнаружено не было, поэтому буду рад комментариям и обратной связи.
До новых встреч!
ws233
Цикл статей о той же идее, но со слегка отличной реализацией.
В частности тут про источник данных. Но есть там статьи и про применение этой же идеи и к делегатам.
Минусы в Вашей реализации имеются. Попробую бегло перечислить, Вы можете сами более детально сравнить с тем, что описывается у нас и сделать свои выводы.
Вы сделали жесткое соответствие типа вьюмодели и типа вьюхи, как один к одному. Этим Вы внесли зависимость вьюхи (представления) от вьюмодели (данных). И наоборот: вьюмодель должна знать о типе ячейки, где она будет отображаться (`$0.base`). Не зря все архитектуры стараются строить на трех элементах, где средний – это прослойка, необходимая для того, чтобы убрать зависимость как представления от данных, так и данных от представления. Углубляться, какие проблемы тут будут не стану, скажу лишь, что добавить такое разделение не сложно. В итоге вы получите связь между вью и вьюмоделью – многие ко многим, что гораздо гибче и удобнее в использовании.
Вы явно не указали, что такое `itemRepresentation: { $0.base }`. Но я предположу, что это некое занесенное во вьюмодель знание о вьюхе, на которую эту вьюмодель надо смэппить. Это снова нарушение принципа из пункта 1. Сложно с этим будет и так лучше не делать. Вам это знание нужно из вьюмодели выносить. Вся информация и все алгоритмы, отвечающие за соответствие должны находиться в одном месте – мы этот класс назвали картой соответствия. Создавая разные карты, Вы можете реализовывать разные алгоритмы соответствия. Можно – класс на класс, можно объект – на класс, объект – на объект, класс – на объект, можно вешать сложную логику по выбору объекта вьюхи или ее класса через замыкания. И все это будет отдельно – менять ни вью, ни вьюмодель не придется. У вас же вся эта ответственность, которую Вы называете ViewRegistration, размазана по многим классам (при необходимости изменения логики соответствия придется менять все перечисленные классы, а их будет ооооочень много):
регистрация – в источнике данных;
идентификатор соответствия – во вьюмодели;
замыкание выбора соответствия – вообще по коду непонятно где. Но я предположу, что в контроллере.
Не смешивайте регистрацию типа ячейки в таблице и регистрацию вьюмодели на ячейку. Это снова нарушение п.1. И это вам не дает видеть и понимать ясно разные ответственности уровня вью, уровня вьюмодели и промежуточного уровня между ними, который осуществляет мэппинг одного в другое без жесткой связи между ними.
Отсутствие жесткой связи в этом месте очень важно. Регистрируйте отдельно. Хоть это и лишний код, но Вы сможете переиспользовать одну таблицу со всеми зарегистрированными ячейками на всех своих экранах. Кажется, что экономия тут больше, чем в Вашем случае. Не настаиваю.
При этом обратите внимание, что Вы чувствуете, что регистрация, это некая ответственность, которую необходимо инкапсулировать. Но инкапсулируете Вы 2 куска разных ответственностей, при этом оставляя куски одной из них размазанными по другим классам и другим ответственностям. Это отсылка к п.2.
В общем, поглядите внимательно на все 3 пункта. Они очень взаимосвязаны и непонимание в одном, ведет к нарушениям в других. Если будет интересно, с радостью подискутирую с Вами по тем или иным моментам. Особенно, буду рад, если Вы и в нашей реализации найдете узкие места и подскажите, как их сделать более эффективными и удобными.