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

Patterns

Используем XSTATE для VueJS

14.09.2020 10:18:41 | Автор: admin


Маленький пример применения библиотеки XState от David Khourshid для декларативного описания логики компонента VueJS 2. XState это очень развитая библиотека для создания и использования конечных автоматов на JS. Неплохое подспорье в трудном деле создания веб приложений.

Предистория


В моей прошлой статье кратко описано зачем нужны машины состояний (конечные автоматы) и приведена простенькая реализация для работы с Vue. В моем велосипеде были только состояния и декларация состояний выглядела так:
{    idle: ['waitingConfirmation'],    waitingConfirmation: ['idle','waitingData'],    waitingData: ['dataReady', 'dataProblem'],    dataReady: [idle],    dataProblem: ['idle']}

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

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

Изучив теорию по роликам из Ютюба, стало понятно, что события нужны и важны. В голове родился такой вид декларации:
{  idle: {    GET: 'waitingConfirmation',  },  waitingConfirmation: {    CANCEL: 'idle',    CONFIRM: 'waitingData'  },  waitingData: {    SUCCESS: 'dataReady',    FAILURE: 'dataProblem'  },  dataReady: {    REPEAT: 'idle'  },  dataProblem: {    REPEAT: 'idle'  }}

А это уже очень напоминает то, как описывает состояния библиотека XState. Почитав внимательней доку, я решил убрать самодельный велосипед в сарай, и пересесть на фирменный.

VUE + XState


Установка очень простая, читайте доку, после установки включаем XState в компонент:
import {Machine, interpret} from xstate

Создаем машину на основе объекта-декларации:
const myMachine = Machine({    id: 'myMachineID',    context: {      /* some data */    },    initial: 'idle',    states: {        idle: {          on: {            GET: 'waitingConfirmation',          }        },        waitingConfirmation: {          on: {            CANCEL: 'idle',            CONFIRM: 'waitingData'          }        },        waitingData: {          on: {            SUCCESS: 'dataReady',            FAILURE: 'dataProblem'          },        },        dataReady: {          on: {            REPEAT: 'idle'          }        },        dataProblem: {          on: {            REPEAT: 'idle'          }        }    }})

Понятно, что есть состояния idle, waitingConfirmation' и есть события в верхнем регистре GET, CANCEL, CONFIRM .

Сама по себе машина не работает, из нее надо создать сервис с помощью функции interpret. Ссылку на этот сервис разместим в наш state, а заодно и ссылку на текущее состояние current:
data: {    toggleService: interpret(myMachine),    current: myMachine.initialState,}

Сервис надо стартануть start(), а также указать, что при переходах состояния мы обновляем значение current:
mounted() {    this.toggleService        .onTransition(state => {            this.current = state         })        .start();    }

В методы добавляем функцию send, ее и используем для управления машиной передачи ей событий:
methods: {   send(event) {      this.toggleService.send(event);   },  } 

Ну а дальше все просто. Передавать событие просто вызовом:
this.send(SUCCESS)

Узнать текущее состояние:
this.current.value

Проверить нахождение машины в определенном состоянии так:
this.current.matches(waitingData')


Cоберем все вместе:

Template
<div id="app">  <h2>XState machine with Vue</h2>  <div class="panel">    <div v-if="current.matches('idle')">      <button @click="send('GET')">        <span>Get data</span>      </button>    </div>    <div v-if="current.matches('waitingConfirmation')">      <button @click="send('CANCEL')">        <span>Cancel</span>      </button>      <button @click="getData">        <span>Confirm get data</span>      </button>    </div>    <div v-if="current.matches('waitingData')" class="blink_me">      loading ...    </div>    <div v-if="current.matches('dataReady')">      <div class='data-hoder'>        {{ text }}      </div>      <div>        <button @click="send('REPEAT')">          <span>Back</span>        </button>      </div>    </div>    <div v-if="current.matches('dataProblem')">      <div class='data-hoder'>        Data error!      </div>      <div>        <button @click="send('REPEAT')">          <span>Back</span>        </button>      </div>    </div>  </div>  <div class="state">    Current state: <span class="state-value">{{ current.value }}</span>  </div></div>


JS
const { Machine, interpret } = XStateconst myMachine = Machine({    id: 'myMachineID',    context: {      /* some data */    },    initial: 'idle',    states: {        idle: {          on: {            GET: 'waitingConfirmation',          }        },        waitingConfirmation: {          on: {            CANCEL: 'idle',            CONFIRM: 'waitingData'          }        },        waitingData: {          on: {            SUCCESS: 'dataReady',            FAILURE: 'dataProblem'          },        },        dataReady: {          on: {            REPEAT: 'idle'          }        },        dataProblem: {          on: {            REPEAT: 'idle'          }        }    }})new Vue({  el: "#app",  data: {  text: '',  toggleService: interpret(myMachine),    current: myMachine.initialState,  },  computed: {  },  mounted() {    this.toggleService        .onTransition(state => {          this.current = state        })        .start();  },  methods: {    send(event) {      this.toggleService.send(event);    },    getData() {      this.send('CONFIRM')    requestMock()      .then((data) => {             this.text = data.text         this.send('SUCCESS')      })      .catch(() => this.send('FAILURE'))    },  }})function randomInteger(min, max) {  let rand = min + Math.random() * (max + 1 - min)  return Math.floor(rand);}function requestMock() {  return new Promise((resolve, reject) => {  const randomValue = randomInteger(1,2)  if(randomValue === 2) {    let data = { text: 'Data received!!!'}      setTimeout(resolve, 3000, data)    }    else {    setTimeout(reject, 3000)    }  })}


Ну и конечно все это можно потрогать на jsfiddle.net

Visualizer


XState предоставляет замечательный инструмент Visualizer . Можно посмотреть диаграмму именно вашей машины. И не только посмотреть но и пощелкать по событиям и осуществить переходы. Вот так выглядит наш пример:


Итог


XState отлично работает, вместе с VueJS. Это упрощает работу компонента, позволяет избавиться от лишнего кода. Главное декларация машины позволяет быстро понять логику. Данный пример простой, но я уже пробовал и на более сложном примере для рабочего проекта. Полет нормальный.

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

  • Guarded transitions
  • Actions (entry, exit, transition)
  • Extended state (context)
  • Orthogonal (parallel) states
  • Hierarchical (nested) states
  • History

А есть еще аналогичные библиотеки, например Robot. Вот сравнение Comparing state machines: XState vs. Robot. Так что если вас заинтересовала тема, вам будет чем заняться.
Подробнее..

Тотальный JavaScript изучаем JS с акцентом на практической составляющей

22.02.2021 16:14:10 | Автор: admin


Доброго времени суток, друзья!

Когда речь заходит об изучении JavaScript, будь то первое знакомство с языком или углубление имеющихся знаний, найти в интернетах теоретические материалы не составляет особого труда. Мой топ-5:


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

Предлагаю вашему вниманию Тотальный JavaScript.

Вот что вы найдете в этом репозитории:

  • Огромное количество сниппетов (утилит, вспомогательных функций), разделенных по типам данных не могу назвать точного количества (порядка 4000 строк кода без комментариев и пробелов). Следует отметить, что не все функции являются настоящими сниппетами с точки зрения возможности их использования (как есть) в реальных приложениях, некоторые всего лишь эксперименты, демонстирующие те или иные (безграничные?) возможности языка. Коллекция все время пополняется
  • 230 практических вопросов приводится пример кода, необходимо выполнить его в уме и решить, что будет выведено в консоль. Конечно, на практике мы редко занимается чем-то подобным, ведь гораздо легче и, главное, быстрее законсолить кусок подозрительного кода. Однако, на мой взгляд, умение решать подобные задачи как нельзя лучше демонстрирует понимание основных принципов и характерных особенностей работы JavaScript. В качестве недостатка этого раздела отмечу почти полное отсутствие вопросов по классам и this. Постараюсь в ближайшем будущем его устранить
  • 68 задач разного уровня сложности подборка задач из учебника Ильи Кантора (большинство), немного адаптированных под нужды реальных приложений. Структура раздела, в основном, следует структуре учебника с небольшими лирическими отступлениями
  • Паттерны проектирования подробное описание и примеры всех паттернов, которые называет Банда Четырех в своей книге Паттерны объектно-ориентированного программирования, на JavaScript (также в разделе имеются примеры на TypeScript смотрите исходный код). При подготовке данного раздела многое позаимствовано у Refactoring Guru, за что ему (или им) огромное спасибо
  • Что за черт, JavaScript? список тонких моментов работы JavaScript. Этот раздел не слишком актуален, учитывая возможности современного JS, однако интересен тем, что позволяет узнать, каким был язык раньше, до того, как завоевал мир веб-разработки. Де факто, он остается прежним, но следование простым правилам (например, использование const или let вместо var или "===" вместо "==") позволяет решить большую часть проблем, с которыми сталкивались разработчики в прошлом

Уверен, что каждый найдет для себя что-нибудь интересное.

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

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

Благодарю за внимание. Всех, кому дым отечества и сладок, и приятен, с наступающим праздником.
Подробнее..

А такой ли уж анти-паттерн этот Service Locator?

28.01.2021 10:04:30 | Автор: admin

В индустрии сложилось устойчивое мнение, что Service Locator является анти-паттерном. Из wiki:

Стоит заметить, что в некотором случае локатор служб фактически является анти-шаблоном.

В этой публикации я рассматриваю тот случай, когда, на мой взгляд, Service Locator анти-шаблоном не является.

Вот что пишут в интернетах по поводу Локатора:

Некоторые считают Локатор Служб анти-паттерном. Он нарушает принцип инверсии зависимостей (Dependency Inversion principle) из набора принциповSOLID. Локатор Служб скрывает зависимости данного класса вместо их совместного использования, как в случае шаблона Внедрение Зависимости (Dependency Injection). В случае изменения данных зависимостей мы рискуем сломать функционал классов, которые их используют, вследствие чего затрудняется поддержка системы.

Service Locator идёт рука об руку с DI настолько близко, что некоторые авторы (Mark Seemann, Steven van Deursen) специально предупреждают:

Service Locator is a dangerous pattern because it almost works. ... Theres only one area where Service Locator falls short, and that shouldnt be taken lightly.

Т.е., Локатор чертовски хорош и работает почти как надо, но есть один момент, который всё портит. Вот он:

The main problem withService Locators the impact of reusability of the classes consuming it. This manifests itself in two ways:

* The class drags along theService Locatoras a redundantDependency.

* The class makes it non-obvious what itsDependenciesare.

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

Другими словами, вот так создавать объекты и внедрять в них зависимости благословляется:

public function __construct(IDep1 $dep1, IDep2 $dep2, IDep3 $dep3){    $this->dep1 = $dep1;    $this->dep2 = $dep2;    $this->dep3 = $dep3;}

а вот так - нет:

public function __construct(ILocator $locator){    $this->locator = $locator;    $this->dep1 = $locator->get(IDep1::class);    $this->dep2 = $locator->get(IDep2::class);    $this->dep3 = $locator->get(IDep3::class);}

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

Property Injection should only be used when the class youre developing has a good Local Default, and you still want to enable callers to provide different implementations of the classs Dependency. Its important to note that Property Injection is best used when the Dependency is optional. If the Dependency is required, Constructor Injection is always a better pick.

Если мы перепишем наш класс с Локатором в таком виде:

public function __construct(ILocator $locator = null){    if ($locator) {        $this->dep1 = $locator->get(IDep1::class);    }}public function setDep1(IDep1 $dep1){    $this->dep1 = $dep1;}

то, а) мы делаем его независимым от наличия Локатора (например, в тестовой среде), б) явным образом выделяем зависимости в setter'ах (также можно аннотировать, документировать, ставить префиксы и решать проблему "неочевидности" зависимостей любым другим доступным способом, вплоть до Ctrl+F по ключу "$locator->get" в коде).

Вот мы и подошли к тому моменту, когда, на мой взгляд, использование Локатора оправдано. В комментах к статье "Какое главное отличие Dependency Injection от Service Locator?" коллега @symbix резюмировал тему статьи так:

SL работает по принципу pull: конструктор "вытягивает" из контейнера свои зависимости.

DI работает по принципу push: контейнер передает в конструктор его зависимости.

Т.е., по сути дела, DI-контейнер объектов может использоваться и как Service Locator:

// push deps into constructorpublic function __construct(IDep1 $dep1, IDep2 $dep2, IDep3 $dep3) {}// pull deps from constructorpublic function __construct(IContainer $container) {    if ($container) {        $this->dep1 = $container->get(IDep1::class);        $this->dep2 = $container->get(IDep2::class);        $this->dep3 = $container->get(IDep3::class);    }}

Как мы уже отметили выше, первый способ считается допустимым, второй - анти-паттерн. Но к чему приводит применение первого способа в промышленных объёмах? К тому, что, чтобы запустить приложение, мы должны при создании заинжектить в конструктор приложения его зависимости, а в их конструкторы - зависимости зависимостей и т.д. по всей иерархии. Т.е., чтобы объект приложения был хотя бы создан, мы должны по цепочке создать все зависимости, упомянутые во всех конструкторах, даже если мы собираемся всего лишь получить справку о параметрах запуска приложения в консольном режиме.

"Анти-паттерн" Service Locator же позволяет нам "вытягивать" из контейнера нужные нам зависимости по мере обращения к ним:

class App {    /** @var \IContainer */    private $container;    /** @var \IDep1 */    private $dep1;    public function __construct(IContainer $container = null) {        $this->container = $container;    }    private function initDep1() {        if (!$this->dep1) {            $this->dep1 = $this->container->get(IDep1::class);        }        return $this->dep1;    }    public function run() {        $dep1 = $this->initDep1();    }    public function setDep1(IDep1 $dep1) {        $this->dep1 = $dep1;    }}

Итого, приведённый выше код:

  • может быть использован без контейнера в конструкторе за счёт возможности внедрения зависимости через setter (например, в тестах);

  • зависимости явно описываются через набор private-методов с префиксом init;

  • иерархия зависимостей не тянется при создании экземпляра данного класса, а создаётся по мере использования.

В таком варианте использования паттерн Service Locator вызывает во мне положительные эмоции и не вызывает отрицательных. Ну если только за малым исключением - при внедрении зависимостей в конструктор (режим "push") DI-контейнер знает, для какого класса создаются зависимости и может внедрять различные имплементации одного и того же интерфейса на основании внутренних инструкций. В режиме "pull" у контейнера нет информации для кого он создаёт зависимости, нужно её дать:

$this->dep1 = $this->container->get(IDep1::class, self::class);

Вот в таком варианте Service Locator становится очень даже "pattern" без всяких "anti".

Подробнее..

SOLID ООП?

03.07.2020 16:05:03 | Автор: admin

Наверное я не ошибусь, если скажу, что чаще всего на собеседованиях спрашивают о SOLID принципах. Технологии, языки и фреймворки разные, но принципы написания кода в целом похожи: SOLID, KISS, DRY, YAGNI, GRASP и подобные стоит знать всем.


В современной индустрии уже много десятков лет доминирует парадигма ООП и у многих разработчиков складывается впечатление, что она лучшая или и того хуже единственная. На эту тему есть прекрасное видео Why Isn't Functional Programming the Norm? про развитие языков/парадигм и корни их популярности.


SOLID изначально были описаны Робертом Мартином для ООП и многими воспринимаются как относящиеся только к ООП, даже википедия говорит нам об этом, давайте же рассмотрим так ли эти принципы привязаны к ООП?


Single Responsibility


Давайте пользоваться пониманием SOLID от Uncle Bob:


This principle was described in the work of Tom DeMarco and Meilir Page-Jones. They called it cohesion. They defined cohesion as the functional relatedness of the elements of a module. In this chapter well shift that meaning a bit, and relate cohesion to the forces that cause a module, or a class, to change.

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


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


Open Closed


SOFTWARE ENTITIES (CLASSES, MODULES, FUNCTIONS, ETC.) SHOULD BE OPEN FOR EXTENSION, BUT CLOSED FOR MODIFICATION
Bertrand Meyer

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


При этом функция это одна из лучших абстракций (исходя из принципа сегрегации интерфейсов, о котором позже). Использование функций для обеспечения этого принципа настолько удобно, что подход уже прочно перекочевал из функциональных языков во все основные ООП языки. Для примера можно взять функции map, filter, reduce, которые позволяют менять свой функционал прямой передачей кода в виде функции. Более того, весь этот функционал можно получить используя только одну функцию foldLeft без изменения ее кода!


def map(xs: Seq[Int], f: Int => Int) =   xs.foldLeft(Seq.empty) { (acc, x) => acc :+ f(x) }def filter(xs: Seq[Int], f: Int => Boolean) =   xs.foldLeft(Seq.empty) { (acc, x) => if (f(x)) acc :+ x else acc }def reduce(xs: Seq[Int], init: Int, f: (Int, Int) => Int) =  xs.foldLeft(init) { (acc, x) => f(acc, x) }

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


Liskov Substitution


Обратимся к самой Барбаре:


If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

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


Как видите тут речь хоть и идет об "объектах", но ни слова о классах нет, "объект" тут это просто значение типа. Многие говорят о том, что этот принцип регламентирует наследование в ООП и они правы! Но принцип шире и может быть даже использован с другими видами полиморфизма, вот пример (немного утрированный конечно), который без всякого наследования нарушает этот принцип:


static <T> T increment(T number) {  if (number instanceof Integer) return (T) (Object) (((Integer) number) + 1);  if (number instanceof Double) return (T) (Object) (((Double) number) + 1);  throw new IllegalArgumentException("Unexpected value "+ number);}

Тут мы объявляем, что функция принимает тип T, не ограничивая его, что делает все типы его "подтипом" (т.е. компилятор позволяет передать в функцию объект любого типа), при этом функция ведет себя не так, как объявлена работает не для всех типов.


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


Interface Segregation


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


С одной стороны этот принцип говорит об интерфейсах, как о наборе функций, "протоколе" который обязуются выполнять реализации и казалось бы уж этот принцип точно про ООП! Но существуют другие схожие механизмы обеспечения полиморфизма, например классы типов (type classes), которые описывают протокол взаимодействия с типом отдельно от него.


Например вместо интерфейса Comparable в Java есть type class Ord в haskell (пусть слово class не вводит вас в заблуждение haskell чисто функциональный язык):


// упрощенноclass Ord a where    compare :: a -> a -> Ordering

Это "протокол", сообщающий, что существуют типы, для которые есть функция сравнения compare (практически как интерфейс Comparable). Для таких классов типов принцип сегрегации прекрасно применим.


Dependency Inversion


Depend on abstractions, not on concretions.

Этот принцип часто путают с Dependency Injection, но этот принцип о другом он требует использования абстракций где это возможно, причем абстракций любого рода:


int first(ArrayList<Integer> xs) // ArrayList это деталь реализации -> int first(Collection<Integer> xs) // Collection это абстракция -> <T> T first(Collection<T> xs) // но и тип элемента коллекции это только деталь реализации

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


def sum[F[_]: Monad](xs: Seq[F[Int]]): F[Int] =  if (xs.isEmpty) 0.pure  else for (head <- xs.head; tail <- all(xs.tail)) yield head + tailsum[Id](Seq(1, 2, 3)) -> 6sum[Future](Seq(queryService1(), queryService2())) -> Future(6)

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




Вот и выходит, что принципы SOLID более общие, чем ООП в сегодняшнем нашем его понимании. Не забывайте оглядываться по сторонам, докапываться до смысла и узнавать новое!

Подробнее..

Унификация поиска пути вместо различной логики ИИ

11.02.2021 12:14:32 | Автор: admin

Для одной незамысловатой игры мне понадобилось реализовать поведение ИИ с базовым функционалом: патрулирование, преследование и ведение боя. Сама по себе задача простая, однако, типов локаций было два и с разными уровнями абстракций.

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

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

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

Реализация

Основные объекты:

  • IPath<TPoint> (данные о пути)

  • IPathProvider<TPoint> (поисковик или объект предоставляющий путь)

  • IPathResponse<TPoint> (содержащий путь ответ, получаемый от поисковика)

  • IPathRequestToken<TPoint> (токен для формирования ответа)

IPath

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

public interface IPath<TPoint>{    // Текущая используемая точка.    TPoint Current { get; }    // Коллекция всех точек.    IEnumerable<TPoint> Points { get; }    // Метод для перехода к следующей точке из коллекции.    bool Continue(TPoint origin);}

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

Сделаем реализацию пути пустого. Но для чего? Разве нельзя представлять пустой путь как null? И это возможный вариант, но он добавит нам ручной работы для проверок на существование данных, в то время как реализация в виде конкретного объекта просто не позволит боту передвигаться, т.к. все время будет возвращать отрицательный результат.

public class EmptyPath<TPoint> : IPath<TPoint>{    public TPoint Current => default(TPoint);    public IEnumerable<TPoint> Points => null;        public bool Continue(TPoint origin) => false;}// Исключение, которое нужно бросить при пустых результатах.public class EmptyPathException : Exception{    public EmptyPathException()        : base("Path is empty! Try using EmptyPath<TPoint> instead of Path<TPoint>")    {}}

Добавим стандартную реализацию для пути:

public class Path<TPoint> : IPath<TPoint>{    // Функция перехода.    // Проверяет необходимость смены текущей точки.    protected readonly Func<TPoint, TPoint, bool> ContinueFunc;    protected readonly IEnumerator<TPoint> PointsEnumerator;        // Текущая точка.    public TPoint Current { get; protected set; }    // Коллекция точек.    public IEnumerable<TPoint> Points { get; protected set; }        // Продолжено ли движение по пути.    // Внутреннее свойство.    public bool Continued { get; protected set; }        public Path(IEnumerable<TPoint> points, Func<TPoint, TPoint, bool> continueFunc)    {        // Мы не должны допускать пустых данных.        if(points == null)            throw new EmptyPathException();                ContinueFunc = continueFunc;        PointsEnumerator = points.GetEnumerator();                Points = points;                // Изначально указатель никуда не указывает         // и его нужно сдвинуть на первый элемент.        MovePointer();    }    // Проверка на возможность продолжения перемещения.    public bool Continue(TPoint origin)    {        // Если нужно двигаться к следующей точке.        if (ContinueFunc(origin, Current))            MovePointer();                // Продолжен ли путь.        return Continued;    }         // Передвигаем указатель на следующий элемент,    // если возможно продолжить путь.    protected void MovePointer()    {        // Если есть элементы в коллекции точек.        if (PointsEnumerator.MoveNext())        {            Current = PointsEnumerator.Current;            Continued = true;        }        else        {            // Путь невозможно продолжить            Continued = false;        }    }}

ДелегатFunc<TPoint, TPoint, bool> ContinueFunc нужен для проверки текущей цели (точки, к которой мы двигаемся). Если бот подойдет к цели, то ее логично будет сменить на следующую точку в пути. Этот делегат передается извне.

ПеречислительIEnumerator<TPoint> PointsEnumerator нужен для ручного обхода по коллекции точек.

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

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

Да и к тому же нам все еще нужно знать к кому обращаться за поиском пути :)

IPathProvider и IPathResponse

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

IPathProvider<TPoint> интерфейс, сообщающий нам о том, что у объекта можно запросить путь нужного нам типа. Один объект может реализовывать несколько вариантов этого интерфейса. Определение поисковика:

public interface IPathProvider<TPoint>{    // Метод запроса, возвращающий путь, но внутри обекта ответа.    IPathResponse<TPoint> RequestPath(TPoint entryPoint, TPoint endPoint);}

Определение ответа на запрос:

public interface IPathResponse<TPoint>{    // Флаг о готовности данных пути.    bool Ready { get; }    // Сам путь, который может быть null.    IPath<TPoint> Path { get; }}

IPathResponse<TPoint>содержит в себе путьPathи флагReady, сигнализирующий о завершении поиска пути провайдером. При асинхронном/многопоточном вычислении флаг не сразу может быть со значением true.

Синхронная реализация ответа выглядит следующим образом:

public sealed class PathResponseSync<TPoint> : IPathResponse<TPoint>{    public bool Ready { get; private set; }    public IPath<TPoint> Path { get; private set; }    public PathResponseSync(IPath<TPoint> path)    {        if(path == null)            throw new EmptyPathException();        Path = path;        Ready = true;    }}

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

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

Токен:

public sealed class PathRequestToken<TPoint>{public bool IsReady { get; private set; }    public IPath<TPoint> Path { get; private set; }        public void Ready(IPath<TPoint> path)    {    if (path == null)        throw new EmptyPathException();                IsReady = true;        Path = path;    }        }

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

Ответ:

public sealed class PathResponse<TPoint> : IPathResponse<TPoint>{    private readonly PathRequestToken<TPoint> _token;        public bool Ready => _token.IsReady;    public IPath<TPoint> Path => _token.Path;    public PathResponse(PathRequestToken<TPoint> token)    {        _token = token;    }    // Метод для упрощенного создания объектов ответа и токена.    public static void New(out PathRequestToken<TPoint> token,        out PathResponse<TPoint> response)    {        token = new PathRequestToken<TPoint>();        response = new PathResponse<TPoint>(token);    }}

Реализация класса асинхронного/многопоточного ответа подходит и для синхронных вычислений.

Здесь нет установки значений, только обращение к значениям токена и один статический метод для удобства создания объектов ответа и токена.

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

Теперь у нас есть все, чтобы абстрагироваться от конкретных алгоритмов при этом не меняя логику ИИ! К тому же мы избавились от надобности знать способ выполнения алгоритма: синхронный он или нет нас не интересует благодаря IPathResponse.

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

В классе ИИ я определил поля для ответа и провайдера, а методе Update содержится логика запроса и следования:

..private IPathProvider<Vector3> _pathProvider;private IPathResponse<Vector3> _pathResponse;..  public override void Update(float deltaTime){    // Обновление пути при преследовании.    _pathUpdateTimer += deltaTime;    if (_pathUpdateTimer >= Owner.PathUpdateRate)    {        _pathUpdateTimer = 0f;                        if (Target == null)            Target = _scanFunction(Owner);        if (Target == null)            return;                // Запрашиваем путь у поисковика.        _pathResponse = _pathProvider            .RequestPath(Position, Target.transform.position);    }    // Следование по пути, если есть ответ и путь просчитан.    if (_pathResponse != null)    {        // Если данные о пути готовы к использованию        if (_pathResponse.Ready)        {            var path = _pathResponse.Path;            // Объект пути вычисляет надобность в смене следующей точке            // и возможности дальнейшего передвижения.            if (path.Continue(Position))            {                // Какая-то логика передвижения                var nextPosition = Vector3.MoveTowards( Position, path.Current,                    Owner.MovementSpeed * deltaTime);                                    Position = nextPosition;            }        }    }      }

Функция для для перехода по точкам:

public static bool Vector3Continuation(Vector3 origin, Vector3 current){    var distance = (origin - current).sqrMagnitude;    return distance <= float.Epsilon;}

Ну и пример поисковика:

public IPathResponse<Vector3> RequestPath(Vector3 entryPoint, Vector3 endPoint){    // ЗДЕСЬ БЛА ЛОГИКА, НО ЕЕ УКРАЛО НЛО...    // Найденный путь с объектами типа LinkedAPoint.    var pathRaw = _jastar.FindPath(startPointJastar, endPointJastar);                // Если пути нет, то возвращается синхронный ответ с пустым путем.    if(pathRaw.Count == 0)        return new PathResponseSync<Vector3>(new EmptyPath<Vector3>());      var vectorList = pathRaw.ToVector3List();      // Возвращение пути со списком точек и заданной функцией продолжения.    return new PathResponseSync<Vector3>(        new Path<Vector3>(vectorsList, PathFuncs.Vector3Continuation));}

Посмотреть исходники можно здесь. Также там есть пара алгоритмов из игрушки.

Подробнее..

Из песочницы Rust vs. State

27.08.2020 14:07:16 | Автор: admin

Важно: для комфортного прочтения статьи нужно уметь читать исходный код на Rust и понимать, почему оборачивать всё в Rc<RefCell<...>> плохо.


Введение


Rust не принято считать объектно-ориентированным языком: в нём нет наследования реализации; инкапсуляции на первый взгляд тоже нет; наконец, столь привычные ООП-адептам графы зависимостей мутабельных объектов здесь выглядят максимально уродливо (вы только посмотрите на все эти Rc<RefCell<...>> и Arc<Mutex<...>>!)


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


Первым подопытным кроликом станет паттерн State, чистой реализации которого и посвящена эта статья.


Он был выбран не просто так: этому же паттерну посвящена глава из The Rust Book. Цель той главы была в том, чтобы показать, что объектно-ориентированный код на Rust пишут только плохие мальчики и девочки: здесь вам и лишний Option, и тривиальные реализации методов нужно копипастить во все реализации типажа. Но стоит применить пару трюков, и весь бойлерплейт пропадёт, а читаемость повысится.


Масштаб работ


В оригинальной статье моделировался workflow поста в блоге. Проявим фантазию и адаптируем исходное описание под суровые русские реалии:


  1. Любая статья на Хабре когда-то была пустым черновиком, который автор должен был наполнить содержимым.
  2. Когда статья готова, она отправляется на модерацию.
  3. Как только модератор одобрит статью, она публикуется на Хабре.
  4. Пока статья не опубликована, пользователи не должны видеть её содержимое.

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


Листинг ниже демонстрирует код, соответствующий описанию выше.


// main.rsuse article::Article;mod article;fn main() {    let mut article = Article::empty();    article.add_text("Rust не принято считать ООП-языком");    assert_eq!(None, article.content());    article.send_to_moderators();    assert_eq!(None, article.content());    article.publish();    assert_eq!(Some("Rust не принято считать ООП-языком"), article.content());}

Article пока выглядит следующим образом:


// article/mod.rspub struct Article;impl Article {    pub fn empty() -> Self {        Self    }    pub fn add_text(&self, _text: &str) {        // no-op    }    pub fn content(&self) -> Option<&str> {        None    }    pub fn send_to_moderators(&self) {        // no-op    }    pub fn publish(&self) {        // no-op    }}

Это проходит все ассерты, кроме последнего. Неплохо!


Реализация паттерна


Добавим пока пустой типаж State, состояние Draft и пару полей в Article:


// article/state.rspub trait State {    // empty}// article/states.rsuse super::state::State;pub struct Draft;impl State for Draft {    // nothing}// article/mod.rsuse state::State;use states::Draft;mod state;mod states;pub struct Article {    state: Box<dyn State>,    content: String,}impl Article {    pub fn empty() -> Self {        Self {            state: Box::new(Draft),            content: String::new(),        }    }    // ...}

Беды с башкой дизайном


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


trait State {    fn send_to_moderators(&mut self) -> &dyn State;}

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


А если хранить состояние в куче?


pub trait State {    fn send_to_moderators(&mut self) -> Box<dyn State>;}

Уже лучше. Но в большинстве случаев состояние должно возвращать себя же. И что, каждый раз копировать себя и класть новую копию в кучу?


В оригинальном туториале было выбрано следующее решение:


pub trait State {    fn send_to_moderators(self: Box<Self>) -> Box<dyn State>;}

Но у этого решения есть один серьёзный недостаток: мы не можем сделать его автоматическую имплементацию (возвращать self). Потому что для этого нужно, чтобы Self: Sized, т.е. размер объекта был фиксирован и известен на момент компиляции. Но это лишает нас возможности создавать trait object, т.е. никакого динамического диспатча не будет.


Решение


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


P.S.: это решение честно подсмотрено в игровом движке Amethyst.


use crate::article::Article;pub trait State {    fn send_to_moderators(&mut self) -> Transit {        Transit(None)    }}pub struct Transit(pub Option<Box<dyn State>>);impl Transit {    pub fn to(state: impl State + 'static) -> Self {        Self(Some(Box::new(state)))    }    pub fn apply(self, article: &mut Article) -> Option<()> {        article.state = self.0?;        Some(())    }}

Теперь мы, наконец, готовы реализовать эту функцию для Draft:


// article/states.rsuse super::state::{State, Transit};pub struct Draft;impl State for Draft {    fn send_to_moderators(&mut self) -> Transit {        Transit::to(PendingReview)    }}pub struct PendingReview;impl State for PendingReview {    // nothing}// article/mod.rsimpl Article {    // ...    pub fn send_to_moderators(&mut self) {        self.state.send_to_moderators().apply(self);    }    // ...}

Осталось совсем чуть-чуть


Добавление состояния для опубликованной статьи тривиально: добавляем структуру Published, реализуем для неё типаж State, добавляем в этот типаж метод publish и переопределяем его для PendingReview. Ещё нужно не забыть вызвать этот метод внутри Article::publish :)


Осталось делегировать управление контентом статьи состояниям. Добавим метод content в типаж State, переопределим реализацию для Published и, собственно, делегируем управление контентом из Article:


// article/mod.rsimpl Article {    // ...    pub fn content(&self) -> Option<&str> {        self.state.content(self)    }    // ...}// article/state.rspub trait State {    // ...    fn content<'a>(&self, _article: &'a Article) -> Option<&'a str> {        None    }}// article/states.rsimpl State for Published {    fn content<'a>(&self, article: &'a Article) -> Option<&'a str> {        Some(&article.content)    }}

Хмм, почему же ассерт всё ещё вызывает панику? Ах да, мы же забыли само действие добавления текста!


impl Article {    // ...    pub fn add_text(&mut self, text: &str) {        self.content.push_str(text);    }    // ...}

(Голосом Лапенко) Как говорят в Америке, быстро и грязно.


Все ассерты работают! Работа сделана!


Однако, если бы наш Article публиковался не на Хабре, а на каком-то другом ресурсе, вполне могло бы оказаться, что менять текст уже опубликованной статьи нельзя. Что тогда делать? Делегировать работу состояниям, конечно же! Но это мы оставим в качестве упражнения пытливым читателям.


Вместо заключения


Исходный код можно найти в этом репо.


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


В следующих статьях, если они будут, я хочу разобрать ещё несколько самых интересных для переноса в Rust паттернов. Например, Observer: я пока вообще без понятия, как там обойтись без Arc<Mutex<...>>!


Спасибо за внимание, до скорых встреч.

Подробнее..

Бюджетный 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. Стало очень просто работать с секциями, настраивать ячейки, писать меньше шаблонного кода. В то же время осталась возможность настройки таблицы и расширения функционала при необходимости.


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

Подробнее..

Категории

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

  • Имя: Макс
    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