Русский
Русский
English
Статистика
Реклама

Mvvm

Фреймворк-независимое браузерное SPA

14.03.2021 16:17:43 | Автор: admin

1. Но... зачем?

  1. Существует огромное количество фреймворков для разработкиSPA(Single Page Application).

  2. Существует огромное количество документации, иллюстрирующей как создавать приложение набазе конкретного фреймворка.

  3. Однако всякая подобная документация ставит фреймворк воглаву угла. Тем самым превращая фреймворк издетали реализации вопределяющий фактор. Таким образом значительная часть кода пишется недля удовлетворения нужд бизнеса адля удовлетворения нужд фреймворка.

Учитывая насколько hype-driven является разработка софта внаше время, можно быть уверенным втом что через несколько лет будут существовать новые модные фреймворки для фронтенд разработки. Вмомент когда фреймворк набазе которого построено приложение выходит измоды вывынуждены либо поддерживать устаревшую (legacy) кодовую базу либо стартовать процесс перевода приложения нановый фреймворк.

Оба варианта сопряжены сущербом для бизнеса. Поддержка устаревшей кодовой базы означает проблемы снаймом новых имотивацией текущих разработчиков. Перевод приложения нановый фреймворк стоит времени (следственно денег) ноненесет никакой пользы для бизнеса.

Данная статья является примером построения SPA сиспользованием высокоуровневых принципов дизайна архитектуры. При этом конкретные библиотеки ифреймворки выбираются для удовлетворения ответственностей, определённых желаемой архитектурой.

2. Архитектурные цели иограничения

Цели:

  1. Новый разработчик может понять назначение приложения при поверхностном взгляде наструктуру кода.

  2. Стимулируется разделение ответственностей (separation ofconcerns) иследовательно модульность кода так что:

    • Модули легко поддаются тестированию

    • Интеграции свнешними сервисами (boundaries) атакже грязные хаки иворкэраунды вынесены вотдельные модули инепротянуты через несколько различных файлов. Таким образом смена реализации интеграции ссервисом или отказ отхака становится реалистичной задачей анедолгосрочным рефакторингом

  3. Логически связанные вещи размещены недалеко друг отдруга вструктуре файлов.Вот хорошая статья, которая иллюстрирует разницу между структурированием кода потехническим слоямипофункциональным ответственностям.

  4. Позволяет реализовать слабую связность между модулями. Как следствие изменение реализации модуля (или его замена) недолжна приводить кизменению кода, использующего данный модуль.

  5. Механики взаимодействия модулей неприводят кнедопустимым проблемам спроизводительностью.

  6. Зависимости отбиблиотек неторчат сквозь всю кодовую базу. Они удерживаются внутри ограниченного числа модулей, ответственных заинтеграцию сконкретной библиотекой.

Ограничения:

Приложение должно работать вбраузере. Следовательно оно должно быть написано сиспользованием (или скомпилированов) HTML+CSS для определения статического интерфейса иJavaScript для добавления динамического поведения.

3. Ограничим тему данной статьи

Существует большое количество архитектурных подходов кструктурированию кода. Наиболее распространенные наданный момент: слоеная (layered), луковичная (onion) ишестигранная (hexagonal). Беглое сравнение было дано вмоей предыдущейстатье.

Данная статья ограничивается слоем представления втерминологии слоеной/луковичной архитектур поскольку большинство SPA занимается исключительно отображением данных. Таким образом слои домена (domain) иприложения (application) могут быть проигнорированы. Как следствие, наиболее естественный способ понять назначение такого приложения получить обзорное представление ослое представления.

Тем неменее данная статья определяет механики взаимодействия сослоями приложения идомена, вслучае когда такие слои присутствуют.

Интересно отметить что вслучае отсутствия вышеупомянутых слоев приложение напоминает классическую шестигранную структуру (также называемуюPorts and Adapters) вкоторой представлениеявляетсяприложением. Взгляните наинтеграцию сlocalStorage вTodoMVCпримере созданном вкачестве иллюстрации кданной статье (папкаboundaries/local-storage).

4. Структура файлов. Как заставить SPAкричать?

Будем исходить из терминологии дяди Боба.

Рассмотрим типичный онлайн магазин. Приблизительно так онмогбы быть нарисован насалфетке владельцем бизнеса:

Рисунок1: типичный онлайн магазин, нарисованный насалфетке

Каким может быть наиболее кричащий способ структурировать кодовую базу? Нарисунке 2все страницы отражены как папки.

Рисунок2: структура папок верхнего уровня, отражающая страницы определённые нарисунке 1

Заметим что мыдобавили папку shared как место где будут определены общие UIблоки, такие как шаблон, панель навигации, корзина.

Наши страницы построены излогических (ивидимых) частей. Пока что назовем их блоками иположим впапку сименем parts. Посмотрим что получилось (рисунок 3).

Рисунок3: размещение вложенных блоков внутри подпапки parts

Как видно, вложенность выглядит отвратительно уже для второго уровня для страницы goods catalogue. Путь goods-catalogue/parts/goods-list/parts/good-details.jsуже награнице адекватной длины пути кфайлу. При том что вреальных приложениях два уровня вложенности далеко непредел.

Давайте избавимся отпапок parts вфайловой структуре. Посмотрим нарисунок 4.

Рисунок4: вложенные блоки вынесены изпапок parts

Теперь внутри пути goods-catalogue/goods-listнаходится три файла.goods-list.js(родительский) расположен между файлами, определяющими вложенные внего блоки. Вреальных проектах, учитывая кол-во разнородных файлов (js, html, css) это приводит кневозможности разделить файлы, определяющие текущий блок ифайлы, отвечающими завложенные внего блоки.

Решение:

  1. Если конкретный блок определяется несколькими файлам создаем для него папку.

    • goods-listявляется блоком исостоит изболее чем одного файла, потому для него создана папка.

    • filtersявляется блоком состоящим изодного файла, потому для него несоздана отдельная папка.

  2. Если конкретный блок (неважно изодного файла или изнескольких) являетсявложенным блоком добавим кназванию файла префикс _. Таким образом все вложенные блоки будут подняты кверху папки вфайловом обозревателе.

    • _goods-list folderявляется вложенным блоком относительноgoods-catalogueсоответственно кназванию папки добавлен префикс.

    • goods-list.jsявляется частью определения блока_goods-listсоответственно префикс недобавлен.

    • _good-details.jsявляется вложенным блоком относительно_goods-listсоответственно префикс добавлен.

Рисунок5: использование префикса _ для разделения вложенных блоков отихродителей

Готово! Теперь открывая папку сблоком мыможем сразуже увидеть иоткрыть основной файл, определяющий данный блок. После чего при необходимости перейти квложенному блоку. Обратите внимание что папкаpagesбыла переименована вcomponentsнарисунке 5. Так сделано поскольку страницы иблоки логически являются разными вещами новтерминологии HTML итоидругое можетбы представлено какcomponent. Сэтого момента папкаcomponentsявляется основной папкой нашего приложения, домом для слоя представления.

5. Язык разработки. JavaScript?

Единственный язык который может быть выполнен вбраузере это JavaScript. Существует множество статей посвященных его несуразности. Выможетепосмеяться онем (тайм код1-20), ноэто только веселая часть...

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

Потому даже при использовании чистого JavaScript разработчик вынужден использовать транспилятор (babel) стем чтобы получить JavaScript, совместимый сбраузерами изсовременного имодного JavaScript. Поскольку использование транспилятора исоответствующее замедление сборки приложения неизбежно нет причин игнорировать другие, более предсказуемые иболее функциональные языки программирования.

Глубокий анализ возможных опций лежит вне границ данной статьи, номой персональный выборTypeScriptпосколькуон:

  • Обеспечивает проверку типов наэтапе компиляции

  • Будучи над-множеством JavaScript, может выполнять JavaScript код без дополнительного интеграционного кода

  • Определения типов (typings) могут быть добавлены поверх существующего JavaScript кода без его изменения. Благодаря простоте этой возможности, большинство существующих npm пакетов уже покрыты тайпингами. Таким образом выможете использовать эти пакеты так, как будтобы они являются TypeScript пакетами. Соответственно ихиспользование также является типо-безопасным.

Хинт: рекомендую посмотреть всторонуasm.js,blazorиelmесли вызаинтересованы вдругих опциях

6. Требования кдизайну приложения

Давайте вспомним ограничения, накладываемые браузерами: HTML, CSS, JavaScript. Также вспомним структуру файлов, определенную вразделе4: дерево директорий, отражающее дерево визуальных элементов.

Таким образомпервой целью [6.1]будет возможность определения компонентов средствами HTML иCSS иихпоследующее переиспользование другими компонентами.

Существенный недостаток чистого HTML состоит втом что оннетипизирован. Существует достаточное количество движков шаблонизации, таких какunderscore.js,handlebars.js. Однако все они принимают навход строки, что ограничивает нас впроверке корректности используемых вшаблоне данных наэтапе компиляции приложения.

Таким образомвторой целью [6.2]является возможность определить TypeScript интерфейсы отражающие все свойства, используемые вшаблоне (компоненте).После чего наэтапе компиляции выбросить исключение вслучае если вразметке компонента происходит обращение кнеопределенному свойству.

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

Таким образомтретьей целью [6.3]является возможность компонентов принимать данные изатрибутов иизхранилищ одновременно. Компоненты должны быть перерисованы при изменении любой части принимаемых данных.

Четвертой целью [6.4]станет определение требований ктаким хранилищам:

  • Должна существовать возможность использовать хранилище несколькими компонентами, каждый изкоторых отображает лишь часть общего набора данных.

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

  • Хранилища должны иметь возможность использовать сервисы ифункции слоев Domain иApplication. Воизбежание сильной связности между хранилищем играницами приложения, сервисы должны быть использованы спомощью механизмаDependency Injection. Хранилища должны ссылаться только наинтерфейсы.

Ипоследнее мынехотим чтобы данные внутри хранилищ были публичными воизбежание нежелательного изменения данных впроцессе рендеринга. Хранилища должны быть ответственны засвою целостность. Компонентыже, всвою очередь, должны быть ничем большим чем строго-типизированные-и-оптимизированные-html-шаблоны. Для достижения подобного разделения хранилища должны инкапсулировать данные внутри себя ипредоставлять методы для работы сэтими данными. Другими словами, хранилища должны быть классическими экземплярами классов.

Однако, как замечено ранее, хранилище может быть использовано большим количеством мелких компонентов. Былобы полезно определять срез данных, используемый конкретным компонентом. Таким образом мыдостигнем:

  • Лучшей читаемости кода, т.к. разработчик может предположить назначение компонента изнабора данных принимаемых этим компонентом.

  • Лучшей производительности т.к. можно избежать перерисовки компонента вслучае изменения неиспользуемых данных.

Таким образом,пятая цель [6.5] позволить хранилищам данных быть определенными как классические TypeScript классы. Обозначить механику определения среза данных, используемого конкретным компонентом.

Держа эти цели вголове, давайте перечислим необходимые логические блоки кода:

  • Компоненты (Components) строго типизированные HTML шаблоны + CSS стили

  • Модели вида (ViewModels) классы, инкапсулирующие состояние данных, используемое компонентом (ивсей иерархией компонентов под ним).

  • Фасады моделей вида (ViewModel facades) ограничивают видимость свойств модели вида теми, которые используются вконкретном компоненте.

Рисунок6: желаемая структура кода вслое представления

  • Не-пунктирные стрелки отражают рендеринг компонентов родительскими компонентами. Направление стрелки отражает направление передачи атрибутов.

  • Пунктирные линии отражают зависимости одних логических кусков кода отдругих (ссылки).

  • Блоки сзеленой рамкой границы модуля. Каждый модуль/подмодуль отражен выделенной под него папкой. Общие модули лежат впапке shared.

  • Голубые блоки модели вида. Модели вида определены поштуке намодуль/подмодуль.

Что упущено? Заметьте как модели вида нарисунке 6не имеют никаких параметров. Это всегда справедливо для модулей верхнего уровня (страниц) иглобальных моделей вида. Ноподмодули зачастую зависят отпараметров, определённых впроцессе работы сприложением.

Обозначимшестую цель [6.6] позволить атрибутам подмодуля быть использованными моделью вида этого подмодуля.

Рисунок7: атрибуты передаются нетолько вкорневой компонент модуля ноивего модель вида

7. Техническая реализация

Ябуду использовать популярные библиотеки стем чтобы сделать статью легче для восприятия. Поэтой причине тут небудет сравнения различных вариантов для реализации конкретной ответственности всегда будет использована наиболее популярная библиотека.

7.1. Компоненты

Для отрисовки строго-типизированной разметки можно использовать синтаксис tsx (типизированныйjsx). Рендеринг tsx поддерживается различными библиотеками, такими какReact,PreactandInferno. TsxНЕявляется чистым HTML, тем неменее онможет быть автоматически сконвертирован в/из HTML. Потому зависимость отtsx мне кажется допустимой т.к.вслучае миграции начистый HTML, значительная часть работы может быть выполнена автоматически.

Для уменьшения зависимости отконкретной библиотеки для рендера, мыограничим компоненты так, чтобы они были чистыми функциями, принимающими атрибуты ивозвращающими JSXузел. Этотподходбыл популярен иотлично себя зарекомендовал вранний период развития React.

Хинт: впоследние годы функциональные компоненты ввиде чистых функций вышли измоды всообществе React. Использованиеreact hooksнаделяет функциональные реакт компоненты сайд-еффектами ипоощряет смешивание рендера слогикой управления состоянием. Хуки являются специфическим API для React инедолжны использоваться при разработке вподходе, описанном вданной статье.

Другими словами,компонентылишены состояния. Представим ихчерез выражение UI=F(S) где

  • UI видимая разметка

  • F определение компонента

  • S текущее значение данных внутри модели вида (здесь идалее вьюмодели)

Пример компонента может выглядет так:

interfaceITodoItemAttributes{name:string;status:TodoStatus;toggleStatus:()=>void;removeTodo:()=>void;}constTodoItemDisconnected=(props:ITodoItemAttributes)=>{constclassName=props.status===TodoStatus.Completed?'completed':'';return(<liclassName={className}><divclassName="view"><inputclassName="toggle"type="checkbox"onChange={props.toggleStatus}checked={props.status===TodoStatus.Completed}/><label>{props.name}</label><buttonclassName="destroy"onClick={props.removeTodo}/></div></li>)}

Этот компонент отвечает заотрисовку одного todo элемента внутриTodoMVCприложения.

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

Итого мыдостигли целей[6.1]и[6.2].

Хинт: яиспользую react дляTodoMVC приложенияприведенного вкачестве примера.

7.2. Модели Вида (вьюмодели)

Как было сказано ранее, мыхотим чтобы вьюмодели были написаны ввиде TypeScript классов стем что-бы:

  • Обеспечивать инкапсуляцию данных.

  • Предоставлять возможность взаимодействия сослоями domain/application посредством механизма dependency injection.

Однако, классы непредоставляют встроенных механик перерисовки компонентов использующих данные, инкапсулированные экземпляром класса.

Применим принципы реактивного интерфейса (reactive UI). Подробное описание этих принципов приведено вэтом документе. Данный подход был впервые представлен вWPF (C#) иназванModel-View-ViewModel. ВJavaScript сообществе, объекты предоставляющие доступ кобозреваемым (observable) данным чаще называются хранилищами (stores) следуя терминологииflux. Отмечу чтохранилищеэто очень абстрактный термин, онможет определять:

  • Глобальное хранилище данных для всего приложения.

  • Доменный объект, инкапсулирующий логику логику бизнеса инепривязанный кконкретному компоненту ноинеявляющийся глобальным.

  • Локальное хранилище данных для конкретного компонента или иерархии компонентов.

Таким образом любая вьюмодель является хранилищем, нонекаждое хранилище является вьюмоделью.

Определим ограничения креализации вьюмоделей:

  • Код, обеспечивающий реактивность, недолжен быть смешан скодом реализации конкретных бизнес функций.

  • Вьюмодель недолжна ссылаться накомпоненты инедолжна знать отом что существуют конкретные компоненты, ссылающиеся наэту вьюмодель.

Яиспользуюmobxдекораторы для того, чтобы сделать поля класса обозреваемыми. Пример вьюмодели:

classTodosVM{@mobx.observableprivatetodoList:ITodoItem[];//use"poormanDI",butintherealapplicationstodoDaowillbeinitializedbythecalltoIoCcontainerconstructor(props:{status:TodoStatus},privatereadonlytodoDao:ITodoDAO=newTodoDAO()){this.todoList=[];}publicinitialize(){this.todoList=this.todoDao.getList();}@mobx.actionpublicremoveTodo=(id:number)=>{consttargetItemIndex=this.todoList.findIndex(x=>x.id===id);this.todoList.splice(targetItemIndex,1);this.todoDao.delete(id);}publicgetTodoItems=(filter?:TodoStatus)=>{returnthis.todoList.filter(x=>!filter||x.status===filter)asReadonlyArray<Readonly<ITodoItem>>;}///...othermethodssuchascreationandstatustogglingoftodoitems...}

Обратите внимание что мыссылаемся наmobx напрямую, однако декораторы неприсутствуют втеле методов.

Япродемонстрирую как абстрагировать реактивность иубрать зависимость отmobx вследующей статье. Пока что посчитаем достаточным обращаться кдекораторам через пространство имен mobx. При таком ограничении заменить декораторы надекораторы издругой библиотеки можно будет спомощью автоматизированного скрипта.

Также обратите внимание что конструктор вьюмодели принимает первый аргумент типа{status:TodoStatus}. Это позволяет удовлетворитьцели [6.6]. Тип должен совпадать стипом определяющим атрибутыкорневого компонентамодуля. Ниже обобщенный интерфейс вьюмодели:

interfaceIVMConstructor<TProps,TVMextendsIViewModel<TProps>>{new(props:TProps,...dependencies:any[]):TVM;}interfaceIViewModel<IProps=Record<string,unknown>>{initialize?:()=>Promise<void>|void;cleanup?:()=>void;onPropsChanged?:(props:IProps)=>void;}

Все методы вьюмодели необязательны. Они могут быть определены для:

  • Выполнения кода при создании вьюмодели

  • Выполнения кода при удалении вьюмодели

  • Выполнения кода при изменении атрибутов (под-)модуля.

Вотличии откомпонентов, вьюмодель хранит свое состояние (является statefull). Она должна быть создана когда модуль появляется настранице иудалена как только модуль исчезает состраницы.

Как показано нарисунке7, точкой входа для модуля является его корневой компонент. Таким образом вьюмодель должна быть создана когда корневой компонент модуля добавлен вструктуру DOM(mounted) иудалена когда онудаляется состраницы(unmounted). Решить эту задачу можно спомощью техники компонентов высшего порядка (higher order components).

Определим тип функции:

typeTWithViewModel=<TAttributes,TViewModelProps,TViewModel>(moduleRootComponent:Component<TAttributes&TViewModelProps>,vmConstructor:IVMConstructor<TAttributes,TViewModel>,)=>Component<TAttributes>

Эта функция возвращает компонент высшего порядка над moduleRootComponent, который:

  • Должен обеспечить создание вьюмодели перед созданием имонтированием (mount) компонента.

  • Должен обеспечить зачистку(удаление) вьюмодели при демонтировании (unmount).

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

Пример использования данной функции:

constTodoMVCDisconnected=(props:{status:TodoStatus})=>{return<sectionclassName="todoapp"><Header/><TodoListstatus={props.status}/><FooterselectedStatus={props.status}/></section>};constTodoMVC=withVM(TodoMVCDisconnected,TodosVM);

Вразметку корневой страницы приложения (либо роутера, зависит оттого что как построено ваше приложение), результирующий компонент будет вставлен как<TodoMVCstatus={statusReceivedFromRouteParameters}/>. После чего, экземплярTodosVMстановится доступным для всех под-компонентов внутри компонентаTodoMVC.

Важно что реализация создания вьюмодели, атакже доступности вьюмодели для дочерних компонентов, скрыта внутри withVM.

  • TodoMVCDisconnected компонент независит отбиблиотеки рендера

  • TodoMVC компонент может быть прорендерен вкомпоненте, независящем отбиблиотеки рендера

  • TodosVM ссылается только надекораторы. Потому, как описано выше, еёотвязка отmobx реальна.

Хинт: вреализации изпримера, функцияwithVMзависит отreact context API. Выможете попробовать реализовать аналогичное поведение вобход контекст апи. Важно, что реализация должна быть синхронизирована среализацией доступа квьюмодели изфасадов вьюмоделей смотрите описание функцииconnectFnвследующем разделе.

7.3. Фасады вьюмоделей

Фасадопределяет класс, выставляющий для публичного доступа ограниченное количество функций/данных модуля. Изрисунка 6видно что мыхотим иметь пофасаду накаждый компонент. Однако создание дополнительного класса накаждый компонент будет излишне многословным.

Попробуем вместо классических фасадов использовать функции, принимающие вьюмодель (или несколько вьюмоделей) ивозвращающие набор функций/данных, необходимых конкретному компоненту. Назовем ихфункциями среза (slicing function). Что если такая функция будет получать атрибуты компонента, который она обслуживает, вкачестве последнего аргумента?

Рисунок8: передача атрибутов компонента фасаду вьюмодели (функции среза/slicing function)

Посмотрим насинтаксис (вслучае одной вьюмодели):

typeTViewModelFacade=<TViewModel,TOwnProps,TVMProps>(vm:TViewModel,ownProps?:TOwnProps)=>TVMProps

Выглядит очень похоже нафункцию connectизбиблиотеки Redux. Стой лишь разницей что вместо аргументовmapStateToProps,mapDispatchToActionsиmergePropsмы имеем один аргумент функцию среза, которая должна вернуть данные иметоды одним объектом. Ниже пример функции среза для компонентаTodoItemDisconnectedивьюмоделиTodosVM.

constsliceTodosVMProps=(vm:TodosVM,ownProps:{id:string,name:string,status:TodoStatus;})=>{return{toggleStatus:()=>vm.toggleStatus(ownProps.id),removeTodo:()=>vm.removeTodo(ownProps.id),}}

Заметка: Яназвал аргумент функции, содержащий атрибуты компонента OwnProps что-бы приблизить его ктерминологии применяемой вreact/redux.

Такая схожесть диктует наиболее естественный способ использования функций среза спомощью компонентов высшего порядка. Представим что вьюмодель уже хостится компонентом выше вдереве компонентов спомощью функцииwithVM. Давайте определим тип функции, которая принимает функцию среза икомпонент, авозвращает компонент высшего порядка, привязанный квьюмодели:

typeconnectFn=<TViewModel,TVMProps,TOwnProps={}>(ComponentToConnect:Component<TVMProps&TOwnProps>,mapVMToProps:TViewModelFacade<TViewModel,TOwnProps,TVMProps>,)=>Component<TOwnProps>constTodoItem=connectFn(TodoItemDisconnected,sliceTodosVMProps);

Отрисовка такового компонента всписке todo элементов:<TodoItemid={itemId}name={itemName}status={itemStatus}/>

Заметьте чтоconnectFnскрывает детали реализации реактивности:

  • Она берёт компонентTodoItemDisconnectedифункцию срезаsliceTodosVMProps обе незнающие ничего ореактивности иобиблиотеке для рендеринга JSX.

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

Смотрите нареализациюфункции connectFnдля TodoMVCприложения, сделанного вкачестве примера.

8. Заключение

Итого весь код, относящийся кконкретным бизнес задачам приложения, независим отфреймворков. TypeScript объекты, функции, TSX это все кчему мыпривязаны.

Надеюсь что прочтение этой статьи продемонстрировало пользу проработки архитектуры SPA приложения вперёд старта разработки. Буду счастлив если майндсет хотябы одного разработчика настарте разработки SPA изменится сберем свежий фреймворк ивсе должно быть хорошо наподумаем что конкретно нужно сделать ивыберем подходящие инструменты.

Всеже, можетли слой представления быть полностью независим отфреймворков вреальном приложении?

Для того что-бы убрать ссылки наmobx, react иmobx-react изслоя представления, нужно сделать немного больше:

  • Абстрагироваться отmobx декораторов

  • Абстрагировать все фреймворко-зависимые библиотеки, используемые слоем представления. КпримеруTodoMVCзависит отбиблиотек react-router иreact-router-dom.

  • Абстрагироваться отсинтетических событий, специфичных для конкретной библиотеки, отрисовывающей JSX.

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

Япрагматично продолжаю ссылаться насинтетические события реакта всвоих проектах. Таким образом замена React надругую библиотеку рендера потребует определенной работы, пусть эта работа ибудет локализована внутри компонентов инезатронет вьюмодели ифункции среза.

P.S. Сравнение рассмотренной структуры иеереализации спопулярными фреймворками для разработки SPA:

  • Всравнении сосвязкойReact/Redux: вьюмодели заменяютreducers,action creatorsиmiddlewares. Вьюмодели содержат состояние (являются stateful). Нет time-travel. Множество хранилищ. Отсутствие просадки производительности вызванной наличием большого числа использований функции connect скакой тологикой внутри. Redux-dirven приложения становятся все медленнее имедленнее стечением времени иззадобавления новых connected компонентов вприложение. При этом несуществует какого токонкретного ботлнека, устранением которого можно былобы исправить ситуацию.

  • Всравнении сvue: строго типизированные представления благодаря TSX. Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Vue.js заставляет определять состояниевнутри определенной структурыимеющей свойства data,methods, ит.д. Отсутствие vue-специфических директив исинтаксиса привязки кмодели.

  • Всравнении сangular: строго типизированные представления благодаря TSX. Отсутствие angular-специфических директив исинтаксиса привязки кмодели. Инкапсуляция данных внутри вьюмоделей вотсутствие двусторонней привязки данных (two-way data binding).Хинт: для определенных сценариев, таких как формы, двусторонняя привязка данных удобна иполезна.

  • Всравнении счистым react где управление состоянием выполняется спомощью хуков (hooks, такие какuseState/useContext):Лучшее разделение ответственностей. Вьюмодели могут восприниматься втерминологии реакта как контейнер компоненты, которые лишены возможность рендерить что-либо иявляются ответственными исключительно заработу сданными. Нет необходимости:

    • следить запоследовательностью вызова хуков.

    • отслеживать зависимости хуков useEffect внутри deps массива.

    • проверять смонтированли все еще компонент после каждого асинхронного действия.

    • следить что замыкания изпредыдущих рендеров неиспользуются внутри обработчика хука эффекта.

    Как любая технология, хуки (ивчастности useEffect) требует разработчика следовать некоторым рекомендациям. Эти рекомендации неявляются частью интерфейсов, ноприняты как подход, модель мышления (mental model) или стандартные практики (best practices). Прекраснаястатья про использование хуковотчлена команды разработки react. Прочитайте ееиответьте себе надва вопроса:

    • Что выполучаете используя хуки?

    • Как много правил, неконтролируемых компилятором/линтером исложно отслеживаемых наручном код ревью, нужно соблюдать чтобы использование хуков оставалось предсказуемым?Если второй список получился больше первого это хороший сигнал что относится кхукам стоит сбольшой осторожностью.Хинт: примерфрустрацииотхуков

  • Всравнении сreact-mobx интеграцией. Структура кода неопределяется пакетом react-mobx инепредлагается документацией кнему. Разработчик должен придумать подход кструктурированию кода сам. Рассмотренную встатье структуру можно считать таким подходом.

  • Всравнении сmobx-state-tree: Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками.Определение типавнутри mobx-state-tree опирается наспецифические функции этого пакета. Использование mobx-state-tree всвязке сTypeScript провоцирует дублирование информации поля типа объявляются как отдельный TypeScript интерфейс нопри этом обязаны быть перечислены вобъекте, используемом для определения типа.

Оригинал статьи наанглийском языке вблоге автора (меня же)

Подробнее..

MVVM и выбор элементов в адаптере Базовый адаптер

08.11.2020 12:22:14 | Автор: admin

Итак, я наконец-то добрался до кульминации своей идеи с библиотекой, включающей в себя логику выбора элементов из списка в адаптере. После решения, независимого от платформы, и библиотеки, основанной на LiveData, я написал то, что поможет быстро и легко связать всё это с адаптером, чтобы сократить код в целом.


Интерфейс SelectingListAdapter


Начнём с простого интерфейса SelectingListAdapter, который я добавил для адаптера с обычным линейным списком. По моему опыту, где-то 90-95% адаптеров реализуются именно в таком виде.


interface SelectingListAdapter<T> {    fun setListItems(items: ArrayList<T>)}

Ничего сложного только обновление списка элементов. И, на мой взгляд, не составит труда добавить этот интерфейс к списку реализуемых в вашем адаптере. Зачем? Чтобы можно было использовать оба метода расширения, которые я укажу далее:


fun <T> SelectingListAdapter<T>.observeItemsChange(lifecycleOwner: LifecycleOwner,                                                   liveDataSource: LiveDataSource<T>) {    liveDataSource.allItems.observe(lifecycleOwner, { items -> setListItems(items) })}

Метод observeItemsChange подписывает на изменения списка элементов в LiveDataSource.


fun RecyclerView.Adapter<*>.observeSelectionChange(lifecycleOwner: LifecycleOwner,                                                   liveDataSource: LiveDataSource<*>) {    liveDataSource.observeSelectionChange(lifecycleOwner) { position, _ ->        notifyItemChanged(position)    }}

Метод observeSelectionChange уже подписывает на изменения в выборе элементов. Здесь происходит только оповещение о том, что элемент надо обновить. О проверке, выбран ли уже этот элемент, будет описано ниже.


fun <T, TAdapter> TAdapter.observeAllChanges(lifecycleOwner: LifecycleOwner,                                             liveDataSource: LiveDataSource<T>)        where TAdapter : RecyclerView.Adapter<*>,              TAdapter : SelectingListAdapter<T> {    observeSelectionChange(lifecycleOwner, liveDataSource)    observeItemsChange(lifecycleOwner, liveDataSource)}

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


Класс BaseSelectingListAdapter


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


abstract class BaseSelectingListAdapter<T, VH: BaseSelectingListHolder<T>>         : RecyclerView.Adapter<VH>(), SelectingListAdapter<T> {    var callback: SelectingListAdapterCallback? = null    ...}abstract class BaseSelectingListHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView) {    abstract fun bindItem(item: T, isSelected: Boolean, onClick: (() -> Unit)?)}

Пока сосредоточимся на том, что же это за SelectingListAdapterCallback.


interface SelectingListAdapterCallback {    fun isItemSelected(position: Int): Boolean    fun clickItem(position: Int)}

Это весьма простенький колбэк, на котором будет как раз определение, выбран ли запрашиваемый элемент, а так же на него будет перенаправлена обработка клика по элементу.
И в BaseSelectingListAdapter есть метод, чтобы создать этот самый колбэк на основании LiveDataSource.


fun <T> setCallback(liveDataSource: LiveDataSource<T>) {    callback = object : SelectingListAdapterCallback {        override fun isItemSelected(position: Int) =            liveDataSource.isItemSelected(position)        override fun clickItem(position: Int) {            liveDataSource.clickPosition(position)        }    }}

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


    fun fullyInitialize(lifecycleOwner: LifecycleOwner,                        liveDataSource: LiveDataSource<T>) {        observeAllChanges(lifecycleOwner, liveDataSource)        setCallback(liveDataSource)    }

Ну и метод fullyInitialize, который помимо установки колбэка ещё и сразу подписывает на все изменения. Лучше названия для него придумать так и не смог. Либо английский недостаточно хорошо знаю, либо этот метод просто делает слишком много, поэтому он вовсе существовать не должен.


В итоге в вашем адаптере остаётся только реализовать метод создания холдера onCreateViewHolder и реализовать метод холдера bindItem. По-моему, получилось избавиться от достаточно большого boilerplate куска.


class MyAdapter : BaseSelectingListAdapter<User, MyHolder>() {    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =        MyHolder(...)//create your holder view}class MyHolder(itemView: View) : BaseSelectingListHolder<User>(itemView) {    override fun bindItem(item: User, isSelected: Boolean, onClick: (() -> Unit)?) {        //bind your data    }}

Перспективы


Эта часть уже скорее для того, чтобы самому не забыть о возможных дополнениях. Если моё решение будет кто-нибудь использовать, то вот список того, что следовало бы дополнить:


  1. Потокобезопасность.
  2. Изменение типа выбора элементов (с одиночного на множественный, например), то есть подмена SelectionManager'а.

Сылки


Исходные коды для библиотек:



Ссылки в Gradle:
implementation 'ru.ircover.selectionmanager:core:1.1.0'
implementation 'ru.ircover.selectionmanager:livesource:1.0.1'
implementation 'ru.ircover.selectionmanager:selectingadapter:1.0.0'

Подробнее..

Легкий DataBinding для Android

22.03.2021 04:10:35 | Автор: admin

Здравствуйте уважаемые читатели. Все мы любим и используем DataBinding, который представила компания Google несколько лет назад, для связи модели данных с вьюшками через ViewModel. В этой статье, хочу поделиться с вами, как можно унифицировать этот процесс с помощью языка Kotlin, и уместить создание адаптеров для RecyclerView (далее RV), ViewPager и ViewPager2 в несколько строчек кода.

Начну с того, что раньше разрабатывали кастомные адаптеры, которые под капотом создавали ViewHolder'ы, и их написание, а тем более поддержка, занимала достаточно большое количество времени. Ниже приведу пример типичного адаптера для RV:

class CustomAdapter(private val dataSet: Array<String>) : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {        class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {    val textView: TextView            init {      textView = view.findViewById(R.id.textView)    }  }       override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {    val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.text_row_item, viewGroup, false)          return ViewHolder(view)  }         override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {    // Get element from your dataset at this position and replace the    viewHolder.textView.text = dataSet[position]  }        override fun getItemCount() = dataSet.size}

С тем как проект увеличивается, подобных адаптеров может становиться намного больше. Помню, однажды, адаптер был настолько огромный, в несколько сотен строчек кода, что разобраться, что там происходит, а тем более добавить что-то новое занимало колосальное количество времени, так как он работал с разными моделями данных, а так же должен был создавать различные отображения для каждого типа данных. Честно скажу, это было тяжело.

Затем появился DataBinding и большую часть по связыванию данных перекладывалась на него, но адаптеры все равно приходилось писать вручную, изменились только методы onCreateViewHolder, где вместо инфлэйтинга через LayoutInflater, использовался DataBindingUtil.inflate, а при создании вьюхолдеров данные связывались непосредственно с самой вьюшкой через ссылку на созданный объект байдинга.

class BindingViewHolder(val binding: ItemTextRowBinding) : RecyclerView.ViewHolder(binding.root)override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {        val binding = DataBindingUtil.inflate<ItemTextRowBinding>(LayoutInflater.from(parent.context), viewType, parent, false)        val viewHolder = BindingViewHolder(binding)        return viewHolder}override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {      holder.binding.setVariable(BR.item, dataSet[position])}

Выглядит уже лучше, но что если в RV, по прежнему должны отображаться элементы лайаута с разными типами данных, то такая реализация не сильно помогла решить проблему больших адаптеров. И здесь на помощь приходит аннотация BindingAdapter из библиотеки androidx.databinding. С ее помощью, можно создать универсальное решение, которое скрывает реализацию создания адаптера для RV, если использовать вспомогательный объект-конфигуратор DataBindingRecyclerViewConfig, в котором содержится ряд свойств для настройки адаптера.

В результате на свет появилась библиотека, которая называется EasyRecyclerBinding. В нее так же вошли BindingAdapters для ViewPager и ViewPager2. Теперь процесс связывания данных выглядит следующим образом:
1) В лайауте фрагмента, необходимо добавить специальные переменные, которые содержат список отображаемых моделей данных и конфигурацию, указав их атрибутами для RV, - app:items и app:rv_config.

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>    <variable        name="vm"        type="com.rasalexman.erb.ui.base.ExampleViewModel" />        <variable            name="rvConfig"            type="com.rasalexman.easyrecyclerbinding.DataBindingRecyclerViewConfig" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:id="@+id/main"        android:layout_width="match_parent"        android:layout_height="match_parent">        <androidx.recyclerview.widget.RecyclerView            android:layout_width="0dp"            android:layout_height="0dp"            app:items="@{vm.items}"            app:rv_config="@{rvConfig}"            app:layout_constraintBottom_toBottomOf="parent"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:listitem="@layout/item_recycler"/>    </androidx.constraintlayout.widget.ConstraintLayout></layout>

ViewModel, соответственно, содержит список моделей данных для адаптера, которые должны отображаться в RV, а фрагмент конфигурацию DataBindingRecyclerViewConfig.

// названия пакетов не указаны для простоты примераclass ExampleViewModel : ViewModel() {     val items: MutableLiveData<MutableList<RecyclerItemUI>> = MutableLiveData()}data class RecyclerItemUI(    val id: String,    val title: String)

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

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable            name="item"            type="com.rasalexman.erb.models.RecyclerItemUI" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="@android:drawable/list_selector_background">        <TextView            android:id="@+id/titleTV"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingTop="8dp"            android:paddingEnd="16dp"            android:textColor="@color/black"            android:textSize="18sp"            android:text="@{item.title}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:text="Hello world" />        <TextView            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingEnd="16dp"            android:paddingBottom="8dp"            android:textColor="@color/gray"            android:textSize="14sp"            android:text="@{item.id}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toBottomOf="@+id/titleTV"            tools:text="Hello world" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>

2) Во фрагменте нам нужно получить конфигурацию для адаптера и передать её в отображение через инстанс dataBinding, используя специальную функцию-конструктор createRecyclerConfig<I : Any, BT : ViewDataBinding>, которая создаст и вернет инстанс DataBindingRecyclerViewConfig, указав при этом id лайаута для выбранной модели, и название свойства, к которому будет прикреплена данная модель.

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, ExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment  override val viewModel: ExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerConfig<RecyclerItemUI, ItemRecyclerBinding> {            layoutId = R.layout.item_recycler        itemId = BR.item                    }    }}

Это все, что нужно сделать, чтобы связать данные из ViewModel с отображением списка в RV. Так же при создании адаптера можно назначить слушатели событий для байдинга вьюхолдера, такие как onItemClick,onItemCreate, onItemBind и другие.

А чтобы использовать вьюхолдеры с разными визуальными лайаутами, к которым привязаны свои модели отображения данных, необходимо имплементировать в них специальный интерфейс из библиотеки EasyRecyclerBinding - IBindingModelи переопределить поле layoutResId, - id лайаута, который будет отображаться для этой модели в списке.

data class RecyclerItemUI(    val id: String,    val title: String) : IBindingModel {        override val layoutResId: Int            get() = R.layout.item_recycler}data class RecyclerItemUI2(    val id: String,    val title: String) : IBindingModel {    override val layoutResId: Int        get() = R.layout.item_recycler2}

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

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, RecyclerViewExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment    override val viewModel: RecyclerViewExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerMultiConfig {            itemId = BR.item        }    }}class RecyclerViewExampleViewModel : BasePagesViewModel() {    open val items: MutableLiveData<MutableList<IBindingModel>> = MutableLiveData()}

Таким образом, создание адаптеров для отображения данных в RV, превратилось в простую задачу состоящую из пары строчек кода, где разработчику уже не надо думать о том, как поддерживать, фактически, не нужную часть presentation слоя. И даже, если модель данных изменится, достаточно будет поменять только её отображение, и связать его с новыми данными, не заботясь о поддержке адаптеров.


Аналогичный процесс создания адаптеров для ViewPager и ViewPager2, представлен в примере на github вместе с открытым кодом, ссылку на который, я разместил в конце статьи. В настоящий момент библиотека еще дорабатывается, и хочется получить адекватный фидбек, и пожелания по дальнейшему ее развитию. Так же в неё вошли вспомогательные функции для удобного создания байдинга, в том числе в связке с ViewModel. (LayoutInflater.createBinding, Fragment.createBindingWithViewModel, etc)

Спасибо, что дочитали до конца. Приятного кодинга и хорошего настроения)

Подробнее..

Генерация типизированных ссылок на элементы управления AvaloniaUI с атрибутом xName с помощью C Source Generators API

29.11.2020 22:13:53 | Автор: admin


В апреле 2020-го года разработчиками платформы .NET 5 был анонсирован новый способ генерации исходного кода на языке программирования C# с помощью реализации интерфейса ISourceGenerator. Данный способ позволяет разработчикам анализировать пользовательский код и создавать новые исходные файлы на этапе компиляции. При этом, API новых генераторов исходного кода схож с API анализаторов Roslyn. Генерировать код можно как с помощью Roslyn Compiler API, так и методом конкатенации обычных строк.


Постановка задачи


С помощью новых генераторов исходного кода может получиться элегантно решить широкий спектр задач, включая генерацию шаблонного кода, который было бы не очень интересно и совсем не продуктивно писать вручную. Например, в приложениях, использующих AvaloniaUI фреймворк для разработки кроссплатформенных приложений с графическим интерфейсом, о котором недавно вышла статья на Хабре нередко приходится писать следующий код для получения ссылки на элемент управления, объявленный в XAML:


TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");

Элемент типа TextBox с именем PasswordTextBox при этом объявлен в XAML следующим образом:


<TextBox x:Name="PasswordTextBox"         Watermark="Please, enter your password..."         UseFloatingWatermark="True"         PasswordChar="*" />

Получать ссылку на элемент управления в XAML может понадобиться в случае необходимости применения анимаций, программного изменения стилей и свойств элемента управления, или использования кроссплатформенных типизированных привязок данных ReactiveUI, таких, как Bind, BindCommand, BindValidation, позволяющих связывать компоненты View и ViewModel без использования синтаксиса {Binding} в XAML-разметке.


