Как мыслить реактивно и анимировать движущиеся объекты с помощью RxJ

1656623318 kak myslit reaktivno i animirovat dvizhushhiesya obekty s pomoshhyu

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

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

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

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

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

В реализации мы будем использовать RxJs, JavaScript-версию ReactiveX и Typescript.

Код для полной демонстрационной реализации можно найти здесь.

Если вам это нравится, это вторая статья по этим темам.

Краткий результат простых основ динамики

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

dV = A*dT

Аналогично, если вы знаете скорость V, тогда вы можете вычислить изменение пространства dS в интервале времени dT с формулой

dS = V * dT

Вывод: если у вас есть ускорение А пораженный объектом, начальная скорость которого равна V0, можно примерно оценить скорость объекта в интервале времени dT с его средним, вот так:

среднийVel=(V0+V1)/2=(V0+V0+dV)/2=V0+A/2*dT

а затем вычислите приблизительное изменение пространства dS в том же интервале dT с формулой

dS = среднийVel * dT = V0 * dT + A/2 * dT²

Тем более короткий интервал времени dT, тем лучшее приближение.

Что означает «оживление объекта движением».

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

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

Используя подход PULL – спросите информацию

Мы можем использовать приведенную выше функцию и тянуть из него нужна нам информация (сколько объект переместился за последний промежуток времени dT при определенном ускорении А и начальная скорость V). Мы взяли бы результат функции и использовали бы его для вычисления новой позиции, если мы можем каким-либо образом запомнить предыдущую позицию.

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

Реактивный метод: подход PUSH (и команды).

Если вы думаете о транспортном средстве, которым кто дистанционно управляет, вы, наверное, представляете, что:

  • транспортное средство на регулярной частоте передает контроллеру свое положение и скорость
  • контроллер может изменять ускорение транспортного средства (управление и торможение – это лишь изменения ускорений вдоль космической оси), чтобы управлять движением транспортного средства
xMJxr0MCDKLyK2hdk2SlSGeAoCC76IlmF6M1

Такой подход имеет преимущество в том, что четко распределяет обязанности:

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

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

  • транспортное средство, передающее детали своей динамики (например, скорость, положение, направление) — наблюдаемое
  • контроллер, прослушивающий такие передачи и выдающий команды на ускорение, замедление, управление и торможение — Observer

Реактивная реализация — RxJs

Для разработки решения мы используем Typescript как язык программирования и модель ReactiveX через реализацию RxJs. Но концепции можно легко перенести на многие другие языки, поддерживаемые ReactiveX.

Класс MobileObject — представление движущихся объектов в пространстве.

Мы собираемся построить наш симулятор с использованием реактивных методов с функциональным стилем программирования. Но мы будем использовать старые добрые объектно-ориентированные (OO) концепции, чтобы построить четкие рамки для нашей реализации. Итак, начнем с класса MobileObject:

export class MobileObject {

}

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

Давайте представим Mr. Observable, ядро ​​нашего MobileObject

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

  • его текущую скорость
  • его текущее положение
  • насколько изменились его положения и скорость с последнего интервала времени

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

Наши часы: последовательность интервалов времени

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

ahrBFMkgyRbltV-TnWmVL9aMM0HOwf3ZsEqP
Наши часы: последовательность интервалов времени

С помощью RxJs мы можем создать такой часы с Observable с помощью такой функции:

private buildClock(frameApproximateLenght: number) {
  let t0 = Date.now();
  let t1: number;
  return Observable.timer(0, frameApproximateLenght)
    .do(() => t1 = Date.now())
    .map(() => t1 - t0)
    .tap(() => t0 = t1)
    .share();
}
const clock = buildClock(xxx);

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

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

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

Предположим, что MobileObject подвергается ускорению А. Теперь, когда мы а часымы можем вычислить изменение скорости dV используя формулу dV = A*dT. Используя эту формулу и map оператор RxJs, мы можем создать Observable, излучающий изменение скорости со временем:

izVvKH1QuAKcn-91IPyl1fY9qBv5d2k4h8H3
Изменение скорости как последовательность событий во времени

Если мы сохраняем с переменной скоростью vel вовремя tXмы можем вычислить приблизительное изменение пространства на следующем интервале времени t(X+1) с формулой dS = vel*dT+A/2*dT². Опять же, используя map оператор, мы можем получить Observable, который излучает изменение пространства во времени.

aSBleP5jlzAajO2KZlTPJFs7v91LmCTnzIzI
Сменность пространства как последовательность событий во времени

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

fxt4zKEqeOh91UekArw663MwbqkMZ3y5QgT9

Но ускорение может измениться – и что?

Это работает, если мы знаем ускорение А и если А является константой.

Но что произойдет, если ускорение изменится с течением времени? Возможно, начнем с ускорения A0затем через некоторое время P0 сила изменяет его на A1потом после P1 он меняется на A2, а затем до A3как на следующей схеме.

