Как стать автором
Обновить
31.91
Тензор
Разработчик системы Saby

Универсальные датасорсы в iOS-разработке

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров4.3K

В мире iOS-разработки, коллекции – это основной инструмент для представления данных в упорядоченной форме, таких как списки, таблицы, галереи фотографий или медиа-файлов. Они широко используются в различных сервисах, от социальных сетей до медицинских приложений. Однако, для создания эффективных и высокопроизводительных систем, необходимо иметь гибкое и масштабируемое решение для управления данными в коллекциях. Именно здесь на сцену выходят универсальные датасорсы.

Универсальные датасорсы предоставляют разработчикам API для доступа к данным в коллекциях без привязки к конкретному источнику и типу данных. Они обеспечивают абстракцию, которая дает разработчикам возможность работать с данными в коллекциях независимо от их происхождения или формата хранения. Это позволяет легко изменять источник данных, добавлять новые функции и поддерживать разные типы данных в приложении.

Datasource - хранилище данных для коллекции. Там находятся методы для управления данными и предоставления ячеек коллекции.

Обычно, чтобы отобразить какую-то ячейку в коллекции необходимо сделать несколько действий:

  1. Создать ячейку

  2. Зарегистрировать её в коллекции

  3. Загрузить конкретную ячейку и сконфигурировать её контент

Этот процесс может быть достаточно рутинным и требует написания дублирующегося кода. Универсальные датасорсы позволяют избежать части этих проблем, и далее мы разберем как именного можно этого достичь.

В данной статье рассмотрим:

  • стандартные подходы к регистрации и конфигурации ячеек в коллекции

  • альтернативный подход с использованием 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 для вью модели.

  • Нет кастов. Не нужно явно кастить к определенному типу ячейки или типу вью модели.

  • Нет необходимости создавать базовые ячейки и "пустые" протоколы для вью моделей, как в случае со вторым подходом.

  • Более безопасно. Есть четкая связь модели и вью (ячейки).

  • Универсальность и повышение переиспользуемости кода. Можно создать один универсальный датасорс, который будет использоваться в разных частях приложения или даже в разных проектах. Это позволит экономить время и усилия на написание одного и того же кода для разных коллекций.

Недостатки подхода

Существенных недостатков мной обнаружено не было, поэтому буду рад комментариям и обратной связи.

До новых встреч!

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии1

Публикации

Информация

Сайт
saby.ru
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия