Все преимущества MFS
в полной мере раскрываются при работе с ячейками таблицы.
Цель MFS
: рационально распределять обязанности между категориями ячейки, чтобы обеспечить максимальную контролируемость всех процессов и плавность в 60 FPS при прокрутке таблицы на любых устройствах.
(⚠️) Дорогие коллеги, встроенный редактор кода в Хабре является непредсказуемым.
Вставки кода во время предосмотра и по итогу могут отличаться.
Это значит, что могут появиться отступы и переносы в самых необычных местах, что мешает при чтении.
Если вам это причиняет дискомфорт, приглашаю вас на мой гитхаб, где подобных эксцессов нет.
Причина создания паттерна
MFS
был создан потому, что стандартные методы UITableViewCell
, которые нам предлагают переопределить, не отвечают современным потребностям сложных интерфейсов.
Вопросы следующего характера, перечисленные ниже, по-прежнему оставались не закрытыми:
В каком методе скрывать ненужные
subviews
у переиспользованной ячейки ?В каком методе производить перерасчет размеров и координат ?
В каком методе осуществлять вставку контент в
subviews
ячейки ?
В конечном итоге, было принято решение проанализировать опыт работы и систематизировать его на основании приобретенных знаний в готовый паттерн, который можно было бы использовать как шпаргалку при построении новых проектов.
Чтобы более глубоко ознакомиться с причинами и историей создания паттерна, а также узнать, как паттерн функционирует при работе с контроллерами, рекомендуем ознакомиться с
Деление на категории
Изначально паттерн получил свое название от сокращения названий категорий Managment
-Frames
-Styles
, которые расширяли стандартный класс вью контроллера, возлагая на себя определенные функциональные обязанности.
При работе с ячейками подобный подход был пересмотрен и код из категории +Managment
было решено оставить в главном файле имплементации ячейки.Потому что, традиционно, класс ячейки менее нагружен, чем самый простой пример контроллера.
Однако, если ваша ячейка, к примеру, вынуждена выполнять протоколы UIScrollViewDelegate
,UITableViewDataSource
,UITableViewDelegate
и вы считаете, что главный имплементационный файл и так нагружен, то вы можете смело создать категорию +Managment
и вынести весь код отвечающий за построения UI
в нее.
Предназначение каждой из категорий хорошо описано в таблице ниже:
Название категории | Обязанности |
---|---|
(Optional for cells) | Содержит методы дополнительного жизненного цикла интерфейса, которые инициализируют, добавляют на экран, наполняют контентом и совершают прочие действия. |
| Содержит |
| Содержит методы графической конфигурации |
Главные методы и порядок их вызовов
Не секрет, что имея целый набор специальных методов для создания UI
, в зависимости от функциональных обязанностей, которые стоят перед отдельной ячейкой, порядок их вызовов может несколько отличаться.
На схеме, расположенной выше, приведен усредненный пример цепочки вызванных методов для построения UI
ячейки.
Различий, о которых шла речь, может быть множество, мы же перечислим только некоторые из них:
Если все ячейки таблицы содержат однотипный контент (например список друзей пользователя), то вызывать каждый раз метод
removeSubviews
, который удаляет всеsubviews
сcontentView
, является нецелесообразно.При перевороте таблицы системой вызывается метод
layoutSubviews
, что также порождает ветвление алгоритма в зависимости от ситуации, где-то требуется вызывать толькоresizeSubviews
, где-то еще иbindDataFrom
, а в некоторых случаях требуется полностью перерисовать интерфейс ячейки.
Функциональные обязанности главных методов
Имя метода | Принимает ли вьюМодель | Предназначение |
---|---|---|
| ❌ | Главный метод построения |
| ❌ | Удаляет все |
| ✅ | Инициализирует нужные |
| ✅ | Вызывает индивидуальные методы настройки для каждого |
| ✅ | Вставляет данные из вьюМодели в |
| ✅ | Вызывает индивидуальные методы расчета размеров и координат для каждой |
| ❌ | Добавляет |
Из выше перечисленных методов явно прослеживается виденье того, как должен строиться UI.
Порядок действий при первой инициализации ячейки:
Инициализация нужных
subviews
.Обновление стилей
subviews
(цвета/размер шрифта итд).Вставка данных в
subviews
.Расчет и установка корректных
frames
дляsubviews
.Добавление полностью готовых
subviews
на родительское представление.
Порядок действий при пере-использовании ячейки:
Вызывается сеттер установки вьюМодели
setViewModel
, который вызывает остальные методы по цепочке.Обновляются стили
subviews
(например увеличивается отступ когда пост содержит четырехзначное число лайков)updateStyles
.Происходит расчет новых
frames
дляsubviews
на основании данных новой вьюМодели.Осуществляется биндинг данных.
Порядок действий при перевороте таблицы:
Системой вызывается метод
layoutSubviews
.А в переопределенной реализации
layoutSubviews
мы вызываемresizeSubviews
.
⚠️ Еще раз вынужден повторить, порядок и набор вызываемых методов описанных выше, ЯВЛЯЕТСЯ ОБОБЩЕННЫМ примером, в зависимости от разной сложности интерфейса, вам придется самостоятельно конфигурировать схему вызовов.
Обзор листинга методов
Разбор методов мы будем осуществлять на примере ячейки поста из социальной сети.
Обратите внимание, на то, что демо-приложение из примера использует архитектуру MVVM.
Где:
View
- отвечает исключительно заUI
пользователя.ViewModel
- является промежуточным звеном междуview
иmodel
.
Хранит методы обертки (преобразует данные из модели для отображения воview
), хранит различные кэшируемые данные (высоту ячейки,frames
внутренних элементов итд).Model
- хранит сырые данные.
Главным камнем преткновения использования таблицы является вопрос пере-использования ячеек.
Перед началом работы вы должны решить - ячейки в вашей таблице буду всегда инициализировать один и тот же набор subviews
, а потом, если это требуется, скрывать их в отдельных ячейках (например кнопку read more... вы можете проинициализировать в каждой ячейки, но в тех ячейках, в которых текст не превышает лимит, эту кнопку вам потребуется скрыть) - или вы будете каждый раз удалять не нужные subviews
с contentView
и инициализировать нужные.
От этого решения (о котором подробнее будет написано ниже) будет зависеть порядок вызовов методов рисования.
Первым в нашей цепочке идет системный метод инициализации ячейки.
В нем мы сразу вызываем метод prepareUI
, который подготовит весь UI
ячейки.
Помните, что initWithStyle:reuseIdentifier:
как правило вызывается всего несколько раз за все время использования таблицы, так как ячейки пере-используются.
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(nullable NSString *)reuseIdentifier
{
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self.contentView) {
[self prepareUI];
}
return self;
}
Метод создания ячейки всегда вызывается раньше, чем сеттер ее вьюМодели, поэтому, на момент исполнения метода prepareUI
ее вьюМодел равняется nil
.
Следовательно, у вас имеется всего два пути:
Вызывать
prepareUI
изinitWithStyle:reuseIdentifier:
.
Так можно делать если каждая ячейка содержит один и тот же ограниченный наборsubviews
.
Если же ячейки все-таки имеют некую вариативность, например в посте может отсутствовать либо присутствовать фото, тогда рекомендуется не удалять каждый разsubviews
, а просто в методеbindDataFrom
скрывать ненужные элементы.Вызывать
prepareUI
изsetViewModel
.
Так нужно делать, если содержимое ячейки очень вариативное и содержать общий набор проинициализированныхsubviews
вы просто не можете.
/*-------------------------------------------
Основной метод построения интерфейса.
Вызывает нужную последовательность методов
-------------------------------------------*/
- (void) prepareUI
{
if (self.contentView){
[self initSubviews:self.viewModel];
[self updateStyles:self.viewModel];
[self resizeSubviews:self.viewModel];
[self bindDataFrom:self.viewModel];
[self addSubviewsToSuperView];
}
}
Обратите внимание, что в этом методе происходит чистая инициализация, без каких- либо настроек. Также алгоритм имеет встроенную защиту от повторной инициализации subviews
.
/*----------------------------------------------------------
Инициализирует нужные subviews на основе данных из viewModel
----------------------------------------------------------*/
- (void) initSubviews:(WallPostCellVM*)viewModel
{
if (self.contentView)
{
if (!self.avatarImgView) self.avatarImgView = [[UIImageView alloc] init];
if (!self.ownerNameLbl) self.ownerNameLbl = [[UILabel alloc] init];
if (!self.postDateLbl) self.postDateLbl = [[UILabel alloc] init];
if (!self.textLbl) self.textLbl = [[UILabel alloc] init];
if (!self.likeBtn) self.likeBtn = [UIButton buttonWithType:UIButtonTypeCustom];
....
}
}
В данном методе мы вызываем функции определенные в категории +Styles
.
Они настраивают цвет, шрифт, иконки, селекторы и т.д. для subviews
.
Обратите внимание на использование некого проперти isStylized
.
Данное проперти динамически добавлено в класс UIView
соответствующей категорией.
Дело в том, что некоторые subview
имеет смысл конфигурировать всего один раз, ввиду крайней трудоемкости этого процесса, а isStylized
используется как флаг, который оповещает алгоритм, о том, что данное проперти уже настроено.
/*-------------------------------------------------------
Вызывает методы графической конфигурации каждого subview
--------------------------------------------------------*/
- (void) updateStyles:(WallPostCellVM*)viewModel
{
if (!viewModel) return;
if (self.avatarImgView)
[self styleFor_avatarImgView:self.avatarImgView vm:viewModel];
if (self.ownerNameLbl)
[self styleFor_ownerNameLbl:self.ownerNameLbl vm:viewModel];
if (self.postDateLbl)
[self styleFor_postDateLbl:self.postDateLbl vm:viewModel];
if (self.textLbl)
[self styleFor_textLbl:self.textLbl vm:viewModel];
if ((self.photoGallery) && (!self.photoGallery.isStylized))
[self styleFor_photoGallery:self.photoGallery vm:viewModel];
if (self.commentBtn)
[self styleFor_commentBtn:self.commentBtn vm:viewModel];
if (self.likeBtn)
[self styleFor_likeBtn:self.likeBtn vm:viewModel];
if (self.repostBtn)
[self styleFor_repostBtn:self.repostBtn vm:viewModel];
if (self.readMoreBtn)
[self styleFor_readMoreBtn:self.readMoreBtn vm:viewModel];
self.selectionStyle = UITableViewCellSelectionStyleNone;
}
Далее в нашей цепочке идут методы resizeSubviews
и bindDataFrom
, которые имеют одну схожую проблему.Так, как выполнение данных методов может быть достаточно трудозатратным процессом, нам необходимо выработать условия, которые не будут позволять выполнять код внутри этих функций, если не произошло критическое изменение данных или каких-либо других внешних условий.
"Холостой" вызов методов может произойти по вине самой системы, которая часто вызывает такие системные методы как layoutSubviews
или же setFrame
по нескольку раз без видимых на то для нас причин.
Для предотвращения "холостых" вызовов мы должны добавить два проперти в нашу ячейку.
@interface WallPostCell ()
/*---------------------------------------------------------
Во время вызыова метода 'bindDataFrom:' алгоритм проверяет
идентичность данных в 'oldModel' и модели из аргументов.
Если модели идентичны, то биндинга данных не происходит.
Если модели разные, то происходит биндинг данных и в
проперти 'oldModel' присваивается значение из аргументов.
----------------------------------------------------------*/
@property (nonatomic, weak) WallPostCellVM* oldViewModel;
/*----------------------------------------------------------
Во время вызыова метода 'resizeSubviews:' алгоритм проверяет
равенство 'self.oldSize' c 'self.frame.size',
если значения индентичны (и viewModels также иднетичны),
то ячейки не требуется расчет новых координат для subviews.
---------------------------------------------------------*/
@property (nonatomic, assign) CGSize oldSize;
@end
Чтобы предотвращать "холостой" биндинг данных, ваша viewModel
должна самостоятельно реализовать метод isEqualToModel
, который должен сравнивать две вьюмодели и определять, имеют ли они критическое отличие друг от друга, которое нужно отобразить на UI
, или нет.Если бы мы в качестве примера делали простую ячейку, которая содержит только текст, то в методе isEqualToModel
проверяли бы текст на идентичность…
/*---------------------------------------
Сравнивает модели данных на индетичность.
----------------------------------------*/
- (BOOL) isEqualToModel:(WallPostCellVM*)object
{
BOOL isEqual = YES;
if (self.model.postID != object.model.postID){
isEqual = NO;
}
return isEqual;
}
Реализовываем ту самую конструкцию, которая ограничивает повторные выполнения кода внутри метода. И вызываем функции из категории +Frames
, которые пересчитывают размеры и координаты для subviews
ячейки.
/*-----------------------------------------------
Вызывает индивидуальные методы расчета размеров
и координат для subviews.
-----------------------------------------------*/
- (void) resizeSubviews:(WallPostCellVM*)viewModel
{
if (!viewModel) return;
// Выходим если модель данных и размеры одни и те же
if (([self.oldViewModel isEqualToModel:self.viewModel]) && (CGSizeEqualToSize(self.oldSize, self.frame.size))){
return;
}
if (self.contentView){
if (self.avatarImgView) self.avatarImgView.frame = [WallPostCell rectFor_avatarImgView:viewModel tableSize:self.frame.size];
if (self.ownerNameLbl) self.ownerNameLbl.frame = [WallPostCell rectFor_ownerNameLbl:viewModel tableSize:self.frame.size];
if (self.postDateLbl) self.postDateLbl.frame = [WallPostCell rectFor_postDateLbl:viewModel tableSize:self.frame.size];
if ((self.textLbl) && (viewModel.text.length > 0)) self.textLbl.frame = [WallPostCell rectFor_textLbl:viewModel tableSize:self.frame.size];
if ((self.photoGallery) && (viewModel.photos.count > 0)){
self.photoGallery.frame = [WallPostCell rectFor_photoGallery:viewModel tableSize:self.frame.size];
[self.photoGallery.collectionView reloadData];
}
[self.photoGallery.collectionView reloadData];
// превышен ли лимит текста
if ([viewModel isExceededTextLimit:CGRectGetWidth(self.contentView.frame)])
{
// Если мы хотим свернутый режим
if (!viewModel.isExpandText)
{
self.readMoreBtn.frame = [WallPostCell rectFor_readMoreBtn:viewModel tableSize:self.frame.size];
self.readMoreBtn.hidden = NO;
}else{
self.readMoreBtn.hidden = YES;
}
}
if (self.commentBtn) self.commentBtn.frame = [WallPostCell rectFor_commentBtn:viewModel tableSize:self.frame.size];
if (self.likeBtn) self.likeBtn.frame = [WallPostCell rectFor_likeBtn:viewModel tableSize:self.frame.size];
if (self.repostBtn) self.repostBtn.frame = [WallPostCell rectFor_repostBtn:viewModel tableSize:self.frame.size];
}
self.oldSize = self.frame.size;
}
Вставляем преобразованные данные из viewModel
в subviews
, также скрываем или раскрываем subviews
.
/*-------------------------------------------
Вставляет контент из вьюМодели в subviews
-------------------------------------------*/
- (void) bindDataFrom:(WallPostCellVM*)viewModel
{
if (!viewModel) return;
// Если модели идентичны, то биндинга данных не происходит
if ([self.oldViewModel isEqualToModel:viewModel]){
return;
}else {
self.oldViewModel = viewModel;
}
// Скачивание автарки
[self.avatarImgView cancelCachingImage];
if (viewModel.avatar.length > 0){
[self.avatarImgView imgURL:viewModel.avatar];
}
// Установка имени пользователя или название группы
if (viewModel.ownerName.length > 0){
self.ownerNameLbl.text = (viewModel.ownerName) ?
viewModel.ownerName : @"Hidden name";
}
// Установка даты поста
if (viewModel.postDate.length > 0){
self.postDateLbl.text = (viewModel.postDate) ?
viewModel.postDate : @"Unknow date";
}
// Вставляем текст (если он имеется)
if (viewModel.text.length > 0){
self.textLbl.text = viewModel.text;
self.textLbl.hidden = NO;
if (([viewModel isExceededTextLimit:
CGRectGetWidth(self.contentView.frame)])
&& (!viewModel.isExpandText))
{
self.readMoreBtn.hidden = NO;
}else {
self.readMoreBtn.hidden = YES;
}
}else {
self.textLbl.hidden = YES;
self.readMoreBtn.hidden = YES;
}
// Вставка плитки с фотографиями
if (viewModel.photos.count > 0){
self.photoGallery.models = self.viewModel.imageGalleryModels;
[self.photoGallery.collectionView reloadData];
self.photoGallery.hidden = NO;
}else {
self.photoGallery.hidden = YES;
}
[self.likeBtn setTitle:viewModel.likesCount
forState:UIControlStateNormal];
[self.repostBtn setTitle:viewModel.repostsCount
forState:UIControlStateNormal];
[self.commentBtn setTitle:viewModel.commentsCount
forState:UIControlStateNormal];
}
Алгоритм имеет защиту от повторного добавления subviews
на superView
.
/*------------------------------
Добавляет subviews на superView
------------------------------*/
- (void) addSubviewsToSuperView
{
if (self.contentView){
if ((self.avatarImgView) && (!self.avatarImgView.superview)){
[self.contentView addSubview:self.avatarImgView];
}
if ((self.ownerNameLbl) && (!self.ownerNameLbl.superview)){
[self.contentView addSubview:self.ownerNameLbl];
}
if ((self.postDateLbl) && (!self.postDateLbl.superview)){
[self.contentView addSubview:self.postDateLbl];
}
if ((self.textLbl) && (!self.textLbl.superview)){
[self.contentView addSubview:self.textLbl];
}
if ((self.photoGallery) && (!self.photoGallery.superview)){
[self.contentView addSubview:self.photoGallery];
}
if ((self.postDateLbl) && (!self.postDateLbl.superview)){
[self.contentView addSubview:self.postDateLbl];
}
if ((self.commentBtn) && (!self.commentBtn.superview)){
[self.contentView addSubview:self.commentBtn];
}
if ((self.likeBtn) && (!self.likeBtn.superview)){
[self.contentView addSubview:self.likeBtn];
}
if ((self.repostBtn) && (!self.repostBtn.superview)){
[self.contentView addSubview:self.repostBtn];
}
if ((self.readMoreBtn) && (!self.readMoreBtn.superview)){
[self.contentView addSubview:self.readMoreBtn];
}
}
}
Переопределяем layoutSubviews
и вызываем из него resizeSubviews
для адаптации subviews
под новый размер таблицы.
/*-------------------------------------------------------
Вызывается при перевороте экрана.
Также вызывается автоматически после инициализации в
-cellForRowAtIndexPath:..
--------------------------------------------------------*/
-(void)layoutSubviews
{
[super layoutSubviews];
[self resizeSubviews:self.viewModel];
}
Так же, как и было сказано ранее, на примере других функций, та цепочка методов, которая вызывается из данного сеттера, является индивидуальной для каждого случая.Где-то понадобится вызывать только bindDataFrom
, где-то понадобится вызывать целый набор, который будет удалять, растягивать, пересчитывать и вставлять.
/*----------------------------------------
После установки новой вьюМодели вызываем
цепочку методов, чтобы те обновили UI
----------------------------------------*/
- (void)setViewModel:(WallPostCellVM *)viewModel
{
_viewModel = viewModel;
[self updateStyles:viewModel];
if (self.superview) [self resizeSubviews:viewModel];
[self bindDataFrom:viewModel];
}
Кэширование размеров и координат subviews
Для того, чтобы обеспечить максимальную плавность интерфейса, нам необходимо предпринять следующие шаги:
Постараться вынести всю работу по вычислению координат
subviews
в фоновые потоки.Постараться кэшировать ранее вычисленные данные, чтобы не нагружать процессор вычислениями, которые он совершал прежне.
В том числе, по этой же причине мы не используем Autolayout
, потому что он представляет собой систему линейных неравенств, что негативно сказывается на скорости вычисления frames
для subviews
, поскольку при каждом добавлении даже, как нам кажется, самого незначительного элемента на view
ячейки, происходит стремительно усложнение неравенства.
На которое тратится все больше и больше процессорного времени.
В нашей парадигме каждый кэш может принадлежать одной из двух сущностей:
Если
subviews
ячеек данного класса всегда имееют одни и те же размеры и коордианты, вне зависимости от модели данных, тогда кэш имеет смысыл сделать собственностью класса ячейки.Если
subviews
основываясь на собственную модель данных может иметь разныеframes
, тогда кэш со значениями будет хранить объектviewModel
данной конкретной ячейки.
Типы ячеек
Для того, чтобы начать кэшировать вычисленные значения размеров и координат subviews
, нам нужно классифицировать все возможные типы ячеек, что поможет нам понять, по какому принципу и сколько нужно создавать словарей для кэширования.
Первый тип
Для ячеек первого типа характерны следующие черты:
Как правило, данные ячейки однотипны и однообразны.
Размеры и координаты всех
subviews
заранее предопределены.
Пример: таблица контактов,frames
аватарки и ФИО предопределены заранее.Если и будет иметь место какое-то различие между ячейками данного класса на основании данных полученных из их
viewModel
, то всеframes
при таком сценарии тоже должны быть жестко определены.
Пример: отображение времени последнего выхода в сеть (если оно не скрыто настройками приватности), тогда этотUILabel
будет отображать под основным.Не всегда, но как правило, ячейки этого типа имеют одну и ту же высоту.
Если имеется возможность "развернуть" ячейку, то параметры данного разворота также строго описаны.
Рациональным подходом будет всего один раз вычислить frames
каждого из subviews
, поскольку для каждой ячейки они будут одними и тем же, а затем сохранить значение в кэш.
Второй тип
Для ячеек второго типа характерны следующие черты:
Размеры и координаты
subviews
всегда индивидуальны и зависят от данных представленных воviewModel
ячейки.Высота ячейки является статичной и зависит от данных внутри
viewModel
(может меняться в зависимости от ориентации).
В качестве примера можно привести ячейку, которая содержит текстовое сообщение пользователя.
Высота ячейки зависит от длины сообщения.
Непосредственный frame
для UILabel
будет храниться внутри словаря subviewFrames
во viewModel
ячейки.
Третий тип
Для ячеек третьего типа характерны следующие черты:
имеет сложный, составной UI.
могут содержать
subviews
с динамическими размерами и координатами.
(то есть могут разворачиваться и сворачиваться).ячейка может менять высоту.
при смене ориентации могут изменяться как размеры, так и координаты
subviews
.
Хорошим примером является ячейка, которая содержит пост пользователя. Текст может расширяться, что, во-первых, изменяет размеры самого текста и также меняет координаты subviews
расположенных ниже его самого. И во-вторых, это меняет высоту самой ячейки.
Типы кэшей
Для поддержки кэширования значений ячеек нам потребуется три различных кэша.
Чтобы избежать путаницы в названиях, ниже будет приведена таблица с определениями каждого из кэшей.
Название | Кто хранит strong ссылку | Функциональная нагрузка |
---|---|---|
|
| Хранят |
|
| Хранят |
|
| Хранит размеры динамических |
Структуры кэшей
Самой основополагающей чертой является тот факт, что словари не должны иметь строгой типизации.
Это делается для того, чтобы пользователь мог хранить дополнительные флаги или значения, которые будут помогать строить ему UI
.
В кэшах имеется только одно традиционное деление - это деление на ширину экрана, оно нужно для того, чтобы поддерживать вариативность размеров и координат для разных ориентаций.
Структура +subviewStaticFrames и -subviewFrames
Кэши имеют одинаковую структуру, а разница заключается только в том, что первый хранит универсальные значения для всех ячеек данного класса, а второй хранит уникальные значения для каждой отдельной ячейки.
/*-------------------------------------------------------------
Structure of cache:
@{
| 320 : @{
| (screen) | "imgView" : nsValue(cgRect:25,25,300x50),
| (width) | "mainLbl" : nsValue(cgRect:50,25,300x50)
| },
}
-------------------------------------------------------------*/
Структура -subviewSizes
Как было написано выше, словари не должны иметь строгой типизации, ключ "baseCellHeight", который находится в словаре, является наглядной демонстрацией этого правила.
В данном примере мы видим деление на minSize
и maxSize
, все деления являются условными и могут быть адаптируемые под ваш случай, например, вам ничто не мешает внедрить ключ middleSize
, если на то будет надобность.
/*----------------------------------------------------------------
Structure of cache:
@{
| 320 : @{
| (screen) | "titleLbl" : @{
| (width) | | "minSize" : nsValue(cgSize300x50),
| | | "maxSize" : nsValue(cgSize300x50),
| | },
| |
| | "mainText" : @{
| | | "minSize" : nsValue(cgSize300x70),
| | | "maxSize" : nsValue(cgSize300x140),
| | },
| |
| | "baseCellHeight" : @190
| },
}-------------------------------------------------------------*/
Для более глубокого понимания какие кэши использовать в каких ситуациях, предлагаю обезличить пост пользователя и взглянуть на него как на набор прямоугольников, которые могут меняться в размерах и координатах.
Зеленым цветом представлены элементы frames
которых хранятся в словаре +subviewStaticFrames
,
который принадлежит классу ячейки.
Это сделано потому, что frames
этих элементов всегда один и тот же, вне зависимости от модели данных, различие может быть только при смене ориентации.
Синим цветом представлены элементы frames
которых хранятся в словаре -subviewFrames
,
который принадлежит вьюМодели конкретного экземпляра ячейки.
Это сделано потому, что size
этих элементов может различаться, потому что напрямую зависит от модели данных.
Обратите внимание, на то что, у данных subviews
могут отличаться только размеры, а {x.y}
для каждой из ориентаций являются статичными.
Красным цветом представлены элементы sizes
которых хранятся в словаре -subviewSizes
, который принадлежит вьюМодели конкретного экземпляра ячейки.
В словаре хранятся именно их размеры, а не координаты, это сделано потому что, их координаты могут изменяться в зависимости от frames
их соседних subviews
.
То есть, хранить {x,y}
галереи фотографий не имеет смыслы, потому что они изменятся, если текст будет развернут.
Соответственно их {x,y}
вычисляется каждый раз путем вызова метода, который вычисляет frames
высшего subview
, а затем мы самостоятельно прибавляем отступ к эту frame
.
Методы категории +Frames поддерживающие кэширование
Демонстрация работы с словарем +subviewsStaticFrames
Традиционно в качестве примера мы будем использовать ячейку, отображающую фото и имя друга нашего пользователя.
// FriendCell.h
@interface FriendCell : UITableViewCell
// UI
@property (nonatomic, strong, nullable) UIImageView* avatarImgView;
@property (nonatomic, strong, nullable) UILabel* fullNameLbl;
// ViewModel
@property (nonatomic, weak, nullable) FriendCellVM* viewModel;
@end
Сам словарь непосредственно объявляется как внутренние проперти в категории +Frames
.
// FriendCell+Frames.m
static NSMutableDictionary<NSNumber*,NSMutableDictionary*>*
_subviewsStaticFrames = nil;
@interface FriendCell ()
@property (nonatomic, strong, class)
NSMutableDictionary<NSNumber*,NSMutableDictionary*>* subviewsStaticFrames;
@end
@implementation FriendCell (Frames)
+ (CGRect) rectFor_AvatarImgView:(FriendCellVM*)viewModel
parentSize:(CGSize)parentSize
{
NSString* property = @"avatarImgView";
// Извлекаем значение из кэша (если оно было кэшировано ранее)
if (FriendCell.subviewsStaticRects[@(parentSize.width)][property]){
return
[FriendCell.subviewsStaticRects[@(parentSize.width)][property] CGRectValue];
}
// Вычисляем
CGPoint point = CGPointMake(pt10, pt5);
CGSize size = CGSizeMake(44, 44);
CGRect rect = CGRectMake(point.x, point.y, size.width, size.height);
rect = CGRectIntegral(rect);
// Создаем вложенный словарь, который содержит значение для данной ориентации
if (!FriendCell.subviewsStaticRects[@(parentSize.width)]){
FriendCell.subviewsStaticRects[@(parentSize.width)] =
@{}.mutableCopy;
}
// Save in the cache
NSMutableDictionary* nastedDict =
FriendCell.subviewsStaticRects[@(parentSize.width)];
nastedDict[@"avatarImgView"] = [NSValue valueWithCGRect:rect];
return rect;
}
@end
В итоге после вычислений для первой ячейки в нашем кэше будут значения, которые мы будем использовать при построении других экземпляров ячеек данного класса.
/*-------------------------------------------------------------
Structure of cache:
@{
| 320 : @{
| (screen) | "avatarImgView" : nsValue(cgRect:25,25,300x50),
| (width) | "fullNameLbl" : nsValue(cgRect:50,25,300x50)
| },
}
-------------------------------------------------------------*/
Демонстрация работы с словарем -subviewsFrames
Для примера случая, где может пригодиться -subviewsFrames
мы выбрали ячейку, которая содержит текстовое сообщение пользователя.
// MessageCell.h
@interface MessageCell : UITableViewCell
...
// UI
@property (nonatomic, strong, nullable) UILabel* messageLbl;
// ViewModel
@property (nonatomic, weak, nullable) MessageCellVM* viewModel;
@end
На этот раз кэш-словарь содержит непосредственно экземпляр вьюМодели ячейки.
@interface MessageCellVM : NSObject
...
@property (nonatomic, strong)
NSMutableDictionary<NSNumber*,NSMutableDictionary*>* subviewsFrames;
@end
Ну и собственно - сам метод вычисления.Структура -subviewsFrames
будет такая же, как и у +subviewsStaticFrames
.
// MessageCell+Frames.m
+ (CGRect) rectFor_messageLbl:(MessageCellVM*)vm parentSize:(CGSize)parentSize
{
// Извлекаем значение из кэша (если оно было кэшировано ранее)
if (vm.subviewsFrames[@(parentSize.width)][@"messageLbl"]){
return
[vm.subviewsFrames[@(parentSize.width)][@"messageLbl"] CGRectValue];
}
// Вычисляем
float width = tableSize.width-(2*pt10);
CGPoint point = CGPointMake(25, 25);
CGSize size = [UILabel findHeightForText:vm.text
forWidth:width
font:[MessageCell font_messageLbl]];
CGRect rect = CGRectMake(point.x, point.y, size.width, size.height);
rect = CGRectIntegral(rect);
// Создаем вложенный словарь, который будет содержать
// значение для данной ориентации
if (!viewModel.subviewsFrames[@(parentSize.width)]){
viewModel.subviewsFrames[@(parentSize.width)] = @{}.mutableCopy;
}
[(NSMutableDictionary*)vm.subviewsFrames[@(parentSize.width)]
setObject:[NSValue valueWithCGRect:rect]forKey:@"messageLbl"];
return rect;
}
Демонстрация работы с словарем -subviewSizes
На сей раз, в качестве примера, будет рассмотрен случай, когда UI
элемент может разворачиваться.
Это будет UILabel
в ячейке поста пользователя.
@interface WallPostCell : UITableViewCell
...
// UI
@property (nonatomic, strong, nullable) UILabel* textLbl;
// ViewModel
@property (nonatomic, weak, nullable) WallPostCellVM* viewModel;
@end
Кэш-словарь также содержит вьюМодель ячейки.
@interface WallPostCellVM : NSObject
...
@property (nonatomic, strong)
NSMutableDictionary<NSNumber*,NSMutableDictionary<NSString*,id>*>* subviewSizes;
@end
Если вы ранее вызывали метод и он уже закэшировал размер текстового поля, то метод также способен возвращать разные размеры, в зависимости от значения внутри флага isExpandText
, хранящегося в viewModel
.
По нажатию на кнопку "read more" вы будете менять значение переменной, и тогда метод будет возвращать вам нужный размер.
+ (CGRect) rectFor_textLbl:(WallPostCellVM*)vm tableSize:(CGSize)tableSize
{
CGRect rect = CGRectZero;
if (vm.text.length < 1){
return rect;
}
CGPoint point = CGPointMake(25,25);
CGSize size = CGSizeZero;
// Создаем вложенный словарь, который будет содержать
// значение для данной ориентации
if (!vm.subviewSizes[@(tableSize.width)]){
vm.subviewSizes[@(tableSize.width)] =
@{ @"textLbl" : @{}.mutableCopy }.mutableCopy;
}
NSMutableDictionary* nastedDict =
(NSMutableDictionary*)vm.subviewSizes[@(parentSize.width)][@"textLbl"];
// Вычисляем размер
if (!vm.subviewSizes[@(tableSize.width)][@"textLbl"])
{
float width = tableSize.width-(2*pt10);
CGSize textSize = [UILabel findHeightForText:vm.text
forWidth:width
font:[WallPostCell font_textLbl]];
// Записываем значение в кэш
if (textSize.height < minTextHeight){
NSValue* minSize =
[NSValue valueWithCGSize:CGSizeMake(width, minTextHeight)];
nastedDict[@"minSize"] = minSize;
nastedDict[@"maxSize"] = minSize;
}else if ((textSize.height >= minTextHeight) &&
(textSize.height <= maxTextHeight)){
NSValue* originalSize = [NSValue valueWithCGSize:textSize];
nastedDict[@"minSize"] = originalSize;
nastedDict[@"maxSize"] = originalSize;
}
else if (textSize.height > maxTextHeight){
nastedDict[@"minSize"] = [NSValue valueWithCGSize:CGSizeMake(width, maxTextHeight)];
nastedDict[@"maxSize"] = [NSValue valueWithCGSize:textSize];
nastedDict[@"isExceededTextLimit"] = @(YES);
}
}
if (vm.isExpandText){
size = [nastedDict[@"maxSize"] CGSizeValue];;
}else {
size = [nastedDict[@"minSize"] CGSizeValue];
}
rect = CGRectMake(point.x, point.y, size.width, size.height);
return rect;
}
Вычисление высоты ячеек таблицы
Важной составляющей частью работы таблицы является вычисление высоты ячеек.
В нашей парадигме этот процесс должен выглядеть следующим образом:
Поскольку мы используем MVVM
в качестве архитектурного паттерна для всего приложения, то каждое view
(в том числе и ViewController
) должно иметь собственную viewModel
.
В случае с TableViewController
его viewModel
должен содержать массив с вьюМоделями ячеек, на основании которых строится сама таблица.
Обязательной для каждой ячейки является наличие метода +calculateCellHeightFromVM:tableSize:
, в который мы передаем вьюМодель ячейки, а данный метод вычисляет и кэширует ее высоту.
Непосредственно процесс вычисления выглядит так:
Получаем
viewModel
ячейки по индексу из массива.Вызываем вспомогательный метод
+getClassNameByViewModelCell
, который возвращает названиеUI
класса ячейки по ее вьюМодели.Конструкция
if
проверяет наичие данного метода у класса ячейки, и если он имеется, то вызывает соответствующий метод.
#pragma mark - UITableViewDelegate
/*-------------------------------------------------------
Запрашивает у делегата таблицы высоту ячейки по индексу
-------------------------------------------------------*/
- (CGFloat)tableView:(UITableView*)tableView
heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
CGFloat height = 40.f;
// Получаем viewmodel ячейки из массива принадлежащего viewModel контроллера.
id vm = self.viewModel.cellsViewModel[indexPath.section];
Class cellClass = NSClassFromString([UserProfileTVC getClassNameByViewModelCell:vm]);
// Вызываем метод вычисления высоты ячейки по данным расположенным в viewModel
if ([cellClass respondsToSelector:@selector(calculateCellHeightFromVM:tableSize:)]){
height = [cellClass calculateCellHeightFromVM:vm tableSize:tableView.frame.size];
}
return roundf(height);
}
Метод-помощник возвращает название класса ячейки пользуясь функцией NSStringFromClass()
.
/*---------------------------------------------------
Возвращает название класса ячейки после анализа
экземпляра переданной вьюМодели
-----------------------------------------------------*/
+ (NSString*) getClassNameByViewModelCell:(id)viewModel
{
NSString* identifier;
if ([viewModel isKindOfClass:[UserProfileCellVM class]])
identifier = NSStringFromClass([UserProfileCell class]);
if ([viewModel isKindOfClass:[UserProfileGalleryCellVM class]])
identifier = NSStringFromClass([UserProfileGalleryCell class]);
if ([viewModel isKindOfClass:[WallPostCellVM class]])
identifier = NSStringFromClass([WallPostCell class]);
if (!identifier)
identifier = NSStringFromClass([viewModel class]);
return identifier;
}
К слову говоря метод cellForRowAtIndexPath
имеет такую же философию, что и heightForRowAtIndexPath
.
/*-----------------------------
Запрашивает ячейку по индексу
-----------------------------*/
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell* cell = nil;
id vm = self.viewModel.cellsViewModel[indexPath.section];
// Получаем названия класса ячейки по типу класса вьюМодели
NSString* identifier =
[UserProfileTVC getClassNameByViewModelCell:vm];
cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell){
cell = [[NSClassFromString(identifier) alloc]
initWithStyle:UITableViewCellStyleDefault
reuseIdentifier:identifier];
}
// Вставляем viewModel для дальнейшей конфигурации ячейки
if ([(id)cell respondsToSelector:@selector(setViewModel:)]){
[(id)cell setViewModel:vm];
}
return cell;
}
Реализация метода +calculateCellHeightFromVM:tableSize:
может быть свободной.
В некоторых случаях вы можете возвращать строго заданную константу, в некоторых вычислять координаты самого нижнего UI
элемента ячейки и прибавлять к нему отступ.
Ниже будут приведены несколько типичных реализаций:
FriendCell
Для этой ячейки высота задана несколькими константами.
+ (CGFloat) calculateCellHeightFromVM:(FriendCellVM*)vm
tableSize:(CGSize)tableSize
{
float cellHeight = pt5+defaultHeight+pt10;
return cellHeight;
}
MessageCell
В реализации расчета высоты для ячейки сообщения у нас вычисляется размеры текстового поля и к нему каждый раз прибавляется отступ.
То есть, сама высота не кэшируется, кэшируется только размер главного subview
.
+ (CGFloat) calculateCellHeightFromVM:(MessageCellVM*)vm
tableSize:(CGSize)tableSize
{
if (!vm) return emptyMessageHeight;
float cellHeight = 0.f;
CGRect messageLabelRect =
[MessageCell rectFor_messageLbl:vm parentSize:tableSize];
cellHeight += CGRectGetMaxY(messageLabelRect)+pt10;
return cellHeight;
}
WallPostCell
В случае с ячейкой содержащей пост пользователя нам нужно обеспечить максимальную плавность и производительность при быстрой прокрутке.
Для достижения этой цели мы будем кэшировать не только размеры subviews
, а еще и высоту самой ячейки, чтобы выиграть дополнительные миллисекунды для избежания доп.вычислений.
Реализация вспомогательных методов по типу -isCalculatedHeight:
тут приведена не будет, по причине своей ненадобности.
Стоит лишь только сказать, что все это разного рода методы обертки, которые сокращают синтаксис при работе со словарями содержащими значения.
Для сложно составных ячеек, как правило, не хватает просто вызывать rectFor_
метод возвращающий frame
самого нижнего элемента, а требуется вызвать все методы вычисляющие все необходимые frames
для всех subviews
.
Что собственно и делает метод обертка calculateCoordinatesForVM
, который просто вызывается все rectFor_
методы подряд, тем самым кэширует значения для всех subviews
.
+ (CGFloat) calculateCellHeightFromVM:(WallPostCellVM*)vm
tableSize:(CGSize)tableSize
{
if (!vm) return baseHeight;
if ([vm isCalculatedHeight:tableSize.width]) {
return (vm.isExpandText) ?
[vm maxHeight:tableSize.width] : [vm compactHeight:tableSize.width];
}
// Вычисляем координаты для subviews ячеек
[WallPostCell calculateCoordinatesForVM:vm tableSize:tableSize];
CGRect bottomElementRect = [WallPostCell rectFor_commentBtn:vm
tableSize:tableSize];
float cellHeight = CGRectGetMaxY(bottomElementRect)+pt10;
return cellHeight;
}
Расчет размеров и координат subviews ячеек в фоне
Итак, последнюю вещью, которую мы можем предпринять для обеспечения максимальной плавности и производительности - это инициировать процесс вычисления размеров и координат в фоновом потоке.
Как правило, подобная возможность у нас появляется в методе scrollViewDidScroll
, когда по достижению нижней границы таблицы мы запускаем процесс подгрузки новых данных.
Тогда, после получения вьюМоделей мы можем вызвать методы кэширования, которые будут выполняться на фоновом потоке.
#pragma mark - <UIScrollViewDelegate>
/*--------------------------------------------
Метод обрабатывает измения позиции скроллБара.
---------------------------------------------*/
-(void) scrollViewDidScroll:(UIScrollView*)scrollView
{
float contentOffsetY = scrollView.contentOffset.y;
float contentSizeHeight = scrollView.contentSize.height;
float tableViewHeight = CGRectGetHeight(self.tableView.frame);
if ((contentSizeHeight > 0) &&
((contentSizeHeight - contentOffsetY) <=
(tableViewHeight+(tableViewHeight/10))) &&
(!self.isLoadingData))
{
// Устанавливаем значение флага для избежания повторного
// попадания в if-блок.
self.isLoadingData = YES;
// Получаем размер таблицы, чтобы потом можно было
// воспользоваться значением в фоновом потоке
CGSize tableSize = self.tableView.frame.size;
[self.footerView.footerLoader startAnimating];
// Вызываем метод viewModel, для получения данных
__weak UserProfileTVC* weak = self;
[self.viewModel wallOpRunItself:NO onQueue:APIManager.aSyncQueue completion:^(NSError* error, NSArray<NSIndexPath*>* indexPaths){
...
// Таким образом вычилсяем и кэшируем все значения для
// контента внутри ячейки - здесь, на фоновом потоке.
for (WallPostCellVM* cellVM in viewModels) {
[WallPostCell calculateCoordinatesForVM:cellVM
tableSize:tableSize];
}
MainQueue(^{
[weak.tableView reloadData];
weak.isLoadingData = NO;
[weak.footerView.footerLoader stopAnimating];
});
}];
}
}
Заключение
Выпустив SwiftUI
, Apple конечно же частично купирует проблему поддержки сложного UI
для будущих приложений.
Но оставляет без всякого внимания абсолютное большинство уже существующих, которые по естественным причинам не могут использовать SwiftUI
в своих проектах.
Паттерн MFS
является своего рода ответом на кричащие потребности сегодняшнего многомиллионного рынка приложений.
Архитектурная грамотность, с которой был построен паттерн, дает возможность имплементировать его на устройствах, начиная буквально с самых первых версий iOS.
Что, в свою очередь, позволяет использовать решение таким гигантам рынка, как банковские приложения и социальные сети, которые должны охватывать максимальное количество действующих устройства.
Уверен в том, что в ближайшем обозримом будущем отрасль породит около десятка похожих решений, где философия MFS
будет играть не последнюю роль.
Одна из главных задач MFS
- это инициировать диалог в среде разработчиков и архитекторов, о том как всем нам грамотно отреагировать на данные изменения рынка.