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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В мире 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 для вью модели.

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

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

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

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

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

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

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

Источник: https://habr.com/ru/companies/tensor/articles/763582/


Интересные статьи

Интересные статьи

В этом посте я расскажу про мониторинг  — процесс сбора и анализа информации для принятия обоснованных управленческих решений и достижения показателей назначения. Начну с того, насколько стоит по...
С февраля западные страны и отдельные компании наложили более 6 тысяч санкций на бизнес в РФ и РБ. Это не обошло и рынок мобильных приложений. Мы занимаемс...
При написании стилей необходимо сразу учитывать, что контент страницы может быть динамическим, чтобы не возникла ситуация, где мы добавили чуть больше текста, или уменьшили ширину экрана, и вёрстка по...
15 октября в 17:00 «Технократия» приглашает на открытый онлайн-митап, посвященный iOS-разработке. В этот раз поговорим про то, что такое Swift Package Manager и для чего он нужен в iOS разработке. ...
Недавно я написал первый пост о том, как начал переделывать обычные светодиодные светильники в диммируемые. Многим не понравилось что свой диммер я делаю на базе микрокон...