NlTiHdfTk2bnKTEZe3ivmx4QWWDO8qdM9anG
Ускорение с течением времени как наблюдается

ускорение выглядит как Observable, не правда ли? Каждое событие представляет изменение ускорения MobileObject (т.е. тот факт, что к MobileObject была применена новая сила).

Зная A0 мы можем вычислить скорость и положение MobileObject за период P0 используя наблюдаемый dyn0, построенный по логике, описанной выше. Когда ускорение меняется, мы все еще можем вычислить скорость и положение, но нам придется отказаться dyn0 и switch к новому Observable дин 1который построен по той же логике, что и dyn0, но теперь используем новое ускорение A1. То же переключение повторяется, когда становится ускорение A2 и затем A3.

xZFwskoz-QaNZv7pLtXmMsy525c1KA7JXZEy
Переключатель можно наблюдать при изменении ускорения

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

UWDKevUFEvJw5uFGQMP4yoQZ1DL9z8thhq-v

Добро пожаловать сейчас, Мистер Субъект – педаль акселератора MobileObject

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

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

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

Субъект – это наблюдаемый, который предлагает следующие методы:

  • следующий(val) : выпускает событие val как ценность
  • ошибка() : заканчивается ошибкой
  • завершить() : завершается изящно

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

qvAl5YmGtLEPGhOFLZ4rQaN5Sq9eFtiK0FEE
Ускорение как субъект

Заверните все в класс MobileObject

Теперь, когда у нас есть все необходимые части, нам остается просто собрать их в целостный класс MobileObject.

bVtpSszZRlZabVfeuKflJ53eq-H5asbdKTu2

Короче говоря, это то, как MobileObject моделируется в реактивном мире. Существует:

  • некоторые наблюдаемые, dynamicsX и динамикаY из примера, издающего данные о его динамике вдоль различных измерений пространства (в приведенном выше примере только 2, X и Y, в двумерном плане)
  • некоторые предметы, ускорениеX и ускорениеY из примера, позволяющих контролерам изменять ускорения в разных измерениях
  • внутренние часы, которые устанавливают частоту интервалов времени

В двумерном пространстве мы имеем 2 разных наблюдаемых, излучающих изменение пространства. Такие наблюдаемые необходимо share так же часы если мы хотим слаженного движения. И часы сам по себе наблюдаемый. Чтобы они могли делиться одним и тем же наблюдаемым, мы добавили share() оператор в конце buildClock() функцию, которую мы описывали раньше.

Последний штрих: тормоз

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

Через некоторый промежуток времени скорость автомобиля станет 0, и в этот момент к автомобилю больше не ускоряется.

czXu7Xwqr0ENn8L4RqrXOjq2ed-2FKAhRafB
Что означает торможение

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

Знать направление просто. Нам остается только взять первое событие, выпущенное dynamicsX или динамикаY наблюдается, в зависимости от интересующей нас оси и проверяем, является ли скорость последнего события положительной или отрицательной. Символом скорости является направление.

directionX = mobileObject.dynamicsX
.take(1)
.map(dynamics => dynamics.vel > 0 ? 1 : -1)

направлениеX является наблюдаемым, которое излучает только одно событие. Излучаемое значение равно 1, если скорость положительна, -1 – в противном случае.

Итак, когда MobileObject получает команду на торможение, все, что ему нужно сделать, это определить направление и применить противоположное ускорение, например:

directionX
.switchMap(
   // BRAKE is a constant of acceleration when mobileObject brakes
   dir => mobileObject.accelerationX.next(-1 * dir * BRAKE)
)

Мы почти на месте. Нам просто нужно убедиться, что как только скорость достигнет 0 или около 0, мы удалим любое ускорение. И вот как мы можем получить то, что мы хотим.

directionX
.switchMap(
   // BRAKE is a constant of acceleration when mobileObject brakes
   dir => {
      mobileObject.accelerationX.next(-1 * dir * BRAKE);
      return mobileObject.dynamicsX
      // VEL_0 is a small value below which we consider vel as 0
      .filter(dynamics => Math.abs(dynamics.vel) < VEL_0)
      .do(() => mobileObject.accelerationX.next(0)
      .take(1)
   }
).subscribe()

Здесь, после подачи команды ускорение тормоза, мы просто выбираем первое событие. dynamicsX наблюдается, где скорость достаточно мала, чтобы считаться 0. Затем мы предоставляем команду применить ускорение, равное нулю. Последний take(1) оператор добавляется, чтобы убедиться, что мы немедленно отменили подписку, поскольку тормозной observable завершил свою работу.

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

Вернуться к началу: анимация

Всё это может выглядеть хорошо, но мы все равно хотим анимировать наш MobileObject. Например, мы хотим создать приложение, где пользователь может выдавать команды ускорения через 4-кнопочную консоль и видеть, как двигается MobileOject соответственно.

Wrg61mYRd8Mjjrh63qfxXI9pMPIrWdcci6wm
Образец программы для управления MobileObject и просмотра его движения

Такое приложение выполняет роль контроллер MobileObject и как монитор для отображения анимации.

Выдача команд

Управление движением MobileObject означает, что нам нужно применить ускорение. Приложение для браузера может сделать это с помощью ускорениеX Тема, предоставленная MobileObject, как показано в следующем фрагменте.

<button id="positiveAccX" 
   (mousedown)="pAccX()" (mouseup)="releaseAccX()"/>

// mobileObject contains the instance we want to control
const accelerationValue = 100;
pAccX() {
   mobileObject.accelerationX.next(accelerationValue);
}
releaseAccX() {
   mobileObject.accelerationX.next(0);
}

Ускорение 100 применяется когда кнопка мыши нажата, а ускорение устанавливается на 0, когда кнопка мыши отпускается, имитируя педаль акселератора.

Покажите анимированное движение

MobileObject раскрывает dynamicsX и динамикаY, 2 Объекты наблюдения, которые непрерывно выдают данные о движении вдоль соответствующей оси (например, deltaSpace, скорость течения, ускорение вдоль X и Y). Итак, программа для браузера должна подписаться на них, чтобы получать эти потоки событий и изменять позицию MobileObject во время каждого выпускаемого события, как показано в этом примере фрагмента:

interface Dynamics {deltaVel: number; vel: number; deltaSpace: number; space: number}
const mobileObjectElement = document.querySelector('.mobileobj');
mobileObject.dynamicsX.subscribe(
   (dyn: Dynamics) => {
     const currentPositionX = mobileObjectElement.style.left;
     const deltaSpaceX = dyn.deltaSpace;
     mobileObjectElement.style.left = currentPositionX + deltaSpace;
   }
)

Анимационный кадр

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

RxJs обеспечивает a Планировщик звонил animationFrame окутывающий requestAnimationFrame API браузера. А Планировщик это тип RxJ, который контролирует, когда наблюдаемые события действительно происходят.

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

Observable.interval(0, animationFrame)

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

FzjfjgMujwYxJrPRUCOCTFbQ3nC-h4C2uf4j
Часы, синхронизированные с кадром анимации

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

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

Вы также можете заметить defer функция. Это функция RxJs, которая возвращает Observable, но гарантирует, что логика, определенная в функции, передается как параметр defer выполняется только тогда, когда Observable подписано.

Это позволяет нам выполнять buildClock() метод в любое время, возможно, при инициализации компонента пользовательского интерфейса. Это также позволяет нам быть уверенным, что часы начнут тикать только после подписки и в правильное время. Более конкретно let startOfPreviousFrame = animationFrame.now(); будет выполнено только тогда, когда часы observable подписан.

И последнее, но не менее важное, несколько слов о функциональном стиле программирования

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

map(dT => {
  const dV = A * dT;
  vel = vel + dV;
  const dS = vel * dT + A / 2 * dT * dT; 
  space = space + dS;
  return {dV, vel, dS, space};
})

Это предполагает, что мы определили переменные vel и space где-то так, чтобы они были видны в пределах функции, передаваемой как параметр до map оператор.

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

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

Вот здесь нам на помощь приходит многофункциональное программирование.

Функции высшего уровня

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

The динамика observable MobileObject можно построить, если у нас есть часы наблюдается, и мы знаем ускорение А. Так что мы можем так сказать динамика является функцией часы наблюдаемый и значение ускорения А.

F0T4AxwqGDWhq1MppFERaV-OKh0Pj5Q5XvlL

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

LhTgux7QBkkkMhgCEh0ROZmdkzLqslbAS4RZ
Пример функции высшего порядка

Обратите внимание, что в динамика F, мы определили переменные vel и spaceкоторые прекрасно видны изнутри dFчто делает наш код последовательным и правильным.

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

const dynFunction = dynamicsF();
const dynamics = dynFunction(clock, A);

Ключевой момент в том, что сейчас dynFunction содержит в своих внутренних элементах переменные vel и space. Он сохраняет их внутри в своем собственном состоянии, состоянии, которое не видно ни для чего вне функции.

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

const dfX = this.dynamicsF();
this.dynamicsX = this.accelerationX
                     .swithMap(a => dfX(this.clock, a));

При этом мы ограничили информацию о состоянии о текущей скорости и пространстве в функции dfX. Мы также устранили необходимость определения свойств для текущей скорости и пространства в MobileObject. И мы улучшили повторное использование, поскольку динамика F() не имеет ссылки ни на одну ось и может использоваться для вычисления обоих dynamicsX и динамикаY через композицию функции.

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

Вывод

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

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

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

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

Всю кодовую базу можно найти здесь.

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *