Логика развития мобильных приложений заключается в постепенном
усложнении функциональной нагрузки на пользовательский
интерфейс.
Что, в свою очередь, приводит к росту кодовой базы и затруднению ее
обслуживания.
MFS
- позволяет создавать современный дизайн
приложений и при этом избежать такого явления как
MassiveViewController
.
Фото: 10 years of the App Store:
The design evolution of the earliest apps - 9to5Mac
Причины создания паттерна
Времена когда весь интерфейс контроллера настраивался целиком в
методеviewDidLoad
давно закончились.
Настройка графических компонентов и
пользовательскогоlayout
стала занимать сотни строк
кода.
Помимо массивной настройки и инициализации, встает второй немало
важный вопрос поддержки и управления созданными активами.
О существующих трудностях знают в Купертино, на изменение
требований рынка компания отреагировала выпуском перспективной
технологиейSwiftUI
.
Которая, к сожалению, имеет ряд серьезных ограничений, например
поддержка отiOS 13
и выше.
Что на данный момент - абсолютно неприемлемо для большинства
солидных приложений, которые стараются охватить максимально большую
аудиторию пользователей.
MFS
- же напротив ориентирован на поддержку и
разгрузку уже существующих приложений.
А набор используемых технологий позволяет внедрять паттерн на самую
большую аудиторию приложений.
Архитектурный
паттернMFS
(Managment
-Frames
-Styles
)
был разработан, для того чтобы соответствовать духу времени и его
потребностям.
Кому может понадобиться MFS ?
В первую очередь паттерн рекомендуется использовать тем
разработчикам, которые занимаются строительством сложных
интерфейсов, в особенности если дизайн значительно различается в
зависимости от ориентации устройства.
Если же ваше приложение имеет крайне простой интерфейс, то
возможно более оптимальным будет использование
стандартногоStoryboard
иAutolayout
.
Отношение к Autolayout и другим подобным технологиям
Поскольку исторически паттерн создавался именно для реализации
сложных дизайнов, поэтому в стандартной своей
реализацииMFS
- имеет ручное вычисление
всехframe
, без использования каких-либо сторонних
библиотек.
Подобный подход позволяет крайне гибко реализовывать любые
требования дизайнера, чего к примеру не позволяет делать или же
серьезно ограничиваетAutolayout
.
Однако, использование технологииAutolayout
не
запрещено, поскольку паттерн имеет высокую декомпозированность,
пользуясь которой, вы можете заменить ручной расчет координат на
созданиеconstraints
.
Обзор паттерна
ПаттернMFS
призван равномерно распределить
обязанности между категориями класса, с целью избежания
возникновенияMassiveViewController
.
Название категории
|
Обязанности
|
+Managment
|
Содержит методы дополнительного жизненного цикла интерфейса,
которые инициализируют, добавляют на экран, наполняют контентом и
совершают прочие действия.
|
+Frames
|
Содержит методы вычисляющие размеры и координаты
subviews .
|
+Styles
|
Содержит методы графической конфигурации
subviews .
Например задает цвет,закругление,шрифт, и т.д.
Как правило, для каждого property существует отдельный
метод настройки.
|
Как стало вам понятно, категория+Managment
является
главенствующей, она содержит дополнительные методы, которые
полностью забирают на себя все хлопоты по созданию и обслуживанию
интерфейса контроллера.
Остальные же категории содержат так называемыечистые функции, которые
более широко известны под названиемpure
function.
Функция является "чистой" если она мутирует
только те значения, которые создала самостоятельно или же приняла
из собственных параметров.
То есть, если функция изменяет некие глобальные переменные, которые
не были переданы ей в параметры, то "чистой" она
не является.
Обратите внимание, на то, что методы
категории+Frames
ВСЕГДА должны быть
чистыми.
Методы же категории+Styles
могут быть чистыми по
усмотрению пользователя, поскольку это не так критично.
На главный файл имплементации контроллера
(ViewController.m
) ложится обязанность выполнять
протоколы различных представлений
(напр.:UITableViewDelegate
,UITableViewDataSource
),
а также содержать методыIBAction
.
Выше были перечисленны основные обязанности, но в случае острой
необходимости, вы должны самостоятельно принимать решение -
размещать некий функционал в файле имплементации или же вынести его
в отдельную категорию.
Также стоит заметить, что вся бизнес-логика содержится во вьюМодели
вашего контроллера или же во вьюМоделях
егоsubviews
.
Подобная целенаправленная политика позволяет умещать такие
сложные контроллеры, как профиль пользователя и его стену, всего в
около~300 строчек кода наObjC
и
вероятно еще меньше - наSwift
.
В свою очередь, такая лаконичность полностью исключает побочный
эффект в виде "бесконечного скроллинга" и поиска нужного
метода.
Все методы распределены строго по категориям и если нам, например,
потребуется изменить размер шрифта или поменятьlayout
,
мы с легкостью откроем соответствующий файл и не будем бесконечно
скролить основной код контроллера.
Порядок вызова методов построения UI
На схеме ниже показаны методы и порядок их вызовов для
построенияUI
вUIViewController
.
Как мы можем увидеть процесс построенияUI
начинается
из методаviewDidAppear
, который вызывает
методprepareUI
.
Который в свою очередь, по цепочке, вызывает все остальные.
Также надо сказать, что иногда, при перевороте экрана или же при
установке новой вьюМодели, нам требуется вызывать разный набор
методов - все зависит от непосредственной сложности вашего
интерфейса.
На самых простых представлениях, после переворота экрана, нам
потребуется вызывать только методresizeSubviews
, на
более сложных, где нужно, например скрывать некоторые элементы для
определенных ориентаций, там может понадобиться и
вызовupdateStyles
или жеbindDataFrom
.
Обзор контроллера
Подобный обзор паттерна будет проводиться на примере демо
приложения.
Данный контроллер входа в приложение имеет различную верстку для
разных ориентаций.
В портретной ориентации кнопки находятся друг под другом, а в
горизонтальной находятся на одной высоте, с разных сторон
экрана.
Даже такой минималистичный дизайн является частным случаем
труднореализуемого интерфейса с помощью применения
стандартногоAutolayout
.
Ниже представлены.h
/.m
контроллера.
Обратите внимание, что помимо стандартного набор проперти, наш
контроллер имеет две достаточно необычных, в привычном понимании,
переменных.
@interface LoginController : UIViewController// ViewModel@property (nonatomic, strong, nullable) LoginViewModel* viewModel;@property (nonatomic, strong, nullable) LoginViewModel* oldViewModel;// UI@property (nonatomic, strong, nullable) UIImageView* logoImgView;@property (nonatomic, strong, nullable) UIButton* signInButton;@property (nonatomic, strong, nullable) UIButton* signUpButton;@property (nonatomic, strong, nullable) CAGradientLayer *gradient;@property (nonatomic, assign) CGSize oldSize;#pragma mark - Actions- (void) signUpBtnAction:(UIButton*)sender;- (void) signInBtnAction:(UIButton*)sender;#pragma mark - Initialization+ (LoginController*) initWithViewModel:(nullable LoginViewModel*)viewModel;@end
Речь идет оoldViewModel
иoldSize
- эти
проперти помогают избегать лишних перерисовок и вставок данных.
Подробней о них будет рассказано в разборах отдельных
категорий.
@interface LoginController ()@end@implementation LoginController#pragma mark - Life cycle- (void) viewDidAppear:(BOOL)animated{ [super viewDidAppear:animated]; [self prepareUI];}- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator{ __weak LoginController* weak = self; [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext> context) { [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ [weak resizeSubviews:weak.viewModel]; } completion:nil]; }];}#pragma mark - Action- (void) signUpBtnAction:(UIButton*)sender{ [self.viewModel signUpBtnAction];}- (void) signInBtnAction:(UIButton*)sender{ [self.viewModel signInBtnAction];}#pragma mark - Getters/Setters- (void)setViewModel:(LoginViewModel *)viewModel{ _viewModel = viewModel; if ((!self.oldViewModel) && (self.view)){ [self prepareUI]; } else if ((self.oldViewModel) && (self.view)){ [self bindDataFrom:viewModel]; [self resizeSubviews:viewModel]; }}#pragma mark - Initialization+ (LoginController*) initWithViewModel:(nullable LoginViewModel*)viewModel{ LoginController* vc = [[LoginController alloc] init]; if (vc) { vc.viewModel = (viewModel) ? viewModel : [LoginViewModel defaultMockup]; } return vc;}@end
Просмотрев код расположенный выше, можно сказать, что в
результате декомпозиции основной файл контроллера остается крайне
лаконичным.
Обзор методов категории +Managment
Имя метода
|
Принимает ли вьюМодель
|
Предназначение
|
prepareUI
|
|
Главный метод построения UI , вызывает нужную
последовательность методов.
Данную функцию рекомендуется вызывать из
viewDidAppear .
|
removeSubviews
|
|
Удаляет все subviews с superView .
А также обнуляет все проперти на UI элементы.
|
initSubviews
|
|
Инициализирует нужные subviews .
|
updateStyles
|
|
Вызывает индивидуальные методы настройки для каждого
subviews , куда также передает вьюМодель, на основании
данных которой может быть принято решение относительно стилей.
Например, задает разный цвет для плашек сообщений в зависимости от
пола пользователя.
|
bindDataFrom
|
|
Вставляет данные из вьюМодели в subviews .
|
resizeSubviews
|
|
Вызывает индивидуальные методы расчета размеров и координат для
каждой subviews .
|
addSubviewsToSuperView
|
|
Добавляет subviews на superView если
те были проинициализированы и не добавлены на родительское
представление ранее.
|
postUIsetting
|
|
Здесь должна происходить настройкаsubviews для
которых не было создано уникальных методов конфигурации по причине
их ненадобности.
Например, тут будет
настраиватьсяstatusBar ,gestures ,allowSelectation
и тд.
|
Из выше перечисленных методов явно прослеживается виденье того,
как должен строиться UI
в приложении:
-
Удаление всехsubviews
и обнуление всех проперти
наUI
элементы.
(Если того требует ситуация).
-
Инициализация нужныхsubviews
.
-
Обновление стилейsubviews
(цвет/размер шрифта
итд).
-
Вставка данных вsubviews
.
-
Расчет и установка
корректныхframes
дляsubviews
.
-
Добавление полностью готовыхsubviews
на
родительское представление.
Реализация методов категории +Managment
Для того чтобы внутриviewDidAppear
не вызывать
целый набор методов, был придуман
метод-оберткаprepareUI
,который вызывает методы
категории в нужной последовательности.
Обратите внимание, на методыresizeSubviews
иbindDataFrom
, порядок их вызовов в некоторых
ситуациях может быть прямо противоположенный.
Например, некоторые библиотеки, кэширующие изображения из
интернета, возвращают картинку не в полном разрешении, а уже
заранее подготовленную под размер вашегоUIImageView
,
тогда, если вы сначала попытаетесь вставить картинку в рамку
размером0x0
, у вас может произойти ошибка.
В классическом сценарии сначалаsubviews
наполняются
данными, а потом производится расчет размеров и координат.
/*---------------------------------------------------------------------- Основной метод построения интерфейса. Вызывает нужную последовательность методов ----------------------------------------------------------------------*/- (void) prepareUI{ if (self.view){ [self removeSubviews]; [self initSubviews:self.viewModel]; [self updateStyles:self.viewModel]; [self bindDataFrom:self.viewModel]; [self resizeSubviews:self.viewModel]; [self addSubviewsToSuperView]; [self postUIsetting]; }}
Метод удаления всехsubviews
с родительского
представления.
При работе с контроллером нужен - только в исключительных
случаях.
Например, если данные во viewModel
могут
динамически меняться и набор с расположениемsubviews
зависит от вариативностиviewModel
, то есть для одной
вьюМодели у вас будет один наборsubviews
, в
определенном месте, а для вьюМодел с другим набором данных, будут
иные subviews с прочимиUI
эффектами.
Тогда при сменеviewModel
имеет смысл вызывать
неbindDataFrom
иresizeSubviews
, а
полноценный методprepareUI
, потому что он вызовет всю
цепочку, которая прежде всего удалит все старые представления.
/*---------------------------------------------------------------------- Удаляем все `subviews` и обнуляем все проперти на UI элементы. ----------------------------------------------------------------------*/- (void) removeSubviews{ // removing subviews from superview for (UIView* subview in self.view.subviews){ [subview removeFromSuperview]; } // remove sublayers from superlayer for (CALayer* sublayer in self.view.layer.sublayers) { [sublayer removeFromSuperlayer]; } self.logoImgView = nil; self.signInButton = nil; self.signUpButton = nil; self.gradient = nil;}
Обратите внимание, что в этом методе происходит чистая
инициализация, без каких-либо настроек.
/*---------------------------------------------------------------------- Инициализирует нужные subviews на основе данных из viewModel ----------------------------------------------------------------------*/- (void) initSubviews:(LoginViewModel*)viewModel{ if (self.view) { if (!self.logoImgView) self.logoImgView = [[UIImageView alloc] init]; if (!self.signInButton) self.signInButton = [UIButton buttonWithType:UIButtonTypeCustom]; if (!self.signUpButton) self.signUpButton = [UIButton buttonWithType:UIButtonTypeCustom]; }}
МетодupdateStyles
вызывает индивидуальные методы для
каждого изsubviews
, с целью настроить их внешний
вид.
/*---------------------------------------------------------------------- Задает стили для subviews. Цвета/размера шрифта/селекторы для кнопок ----------------------------------------------------------------------*/- (void) updateStyles:(LoginViewModel*)viewModel{ if (!viewModel) return; if (self.logoImgView) [self styleFor_logoImgView:self.logoImgView vm:viewModel]; if (self.signInButton) [self styleFor_signInButton:self.signInButton vm:viewModel]; if (self.signUpButton) [self styleFor_signUpButton:self.signUpButton vm:viewModel];}
Во время декларации.h
файла контроллера, фигурировало
пропертиoldViewModel
.
В данном случае оно понадобилось нам для осуществления проверки на
идентичность моделей.
Если вьюМодели идентичны, то биндинга данных не произойдет.
Традиционно подобная конструкция чаще используется при работе с
ячейками, но в некоторых случаях может потребоваться и при работе с
контроллером.
/*---------------------------------------------------------------------- Связывает данные из вьюМодели в subviews ----------------------------------------------------------------------*/- (void) bindDataFrom:(LoginViewModel*)viewModel{ // Если модели идентичны, то биндинга данных не происходит if (([self.oldViewModel isEqualToModel:viewModel]) || (!viewModel)){ return; } [self.logoImgView setImage:[UIImage imageNamed:viewModel.imageName]]; [self.signInButton setTitle:viewModel.signInBtnTitle forState:UIControlStateNormal]; [self.signUpButton setTitle:viewModel.signUpBtnTitle forState:UIControlStateNormal]; self.oldViewModel = viewModel;}
МетодisEqualToModel
в каждом отдельном случае имеет
разную реализацию.
Например, может возникнуть ситуация, когда в ваш контроллер
устанавливается новая вьюМодель, но основные данные, критически
важные данные не отличаются, а были изменены только второстепенные
проперти, которые не отображаются в вашемUI
.
Тогда методisEqualToModel
должен вернуть
значениеNO
, чтобы избежать повторного биндинга
данных.
В нашем случае он имеет подобную реализацию:
/*---------------------------------------------------------------------- Сравнивает модели данных на индетичность. ----------------------------------------------------------------------*/- (BOOL) isEqualToModel:(LoginViewModel*)object{ BOOL isEqual = YES; if (![object.imageName isEqualToString:self.imageName]){ return NO; } if (![object.signInBtnTitle isEqualToString:self.signInBtnTitle]){ return NO; } if (![object.signUpBtnTitle isEqualToString:self.signUpBtnTitle]){ return NO; } return isEqual;}
Так же как и вbindDataFrom
,
методresizeSubviews
в самом начале имеет условие
проверки, которое не позволяет повторно вычислять размеры и
координаты дляsubviews
, если модель данных или размер
родительского представления не был изменен.
/*---------------------------------------------------------------------- Вызывает индивидуальные методы расчета размеров и координат для subviews. После изменения ориентации или после первой инициализации. ----------------------------------------------------------------------*/- (void) resizeSubviews:(LoginViewModel*)viewModel{ // Выходим если модель данных и размеры одни и те же if ((([self.oldViewModel isEqualToModel:self.viewModel]) && (CGSizeEqualToSize(self.oldSize, self.view.frame.size))) || (!viewModel)) { return; } if (self.view){ if (self.logoImgView) self.logoImgView.frame = [LoginController rectFor_logoImgView:viewModel parentFrame:self.view.frame]; if (self.signInButton) self.signInButton.frame = [LoginController rectFor_signInButton:viewModel parentFrame:self.view.frame]; if (self.signUpButton) self.signUpButton.frame = [LoginController rectFor_signUpButton:viewModel parentFrame:self.view.frame]; if (self.gradient) self.gradient.frame = self.view.bounds; } self.oldSize = self.view.frame.size;}
Добавляемsubviews
на
родительскоеview
.
/*---------------------------------------------------------------------- Добавляет subviews на superView ----------------------------------------------------------------------*/- (void) addSubviewsToSuperView{ if (self.view){ if ((self.logoImgView) && (!self.logoImgView.superview)) [self.view addSubview:self.logoImgView]; if ((self.signInButton) && (!self.signInButton.superview)) [self.view addSubview:self.signInButton]; if ((self.signUpButton) && (!self.signUpButton.superview)) [self.view addSubview:self.signUpButton]; }}
На этом этапе все методы категории+Managment
были
разобраны и остался единственный метод пост-настройки, который
принадлежит категории+Styles
.
В методеpostUIsetting
мы настраиваемUI
компоненты, для которых не создали индивидуальных методов.
Например, в нем можно добавлятьgestures
, настраивать
таблицу, устанавливать цвет статус бара и т.д.
- (void) postUIsetting{ UIColor* firstColor = [UIColor colorWithRed: 0.54 green: 0.36 blue: 0.79 alpha: 1.00]; UIColor* secondColor = [UIColor colorWithRed: 0.41 green: 0.59 blue: 0.88 alpha: 1.00];; self.gradient = [CAGradientLayer layer]; self.gradient.frame = self.view.bounds; self.gradient.startPoint = CGPointZero; self.gradient.endPoint = CGPointMake(1, 1); self.gradient.colors = [NSArray arrayWithObjects:(id)firstColor.CGColor, (id)secondColor.CGColor, nil]; [self.view.layer insertSublayer:self.gradient atIndex:0];}
Реализация методов категории +Styles
В отличии от
категории+Managment
,+Styles
не имеет
системных методов, а лишь содержит индивидуальные методы
настройкиUI
компонентов.
Ниже будет приведен один из методов.
- (void) styleFor_logoImgView:(UIImageView*)imgView vm:(LoginViewModel*)viewModel{ if (!imgView.isStylized){ imgView.contentMode = UIViewContentModeScaleAspectFit; imgView.backgroundColor = [UIColor clearColor]; imgView.opaque = YES; imgView.clipsToBounds = YES; imgView.layer.masksToBounds = YES; imgView.alpha = 1.0f; imgView.isStylized = YES; }}
Обратите внимание на некое пропертиisStylized
, оно
было добавлено категорией к каждому наследнику
классаUIView
.
Традиция использовать данную переменную при
настройкеUI
элементов пришла от опыта работы с ячейками
в таблице.
Создана была для того, чтобы при переиспользовании однотипных
ячеек не производилась повторная настройка (уже настроенных
элементов), то есть, чтобы еще раз не добавлялись тени, блюры и
т.д.
Реализация методов категории +Frames
Категория+Frames
также как и+Styles
не
имеет системных методов, но может иметь словари класса, в которых
могут быть расположены закэшированные размеры и
координатыsubviews
.
Подробный листинг данных методов публиковать не имеет смысла
из-за их громоздкости, по сути, там не происходит ничего
интересного, стандартное ручное вычисление координат и
размеров.
Но стоит также обратить внимание, что по сравнению с другими
категориями,+Frames
содержитИСКЛЮЧИТЕЛЬНОметоды
класса (+
).
Данная традиция также пришла из опыта работы с ячейками,
первоначально методы класса были созданы для того, чтобы в фоне
можно было заранее вычислить координаты и размеры для
всехsubviews
не имея фактического объекта ячейки, а
имея только лишь ее вьюМодель.
+ (CGRect) rectFor_signUpButton:(LoginViewModel*)viewModel parentFrame:(CGRect)parentFrame{ if (CGRectEqualToRect(CGRectZero, parentFrame)) return CGRectZero; // Calculating... return rect;}
Советы и рекомендации
По собственному опыту использования могу сказать, что
имплементироватьMFS
можно выборочно.
Например, как правило, имеет смысл создавать подобную архитектуру в
тех случаях, когда мы имеем
сложныйUIViewController
(UI
для которого
ввиду его сложности мы создаем кодом), или же когда имеем сложные
ячейки таблицы.
То есть, для контроллера классаUITableViewController
,
за исключением особых случаев - смысла имплементировать данное
решение нет.
Заключение
В данной статье вы имели возможность ознакомиться с
паттерномMFS
на примере работы с вьюКонтроллерами.
Во второй части статьи мы поговорим о
примененииMFS
при работе с ячейками таблицы, как
обеспечивать60FPS
при быстром
скроллинге сложных таблиц на старых девайсах.