public class SignUpView : ReactiveWindow<SignUpViewModel>{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Привязки данных ReactiveUI и ReactiveUI.Validation.        // Можно было бы схожим образом использовать расширение разметки Binding,        // но некоторые разработчики предпочитают описывать биндинги в C#.        // Почему бы не облегчить им (и многим другим) жизнь?        //        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);    }    // Шаблонный код для типизированного доступа к именованным    // элементам управления, объявленным в XAML.    TextBox UserNameTextBox => this.FindControl<TextBox>("UserNameTextBox");    TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");    TextBlock CompoundValidation => this.FindControl<TextBlock>("CompoundValidation");}

Код геттеров свойств, позволяющих получить типизированный доступ к элементам управления из XAML-файла, соответствующего SignUpView, выглядит шаблонным. Было бы неплохо научиться генерировать этот код, чтобы, с одной стороны, избежать многословности, а с другой стороны чтобы избежать возможных ошибок и опечаток в имени элемента управления, объявленного в XAML, или в имени его типа.


Если мы будем всё время писать код, как в примере выше, вручную, нам придётся самостоятельно следить за изменениями имён и типов в XAML-файле, и в случае ошибки мы узнаем о том, что что-то пошло не так, только после запуска приложения. Если бы мы генерировали ссылки некоторым способом, об ошибках и опечатках мы бы узнавали уже на этапе компиляции, и тратили бы меньше времени на отладку нашего кроссплатформенного приложения (как, впрочем, и на написание таких геттеров).


Пример входных и выходных данных


Мы ожидаем, что на вход наш генератор исходного кода будет получать два файла. Для компонента представления с именем SignUpView, данными файлами будут являться XAML-разметка SignUpView.xaml, и code-behind файл SignUpView.xaml.cs, содержащий логику пользовательского интерфейса. Например, для файла разметки пользовательского интерфейса SignUpView.xaml:


<Window xmlns="http://personeltest.ru/aways/github.com/avaloniaui"        xmlns:x="http://personeltest.ru/away/schemas.microsoft.com/winfx/2006/xaml"        x:Class="Avalonia.NameGenerator.Sandbox.Views.SignUpView">    <StackPanel>        <TextBox x:Name="UserNameTextBox"                 Watermark="Please, enter user name..."                 UseFloatingWatermark="True" />        <TextBlock Name="UserNameValidation"                   Foreground="Red"                   FontSize="12" />    </StackPanel></Window>

Содержимое файла SignUpView.xaml.cs будет выглядеть следующим образом:


public partial class SignUpView : Window{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Мы хотим иметь доступ к типизированным элементам управления вот здесь,        // чтобы, например, писать код наподобие вот такого:        // UserNameTextBox.Text = "Violet Evergarden";        // UserNameValidation.Text = "An optional validation error message";    }}

А сгенерированное содержимое SignUpView.xaml.cs должно будет выглядеть следующим образом:


partial class SignUpView{    internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindControl<global::Avalonia.Controls.TextBox>("UserNameTextBox");    internal global::Avalonia.Controls.TextBlock UserNameValidation => this.FindControl<global::Avalonia.Controls.TextBlock>("UserNameValidation");}

Префиксы global:: здесь нужны для избежания коллизий пространств имён. Дополнительно, необходимо полностью указывать имена типов также для избежания коллизий. По аналогии с WPF, мы маркируем генерируемые свойства как internal. В случае использования partial-классов базовый класс можно указывать только в одной из частей partial-класса, поэтому в сгенерированном коде мы опускаем указание базового класса таким образом пользователи нашего генератора смогут наследоваться от какого угодно наследника Window, будь то ReactiveWindow<TViewModel>, или другой тип окна.


Следует заметить, что при вызове метода FindControl обход дерева элементов производиться не будет Avalonia хранит именованные ссылки на элементы управления в словарях, называемых INameScope в терминологии Avalonia. При желании, Вы можете изучить исходный код методов FindControl и FindNameScope на GitHub.


Реализуем интерфейс ISourceGenerator


Простейший генератор исходного кода, который ничего не делает, выглядит следующим образом:


[Generator]public class EmptyGenerator : ISourceGenerator{    public void Initialize(GeneratorInitializationContext context) { }    public void Execute(GeneratorExecutionContext context) { }}

В методе Initialize предлагается проинициализировать новый генератор исходного кода, а в методе Execute выполнить все важные вычисления, и при необходимости добавить сгенерированные файлы исходного кода в контекст выполнения с помощью вызова метода context.AddSource(fileName, sourceText). При этом, файл проекта генератора исходного кода выглядит следующим образом:


<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>        <TargetFramework>netstandard2.0</TargetFramework>        <LangVersion>preview</LangVersion>        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>        <IncludeBuildOutput>false</IncludeBuildOutput>    </PropertyGroup>    <ItemGroup>        <PackageReference            Include="Microsoft.CodeAnalysis.CSharp"            Version="3.8.0-5.final"            PrivateAssets="all" />        <PackageReference            Include="Microsoft.CodeAnalysis.Analyzers"            Version="3.3.1"            PrivateAssets="all" />    </ItemGroup>    <ItemGroup>        <None Include="$(OutputPath)\$(AssemblyName).dll"              Pack="true"              PackagePath="analyzers/dotnet/cs"              Visible="false" />    </ItemGroup></Project>

Давайте, для начала, добавим в сборку проекта, ссылающегося на генератор, некоторый атрибут, с помощью которого пользователи нашего генератора будут помечать классы, для которых необходимо генерировать типизированные ссылки на элементы управления Avalonia, объявленные в XAML. Изменим код нашего генератора следующим образом:


[Generator]public class NameReferenceGenerator : ISourceGenerator{    private const string AttributeName = "GenerateTypedNameReferencesAttribute";    private const string AttributeFile = "GenerateTypedNameReferencesAttribute";    private const string AttributeCode = @"// <auto-generated />using System;[AttributeUsage(AttributeTargets.Class, Inherited=false, AllowMultiple=false)]internal sealed class GenerateTypedNameReferencesAttribute : Attribute { }";    public void Initialize(GeneratorInitializationContext context) { }    public void Execute(GeneratorExecutionContext context)    {        // Добавим код атрибута в файл 'GenerateTypedNameReferencesAttribute.cs'         // проекта разработчика, который решит воспользоваться нашим генератором.        context.AddSource(AttributeFile,            SourceText.From(                AttributeCode, Encoding.UTF8));    }}

Пока ничего сложного мы объявили исходный код атрибута, имя файла, и имя атрибута как константы, с помощью вызова SourceText.From(code) обернули строку в исходный текст, и затем добавили новый исходный файл в проект с помощью вызова context.AddSource(fileName, sourceText). Теперь в проекте, который ссылается на наш генератор, мы можем помечать интересующие нас классы с помощью атрибута [GenerateTypedNameReferences]. Для классов, помеченных данным атрибутом, мы будем генерировать типизированные ссылки на именованные элементы управления, объявленные в XAML. В случае рассматриваемого примера с SignUpView.xaml, code-behind данного файла разметки должен будет выглядеть вот так:


[GenerateTypedNameReferences]public partial class SignUpView : Window{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Мы пока только собираемся генерировать именованные ссылки.        // Если раскомментировать код ниже, проект не скомпилируется (пока).        // UserNameTextBox.Text = "Violet Evergarden";        // UserNameValidation.Text = "An optional validation error message";    }}

Нам необходимо научить наш ISourceGenerator следующим вещам:


  1. Находить все классы, помеченные атрибутом [GenerateTypedNameReferences];
  2. Находить соответствующие классам XAML-файлы;
  3. Извлекать полные имена типов элементов интерфейса, объявленных в XAML-файлах;
  4. Вытаскивать из XAML-файлов имена (значения Name или x:Name) элементов управления;
  5. Генерировать partial-класс и заполнять его типизированными ссылками.

Находим классы, маркированные атрибутом


Для реализации такой функциональности API генераторов исходного кода предлагает реализовать и зарегистрировать интерфейс ISyntaxReceiver, который позволит собрать все ссылки на интересующий синтаксис в одном месте. Реализуем ISyntaxReceiver, который будет собирать все ссылки на объявления классов сборки пользователя нашего генератора:


internal class NameReferenceSyntaxReceiver : ISyntaxReceiver{    public List<ClassDeclarationSyntax> CandidateClasses { get; } =        new List<ClassDeclarationSyntax>();    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)    {        if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&            classDeclarationSyntax.AttributeLists.Count > 0)            CandidateClasses.Add(classDeclarationSyntax);    }}

Зарегистрируем данный класс в методе ISourceGenerator.Initialize(GeneratorInitializationContext context):


context.RegisterForSyntaxNotifications(() => new NameReferenceSyntaxReceiver());

Теперь, во время выполнения нашего генератора, мы можем получить доступ ко всем ClassDeclarationSyntax в сборке, и в цикле найти все классы, маркированные необходимым образом:


// Добавим в CSharpCompilation исходник нашего атрибута.var options = (CSharpParseOptions)existingCompilation.SyntaxTrees[0].Options;var compilation = existingCompilation.AddSyntaxTrees(CSharpSyntaxTree    .ParseText(SourceText.From(AttributeCode, Encoding.UTF8), options));var attributeSymbol = compilation.GetTypeByMetadataName(AttributeName);var symbols = new List<INamedTypeSymbol>();foreach (var candidateClass in nameReferenceSyntaxReceiver.CandidateClasses){    // Извлечём INamedTypeSymbol из нашего класса-кандидата.    var model = compilation.GetSemanticModel(candidateClass.SyntaxTree);    var typeSymbol = (INamedTypeSymbol) model.GetDeclaredSymbol(candidateClass);    // Проверим, маркирован ли класс с помощью нашего атрибута.    var relevantAttribute = typeSymbol!        .GetAttributes()        .FirstOrDefault(attr => attr.AttributeClass!.Equals(            attributeSymbol, SymbolEqualityComparer.Default));    if (relevantAttribute == null) {        continue;    }    // Проверим, маркирован ли класс как 'partial'.    var isPartial = candidateClass        .Modifiers        .Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword));    // Таким образом, список 'symbols' будет содержать только те    // классы, которые маркированы с помощью ключевого слова 'partial'    // и атрибута 'GenerateTypedNameReferences'.    if (isPartial) {        symbols.Add(typeSymbol);    }}

Находим подходящие XAML-файлы


В Avalonia действуют соглашения именования XAML-файлов и code-behind файлов для них. Для файла с разметкой с именем SignUpView.xaml файл code-behind будет называться SignUpView.xaml.cs, а класс внутри него, как правило, называется SignUpView. В нашей реализации генератора типизированных ссылок будем полагаться на данную схему именования. Файлы разметки Avalonia на момент реализации генератора и написания данного материала могли иметь расширения .xaml или .axaml, поэтому код, определяющий имя XAML-файла на основании имени типа будет иметь следующий вид:


var xamlFileName = $"{typeSymbol.Name}.xaml";var aXamlFileName = $"{typeSymbol.Name}.axaml";var relevantXamlFile = context    .AdditionalFiles    .FirstOrDefault(text =>         text.Path.EndsWith(xamlFileName) ||         text.Path.EndsWith(aXamlFileName));

Здесь, typeSymbol имеет тип INamedTypeSymbol и может быть получен в результате обхода списка symbols, который мы сформировали на предыдущем этапе. А ещё здесь есть один нюанс. Чтобы файлы разметки были доступны как AdditionalFiles, пользователю генератора необходимо их дополнительно включить в проект с использованием директивы MSBuild <AdditionalFiles />. Таким образом, пользователь генератора должен отредактировать файл проекта .csproj, и добавить туда вот такой <ItemGroup />:


<ItemGroup>    <!-- Очень важная директива, без которой генераторы исходного         кода не смогут выпотрошить файлы разметки! -->    <AdditionalFiles Include="**\*.xaml" /></ItemGroup>

Подробное описание <AdditionalFiles /> можно найти в материале New C# Source Generator Samples.


Извлекаем полные имена типов из XAML


Этот этап является самым сложным, но в то же время наиболее интересным. Дело в том, что нельзя просто взять и получить информацию о пространстве имён, в котором находится элемент управления, объявленный в XAML-разметке. А нам, из-за нашего желания избежать коллизий и генерировать вменяемый код, который всегда будет компилироваться и работать, позарез нужно уметь получать полную квалификацию пространства имён, в котором находится тип.


Хорошая новость заключается в том, что фреймворк AvaloniaUI использует новый компилятор XamlX, целиком написанный @kekekeks. Этот компилятор мало того, что не имеет рантайм-зависимостей, умеет находить ошибки в XAML на этапе компиляции, работает намного быстрее загрузчиков XAML из WPF, UWP, XF и других технологий, так ещё и предоставляет нам удобный API для парсинга XAML и разрешения типов. Таким образом, мы можем позволить себе подключить XamlX в проект исходниками (git submodule add ://repo ./path), и написать свой собственный MiniCompiler, который наш генератор исходного кода будет вызывать для компиляции XAML и получения полной информации о типах, даже если они лежат в каких-нибудь сторонних сборках. Реализация XamlX.XamlCompiler в виде нашего маленького MiniCompiler, который мы собираемся натравливать на XAML-файлы, имеет вид:


internal sealed class MiniCompiler : XamlCompiler<object, IXamlEmitResult>{    public static MiniCompiler CreateDefault(        RoslynTypeSystem typeSystem,        params string[] additionalTypes)    {        var mappings = new XamlLanguageTypeMappings(typeSystem);        foreach (var additionalType in additionalTypes)            mappings.XmlnsAttributes.Add(typeSystem.GetType(additionalType));        var configuration = new TransformerConfiguration(            typeSystem,            typeSystem.Assemblies[0],            mappings);        return new MiniCompiler(configuration);    }    private MiniCompiler(TransformerConfiguration configuration)        : base(configuration,               new XamlLanguageEmitMappings<object, IXamlEmitResult>(),               false)    {        Transformers.Add(new NameDirectiveTransformer());        Transformers.Add(new DataTemplateTransformer());        Transformers.Add(new KnownDirectivesTransformer());        Transformers.Add(new XamlIntrinsicsTransformer());        Transformers.Add(new XArgumentsTransformer());        Transformers.Add(new TypeReferenceResolver());    }    protected override XamlEmitContext<object, IXamlEmitResult> InitCodeGen(        IFileSource file,        Func<string, IXamlType, IXamlTypeBuilder<object>> createSubType,        object codeGen, XamlRuntimeContext<object, IXamlEmitResult> context,        bool needContextLocal) =>        throw new NotSupportedException();}

В нашем MiniCompiler мы используем дефолтные трансформеры XamlX и один особенный NameDirectiveTransformer, тоже написанный @kekekeks, который умеет преобразовывать XAML-атрибут x:Name в XAML-атрибут Name для того, чтобы впоследствии обходить полученное AST и вытаскивать имена элементов управления было проще. Такой NameDirectiveTransformer выглядит следующим образом:


internal class NameDirectiveTransformer : IXamlAstTransformer{    public IXamlAstNode Transform(        AstTransformationContext context,        IXamlAstNode node)    {        // Нас интересуют только объекты.        if (node is XamlAstObjectNode objectNode)        {            for (var index = 0; index < objectNode.Children.Count; index++)            {                // Если мы встретили x:Name, заменяем его на Name и                 // продолжаем обходить потомков XamlAstObjectNode дальше.                var child = objectNode.Children[index];                if (child is XamlAstXmlDirective directive &&                    directive.Namespace == XamlNamespaces.Xaml2006 &&                    directive.Name == "Name")                    objectNode.Children[index] =                        new XamlAstXamlPropertyValueNode(                            directive,                            new XamlAstNamePropertyReference(                                directive, objectNode.Type, "Name", objectNode.Type),                            directive.Values);            }        }        return node;    }}

Фабрика MiniCompiler.CreateDefault принимает первым аргументом любопытный тип RoslynTypeSystem, который вы не найдёте в исходниках XamlX. Данный тип реализует интерфейс IXamlTypeSystem, а это значит, что всё самое сложное только начинается. Чтобы наш маленький компилятор заработал внутри нашего генератора исходного кода, нам необходимо реализовать систему типов XamlX поверх API семантической модели компилятора Roslyn. Для реализации новой IXamlTypeSystem пришлось реализовывать много-много интерфейсов (IXamlType для классов, IXamlAssembly для сборок, IXamlMethod для методов, IXamlProperty для свойств и др). Реализация IXamlAssembly, например, выглядит вот так:


public class RoslynAssembly : IXamlAssembly{    private readonly IAssemblySymbol _symbol;    public RoslynAssembly(IAssemblySymbol symbol) => _symbol = symbol;    public bool Equals(IXamlAssembly other) =>        other is RoslynAssembly roslynAssembly &&        SymbolEqualityComparer.Default.Equals(_symbol, roslynAssembly._symbol);    public string Name => _symbol.Name;    public IReadOnlyList<IXamlCustomAttribute> CustomAttributes =>        _symbol.GetAttributes()            .Select(data => new RoslynAttribute(data, this))            .ToList();    public IXamlType FindType(string fullName)    {        var type = _symbol.GetTypeByMetadataName(fullName);        return type is null ? null : new RoslynType(type, this);    }}

После реализации всех необходимых интерфейсов мы наконец сможем распарсить XAML инструментами XamlX, создать инстанс нашей реализации RoslynTypeSystem, передав ей в конструктор CSharpCompilation, которую мы уже извлекли из контекста генерации на предыдущем этапе, и трансформировать полученное в результате парсинга AST в AST с включённой информацией о пространствах имён и типах:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());MiniCompiler.CreateDefault(    // 'compilation' имеет тип 'CSharpCompilation'    new RoslynTypeSystem(compilation),    "Avalonia.Metadata.XmlnsDefinitionAttribute")    .Transform(parsed);

Готово! Осталось извлечь все именованные объекты из дерева и дело в шляпе.


Находим именованные объекты XAML


На предыдущем этапе мы уже рассмотрели трансформер AST XamlX, реализующий IXamlAstTransformer, а теперь давайте рассмотрим и напишем посетителя узлов этого AST, реализующий интерфейс IXamlAstVisitor. Наш посетитель будет выглядеть следующим образом:


internal sealed class NameReceiver : IXamlAstVisitor{    private readonly List<(string TypeName, string Name)> _items =        new List<(string TypeName, string Name)>();    public IReadOnlyList<(string TypeName, string Name)> Controls => _items;    public IXamlAstNode Visit(IXamlAstNode node)    {        if (node is XamlAstObjectNode objectNode)        {            // Извлекаем тип AST-узла. Данный тип нам вывел XamlX в            // процессе взаимодействия с нашей RoslynTypeSystem.            //            var clrType = objectNode.Type.GetClrType();            foreach (var child in objectNode.Children)            {                // Если мы в результате обхода потомков встретили свойство,                // которое называется 'Name', и при этом внутри 'Name' лежит строка,                // то добавляем в список элементов '_items' имя и CLR-тип элемента AST.                //                if (child is XamlAstXamlPropertyValueNode propertyValueNode &&                    propertyValueNode.Property is XamlAstNamePropertyReference named &&                    named.Name == "Name" &&                    propertyValueNode.Values.Count > 0 &&                    propertyValueNode.Values[0] is XamlAstTextNode text)                {                    var nsType = $@"{clrType.Namespace}.{clrType.Name}";                    var typeNamePair = (nsType, text.Text);                    if (!_items.Contains(typeNamePair))                        _items.Add(typeNamePair);                }            }            return node;        }        return node;    }    public void Push(IXamlAstNode node) { }    public void Pop() { }}

Процесс парсинга XAML и извлечения типов и имён XAML-элементов теперь выглядит так:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());MiniCompiler.CreateDefault(    // 'compilation' имеет тип 'CSharpCompilation'    new RoslynTypeSystem(compilation),    "Avalonia.Metadata.XmlnsDefinitionAttribute")    .Transform(parsed);var visitor = new NameReceiver();parsed.Root.Visit(visitor);parsed.Root.VisitChildren(visitor);// Теперь у нас есть и типы, и имена элементов.var controls = visitor.Controls;

Генерируем типизированные ссылки


Наконец, можно перейти к заключительному этапу разработки нашего генератора исходного кода. У нас есть всё, что было нужно для полного счастья и типы, и пространства имён, и имена элементов. А это значит, что нам необходимо сгенерировать partial-класс, сложив туда ссылки на все найденные именованные элементы пользовательского интерфейса, объявленные в XAML. Метод, генерирующий такой partial-класс, будет иметь вид:


private static string GenerateSourceCode(    List<(string TypeName, string Name)> controls,    INamedTypeSymbol classSymbol,    AdditionalText xamlFile){    var className = classSymbol.Name;    var nameSpace = classSymbol.ContainingNamespace        .ToDisplayString(SymbolDisplayFormat);    var namedControls = controls        .Select(info => "        " +                       $"internal global::{info.TypeName} {info.Name} => " +                       $"this.FindControl<global::{info.TypeName}>(\"{info.Name}\");");    return $@"// <auto-generated />using Avalonia.Controls;namespace {nameSpace}{{    partial class {className}    {{{string.Join("\n", namedControls)}       }}}}";}

Добавим полученный код в контекст выполнения GeneratorExecutionContext:


var sourceCode = GenerateSourceCode(controls, symbol, relevantXamlFile);context.AddSource($"{symbol.Name}.g.cs", SourceText.From(sourceCode, Encoding.UTF8));

Готово!


Результат


Инструментарий Visual Studio понимает, что при изменении XAML-файла, включённого в проект как <AdditionalFile />, необходимо вызвать генератор исходного кода ещё раз, и обновить сгенерированные исходники. Таким образом, при редактировании XAML-файлов, ссылки на новые элементы управления, добавляемые в XAML в процессе разработки, будут автоматически становиться доступными из C#-файла с расширением .xaml.cs.


ezgif-1-f52e7303c26f


Исходный код генератора доступен на GitHub.


Интеграция генераторов исходного кода с JetBrains Rider и ReSharper доступна в последних EAP, что позволяет утверждать, что реализованная технология является кроссплатформенной, и будет работать на Windows, Linux, и macOS. В дальнейшем мы собираемся заинтегрировать получившийся генератор в Avalonia, чтобы в новых версиях фреймворка генерация типизированных ссылок стала доступна из коробки.


А вот так выглядит обновлённый пример кода из самого начала статьи, с биндингами и ReactiveUI.Validation:


[GenerateTypedNameReferences]public class SignUpView : ReactiveWindow<SignUpViewModel>{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);    }}

Ссылки


Подробнее..

Бюджетный DI на антипаттернах

03.07.2020 08:04:12 | Автор: admin

image


Согласитесь, приятно бывает после утомительного трудового дня отточенным движением руки решительно закрыть рабочий workspace в Xcode, чтобы, облегченно вздохнув, открыть другой workspace со своим домашним проектом.


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


Советую запастись дошираком и энергетиками. Перед вами серия статей о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня пилотный выпуск про управление зависимостями.


Введение


Перво-наперво хочется совершить виртуальный каминг-аут и признаться, что я большой фанат паттерна MVVM. Для меня он не ограничивается сплошной стрелочкой, направленной от View к ViewModel и пунктирной в обратном направлении. Правильно приготовленный MVVM, как мне кажется, это инфраструктура, если хотите фреймворк, включающий в себя, кроме реализации самого паттерна, решение для управления зависимостями, реализацию роутинга и ряд вспомогательных компонентов для упрощения жизни, здоровья и долголетия.


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


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


Хорошее содержание



Принципы


Чтобы придать своему коду больше цельности, а самому себе значимости, я решил, что весь код в этой и последующих статьях должен по возможности подчиняться нескольким общим принципам. Вот они:


  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Иметь такой список принципов очень удобно: это оправдывает все странные решения, которые я собираюсь принимать на протяжении трех статей.


Проблема управления зависимостями


Проблема управления зависимостями довольно типичная в программировании. Мало какая сущность в коде может похвастаться независимостью как твоя бывшая. Обычно все от кого-нибудь зависят. В MVVM, например, вью-контроллер зависит от вью-модели, которая подготавливает для него данные. Вью-модель зависит от сервиса, который за этими данными ходит в сеть. Сервис зависит от другого сервиса низкоуровневой реализации сети, и так далее. Все эти сущности, которых может быть великое множество, нужно где-то создавать и как-то доставлять до потребителей. Для любой типичной проблемы, как правило, есть типичное решение паттерн. В случае с проблемой управления зависимостями таким паттерном является Dependency Injection (DI) контейнер.


У меня нет намерения подробно объяснять, что такое DI-контейнер. Про это классно рассказывают в двух статьях из репозитория Ninject: раз, два (уберите от экрана детей, там код на С#). Еще есть небольшое объяснение в репозитории самого популярного DI-контейнера под iOS Swinject (заметили, что Swinject это Ninject на Swift?). Хардкорщикам могу предложить статью Фаулера от 2004 года.


Тем не менее не могу отказать себе в удовольствии немного поумничать и скажу, что DI-контейнер это такая шляпа, из которой как кролика за уши можно достать практически любую сущность вашей программы. Если эта сущность зависит от других сущностей, а те, в свою очередь, еще от каких-то DI-контейнер знает, что со всем этим графом зависимостей делать. Если у вас в проекте есть DI-контейнер, то на извечный вопрос как мне прокинуть зависимость A до сущности B всегда будет один и тот же ответ: сущность B следует достать из контейнера, который сам рекурсивно разрешит все ее зависимости.


Решение


Существует несколько довольно популярных реализаций DI-контейнеров под iOS (Swinject, Cleanse, Dip, DITranquility, EasyDI), но использовать чужую реализацию, согласитесь, скучно. Гораздо веселее использовать мою.


Готовы немного развлечься и написать DI-контейнер с нуля? Похожую реализацию мне показал однажды один из самых крутых iOS-разработчиков, простой сибирский парень teanet, за что ему огромное спасибо. Я ее немного переосмыслил и готов поделиться с вами. Начнем с протокола IContainer:


protocol IContainer: AnyObject {    func resolve<T: IResolvable>(args: T.Arguments) -> T}

Привычка из прошлой жизни я всегда пишу I перед протоколами. Буква I значит interface. У нашего интерфейса протокола всего один метод resolve(args:), который от нас принимает какие-то аргументы T.Arguments, а взамен возвращает экземпляр типа T. Как видно, не любая сущность может быть Т. Чтобы стать полноправным T, нужно реализовать IResolvable. IResolvable это еще один протокол, о чем нам услужливо подсказывает буква I в начале имени. Он выглядит вот так:


protocol IResolvable: AnyObject {    associatedtype Arguments    static var instanceScope: InstanceScope { get }    init(container: IContainer, args: Arguments)}

Все кролики, которые хотят быть доступны из шляпы, обязаны реализовать IResolvable.


Самое главное здесь инициализатор, принимающий сам контейнер и аргументы. Предполагается, что каждая сущность, реализуя этот инициализатор, сама достанет прямо из контейнера нужные зависимости. А если этому кролику для полноценного существования нужны еще какие-то аргументы, то пожалуйста они тоже есть в инициализаторе.


Свойство instanceScope отвечает за область видимости, в которой будет существовать экземпляр объекта:


enum InstanceScope {    case perRequst    case singleton}

Это довольно стандартная для DI-контейнеров штуковина. Значение perRequest означает, что для каждого вызова resolve(args:) будет создан новый экземпляр T. Значение singleton означает, что экземпляр T будет создан единожды при первом вызове resolve(args:). При последующих вызовах resolve(args:) в случае singleton будет отдаваться закэшированная копия.


С протоколами разобрались, приступаем к реализации:


class Container {    private var singletons: [ObjectIdentifier: AnyObject] = [:]    func makeInstance<T: IResolvable>(args: T.Arguments) -> T {        return T(container: self, args: args)    }}

Тут ничего особенного: кэш синглтонов будем хранить в виде словаря singletons. Ключом словаря нам послужит ObjectIdentifier это стандартный тип, поддерживающий Hashable и представляющий собой уникальный идентификатор объекта ссылочного типа (через него, кстати, реализован оператор === в Swift). Метод makeInstance(args:) умеет на лету создавать любые экземпляры T благодаря тому, что мы обязали все T реализовать один и тот же инициализатор.


В Swift принято выносить реализации протоколов в отдельный экстеншен. Не будем выделяться и поступим как принято покажем старику Латтнеру, что мы говорим на его родном языке без акцента. Нам нужно всего лишь реализовать один метод:


extension Container: IContainer {    func resolve<T: IResolvable>(args: T.Arguments) -> T {        switch T.instanceScope {        case .perRequst:            return makeInstance(args: args)        case .singleton:            let key = ObjectIdentifier(T.self)            if let cached = singletons[key], let instance = cached as? T {                return instance            } else {                let instance: T = makeInstance(args: args)                singletons[key] = instance                return instance            }        }    }}

Здесь все довольно прозаично: если T хочет быть perRequest, сразу возвращаем новый экземпляр. В противном случае нужно залезть в кэш. Что в кэше найдем достаем, отдаем, чего в кэше не найдем создаем, в кэш кладем, отдаем.


Вот, собственно, и все. Мы только что написали свой DI-контейнер в 50 строк кода. Но как этой штукой вообще пользоваться? Да очень просто.


Пример использования


Для примера рассмотрим хрестоматийную историю с клиентами и их заказами. Пусть мы хотим отобразить список заказов конкретного клиента на определенную дату. Заведем для наших целей две сущности: OrdersProvider и OrdersVM эти ребята должны быть доступны из контейнера, а значит, им придется реализовать IResolvable.


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


Полезный экстеншен номер раз:


protocol ISingleton: IResolvable where Arguments == Void { }extension ISingleton {    static var instanceScope: InstanceScope {        return .singleton    }}

И второй такой же, но другой:


protocol IPerRequest: IResolvable { }extension IPerRequest {    static var instanceScope: InstanceScope {        return .perRequst    }}

Теперь вместо IResolvable можно конформить более лаконичным ISingleton/IPerRequest и сэкономить тем самым несколько секунд жизни, потратив их на саморазвитие. А вот и реализация OrdersProvider подъехала:


class OrdersProvider: ISingleton {    required init(container: IContainer, args: Void) { }    func loadOrders(for customerId: Int, date: Date) {        print("Loading orders for customer '\(customerId)', date '\(date)'")    }}

Мы предоставили required init, как того требует протокол, но, так как OrdersProvider ни от чего не зависит, этот инициализатор у нас пустой. Каждый раз, когда мы будем доставать OrdersProvider из контейнера, мы будем получать один и тот же экземпляр, потому что такова дефолтная реализация instanceScope для ISingleton.


А вот и модель представления собственной персоной:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    private let ordersProvider: OrdersProvider    private let args: Args    required init(container: IContainer, args: Args) {        self.ordersProvider = container.resolve()        self.args = args    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Эта вью-модель не может существовать без аргументов OrdersVM.Args, которые мы получаем через required init. В этот инициализатор также попадает сам контейнер, из которого мы без лишней суеты извлекаем экземпляр OrdersProvider посредством вызова resolve().


Вызов метода loadOrders() использует ordersProvider для загрузки заказов, предоставляя ему необходимые для работы аргументы. Каждый раз, когда мы будем доставать OrdersVM из контейнера, мы будем получать новый экземпляр, потому что такова дефолтная реализация instanceScope для IPerRequest.


Финальный штрих. Чтобы получить готовый экземпляр вью-модели, просто достанем его из контейнера, вот так:


let container = Container()let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки, на момент написания этих строк, код выше производит следующий консольный вывод:


Loading orders for customer '42', date '2020-04-22 17:41:49 +0000'

Критика


Использовать или не использовать DI-контейнер из этой статьи в вашем проекте решать не мне. Как ответственный автор, я могу всего лишь предлагать варианты и рассказывать о плюсах и минусах одинаково объективно.


Пытливый ум усидчивого читателя заметит, что реализация, представленная выше, если честно, не очень похожа на DI-контейнер. Эта реализация больше похожа на Service Locator, который, откровенно говоря, в приличном обществе принято считать не иначе как антипаттерном.


Не хочется в рамках данной статьи углубляться в тонкости отличия DI-контейнера от локатора служб, про это можно почитать у того же Фаулера. Но если грубо, то при использовании DI-контейнера ваша сущность, скорее всего, принимает в конструктор инициализатор некий набор зависимостей, закрытых интерфейсами протоколами. Примерно так:


final class OrdersVM {    private let ordersProvider: IOrdersProvider    init(ordersProvider: IOrdersProvider) {       self.ordersProvider = ordersProvider    }}

Если вы осмелились использовать Service Locator, тогда ваша сущность, вероятно, достает зависимости из какого-нибудь сомнительного места типа статической фабрики. Например, вот так:


final class OrdersVM {    private let ordersProvider: IOrdersProvider    init() {        self.ordersProvider = ServiceLocator.shared.resolve()    }}

Программисты недолюбливают Service Locator в первую очередь за то, что он скрывает истинные зависимости сущностей при их создании.


С практической точки зрения это означает, что нет никакой возможности понять, что OrdersVM зависит от IOrdersProvider для этого нужно читать код инициализатора. Кроме того, OrdersVM напрямую зависит от ServiceLocator, что затрудняет переиспользование этого класса в системах, где для DI может быть выбрано другое решение.


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


Все наши сущности, если приглядеться, вообще ни капельки не зависят от абстракций. Напротив, они сами решают, какую конкретную реализацию своих зависимостей следует использовать. Например, OrdersVM достает из контейнера совершенно конкретный OrdersProvider, а не какой-нибудь протокол IOrdersProvider.


С практической точки зрения это затрудняет подмену одной реализации IOrdersProvider на другую реализацию этого протокола. Между тем, такая подмена может вам пригодиться не только в разработке, но и в рамках рефакторинга, а также при написании юнит-тестов.


Полноценные DI-контейнеры, конечно, лишены всех этих недостатков. Более того, они предлагают нам массу дополнительных возможностей. Лишаясь всего этого, что же вы получаете взамен? Взамен вы получаете простую, легкую, надежную и предсказуемую как выборы президента реализацию, которая либо работает корректно, либо не компилируется.


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


Добавление новых зависимостей в существующий класс выглядит максимально лаконично и сводится к извлечению их из контейнера. Если вы пытаетесь извлечь из контейнера что-то неизвлекаемое, ваш код не скомпилируется. При резолве с аргументами невозможно передать аргументы неверного типа либо забыть передать аргументы: некорректный код не скомпилируется.


И, наконец, очень сложно забыть зарегистрировать какую-то сущность, потому что в контейнере не нужно ничего регистрировать. Многое из перечисленного для большинства промышленных DI-контейнеров, к сожалению, недоступно.


Вот вам смысл этого многословного раздела в виде двух списков.


Короче, минусы


  • Зависимости достаем в конструкторе прямо из контейнера (Service Locator).
  • Не получится закрыть зависимость протоколом (принцип на букву D).

Короче, плюсы


  • Простая и лаконичная реализация (50 строк кода).
  • Не надо регистрировать зависимости (вообще не надо).
  • Извлечение из контейнера никогда не сломается (совсем никогда).
  • Нельзя передать невалидные аргументы (не скомпилируется).

Если упомянутые минусы для вашего проекта не критичны, а плюсы вызывают неконтролируемый выброс дофамина добро пожаловать на борт, нам, скорее всего, по пути.


One More Thing: автоматическое внедрение зависимостей через обертки свойств


В 2019 году в компании Apple придумали инкапсулировать повторяющуюся логику гетеров и сетеров в переиспользуемые атрибуты и назвали это обертками свойств (property wrappers). С помощью таких оберток ваши свойства волшебным образом могут получить новое поведение: запись значения в Keychain или UserDefaults, потокобезопасность, валидацию, логирование да много чего.


Можно найти много статей о том, как сделать внедрение зависимостей с помощью оберток свойств, и это довольно весело, поэтому мы проделаем такой фокус прямо сейчас. Следите за руками.


Чтобы написать свою обертку свойства в минимальной комплектации, нужно создать класс или структуру, предоставить свойство wrappedValue и пометить все это дело атрибутом @propertyWrapper:


@propertyWrapperstruct Resolvable<T: IResolvable> where T.Arguments == Void {    private var cache: T?    var wrappedValue: T {        mutating get {            if let cache = cache {                return cache            }            let resolved: T = ContainerHolder.container.resolve()            cache = resolved            return resolved        }    }}

Из этого незамысловатого кода мы видим, что наш property wrapper называется Resolvable. Он работает со всеми типами Т, которые реализуют одноименный протокол и не требуют аргументов при инициализации.


Волшебство происходит при обращении к свойству wrappedValue: мы возвращаем закэшированное значение, если таковое имеется. Если нет достаем это значение из контейнера и сохраняем в кэш. Чтобы наша обертка получила доступ к контейнеру, пришлось провернуть грязный трюк поместить контейнер в статическое свойство класса ContainerHolder:


final class ContainerHolder {    static var container: IContainer!}

Имея в своем арсенале обертку Resolvable<T>, мы можем применить ее к какой-нибудь зависимости, например к ordersProvider:


@Resolvableprivate var ordersProvider: OrdersProvider

Это приведет к тому, что компилятор сгенерирует за нас примерно такой код:


private var _ordersProvider = Resolvable<OrdersProvider>()var ordersProvider: OrdersProvider {  get { return _ordersProvider.wrappedValue }}

Видно, что компилятор сгенерировал свойство ordersProvider, при обращении к которому на самом деле используется обертка, которая умеет доставать из контейнера нужную зависимость.


Теперь знакомая нам модель представления может позволить себе не извлекать из контейнера OrdersProvider в инициализаторе, а просто пометить соответствующее свойство атрибутом @Resolvable. Вот так:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    @Resolvable    private var ordersProvider: OrdersProvider    private let args: Args    required init(container: IContainer, args: Args) {        self.args = args    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Самое время собрать все вместе и порадоваться, что все работает как прежде:


ContainerHolder.container = Container()let viewModel: OrdersVM = ContainerHolder.container.resolve(    args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки. Этот код производит следующий консольный вывод:


Loading orders for customer '42', date '2020-04-23 18:47:36 +0000'



Unit-тесты, раздел под звездочкой


Думаю, все согласятся, что сложно переоценить важность автоматических тестов в современной разработке. Искренне надеюсь, что вы на постоянной основе используете как минимум unit-тесты и в своих ежедневных рабочих задачах, и в домашних проектах. Лично я нет. Может быть, по этой причине DI-контейнер из этой статьи не очень хорошо подходит для интеграции с unit-тестами. Однако, если вы, будучи в здравом уме и твердой памяти, решили пойти тернистым путем автоматизации, у меня есть для вас пара вариантов.


Чтобы немного разогреться, начнем с первого варианта, который попроще. Допустим, для нужд тестирования мы хотим замокать OrdersProvider, чтобы он не лез в сеть, а отдавал тестовые данные. Для начала закроем его протоколом:


protocol IOrdersProvider {    func loadOrders(for customerId: Int, date: Date)}extension OrdersProvider: IOrdersProvider {}

Теперь во вью-модели можем сделать второй инициализатор, который будет принимать этот протокол:


final class OrdersVM: IPerRequest {    struct Args {        let customerId: Int        let date: Date    }    private let ordersProvider: IOrdersProvider    private let args: Args    required convenience init(container: IContainer, args: Args) {        self.init(            ordersProvider: container.resolve() as OrdersProvider,            args: args)    }    init(ordersProvider: IOrdersProvider, args: Args) {        self.args = args        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders(for: args.customerId, date: args.date)    }}

Такой подход позволяет в реальном приложении создавать сущности через контейнер, используя required init, а в тестах пользоваться вторым инициализатором и создавать сущности с замоканными зависимостями.


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


Забегая вперед, скажу, что далее от нас потребуется хранить объекты IResolvable в некоторой коллекции. Однако если мы попробуем сделать это, то столкнемся с суровой действительностью в виде ошибки, до боли знакомой каждому iOS-разработчику: protocol 'IResolvable' can only be used as a generic constraint because it has Self or associated type requirements. Типичный способ как-то справиться с этой ситуацией налить себе чего-нибудь покрепче и применить механизм с пугающим названием стирание типов (type erasure).


Стирание типов предполагает, что вместо неполноценного протокола IResolvable мы будем использовать самую обычную структуру AnyResolvable, не обремененную ассоциированным типом. Такую структуру можно спокойно хранить в массиве, но она, конечно, лишена полезной функциональности IResolvable. Последнее мы компенсируем с помощью замыкания. Внимание на код:


struct AnyResolvable {    private let factory: (IContainer, Any) -> Any?    init<T: IResolvable>(resolvable: T.Type) {        self.factory = { container, args in            guard let args = args as? T.Arguments else { return nil }            return T(container: container, args: args)        }    }    func resolve(container: IContainer, args: Any) -> Any? {        return factory(container, args)    }}

Кода здесь немного, но он хитрый. В инициализатор мы принимаем настоящий живой тип T, который не можем никуда сохранить. Вместо этого мы сохраняем замыкание, обученное создавать экземпляры этого типа. Замыкание впоследствии используется по своему прямому назначению в методе resolve(container:args:), который понадобится нам позже.


Вооружившись AnyResolvable, мы можем написать контейнер для unit-тестов в 20 строк, который позволит нам выборочно мокать часть зависимостей. Вот он:


final class ContainerMock: Container {    private var substitutions: [ObjectIdentifier: AnyResolvable] = [:]    public func replace<Type: IResolvable, SubstitutionType: IResolvable>(        _ type: Type.Type, with substitution: SubstitutionType.Type) {        let key = ObjectIdentifier(type)        substitutions[key] = AnyResolvable(resolvable: substitution)    }    override func makeInstance<T: IResolvable>(args: T.Arguments) -> T {        return makeSubstitution(args: args) ?? super.makeInstance(args: args)    }    private func makeSubstitution<T: IResolvable>(args: T.Arguments) -> T? {        let key = ObjectIdentifier(T.self)        let substitution = substitutions[key]        let instance = substitution?.resolve(container: self, args: args)        return instance as? T    }}

Давайте разбираться.


Класс ContainerMock наследуется от обычного Container, переопределяя метод makeInstance(args:), используемый контейнером для создания сущностей. Новая реализация пытается создать подставную зависимость вместо настоящей. Если ей это не удается, она печально разводит руками и фолбечится на реализацию базового класса.


Метод replace(_:with:) позволяет сконфигурировать моковый контейнер, указав тип зависимости и соответствующий ей тип мока. Эта информация хранится в словаре substitutions, который использует уже знакомый нам ObjectIdentifier для ключа и AnyResolvable для хранения типа мока.


Для создания моков используется метод makeInstance(args:), который по ключу пытается достать нужный AnyResolvable из словаря substitutions и создать соответствующий экземпляр с помощью метода resolve(container:args:).


Использовать все это дело мы будем следующим образом. Создаем моковый OrdersProvider, переопределяя метод loadOrders(for:date:):


final class OrdersProviderMock: OrdersProvider {    override func loadOrders(for customerId: Int, date: Date) {        print("Loading mock orders for customer '\(customerId)', date '\(date)'")    }}

Создаем моковый контейнер и конфигурируем его. Вью-модель достаем из контейнера обычным образом. Контейнер создает экземпляр вью-модели, разрешая все ее зависимости с учетом подстановок:


let container = ContainerMock()container.replace(OrdersProvider.self, with: OrdersProviderMock.self)let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))viewModel.loadOrders()

Для справки, этот код производит следующий консольный вывод:


Loading mock orders for customer '42', date '2020-04-24 17:47:40 +0000'

Заключение


Сегодня мы вероломно поступились принципом инверсии зависимостей и в очередной раз изобрели велосипед, реализовав бюджетный DI с помощью анти-паттерна Service Locator. Попутно мы познакомились с парой полезных техник iOS-разработки, таких как type erasure и property wrappers, и не забыли про unit-тесты.


Автор не рекомендует использовать код из этой статьи в приложении для управления ядерным реактором, но если у вас небольшой проект и вы не боитесь экспериментировать свайп вправо, its a match <3




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Доступный MVVM на хакнутых экстеншенах

10.07.2020 08:14:43 | Автор: admin


Много лет подряд я, помимо всего прочего, занимался настройкой MVVM в своих рабочих и не очень рабочих проектах. Я увлеченно делал это в Windows-проектах, где паттерн является родным. С энтузиазмом, достойным лучшего применения, я делал это в iOS-проектах, где MVVM просто так не приживается.


Сначала я делал это одним способом, потом понимал, что так делать не нужно, и в другой раз делал как-то иначе лишь для того, чтобы в следующий раз снова все переделать.


Советую запастись попкорном и кока-колой это вечернее шоу о том, как я ни в чем себе не отказывал, в очередной раз реализуя MVVM в одном из своих домашних проектов. Сегодня вторая серия: про то, как из MVC сделать MVVM и не наступить в реактивщину.


Введение


Как я уже говорил в прошлой статье, MVVM успешного программиста должен включать в себя, помимо реализации самого паттерна, решение для управления зависимостями и реализацию роутинга. Про управление зависимостями мы уже поговорили и сейчас перейдем непосредственно к реализации MVVM. В яблочном мире паттерн, придуманный в Microsoft, чувствует себя немного иностранцем, и его реализация потребует от нас дополнительных усилий. Эти усилия мы будем прикладывать к конкретному приложению, на примере которого рассмотрим все тонкости и подводные камни. Так как с идеями у меня традиционно не очень, это приложение будет состоять из одного-единственного экрана, отображающего список заказов.


Напомню также, что в коде я стараюсь с переменным успехом придерживаться нескольких простых правил, о которых уже рассказывал:


Нескольких простых правил, о которых уже рассказывал
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Для тех, кто не приемлет неожиданностей, вот полное содержание статьи.


Полное содержание



Действующие лица


Я не планирую расшифровывать каждую букву аббревиатуры MVVM и объяснять, как работает паттерн и зачем он вообще нужен. Уверен, все это вы и без меня знаете. Если по какой-то причине MVVM для вас в новинку, советую перестать читать эту статью и поскорее заполнить пробел в знаниях. Невероятно скучная, но познавательная статья из Википедии может послужить неплохим стартом.


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


OrdersVC Вью-контроллер экрана заказов. Без него никак, потому что iOS это вью-контроллеры. Является источником событий жизненного цикла экрана и занимается отображением данных, которые приходят из вью-модели. В нашем случае он будет содержать таблицу для отображения списка заказов
OrdersView Вьюха для контроллера OrdersVC. Хорошая практика для каждого VC заводить свою собственную View отдельным классом, но в этой статье для упрощения мы так делать не будем. Поэтому OrdersView это такая вьюха, которой нет, но нужно помнить, что она очень даже может быть
OrdersVM Модель представления для OrdersVC, а также для его вьюхи, если бы она у него была. С помощью OrdersProvider вью-модель получает заказы и преобразует их в пригодный для отображения вид
Order Ничего особенного, типичная модель, каких много. Представляет собой заказ
OrderCell Ячейка UITableView, отображающая заказ
OrderVM Модель представления для ячейки OrderCell. Это тот же Order, но пригодный для отображения
OrdersProvider Сервис, который будет загружать заказы из базы данных, из файла, с бэкэнда неважно откуда. Для нашего обучающего примера мы будем грузить заказы из бездонной пустоты небытия

Вот так все эти ребята уживаются вместе на диаграмме классов.






Стоит отметить, что в мире MVVM нет такого понятия, как контроллер, в то время как в iOS, где безраздельно властвует MVC, без вью-контроллеров никуда. Чтобы разрешить это противоречие, здесь и далее мы будем считать, что контроллер это просто View, тем более что в iOS эти две сущности традиционно очень тесно связаны.


Запомните: все, что я говорю в этой статье о View, можно в равной степени отнести к контроллеру, и наоборот.


Начиная отсюда и до конца статьи мы будем заниматься реализацией этой картинки в коде.


Знакомим представление с его моделью


Сплошная стрелочка, направленная от View к ViewModel, символизирует их абьюзивные отношения: вьюха владеет вью-моделью, держит ее сильной ссылкой и напрямую вызывает ее методы. Узаконим эти отношения с помощью протокола. Может существовать сколько угодно реализаций MVVM, но одна штука в них будет неизменной: у View должно появиться свойство viewModel:


protocol IHaveViewModel: AnyObject {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Буква I в начале имени протокола означает interface. В предсказуемом мире статической типизации у разных вьюх экземпляры вью-моделей, скорее всего, будут принадлежать разным классам. Чтобы выразить это средствами языка, нам пригодился протокол с дженериком ассоциированным типом.


Заметим, что свойство viewModel доступно для записи извне. В какой-то момент оно обязательно изменится, что неизбежно приведет к вызову метода viewModelChanged(_:), в котором вьюха обязуется проделать работу по синхронизации своего состояния в соответствии со своей моделью представления. Нехитрая реализация протокола IHaveViewModel на примере связки OrderCell OrderVM могла бы выглядеть вот так:


final class OrderCell: UITableViewCell, IHaveViewModel {    var viewModel: OrderVM? {        didSet {            guard let viewModel = viewModel else { return }            viewModelChanged(viewModel)        }    }    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

Замечу, что для простоты мы взяли ячейку стандартного лайаута, поэтому пользуемся свойством textLabel для отображения текста. Модель представления заказа нам не очень интересна, поэтому сделаем ее максимально неумной:


final class OrderVM {    let order: Order    var name: String {        return "\(order.name) #\(order.id)"    }    init(order: Order) {        self.order = order    }}

Нетерпеливый читатель может задаться вопросом, кто же проставляет свойство viewModel у ячейки и, что более важно, как именно это происходит. На самом деле типичная OrderCell не очень отличается от своих собратьев, и ее жизненный цикл протекает довольно обычным образом:


  1. В методе делегата таблицы tableView(_:cellForRowAt:) извлекаем ячейку при помощи вызова dequeueReusableCell(withIdentifier:for:) и получаем экземпляр класса UITableViewCell.
  2. Осуществляем приведение типа к протоколу IHaveViewModel, чтобы получить доступ к свойству viewModel и записать туда вью-модель.
  3. Грустим оттого, что код, который мы написали на шаге 2, не компилируется.
  4. Гуглим ошибку Protocol 'IHaveViewModel' can only be used as a generic constraint because it has Self or associated type requirements.

Чтобы справиться с такой ошибкой, нам придется применить специальную технику с загадочным названием стирание типов (type erasure). Некоторые авторы выделяют несколько разновидностей стирания типов. Для нашего случая подходит вариант, похожий на секретную технику ниндзя теневое стирание типов (shadow type erasure). Кто придумывает эти названия? На практике весь пафос сводится к тому, что надо просто завести еще один протокол:


protocol IHaveAnyViewModel: AnyObject {    var anyViewModel: Any? { get set }}

Этот протокол не обременен ассоциированным типом, поэтому к нему можно будет кастить любые объекты. Протокол IHaveViewModel почти не изменился, найдите одно отличие:


protocol IHaveViewModel: IHaveAnyViewModel {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Реализация OrderCell теперь будет выглядеть так:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    var anyViewModel: Any? {        didSet {            guard let viewModel = anyViewModel as? ViewModel else { return }            viewModelChanged(viewModel)        }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {        textLabel?.text = viewModel.name    }}

Свойство anyViewModel, лишенное информации о типе, удобно использовать снаружи класса. Оно позволяет любую вьюху привести к типу IHaveAnyViewModel и задать ей вью-модель. Свойство viewModel, которое содержит типизированную вью-модель, удобно использовать внутри класса, например для того, чтобы в методе viewModelChanged(_:) обновлять состояние вьюхи.


Удивительно, насколько отвратительную реализацию MVVM мы с вами только что написали: пользоваться ею решительно невозможно. Все потому, что в каждой вьюхе и вью-контроллере мы вынуждены будем писать очень много повторяющегося кода для реализации IHaveViewModel, что, вообще говоря, довольно утомительно и ни капельки не весело. Хорошая новость в том, что этот недостаток можно преодолеть с помощью расширения, обеспечивающего реализацию по умолчанию для протокола IHaveViewModel.


Реализация по умолчанию через расширение протокола


В основе концепции расширений (extensions) лежит очень простая идея: расширения не могут добавлять никаких новых данных к типу. Все свойства и методы, предоставляемые расширением, являются не чем иным, как комбинацией данных, которые уже объявлены в типе.


Таким образом, если мы попробуем написать реализацию по умолчанию для IHaveViewModel, то ожидаемо столкнемся с неизбежными сложностями в виде ошибки extensions must not contain stored properties:


extension IHaveViewModel {    var anyViewModel: Any? // Не компилируется :(}

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


Представьте, в какую анархию погрузилось бы программирование, если бы все могли вот так запросто добавлять новые данные к любым типам. Возможно, с помощью ошибки extensions must not contain stored properties создатели языка нежно заботятся о нас, не позволяя пойти по скользкой дорожке, покатиться под откос, ринуться в бурлящую бездну хаоса. Вопреки их стараниям, именно этим мы сейчас и займемся, предварительно хакнув свифтовые расширения с помощью старого доброго Objective-C-рантайма. Читай дальше, если не боишься, что полиция экстеншенов придет за тобой:


private var viewModelKey: UInt8 = 0extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {    }}

Это код компилируется, потому что в расширении мы не объявили ни одного нового хранимого свойства. Необходимого поведения удалось достичь с помощью двух функций языка Си: objc_getAssociatedObject и objc_setAssociatedObject, которые мы используем в гетере и сетере соответственно.


Эти функции с успехом заменяют хранимые свойства, так как позволяют сопоставить объекту некоторое значение по некоторому ключу. Обычно в качестве такого ключа используют адрес глобальной переменной, такой как viewModelKey. Благодаря расширению реализация OrderCell избавилась от лишнего кода и выглядит теперь гораздо привлекательнее:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

Повторюсь, что способ, показанный выше, серьезно нарушает концепцию экстеншенов. Постарайтесь не подсесть на иглу Objective-C-рантайма и не начать использовать его по поводу и без повода это может существенно запутать ваш и без того неидеальный код. В нашем случае некоторое нарушение правил позволяет писать гораздо меньше кода и поэтому слишком привлекательно, чтобы им не воспользоваться.


Отображение списка заказов (на самом деле нет)


Вооружившись дефолтной реализацией IHaveViewModel можно быстро накидать код связки OrdersVC OrdersVM. Вью-модель выглядит так:


final class OrdersVM {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }        }    }}

OrdersVM использует OrdersProvider для загрузки отзывов. OrdersProvider с умным видом имитирует асинхронный запрос и отвечает списком отзывов через секунду после вызова loadOrders(completion:):


struct Order {    let name: String    let id: Int}final class OrdersProvider {    func loadOrders(completion: @escaping ([Order]) -> Void) {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            completion((0...99).map { Order(name: "Order", id: $0) })        }    }}

И, наконец, вью-контроллер:


final class OrdersVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrdersVM    private lazy var tableView = UITableView()    override func viewDidLoad() {        super.viewDidLoad()        tableView.dataSource = self        tableView.register(OrderCell.self, forCellReuseIdentifier: "order")        view.addSubview(tableView)        viewModel?.loadOrders()    }    override func viewDidLayoutSubviews() {        super.viewDidLayoutSubviews()        tableView.frame = view.bounds    }    func viewModelChanged(_ viewModel: OrdersVM) {        tableView.reloadData()    }}

В методе viewDidLoad() посредством вызова loadOrders() мы сообщаем вью-модели, что нам хотелось бы начать загрузку заказов. На изменение вью-модели мы реагируем в методе viewModelChanged(_:), перезагружая таблицу. Работу с источником данных для таблицы мы вынесли в отдельный экстеншен:


extension OrdersVC: UITableViewDataSource {    func tableView(_ tableView: UITableView,         numberOfRowsInSection section: Int) -> Int {        return viewModel?.orders.count ?? 0    }    func tableView(_ tableView: UITableView,         cellForRowAt indexPath: IndexPath) -> UITableViewCell {        let cell = tableView.dequeueReusableCell(withIdentifier: "order",             for: indexPath)        if let cell = cell as? IHaveAnyViewModel {            cell.anyViewModel = viewModel?.orders[indexPath.row]        }        return cell    }}

Здесь все совершенно стандартно, за исключением приведения к протоколу IHaveAnyViewModel, которое жизненно необходимо для того, чтобы сконфигурировать вью-модель ячейки. Вот и все. Теперь мы можем собрать всех ребят вместе, примерно так:


let viewModel = OrdersVM(ordersProvider: OrdersProvider())let viewController = OrdersVC()viewController.viewModel = viewModel

После того как мы отобразим OrdersVC на экране, мы должны увидеть таблицу со списком заказов. Однако внимательный читатель, возможно, уже догадался, что ничего у нас не выйдет и список заказов мы так и не увидим.


Дело в том, что метод loadOrders(completion:) работает асинхронно, список заказов формируется только через секунду после вызова viewDidLoad(), а это значит, что на момент вызова reloadData() массив orders пуст. Для того чтобы все заработало, нам не хватает одной важной детали уведомления об изменениях вью-модели.


Уведомление об изменении модели представления


Одна из ключевых концепций MVVM состоит в том, что ViewModel ничего не желает знать о View. Она не держит ссылку на View и не вызывает ее методы ни напрямую, ни через протокол. Вью-модель ведет себя так, словно View просто-напросто не существует. Компенсируя свое нежелание общаться с View, вью-модель поддерживает механизм уведомления о важных событиях, происходящих в ее жизни. Этим механизмом пользуется View, чтобы поддерживать себя в актуальном виде, и на диаграмме классов это выражается пунктирной стрелкой, направленной от ViewModel к View.


В самобытном мире iOS-разработки сложилась невеселая ситуация: уведомления об изменении свойств модели представления чаще всего реализуют через реактивные сигналы. Этот подход настолько распространен, что некоторые авторы едва ли не ставят знак равенства между MVVM и Rx. Между тем MVVM вовсе не подразумевает использование стороннего реактивного фрэймворка. В том же .NET исторической родине паттерна уведомления работают через интерфейс INotifyPropertyChanged, реализуемый на стороне ViewModel, в связке с декларативными биндингами на стороне View.


Автор этой статьи, мягко говоря, не фанат реактивного подхода. Очень уж непросто бывает разобраться в хитросплетении сигналов, которые стреляют другими сигналами, которые трансформируются в третьи сигналы. Написать запутанный реактивный код слишком просто. Сегодня вы добавляете в проект один маленький сигнальчик, а завтра ваше простое на первый взгляд приложение превращается в неуправляемый реактивный истребитель, несущийся на сверхзвуковой скорости в бездну отчаяния. Да и не хочется в мелкий домашний проект целый RxSwift тащить, а Combine так вообще только с iOS 13.


В общем, мы с вами, как обычно, пойдем скользкой, но интересной дорожкой и напишем под iOS нечто похожее на события из .NET. Наше творение обеспечит нам вечную славу и поддержку уведомлений об изменениях ViewModel.


Заново изобретаем события


События в .NET это реализация известного паттерна Наблюдатель, такой сталкинг от программирования: вьюха очень пристально следит за тем, что происходит c вью-моделью. Для нас критически важно, чтобы событие поддерживало несколько подписчиков, потому что, например, на одно и то же событие вью-модели может подписаться как ViewController, так и его View.


Реализовать такое на Swift можно несколькими способами: через массив делегатов, через массив замыканий, через NotificationCenter. Тот способ, который поджидает читателя ниже по тексту, вынуждает нас для начала написать небольшой вспомогательный класс. Вот такой:


final class Weak<T: AnyObject> {    private let id: ObjectIdentifier?    private(set) weak var value: T?    var isAlive: Bool {        return value != nil    }    init(_ value: T?) {        self.value = value        if let value = value {            id = ObjectIdentifier(value)        } else {            id = nil        }    }}

Это нехитрая обертка, которая держит слабую ссылку на экземпляр ссылочного типа, передаваемый в инициализатор. Если в инициализатор пришло что-то отличное от nil, обертка запоминает ObjectIdentifier этого объекта, который впоследствии используется для реализации Hashable:


extension Weak: Hashable {    static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {        return lhs.id == rhs.id    }    func hash(into hasher: inout Hasher) {        if let id = id {            hasher.combine(id)        }    }}

Вооружившись Weak<T>, можно приступить к реализации событий:


final class Event<Args> {    // Тут живут подписчики на событие и обработчики этого события    private var handlers: [Weak<AnyObject>: (Args) -> Void] = [:]    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber, Args) -> Void) {        // Формируем ключ        let key = Weak<AnyObject>(subscriber)        // Почистим массив обработчиков от мертвых объектов, чтобы не засорять память        handlers = handlers.filter { $0.key.isAlive }        // Создаем обработчик события        handlers[key] = {            [weak subscriber] args in            // Захватываем подписчика слабой ссылкой и вызываем обработчик,            // только если подписчик жив            guard let subscriber = subscriber else { return }            handler(subscriber, args)        }    }    func unsubscribe(_ subscriber: AnyObject) {        // Отписываемся от события, удаляя соответствующий обработчик из словаря        let key = Weak<AnyObject>(subscriber)        handlers[key] = nil    }    func raise(_ args: Args) {        // Получаем список обработчиков с живыми подписчиками        let aliveHandlers = handlers.filter { $0.key.isAlive }        // Для всех живых подписчиков выполняем код их обработчиков событий        aliveHandlers.forEach { $0.value(args) }    }}

Этот код не очень сложный, но, чтобы он не казался вам совсем уж примитивным, я написал побольше комментариев. Класс Weak<T>, как оказалось, нужен был для того, чтобы хранить всех подписчиков в словаре и дать им спокойно умереть, если они того желают не держать их сильной ссылкой.


Обработчик события представляет собой замыкание, в аргументы которого попадает живой подписчик и некоторые данные, если таковые актуальны для данного события. Получившийся класс Event<Args> позволяет подписываться на событие с помощью метода subscribe(_:handler:) и отписываться от него с помощью метода unsubscribe(_:). Когда источник события (в нашем случае это вью-модель) захочет уведомить о чем-то свою армию подписчиков, ему следует воспользоваться методом raise(_:).


Для удобства можно обмазаться экстеншеном, который позволяет опустить аргументы типа Void при подписке на событие и при его вызове:


extension Event where Args == Void {    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber) -> Void) {        subscribe(subscriber) { this, _ in            handler(this)        }    }    func raise() {        raise(())    }}

Пользоваться событиями можно примерно так. На стороне источника событий создаем объект события, после чего в какой-то момент стреляем нотификацией:


let event = Event<Void>()event.raise() // Какой-то момент наступил, стреляем

На стороне подписчика подписываемся и выполняем полезную работу в замыкании. Обратите внимание, что в аргументы обработчика события поступает живой экземпляр подписчика, что позволяет не писать weak self, если нам нужен доступ к подписчику внутри замыкания:


event.subscribe(self) { this in    this.foo() // Тут полезная работа}

Если подписчик более не заинтересован в получении событий, он делает вот так:


event.unsubscribe(self) // Нам лучше расстаться

Ура! Вы прочитали почти всю статью, осталось совсем немного. Чтобы передохнуть, отвлекитесь на минутку и подумайте о том, насколько MVVM прекрасен. Следующий раздел почти последний.


Отображение списка заказов


Чтобы научить OrdersVM уведомлять OrdersVC об изменении списка заказов, необходимо во вью-модель добавить соответствующее событие. Однако, согласитесь, не хочется в каждой вью-модели, которая должна уведомлять о своих изменениях, снова и снова писать код по созданию события. Поэтому мы пойдем уже знакомым путем и обратимся за помощью к запретным техникам Objective-C-рантайма, клятвенно пообещав себе больше никогда так не делать:


private var changedEventKey: UInt8 = 0protocol INotifyOnChanged {    var changed: Event<Void> { get }}extension INotifyOnChanged {    var changed: Event<Void> {        get {            if let event = objc_getAssociatedObject(self,                 &changedEventKey) as? Event<Void> {                return event            } else {                let event = Event<Void>()                objc_setAssociatedObject(self,                     &changedEventKey,                     event,                     .OBJC_ASSOCIATION_RETAIN_NONATOMIC)                return event            }        }    }}

С помощью протокола INotifyOnChanged и его дефолтной реализации любая вью-модель сможет бесплатно получить событие changed. С появлением INotifyOnChanged дефолтная реализация протокола IHaveViewModel вынуждена будет немного эволюционировать: в ней мы захотим подписаться на изменение вью-модели и вызвать viewModelChanged(_:) в обработчике события:


extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            (anyViewModel as? INotifyOnChanged)?.changed.unsubscribe(self)            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }            (viewModel as? INotifyOnChanged)?.changed.subscribe(self) { this in                if let viewModel = viewModel {                    this.viewModelChanged(viewModel)                }            }        }    }}

И, наконец, финальный штрих:


final class OrdersVM: INotifyOnChanged {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(name: $0.name) }            self?.changed.raise() // Пыщ!        }    }}

Все, что мы делали выше класс Weak<T>, класс Event<Args>, протокол INotifyOnChanged и его дефолтная реализация, было нужно ради того, чтобы мы смогли написать одну единственную строчку кода во вью-модели: changed.raise().


Вызов rise(), произведенный в подходящий момент, после получения всех данных, приводит к тому, что в контроллере вызывается метод viewModelChanged(_:), который перезагружает таблицу, и она успешно отображает список заказов.


One More Thing: подписка на изменение отдельных свойств модели представления через обертки свойств


Протокол INotifyOnChanged и событие changed неплохо справляются с задачей уведомления об обновлении всей вью-модели с последующей перерисовкой всей вьюхи. В большинстве случаев этого вполне достаточно, но что, если мы хотим из соображений производительности или, что более важно, ради развлечения рассказать View об изменении какого-то одного свойства ViewModel? Очевидно, что мы можем для этих целей завести во вью-модели отдельное событие myPropertyChanged, подписаться на него на стороне вьюхи и дело сделано.


Но зачем самим писать код, который за нас могут генерировать инженеры Apple?


В предыдущей статье я немного рассказывал про обертки свойств, и вы могли убедиться, что это весьма своеобразная, но занимательная штуковина, которая позволяет наделить ничем не примечательные свойства разными суперспособностями.


Чтобы написать свой property wrapper, нужно создать класс или структуру, предоставить свойство wrappedValue и украсить все это дело, как вишенкой на торте, атрибутом @propertyWrapper. Однако обертки свойств не так просты и позволяют манипулировать не только самим свойством, которое они оборачивают, но и его проекцией через специальное свойство projectedValue. Согласитесь, звучит очень непонятно, поэтому, чтобы еще больше вас запутать, рассмотрим такой код:


@propertyWrapperstruct Observable<T> {    let projectedValue = Event<T>()    init(wrappedValue: T) {        self.wrappedValue = wrappedValue    }    var wrappedValue: T {        didSet {            projectedValue.raise(wrappedValue)        }    }}

Мы только что создали обертку свойства и назвали ее Observable. Она умеет работать со свойствами любых типов и может похвастаться наличием projectedValue. Проекция является событием, которое обучено сообщать своим подписчикам о любых изменениях wrappedValue. Это событие, как видно из кода, мы используем по своему прямому назначению в didSet.


Имея в своем арсенале обертку Observable<T>, мы можем применить ее к списку заказов:


@Observablevar orders: [OrderVM] = []

Это приведет к тому, что компилятор сгенерирует за нас примерно такой код:


private var _orders = Observable<[OrderVM]>(wrappedValue: [])var orders: [OrderVM] {  get { _orders.wrappedValue }  set { _orders.wrappedValue = newValue }}var $orders: Event<[OrderVM]> {  get { _orders.projectedValue }}

Видно, что, помимо свойства orders, которое через обертку просто возвращает wrappedValue, компилятор сгенерировал дополнительное свойство $orders, которое возвращает нам projectedValue. В нашем случае projectedValue это экземпляр события, что позволяет на стороне вьюхи подписаться на изменение свойства orders вот таким нехитрым образом:


viewModel.$orders.subscribe(self) { this, orders in    this.update(with: orders)}

Поздравляю! Вы только что в 15 строчках кода написали свой собственный аналог атрибута Published из фрэймворка Combine от Apple, а я только что дописал очередную статью.


Заключение


Сегодня мы вероломно поступились основным принципом работы расширений, хакнув их с помощью Objective-C-рантайма. Это позволило нам, используя протоколы и экстеншены, реализовать паттерн MVVM в одном маленьком приложении под iOS. В процессе у нас возникло непреодолимое желание применить реактивный фреймворк, и мы едва удержались, написав вместо этого свою реализацию событий, вдохновившись дружественной технологией .NET. Попутно познакомились с парой полезных техник iOS-разработки, таких как shadow type erasure и property wrappers с применением projected value.




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Легковесный роутинг на микросервисах

17.07.2020 08:05:27 | Автор: admin


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


По своей функциональности они бывают просто огромными. Некоторые приложения состоят из десятков, сотен экранов и переходов между ними. И пока пользователь открывает очередной экран с деталями заказа, наслаждаясь плавной анимацией, в мире MVVM происходит много всего интересного: вью-контроллер создается, вью-модель создается, аргументы пробрасываются, зависимости резолвятся.


Кто делает всю эту важную, но незаметную работу? Советую запастись чаем и печеньками: это мини-сериал о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня заключительная серия про слой роутинга в iOS-приложении.


Введение


Спешу сообщить, что у меня для вас две новости: первая и вторая. Начну с первой: прежде чем читать эту статью, придется прочитать предыдущие две (первая, вторая), иначе будет совершенно ничего не понятно. Вторая новость заключается в том, что в этот раз я в самом начале статьи покажу конечный результат. А вот и он:



Весь остаток статьи мы будем разбираться, как работает эта строчка кода и зачем вообще она нужна.


Напомню также, что у меня есть некоторые правила и я стараюсь их придерживаться:


Некоторые правила, которых я стараюсь придерживаться
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Традиционно в начале статьи будет ее содержание.


Традиционное содержание



В чем проблема?


В прошлой статье про MVVM мы написали приложение, которое отображает список заказов (с кем не бывает). У нас есть OrdersVC, у которого имеется личная вью-модель OrdersVM. Предположим, что мы хотим при нажатии на ячейку таблицы отображать экран с информацией о деталях соответствующего заказа:



Для тех, кто предпочитает больше конкретики, вот примитивная до неприличия реализация вью-модели нового экрана:


final class OrderDetailsVM: IPerRequest {    typealias Arguments = Order    let title: String    required init(container: IContainer, args: Order) {        self.title = "Details of \(args.name) #\(args.id)"    }}

Модель представления деталей заказа реализует IPerRequest (подробности в статье про DI), а значит, доступна из DI-контейнера. В качестве аргументов она принимает модель заказа и формирует из нее строковый заголовок, пригодный для отображения пользователю. Контроллер этого экрана будет выглядеть не намного сложнее:


final class OrderDetailsVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrderDetailsVM    private lazy var titleLabel = UILabel()    override func viewDidLoad() {        super.viewDidLoad()        view.backgroundColor = .white        view.addSubview(titleLabel)        titleLabel.translatesAutoresizingMaskIntoConstraints = false        titleLabel.centerXAnchor            .constraint(equalTo: view.centerXAnchor)            .isActive = true        titleLabel.topAnchor            .constraint(equalTo: view.topAnchor, constant: 24)            .isActive = true    }    func viewModelChanged(_ viewModel: OrderDetailsVM) {        titleLabel.text = viewModel.title    }}

Контроллер OrderDetailsVC реализует IHaveViewModel (подробности в статье про MVVM) и просто отображает текст, который подготовила для него вью-модель. Для тестовых целей нам этого вполне достаточно.


Чтобы научить OrdersVC реагировать на тап по ячейке таблицы, дополним его экстеншеном:


extension OrdersVC: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        viewModel?.showOrderDetails(forOrderIndex: indexPath.row)    }}

Кстати, этот короткий кусок кода наглядно показывает, как паттерн MVC, который безраздельно властвует в мире iOS, уживается с паттерном MVVM на территории одного приложения. Действие пользователя (тап по ячейке таблицы) обрабатывается контроллером, потому что в iOS по-другому быть не может. Однако контроллер ничего самостоятельно не делает, вместо этого он делегирует всю грязную работу своей модели представления, снабдив ее необходимой информацией в виде индекса интересующего нас заказа.


Напомню, что OrdersVM, которую мы реализовывали в прошлой статье, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Что было дальше?        // ...    }}

Эта модель представления реализует IPerRequest, а значит, доступна из контейнера. Также из контейнера она извлекает OrdersProvider, с помощью которого осуществляет загрузку заказов. По окончании загрузки список заказов заботливо складывается в массив orders, а вью-контроллер получает соответствующее уведомление посредством вызова changed.raise().


В методе showOrderDetails(forOrderIndex:) мы находим нужный заказ и должны открыть новый экран, который отображает детали этого заказа. Чтобы модально показать экран в iOS, нужно создать контроллер этого экрана и воспользоваться методом present(_:animated:completion:), который следует вызвать на текущем контроллере.


В мире MVC это делается очень просто, но в мире MVVM такая элементарная задача вызывает затруднения: вью-модель абсолютно ничего не знает о текущем контроллере. Кроме того, вью-модель понятия не имеет, как создавать новые вью-контроллеры и вью-модели для них. Выпутаться из этой неприятной истории нам поможет отдельный сервис роутер, который осуществит навигацию на нужный экран.


Стоп, что за сервисы вообще такие?


Скорее всего, вы заметили, что модель представления OrdersVM не занимается загрузкой заказов самостоятельно, эту работу она поручает сервису OrdersProvider.


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


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



В реальном приложении сервисов, подобных OrdersProvider, может быть великое множество. Одни будут заниматься получением данных, другие их обработкой, реализуя полезную бизнес-логику. Некоторые сервисы могут делать для вью-моделей часть работы по подготовке данных для отображения: преобразование, форматирование и т. п. Все они будут находиться в весьма запутанных взаимоотношениях друг с другом: одни сервисы будут зависеть от других, а те будут зависеть еще от каких-то, образуя разветвленный граф зависимостей.


Композиция сервисов очень удобный и мощный механизм организации и переиспользования кода. Здесь очень пригождается DI-контейнер, о котором мы говорили в самой первой статье. Когда количество сервисов в вашем приложении начнет исчисляться десятками, это не станет проблемой, потому что DI-контейнер сможет создать для вас любую сущность, попутно разрешив все ее зависимости.


В общем, смысл этого многословного раздела можно уместить в три простые, но настоятельные рекомендации:


  1. Декомпозируйте функциональность приложения на (микро)сервисы с четко определенной зоной ответственности.
  2. Активно используйте композицию сервисов для повторного использования кода.
  3. Используйте DI-контейнер для разрешения зависимостей.

Некоторые сервисы могут заниматься роутингом, помогая вью-моделям осуществлять навигацию на новые экраны приложения.


Обязательно нужен отдельный сервис для роутинга?


Действительно, если действие пользователя, такое как тап в ячейку, прилетает сразу в контроллер, почему бы из этого контроллера не показать новый экран простым вызовом present(_:animated:completion:). Я голосую против, потому что такой подход удобнее только на первый взгляд:


  1. Не всегда переход на другой экран результат действия пользователя. Например, мы можем захотеть показать новый VC по окончании какого-то асинхронного запроса, который будет происходить во вью-модели.
  2. Решение о том, какой экран показать, не всегда тривиальное. Это может быть результат работы сложной бизнес-логики, поэтому удобнее запустить показ экрана в том месте, где вся эта бизнес-логика происходит.
  3. На логику, упомянутую в предыдущем пункте, могут быть написаны тесты. Роутер можно замокать и производить проверки относительно того, какой экран мы собираемся открывать в том или ином случае.
  4. В целях отладки кода удобно иметь единую точку входа для всей навигации в приложении роутер. Это позволяет поставить брейкпоинт в нужном месте и проследить, откуда осуществляется тот или иной переход.

Такие образом, будет полезно вынести функциональность роутинга в отдельный сервис или в несколько сервисов. За показ модальных экранов мог бы отвечать, скажем, PresenterService.


Окей, автор, как мне реализовать роутер?


Вот три простых шага на пути к модальному открытию нового экрана:


  1. Найти экземпляр UIViewController, с которого будет осуществляться переход.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

Начнем с того, что объявим сам класс и сделаем его доступным из контейнера:


final class PresenterService: ISingleton {    private unowned let container: IContainer    public required init(container: IContainer, args: Void) {        self.container = container    }}

Мы предусмотрительно сохранили ссылку на контейнер в инициализаторе, он нам еще понадобится. Стоит сказать, что обычно сохранять ссылку на контейнер, чтобы потом в какой-то момент что-то из него извлечь, не очень хорошая практика: это размазывает код создания зависимостей как в пространстве, так и во времени, делая этот код более запутанным, а ваших коллег более раздражительными. Однако, так как PresenterService занимается роутингом и собирается создавать новые экраны, у него нет другого выбора и владеть ссылкой на контейнер его классовая привилегия.


Первый пункт поиск контроллера можно сделать очень просто с помощью нескольких строк не самого элегантного рекурсивного кода:


var topViewController: UIViewController? {   let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }   return findTopViewController(in: keyWindow?.rootViewController)}func findTopViewController(in controller: UIViewController?) -> UIViewController? {   if let navigationController = controller as? UINavigationController {       return findTopViewController(in: navigationController.topViewController)   } else if let tabController = controller as? UITabBarController,       let selected = tabController.selectedViewController {       return findTopViewController(in: selected)   } else if let presented = controller?.presentedViewController {       return findTopViewController(in: presented)   }   return controller}

Метод findTopViewController(in:) врывается в иерархию контроллеров, как товарищ майор с обыском, и пытается найти там контроллер, который в данный момент отображается на экране. Возможно, это не самый универсальный способ решить задачу и, если в вашем приложении используется более запутанная структура экранов, потребуются некоторые правки, но идея, думаю, понятна.


Мы подбираемся к кульминации и сейчас реализуем метод, который я показывал в самом начале статьи. Он будет состоять буквально из нескольких строк, но по-настоящему важна только вторая строка, в которой и происходит вся магия. Сложно поверить, но ради того, чтобы нормально объяснить, что происходит в этой одной строчке кода, мне потребовалось написать аж три статьи:


func present<VC: UIViewController & IHaveViewModel>(    _ viewController: VC.Type,    args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {    let vc = VC()    vc.viewModel = container.resolve(args: args) // Тут вся магия    topViewController?.present(vc, animated: true, completion: nil)}

Давайте разбираться. Этот метод невероятно тесно интегрирован с нашей реализацией MVVM и с DI-контейнером и состоит, как вы наверняка заметили, всего из трех строк.


  1. В первой строке мы пользуемся тем, что у любого контроллера есть пустой инициализатор, и создаем экземпляр этого контроллера, зная его тип.
  2. Во второй строке мы создаем вью-модель и присваиваем ее соответствующему свойству контроллера. Вью-модель мы можем создать благодаря тому, что обязали ее реализовать IResolvable (про это была статья про DI). Нам всего лишь нужно знать ее тип и аргументы, от которых она зависит. Тип вью-модели известен, потому что все вью-контроллеры предоставляют свойство viewModel в рамках реализации протокола IHaveViewModel (про это была статья про MVVM). Кроме того, у нас имеются необходимые аргументы VC.ViewModel.Arguments и доступ к контейнеру прямо из сервиса. При создании экземпляра вью-модели с помощью магии DI-контейнера самым удобным образом разрешаются все ее зависимости. Прочувствуйте момент: DI-контейнер, MVVM и роутинг сходятся здесь и сейчас в одной точке, и эта точка одна строчка кода. Ух!
  3. И, наконец, в третьей строке, вооружившись знанием о том, какой вью-контроллер сейчас отображается на экране, мы осуществляем показ только что созданного контроллера с помощью банального вызова present(_:animated:completion:).

Чтобы пазл сложился, давайте еще раз взглянем на весь код PresenterService, который до этого мы разбирали по кусочкам:


final class PresenterService: ISingleton {    private unowned let container: IContainer    private var topViewController: UIViewController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        return findTopViewController(in: keyWindow?.rootViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func present<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topViewController?.present(vc, animated: true, completion: nil)    }    func dismiss() {        topViewController?.dismiss(animated: true, completion: nil)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }}

Единственный незнакомый метод, который здесь добавился, это dismiss(), позволяющий закрыть текущий модальный экран. Окончательная реализация OrdersVM, которая с помощью PresenterService научилась отображать детали заказа, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    private let presenter: PresenterService    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()        self.presenter = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Открываем экран с деталями заказа        presenter.present(OrderDetailsVC.self, args: order)    }}

Как видно, в инициализаторе мы без лишней суеты достаем из контейнера наш PresenterService и используем его по назначению в методе showOrderDetails(forOrderIndex:).


Не хочу модальные экраны, хочу пушить экраны в стэк. Как быть?


Для работы с UINavigationController придется написать отдельный сервис. Назовем его, например, NavigationService. Вот три простых шага, которые нужно сделать, чтобы запушить новый экран:


  1. Найти экземпляр UINavigationController, который сейчас виден на экране.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

Как видно, эти шаги очень похожи на таковые для PresenterService, а значит, и код будет аналогичным. Я его даже под спойлер уберу, чтобы под ногами не мешался.


Реализация NavigationService
final class NavigationService: ISingleton {    private unowned let container: IContainer    private var topNavigationController: UINavigationController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        let root = keyWindow?.rootViewController        let topViewController = findTopViewController(in: root)        return findNavigationController(in: topViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func pushViewController<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topNavigationController?.pushViewController(vc, animated: true)    }    func popViewController() {        topNavigationController?.popViewController(animated: true)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }    private func findNavigationController(        in controller: UIViewController?) -> UINavigationController? {        if let navigationController = controller as? UINavigationController {            return navigationController        } else if let navigationController = controller?.navigationController {            return navigationController        } else {            for child in controller?.children ?? [] {                if let navigationController = findNavigationController(in: child) {                    return navigationController                }            }        }        return nil    }}

Сервисы, подобные NavigationService и PresenterService, нужно будет написать для всех контроллеров, которые являются контейнерами для других контроллеров как для стандартных типа UITabBarController, так и для кастомных. Группа таких сервисов образует слой роутинга в вашем приложении.


Мне не подходит реализация роутинга. Что делать?


Весь код в этой и предыдущих статьях очень простой, в нем важна скорее идея, а не реализация. Напишите свою версию MVVM, роутинга, используйте другой DI-контейнер все это категорически неважно. Важны основополагающие принципы, важны прямоугольники со скругленными углами и стрелочки между ними:



Вьюха (контроллер) должна держать вью-модель сильной ссылкой. Вью-модель должна каким-то образом уведомлять вьюху об изменении своего состояния. Вью-модель должна зависеть от многочисленных сервисов, один из которых роутер. Используйте роутер для навигации между экранами. Точка навигации точка большого взрыва, который приводит к созданию нового экрана и всех его зависимостей. Роутер должен уметь создавать пары вьюха вью-модель и все сервисы, от которых они зависят. Для этого он вынужден держать ссылку на DI-контейнер.


Развивайте архитектуру дальше. Например, если у вас длинные цепочки экранов, между которыми передаются полезные данные, попробуйте координатор. Координатор будет держать ссылку на роутер и осуществлять навигацию через него композиция сервисов в действии. Экспериментируйте, пусть ничто не ограничивает вашу фантазию. Веселитесь и получайте удовольствие от своей работы.


Заключение


Сегодня мы говорили про роль (микро)сервисов в мобильных приложениях на примере роутинга. Сервисы роутинга мостик между миром MVC и миром MVVM. Они помогают вью-моделям осуществлять навигацию на новые экраны и имеют право напрямую обращаться к DI-контейнеру для создания пар вьюха вью-модель.


Реализация PresenterService, рассмотренная в этой статье, последний кусочек пазла, необходимый для полноценной работы паттерна MVVM в вашем мобильном приложении. PresenterService глубоко интегрирован с конкретными реализациями MVVM и DI-контейнера, про которые мы говорили в предыдущих статьях, и только в связке с ними раскрывается весь его потенциал.




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Адаптируем UITableView под MVVM

05.12.2020 16:17:54 | Автор: admin

Введение

UITableView один из самых часто используемых компонентов UIKit. Табличное представление зарекомендовало себя как одно из самых удобных взаимодействий пользователя с контентом представленным на экране смартфона.

На сегодняшний день, каждому iOS разработчику необходимо в совершенстве владеть UITableView, знать тонкости и понимать как его адаптировать под разные архитектуры, чтобы использование не вызывало лишних проблем и трудностей.

В этой статье мы поговорим о том, как адаптировать UITableView под архитектуру Model-View-ViewModel (MVVM). Начнём.

Содержание

  1. Введение

  2. Пример

  3. Реализация

  4. Использование

  5. Результат

  6. Вывод

Пример

В качестве примера я реализовал ячейку с кнопкой, картинкой и текстом.

Реализация

Первым делом создадим подкласс от UITableView и назовем его AdaptedTableView.

class AdaptedTableView: UITableView {    }

Определим метод setup(). Он необходим для конфигурации таблицы. Временно заполним обязательные для реализации методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        UITableViewCell()    }    }

Согласно паттерну MVVM, view владеет viewModel. Создадим абстракцию для входных данных и назовем её AdaptedViewModelInputProtocol. AdaptedSectionViewModelProtocol необходим для описания viewModel секции. AdaptedCellViewModelProtocol служит лишь для полиморфизма подтипов наших viewModels для ячеек.

protocol AdaptedCellViewModelProtocol { }protocol AdaptedSectionViewModelProtocol {    var cells: [AdaptedCellViewModelProtocol] { get }}protocol AdaptedViewModelInputProtocol {    var sections: [AdaptedSectionViewModelProtocol] { get }}

Добавляем viewModel. Теперь у нас есть возможность корректно заполнить методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        viewModel?.sections.count ?? .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        viewModel?.sections[section].cells.count ?? .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row] else {            return UITableViewCell()        }            // TO DO: - Register cell      // TO DO: - Create cell                return UITableViewCell()    }    }

На данном этапе с AdaptedTableView почти все готов, однако есть еще пару нерешенных вопросов. Регистрация и переиспользование ячеек. Создадим протокол AdaptedCellProtocol, который будут реализовывать все наши подклассы UITableViewCell, добавим метод register(_ tableView:) и reuse(_ tableView:, for indexPath:).

protocol AdaptedCellProtocol {    static var identifier: String { get }    static var nib: UINib { get }    static func register(_ tableView: UITableView)    static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self}extension AdaptedCellProtocol {        static var identifier: String {        String(describing: self)    }        static var nib: UINib {        UINib(nibName: identifier, bundle: nil)    }        static func register(_ tableView: UITableView) {        tableView.register(nib, forCellReuseIdentifier: identifier)    }        static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self {        tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! Self    }    }

Для порождения ячеек создадим протокол фабричного метода AdaptedCellFactoryProtocol.

protocol AdaptedCellFactoryProtocol {    var cellTypes: [AdaptedCellProtocol.Type] { get }    func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell}

Добавим поле cellFactory и в didSet поместим регистрацию всех ячеек.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?    var cellFactory: AdaptedCellFactoryProtocol? {        didSet {            cellFactory?.cellTypes.forEach({ $0.register(self)})        }    }        ...    }

Исправим метод делегата.

extension AdaptedTableView: UITableViewDataSource {        ...        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard            let cellFactory = cellFactory,            let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row]        else {            return UITableViewCell()        }                return cellFactory.generateCell(viewModel: cellViewModel, tableView: tableView, for: indexPath)    }    }

Использование

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

1. Ячейка

В качестве примера я создам ячейку с лейблом по центру и viewModel к ней. Реализация ячейки с кнопкой и картинкой.

protocol TextCellViewModelInputProtocol {    var text: String { get }}typealias TextCellViewModelType = AdaptedCellViewModelProtocol & TextCellViewModelInputProtocolclass TextCellViewModel: TextCellViewModelType {        var text: String        init(text: String) {        self.text = text    }    }final class TextTableViewCell: UITableViewCell, AdaptedCellProtocol {        // MARK: - IBOutlets        @IBOutlet private weak var label: UILabel!        // MARK: - Public properties        var viewModel: TextCellViewModelInputProtocol? {        didSet {            bindViewModel()        }    }        // MARK: - Private methods        private func bindViewModel() {        label.text = viewModel?.text    }    }

2. Cекция

class AdaptedSectionViewModel: AdaptedSectionViewModelProtocol {        // MARK: - Public properties      var cells: [AdaptedCellViewModelProtocol]        // MARK: - Init        init(cells: [AdaptedCellViewModelProtocol]) {        self.cells = cells    }    }

3. Фабрика

struct MainCellFactory: AdaptedSectionFactoryProtocol {        var cellTypes: [AdaptedCellProtocol.Type] = [        TextTableViewCell.self    ]        func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell {        switch viewModel {        case let viewModel as TextCellViewModelType:            let view = TextTableViewCell.reuse(tableView, for: indexPath)            view.viewModel = viewModel            return view        default:            return UITableViewCell()        }    }    }

Ну и напоследок нам понадобится viewModel самого модуля.

final class MainViewModel: AdaptedSectionViewModelType {        // MARK: - Public properties        var sections: [AdaptedSectionViewModelProtocol]        // MARK: - Init        init() {        self.sections = []                self.setupMainSection()    }        // MARK: - Private methods        private func setupMainSection() {        let section = AdaptedSectionViewModel(cells: [            TextCellViewModel(text: "Hello!"),            TextCellViewModel(text: "It's UITableView with using MVVM")        ])        sections.append(section)    }    }

Все готово, пора добавить UITableView на ViewController, установив в качестве custom class наш AdaptedTableView.

В реальном проекте, MVVM очень часто используют с каким-то паттерном навигации, это может быть координатор или роутер. В зону ответственности таких объектов входит DI (Dependency Injection) внедрение всех необходимых модулю зависимостей. Так как это тестовый проект, я захардкодил viewModel и cellFactory прямо во ViewController.

class ViewController: UIViewController {        // MARK: - IBOutlets        @IBOutlet weak var tableView: AdaptedTableView! {        didSet {            tableView.viewModel = MainViewModel()            tableView.cellFactory = MainCellFactory()                        tableView.setup()        }    }    }

Результат

Вывод

В итоге мы получили решение, которое позволяет удобно использовать UITableView с MVVM. Стало очень просто работать с секциями, настраивать ячейки, писать меньше шаблонного кода. В то же время осталась возможность настройки таблицы и расширения функционала при необходимости.


Весь код представленный в этой статье можно скачать по этой ссылке.

Подробнее..

Борьба за жизни переменных. Или как я попытался упростить жизнь Android разработчикам

17.03.2021 02:16:03 | Автор: admin

Была идея. Собрать в одно место группу важных переменных, чтобывместеонистали чем-то большим. Икогда понадобятся, могли использоваться тогда, когда программисту нужны значения не зависящие от жизненных циклов и высвобождения памяти свернутых приложений.

Идея

Идея появилась из проблемы. Проблема появилась из негодования.

Мне было жутко неприятно от мысли, что при повороте экрана приходится проделывать серьезную работу над тем, чтобы сохранить состояние экрана в том виде, каком оно было до поворота.
Но еще больше негодования у меня вызвала новость, что поворот экрана это не единственная причина, которая может мешать мне сохранять состояние в неизменном виде.
Я ранее слышал про то, что система подчищает ресурсы для чего-то, забивая на свернутые приложения, но когда я немного углубился в это, то очень сильно был огорчен. Именно из этого "негодования" у меня появилась проблема. Как мне сделать это красиво и не так болезненно?
И из этой проблемы появилась идея. Идея собрать все, что должно быть независимо от поворота экрана и высвобождения памяти, в одно место, чтобы я в этом месте был уверен на все сто процентов и всегда знал: Все, что я туда положил, там и останется в неизменном виде.

И не хотелось сильно размазывать код этими переменными...

Каким образом решить эту задачу? Сразу вспоминаешь решения, которые уже есть: bundle и т.д.

Но мне одному все эти решения кажутся неудобными? Мне одному кажется, что они выписываются из общего кода, когда начинаешь с ними работать?

И тут я встал перед выбором, либо я делаю принципиально что-то новое, либо я делаю обертку чего-то, что уже придумано. Да такую обертку, чтобы с ней было удобней работать.

После анализа возможностей и моего знакомства с SavedStateHandle я загорелся желанием сделать обертку над ним и все это использовать в MVVM. Так и родилась библиотека которую сейчас Вы увидите.

Реализация

Ну тут все просто (имхо)

Например, мы уже определились со скоупом наших значений, которые мы хотим хранить. Тогда просто создаем класс в котором просто декларируем их и помечаем этот класс аннотацией @Unkillable

Например вот так:

@Unkillabledata class SampleFragmentState(    val testValue: Double,    val testLiveData: MutableLiveData<Double>) : EmptyState()

Также мы можем там указать и произвольные классы, но только они уже должны быть с Parcelize (Подробней).

Таким образом должен выглядить ViewModel. Естественно, библиотека предлагает не только работу с AndroidViewModel, но и просто ViewModel.

class SampleViewModel(    application: Application,    savedStateHandle: SavedStateHandle) : AndroidStateViewModel(application, savedStateHandle) {    override fun provideState() = createState<UnkillableSampleFragmentState>()}

UnkillableSampleFragmentState сгенерируется у Вас сразу после запуска билда проекта.

Естественно, наша ViewModel должна быть проинициализированна, но не совсем так как обычно. А так, как предлагает Google для использования SavedStateHandle.

activity?.application?.let { application ->     viewModel = ViewModelProvider(this, SavedStateViewModelFactory(application, this))        .get(SampleViewModel::class.java) }

Это все. Теперь можно использовать по назначению! Просто записываем туда данные и достаем их. Отмечу, что для сохранения класса необходимо делать его @Parcelize (Подробнее тут).

Вот таким образом можно воспользоваться библиотекой.

init {    // get values example    Log.d("StateLog", "0 value ${state.testValue}")    Log.d("StateLog", "1 value ${state.testLiveData?.value}")}fun onSetDataClicked() {    // set values example    state.testValue = 2.2    state.updateTestLiveDataValue(3.3) // yourLiveData.value = 3.3    state.postUpdateTestLiveDataValue(3.3) // yourLiveData.postValue(3.3)}

Таким образом, мы защищаемся от подчистки приложения из памяти операционной системой.

Итог

Цель данной библиотеки упростить разработку и разгрузить разработчика от написания кода, при условии, что он работает с сохранением состояния приложения. Также удалось очистить код от всех этих переменных, которые необходимо сохранить. К тому же теперь они логически отделены от общей массы и не засоряют код, что выглядит довольно приятно. Однако пока сейчас она работает только в MVVM от гугла.

GitHub

Подробнее..

Из песочницы Single source of truth (SSOT) on MVVM with RxSwift amp CoreData

21.10.2020 20:04:14 | Автор: admin
Часто в мобильном приложении необходимо реализовать следующий функционал:

  1. Выполнить асинхронный запрос
  2. Забиндить результат в главном потоке на различные view
  3. Если нужно, то асинхронно обновить базу данных на устройстве в фоновом потоке
  4. Если возникают ошибки при выполнении этих операций, то показать уведомление
  5. Соблюсти принцип SSOT для актуальности данных
  6. Всё это протестировать

Решить эту задачу сильно упрощает архитектурный подход MVVM и фреймворки RxSwift, CoreData.

Описанный ниже подход использует принципы реактивного программирования и не привязан исключительно к RxSwift и CoreData. И при желании может быть реализован с помощью других инструментов.

В качестве примера я возьму фрагмент приложения в котором отображаются данные продавца. В контроллере два аутлета UILabel для телефона и адреса и одна UIButton для звонка по этому телефону. ContactsViewController.

Объясню реализацию от model к view.

Model


Фрагмент автосгенерированного файла SellerContacts+CoreDataProperties из DerivedSources
с атрибутами:

extension SellerContacts {    @nonobjc public class func fetchRequest() -> NSFetchRequest<SellerContacts> {        return NSFetchRequest<SellerContacts>(entityName: "SellerContacts")    }    @NSManaged public var address: String?    @NSManaged public var order: Int16    @NSManaged public var phone: String?}

Repository.

Метод предоставляющий данные продавца:

func sellerContacts() -> Observable<Event<[SellerContacts]>> {        // 1        Observable.merge([            // 2            context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),            // 3            updater.sync()        ])    }

Как раз в этом месте реализуется SSOT. Запрос делается к CoreData, и CoreData обновляется, если необходимо. Все данные получаются ТОЛЬКО из БД, а updater.sync() может сгенерировать только Event с ошибкой, но НЕ с данными.

  1. Использование оператора merge позволяет нам добиться асинхронности выполнения запроса к базе данных и её обновления.
  2. Для удобства построения запроса к БД используется RxCoreData
  3. Выполняем обновление БД

Т.к. используется асинхронный подход получения и обновления данных, необходимо использовать Observable<Event<...>>. Это нужно для того, чтобы subscriber не получил Error, при ошибке во время получения remote data, а только показал эту ошибку и продолжал реагировать на изменения в CoreData. Об этом подробнее чуть позже.

DatabaseUpdater
В приложении из примера удаленные данные получаются из Firebase Remote Config. CoreData обновляется только в том случае, если fetchAndActivate() завершается со статусом .successFetchedFromRemote.

Но можно использовать любые другие ограничения обновления, например, по времени.
Метод sync() для обновления БД:

func sync<T>() -> Observable<Event<T>> {        // 1        // Check can fetch        if fetchLimiter.fetchInProcess {            return Observable.empty()        }        // 2        // Block fetch for other requests        fetchLimiter.fetchInProcess = true        // 3        // Fetch & activate remote config        return remoteConfig.rx.fetchAndActivate().flatMap { [weak self] status, error -> Observable<Event<T>> in            // 4            // Default result            var result = Observable<Event<T>>.empty()            // Update database only when config wethed from remote            switch status {            // 5            case .error:                let error = error ?? AppError.unknown                print("Remote config fetch error: \(error.localizedDescription)")                // Set error to result                result = Observable.just(Event.error(error))            // 6            case .successFetchedFromRemote:                print("Remote config fetched data from remote")                // Update database from remote config                try self?.update()            case .successUsingPreFetchedData:                print("Remote config using prefetched data")            @unknown default:                print("Remote config unknown status")            }            // 7            // Unblock fetch for other requests            self?.fetchLimiter.fetchInProcess = false            return result        }    }

  1. Возвращаем пустую последовательность, если получение данных уже идет. Например, другой метод из репозитория уже вызвал sync(). fetchLimiter должен быть потокобезопасным. А именно, получать или записывать значения в поле fetchInProcess нужно в последовательной очереди.
  2. Блокируем обновление для последующих вызовов метода
  3. Выполняем запрос для получения удаленных данных
  4. Создаем результат с пустой последовательностью по умолчанию
  5. Если запрос выполнился с ошибкой то присваиваем результату последовательность с одним элементом Event с ошибкой
  6. Обновляем БД
  7. Включаем возможность обновления БД и возвращаем результат

ViewModel
В данном примере во ViewModel просто вызывается метод sellerContacts() из Repository и возвращается результат.

func contacts() -> Observable<Event<[SellerContacts]>> {        repository.sellerContacts()    }

ViewController
В контроллере нужно забиндить результат запроса в поля. Для этого в viewDidLoad() вызывается метод bindContacts():

private func bindContacts() {        // 1        viewModel?.contacts()            .subscribeOn(SerialDispatchQueueScheduler.init(qos: .userInteractive))            .observeOn(MainScheduler.instance)             // 2            .flatMapError { [weak self] in                self?.rx.showMessage($0.localizedDescription) ?? Observable.empty()            }             // 3            .compactMap { $0.first }             // 4            .subscribe(onNext: { [weak self] in                self?.phone.text = $0.phone                self?.address.text = $0.address            }).disposed(by: disposeBag)    }

  1. Выполняем запрос контактов в фоновом потоке, а с полученным результатом работаем в главном
  2. Если приходит элемент содержащий Event с ошибкой, то показывается сообщение с ошибкой и возвращается пустая последовательность. Подробнее об операторе flatMapError и showMessage ниже
  3. Используем оператор compactMap для получения контактов из массива
  4. Устанавливаем данные в аутлеты

Оператор .flatMapError()
Для преобразования результата последовательности из Event в элемент в нём содержащийся или показа ошибки используется оператор:

func flatMapError<T>(_ handler: ((_ error: Error) -> Observable<T>)? = nil) -> Observable<Element.Element> {        // 1        flatMap { element -> Observable<Element.Element> in            switch element.event {            // 2            case .error(let error):                return handler?(error).flatMap { _ in Observable<Element.Element>.empty() } ?? Observable.empty()            // 3            case .next(let element):                return Observable.just(element)            // 4            default:                return Observable.empty()            }        }    }

  1. Преобразуем последовательность из Event.Element в Element
  2. Если Event содержит ошибку, то возвращаем handler преобразованный в пустую последовательность
  3. Если Event содержит результат, то возвращаем последовательность с одним элементом, содержащим этот результат
  4. По умолчанию возвращается пустая последовательность

Такой подход позволяет обрабатывать ошибки выполнения запросов, не посылая подписчику Error Event. И наблюдение за изменением в БД остаётся активным.

Оператор .showMessage()
Для показа сообщений пользователю используется оператор:

public func showMessage(_ text: String, withEvent: Bool = false) -> Observable<Void> {        // 1        let _alert = alert(title: nil,              message: text,              actions: [AlertAction(title: "OK", style: .default)]        // 2        ).map { _ in () }        // 3        return withEvent ? _alert : _alert.flatMap { Observable.empty() }    }

  1. С помощью RxAlert создаётся окно с сообщением и одной кнопкой
  2. Результат преобразуется в Void
  3. Если необходимо событие после показа сообщения, то возвращаем результат. Иначе сначала преобразуем его в пустую последовательность, а затем возвращаем

Т.к. .showMessage() может использоваться не только для показа уведомлений об ошибках, то полезно иметь возможность регулировать какая последовательность получается в итоге пустая или с событием.

Тесты


Все описанное выше протестировать не трудно. Начнем по порядку изложения.

RepositoryTests
Для теста репозитория используется DatabaseUpdaterMock. Там есть возможность отслеживать вызывался ли метод sync() и устанавливать результат его выполнения:

func testSellerContacts() throws {        // 1        // Success        // Check sequence contains only one element        XCTAssertThrowsError(try repository.sellerContacts().take(2).toBlocking(timeout: 1).toArray())        updater.isSync = false        // Check that element        var result = try repository.sellerContacts().toBlocking().first()?.element        XCTAssertTrue(updater.isSync)        XCTAssertEqual(result?.count, sellerContacts.count)        // 2        // Sync error        updater.isSync = false        updater.error = AppError.unknown        let resultArray = try repository.sellerContacts().take(2).toBlocking().toArray()        XCTAssertTrue(resultArray.contains { $0.error?.localizedDescription == AppError.unknown.localizedDescription })        XCTAssertTrue(updater.isSync)        result = resultArray.first { $0.error == nil }?.element        XCTAssertEqual(result?.count, sellerContacts.count)    }

  1. Проверяем, что последовательность содержит только один элемент, вызывается метод sync()
  2. Проверяем, что последовательность содержит два элемента. Один содержит Event с ошибкой, другой результат запроса из БД, вызывается метод sync()

DatabaseUpdaterTests

testSync()
func testSync() throws {        let remoteConfig = RemoteConfigMock()        let fetchLimiter = FetchLimiter(serialQueue: DispatchQueue(label: "test"))        let databaseUpdater = DatabaseUpdaterImpl(remoteConfig: remoteConfig, decoder: JSONDecoderMock(), context: context, fetchLimiter: fetchLimiter)        // 1        // Not update. Fetch in process        fetchLimiter.fetchInProcess = true        XCTAssertFalse(remoteConfig.isFetchAndActivate)        XCTAssertFalse(remoteConfig.isSubscript)                expectation(forNotification: .NSManagedObjectContextDidSave, object: context)            .isInverted = true            var sync: Observable<Event<Void>> = databaseUpdater.sync()        XCTAssertNil(try sync.toBlocking().first())        XCTAssertFalse(remoteConfig.isFetchAndActivate)        XCTAssertFalse(remoteConfig.isSubscript)        XCTAssertTrue(fetchLimiter.fetchInProcess)                waitForExpectations(timeout: 1)        // 2        // Not update. successUsingPreFetchedData        fetchLimiter.fetchInProcess = false                expectation(forNotification: .NSManagedObjectContextDidSave, object: context)            .isInverted = true                sync = databaseUpdater.sync()        var result: Event<Void>?        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)        XCTAssertTrue(fetchLimiter.fetchInProcess)        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successUsingPreFetchedData, nil)                waitForExpectations(timeout: 1)        XCTAssertNil(result)        XCTAssertTrue(remoteConfig.isFetchAndActivate)        XCTAssertFalse(remoteConfig.isSubscript)        XCTAssertFalse(fetchLimiter.fetchInProcess)        // 3        // Not update. Error        fetchLimiter.fetchInProcess = false        remoteConfig.isFetchAndActivate = false                expectation(forNotification: .NSManagedObjectContextDidSave, object: context)            .isInverted = true        sync = databaseUpdater.sync()        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)        XCTAssertTrue(fetchLimiter.fetchInProcess)        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.error, AppError.unknown)                waitForExpectations(timeout: 1)                XCTAssertEqual(result?.error?.localizedDescription, AppError.unknown.localizedDescription)        XCTAssertTrue(remoteConfig.isFetchAndActivate)        XCTAssertFalse(remoteConfig.isSubscript)        XCTAssertFalse(fetchLimiter.fetchInProcess)        // 4        // Update        fetchLimiter.fetchInProcess = false        remoteConfig.isFetchAndActivate = false        result = nil                expectation(forNotification: .NSManagedObjectContextDidSave, object: context)                sync = databaseUpdater.sync()        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)        XCTAssertTrue(fetchLimiter.fetchInProcess)        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successFetchedFromRemote, nil)                waitForExpectations(timeout: 1)                XCTAssertNil(result)        XCTAssertTrue(remoteConfig.isFetchAndActivate)        XCTAssertTrue(remoteConfig.isSubscript)        XCTAssertFalse(fetchLimiter.fetchInProcess)    }


  1. Возвращается пустая последовательность, если обновление в процессе
  2. Возвращается пустая последовательность, если данные не получены
  3. Возвращается Event с ошибкой
  4. Возвращается пустая последовательность, если данные обновились

ViewModelTests

ViewControllerTests

testBindContacts()
func testBindContacts() {        // 1        // Error. Show message        XCTAssertNotEqual(controller.phone.text, contacts.phone)        XCTAssertNotEqual(controller.address.text, contacts.address)        viewModel.contactsResult.accept(Event.error(AppError.unknown))                expectation(description: "wait 1 second").isInverted = true        waitForExpectations(timeout: 1)        // 2        XCTAssertNotNil(controller.presentedViewController)        let alertController = controller.presentedViewController as! UIAlertController        XCTAssertEqual(alertController.actions.count, 1)        XCTAssertEqual(alertController.actions.first?.style, .default)        XCTAssertEqual(alertController.actions.first?.title, "OK")        XCTAssertNotEqual(controller.phone.text, contacts.phone)        XCTAssertNotEqual(controller.address.text, contacts.address)        // 3        // Trigger action OK        let action = alertController.actions.first!        typealias AlertHandler = @convention(block) (UIAlertAction) -> Void        let block = action.value(forKey: "handler")        let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(block as AnyObject).toOpaque())        let handler = unsafeBitCast(blockPtr, to: AlertHandler.self)        handler(action)                expectation(description: "wait 1 second").isInverted = true        waitForExpectations(timeout: 1)        // 4        XCTAssertNil(controller.presentedViewController)        XCTAssertNotEqual(controller.phone.text, contacts.phone)        XCTAssertNotEqual(controller.address.text, contacts.address)        // 5        // Empty array of contats        viewModel.contactsResult.accept(Event.next([]))                expectation(description: "wait 1 second").isInverted = true        waitForExpectations(timeout: 1)                XCTAssertNil(controller.presentedViewController)        XCTAssertNotEqual(controller.phone.text, contacts.phone)        XCTAssertNotEqual(controller.address.text, contacts.address)        // 6        // Success        viewModel.contactsResult.accept(Event.next([contacts]))                expectation(description: "wait 1 second").isInverted = true        waitForExpectations(timeout: 1)                XCTAssertNil(controller.presentedViewController)        XCTAssertEqual(controller.phone.text, contacts.phone)        XCTAssertEqual(controller.address.text, contacts.address)    }


  1. Показать сообщение об ошибке
  2. Проверить, что в controller.presentedViewController сообщение об ошибке
  3. Выполнить handler для кнопки Ок и убедиться, что окно с сообщением скрылось
  4. Для пустого результата не показывается ошибка и не заполняются поля
  5. Для успешного запроса не показывается ошибка и заполняются поля

Тесты для операторов


.flatMapError()
.showMessage()

Используя подобный подход в проектировании мы реализуем асинхронное получение, обновление данных и уведомление об ошибках без потери возможности реагировать на изменение данных, следуя принципу SSOT.
Подробнее..

Из песочницы Реализация MVVM в ABAP

22.08.2020 16:14:02 | Автор: admin
После окончания университета я несколько лет работал программистом C#. Я разрабатывал приложения на WPF с использованием шаблона проектирования MVVM. Затем перешел на ABAP. К большому удивлению обнаружил что ABAP является скорее процедурным языком чем объектно-ориентированным, хотя SAP прилагает большие усилия для продвижения ОО-парадигмы. Для разделения бизнес-логики от GUI как правило используют архитектурный шаблон MVC. Пытаясь реализовать MVC шаблон я каждый раз сталкивался с определенными сложностями, которые делают поддержку программы еще более сложной чем если бы она была написана на процедурах. Не смотря на то, что реализация MVC подробно и с примерами описана в книге Design Patterns in ABAP Objects и на специализированных ресурсах (sapland.ru, blogs.sap.com и др.), проблемы с разделением логики остаются. В реализации MVC на ABAP независимой частью остается Model, а View и Controller тесно связаны между собой. Сильное сопряжение между View и Controller затрудняет поддержку и масштабируемость. Ниже описано почему так происходит и что с этим делать.

Шаблоны MVC и MVVM


Подробно описывать принцип работы шаблонов MVC и MVVM в данной статье я не буду. Приведу лишь основные моменты, которые понадобятся нам в дальнейшем.

Основное отличие MVC от MVVM в том, что в первой Controller знает как View, так и Model, также допускается что View будет знать о Model.

image

В MVVM шаблоне связь между слоями более слабая. View знает только ViewModel, а ViewModel только Model. View получает данные от ViewModel через ссылку на DataContex.

image

Шаблон MVVM предназначен для разработки в WPF на языке C#. Но его идею можно применять и в ABAP.

Проблемы MVC в ABAP


При реализации MVC, как правило, классы Model выносят в глобальное определение, а классы View и Controller в локальное. Использование локальных классов обусловлено необходимостью взаимодействия с GUI элементами. Дело в том, что на ABAP-классах нельзя построить полноценный GUI. В классах View можно использовать функционал для формирования GUI (CL_SALV_TABLE, REUSE_ALV_GRID_DISPLAY и т.п.), но этого не достаточно. Создать GUI-статусы, заголовки, экраны, PBO, PAI в классе невозможно.

Локальные View и Controller, имеют ряд недостатков:

  1. View и Controller имеют доступ ко всем глобальным переменным и параметрам экрана выбора.
  2. Обработка PBO и PAI в Controller требует получения состояния View (например получение выделенных строк ALV) или обновление View (например обновление таблицы ALV). В качестве решения данной проблемы нередко можно увидеть публичные атрибуты View, на которые воздействует Controller, или когда View имеет ссылку на Controller. Оба решения плохие, т.к. в первом случае нарушается инкапсуляция, а во втором Low Coupling.

MVVM в ABAP или MVA


Желая использовать преимущества MVVM в ABAP и сделать слои более независимыми я определил для себя следующий шаблон разработки.

image

Так как в чистом виде MVVM реализовать на ABAP нельзя, то ViewModel использовать не совсем корректно. Поэтому вместо ViewModel и Controller я использую Application.

Принцип разделения логики аналогичен принципу MVVM. View передает команды пользователя в Application, а Application воздействует на модель. Обратная связь при этом отсутствует.
Особенностью ABAP приложений является то, то представление может обновиться только после действий пользователя. Даже если какой-нибудь асинхронный процесс поменяет модель, то инициировать обновление представление он не сможет. Данная особенность позволяет ослабить связь модель-представление и делегировать функцию обновления представления самому представлению. Иными словами, представление само должно решать, когда надо обновить себя, а когда нет.

Концепция MVA


Реализация MVA основана на объектно-ориентированном подходе, где на каждый слой архитектуры будет реализован один или несколько классов. Каждый из слоев обладает рядом свойств.

Представление (View и IView):

  • MVA работает с абстракцией представления IView. Все классы View должны содержать реализацию IView.
  • IView содержит события, которые требуют взаимодействия с моделью
  • IView содержит контекст ссылка на данные модели, которые необходимо отобразить пользователю
  • View может содержать бизнес-логику, которая не требует взаимодействия с моделью. Например, если требуется реализовать из ALV проваливание в карточку контрагента, то данная логика будет относиться к представлению.
  • View содержит GUI элементы в группе функций, которая связана с классом View.

Приложение (Application):

  • Выполняет роль связки представления и модели и является точкой входа в приложение.
  • Имеет критерии запуска набор параметров, которые определяют с какими параметрами необходимо запустить приложение. Обычно это параметры селекционного экрана.
  • Критерии приложения состоят из критериев модели и представления. Например, если на селекционном экране требуется ввести дату проводки и указать флаг вывода отчета PDF или ALV, то дата проводки будет относиться к критериям модели, а флаг PDF и ALV к критериям представления.
  • В конструктор приложения передаются критерии запуска. Приложение создает модель и представление, подписывается на события представления, связывает контекст представления с моделью.

Модель (Model):

  • Содержит публичные атрибуты, которые необходимы представлению.
  • Содержит критерии расчета модели и метод инициализации.

Реализация MVA


Рассмотрим реализацию MVA на примере отчета по движению материалов. В качестве взаимодействия с моделью будет кнопка обновления данных.

Диаграмма классов будет выглядеть следующим образом.



Model. Конструктор модели будет принимать на входи критерии выбора данных. Класс будет иметь методы инициализации и обновления данных, а также атрибут с данными для отображения.







IView. Интерфейс представления содержит методы установки контекста, отображения представления, события и определение типов контекста.







IView содержит в себе описание структуры контекста, причем поля структуры должны быть ссылочными



View. Представление реализует интерфейс IView. Причем все события пользователя регистрирует класс View и вызывает только те события, которые нужно обработать приложению. В параметрах событий необходимо передать все данные, которые нужны от View (например, выделенные строки ALV).

Реализация класса View в ALV представлении



В данной реализации нам необходимо определение GUI статуса, для этого создадим ФМ и свяжем его с экземпляром CL_SALV_TABLE



Важно что все UI события принимает View (в данном случае через ON_USER_COMMAND) и при необходимости View делает RAISE EVENT для Application. Этот подход делает View и Application более независимыми.

Application. Конструктор приложения принимает на вход критерии приложения (параметры экрана выбора) и создает экземпляры Model и View, подписывается на события View и связывает контекст View с Model. Конструктор это единственное место где приложение знает о View. Application содержит метод RUN, который запускает программу. Запуск приложения можно сравнить с запуском транзакции с заранее определенными параметрами экрана. Это позволяет использовать ее из других программ без SUBMIT.



Запуск Application. Теперь делаем программу, которая будет запускать приложение.



Все, приложение готово. Можно смотреть результат.



Бизнес-логика на стороне View. Обработку событий, которые не требуют обращения к модели и контроллеру, можно делать в самой View.

Например, если требуется реализовать открытие MM03 при двойном клике на MATNR, то обработку данной логики можно сделать на стороне View.



На уровень View данная логика вынесена исходя из соображений: к модели не требуется обращаться за дополнительными данными; логика относится только к ALV, т.е. если бы была реализация View в виде Excel или PDF, то данное событие невозможно было бы обработать.

Литература

Design Patterns in ABAP Objects
Паттерны для новичков: MVC vs MVP vs MVVM
Архитектурные шаблоны в ABAP: MVC, MVP, MVVM, MVA
Подробнее..
Категории: Erp-системы , Mvvm , Sap , Mvc , Abap , Mva

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru