1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00

Backend-driven UI

This commit is contained in:
Sergey Konstantinov
2023-09-14 22:55:27 +03:00
parent f5dcaec39e
commit 3305b238dd
10 changed files with 286 additions and 125 deletions

Binary file not shown.

View File

@@ -5804,7 +5804,7 @@ api.<span class="hljs-title function_">subscribe</span>(
<li>The functionality of the underlying API</li>
<li>The methods of visualizing data and interacting with UI controls that are conventional for the application platform (possibly, creatively improved by our UX designers).</li>
</ul>
<p>These two subject areas could be far away from each other. Furthermore, <strong>the closer a UI is to representing the raw data, the less convenient it is for the user</strong> (huge forms for entering field values as a classical example<a href="#ref-chapter-43-no-84" id="ref-chapter-43-no-84-back" class="ref"><sup>1</sup></a>). If we aim to make an interface ergonomic, we need to replace “forms” with complex interfaces built atop both data and graphical primitives of the platform. This eventually leads to piling up complexities in the SDK architecture:</p>
<p>These two subject areas could be far away from each other. Furthermore, <strong>the closer a UI is to representing the raw data, the less convenient it is for the user</strong> (huge forms for entering field values as a classical example<a href="#ref-chapter-43-no-84" id="ref-chapter-43-no-84-back" class="ref"><sup>1</sup></a>). If we aim to make an interface ergonomic, we need to replace “forms” with complex interfaces built atop both data and graphical primitives of the platform. Furthermore, these complex UI components will inevitably have their own inner state. This eventually leads to piling up complexities in the SDK architecture:</p>
<h5><a href="#chapter-43-paragraph-1" id="chapter-43-paragraph-1" class="anchor">1. Coupling Heterogeneous Functionality in One Entity</a></h5>
<p>We have placed two buttons (to make an order and to show the coffee shop's location) plus a cancel action onto the offer view panel. These buttons may look identical and they react to the user's actions in the same way, but the way the <code>SearchBox</code> component handles pressing each of them is completely different.</p>
<p>Imagine if we allow developers to add their own action buttons onto the panel, for which purpose we introduce a <code>Button</code> class. We will soon learn that this functionality will be used to cover two diametrically opposite scenarios:</p>

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -131,7 +131,7 @@
<li><a href="API.ru.html#sdk-decomposing">Глава 44. Декомпозиция UI-компонентов</a></li>
<li><a href="API.ru.html#sdk-mv-frameworks">Глава 45. MV*-фреймворки</a></li>
<li><a href="API.ru.html#sdk-backend-driven">Глава 46. Backend-Driven UI</a></li>
<li><a href="API.ru.html#chapter-47">Глава 47. Разделяемые ресурсы и асинхронные блокировки</a></li>
<li><a href="API.ru.html#sdk-shared-resources">Глава 47. Разделяемые ресурсы и асинхронные блокировки</a></li>
<li><a href="API.ru.html#chapter-48">Глава 48. Вычисляемые свойства</a></li>
<li><a href="API.ru.html#chapter-49">Глава 49. В заключение</a></li>
</ul>

View File

@@ -18,7 +18,7 @@ At first glance, it might appear that this UI is a superstructure atop the `sear
* The functionality of the underlying API
* The methods of visualizing data and interacting with UI controls that are conventional for the application platform (possibly, creatively improved by our UX designers).
These two subject areas could be far away from each other. Furthermore, **the closer a UI is to representing the raw data, the less convenient it is for the user** (huge forms for entering field values as a classical example[ref Lepinsky, R. Google and Apple Versus Your Company’s Application](https://rodgersnotes.wordpress.com/2010/10/25/google-and-apple-versus-your-companys-application/)). If we aim to make an interface ergonomic, we need to replace “forms” with complex interfaces built atop both data and graphical primitives of the platform. This eventually leads to piling up complexities in the SDK architecture:
These two subject areas could be far away from each other. Furthermore, **the closer a UI is to representing the raw data, the less convenient it is for the user** (huge forms for entering field values as a classical example[ref Lepinsky, R. Google and Apple Versus Your Company’s Application](https://rodgersnotes.wordpress.com/2010/10/25/google-and-apple-versus-your-companys-application/)). If we aim to make an interface ergonomic, we need to replace “forms” with complex interfaces built atop both data and graphical primitives of the platform. Furthermore, these complex UI components will inevitably have their own inner state. This eventually leads to piling up complexities in the SDK architecture:
##### Coupling Heterogeneous Functionality in One Entity

View File

@@ -18,7 +18,7 @@
* собственно функциональность нижележащего API;
* принятые в рамках платформы концепции визуализации данных и взаимодействия с элементами управления (возможно, творчески развитые нашими разработками).
Эти две предметных области могут находиться весьма далеко друг от друга. Более того, **чем более интерфейс приближен к сырым данным, тем он, как правило, менее удобен для пользователя** (как классический пример — огромные формы ввода данных[ref Lepinsky, R. Google and Apple Versus Your Company’s Application](https://rodgersnotes.wordpress.com/2010/10/25/google-and-apple-versus-your-companys-application/)). Если мы хотим построить эргономичный интерфейс, нам придётся заменить формы сложными интерфейсными надстройками над данными и над графическими примитивами платформы. Это, в свою очередь, ведёт к накоплению проблем в архитектуре SDK:
Эти две предметных области могут находиться весьма далеко друг от друга. Более того, **чем более интерфейс приближен к сырым данным, тем он, как правило, менее удобен для пользователя** (как классический пример — огромные формы ввода данных[ref Lepinsky, R. Google and Apple Versus Your Company’s Application](https://rodgersnotes.wordpress.com/2010/10/25/google-and-apple-versus-your-companys-application/)). Если мы хотим построить эргономичный интерфейс, нам придётся заменить формы сложными интерфейсными надстройками над данными и над графическими примитивами платформы, причём имеющими собственное состояние. Это, в свою очередь, ведёт к накоплению проблем в архитектуре SDK:
##### Объединение в одном объекте разнородной функциональности

View File

@@ -1,152 +1,169 @@
### [Разделяемые ресурсы и асинхронные блокировки][sdk-shared-resources]
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении открытие экрана предложения стало требовать выполнения дополнительного запроса к серверу и, таким образом, стало асинхронным. Модифицируем код `OfferPanel`:
```typescript
class OfferPanelComponent {
show (offer) {
let fullData = await api
.getFullOfferData(offer);
}
}
```
class OfferPanel {
constuctor(searchBox) {
searchBox.on(
'selectOffer',
(event) => {
// Показываем выбранное предложение
// в панели, но размещаем её
// за границей экрана
this.render(event.offer, {
left: screenWidth
});
// Анимируем положение панели
this.animate(
'left', 0, '1s'
);
Возникает вопрос: а что должно произойти, если пользователь или разработчик пытается выбрать другой `offerId`, пока ответ сервера ещё не пришёл? Очевидно, нам нужно выбрать, какое из двух открытий панель мы должны запретить. Предположим, что мы решили блокировать интерфейс на время подгрузки данных и, таким образом, не давать выбирать другое предложение. Чтобы реализовать эту функциональность, нам нужно оповестить вышестоящие компоненты о начале и окончании загрузки:
```typescript
class OfferPanelComponent {
show () {
/* <em> */this.events.emit('beginDataLoad');/* </em> */
let fullData = await api
.getFullOfferData(offer);
/* <em> */this.events.emit('endDataLoad');/* </em> */
}
}
```
```typescript
// `Composer` прослушивает события
// на панели предложений и выставляет
// значения соответствующего флага
class SearchBoxComposer {
constructor () {
this.offerPanel.events.on(
'beginFullDataLoad', () => {
/* <em> */this.isDataLoading = true;/* </em> */
}
);
this.offerPanel.events.on(
'endFullDataLoad', () => {
/* <em> */this.isDataLoading = false;/* </em> */
}
);
}
}
```
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, и сейчас происходит анимация панели? Логически мы должны эту операцию запретить, поскольку в ней нет никакого смысла — выезжающая панель всё равно не даст просмотреть новые элементы списка. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
```
searchBox.on('selectOffer', (offer) {
searchBox.offerPanel.render(offer, {
left: screenWidth
});
searchBox.state.isAnimating = true;
await searchBox.offerPanel
.view.animate('left', 0, '1s');
searchBox.state.isAnimating = false;
});
searchBox.on('scroll', (event) => {
// Если сейчас происходит анимация
if (searchBox.state.isAnimating) {
// Запретить действие
return false;
selectOffer (offer) {
if (this.isDataLoading) {
return;
}
}
});
}
```
Но этот код очень плох по множеству причин:
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
* этот код просто плохо читается: совершенно непонятно, почему флаг `isAnimating` влияет на обработку события `scroll`;
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект;
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
* непонятно, как его модифицировать, если у нас появятся разные виды загрузок данных, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
* этот код просто плохо читается: совершенно непонятно, почему события анимации на одном компоненте влияют на прокрутку в другом компоненте;
* если при выполнении анимации произойдёт какая-то ошибка, событие `endAnimation` не произойдёт и прокрутка будет заблокирована навсегда.
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
Если вы внимательно читали предыдущие главы, решение этих двух проблем должен быть очевидным. Необходимо абстрагироваться от самого факта анимации и переформулировать проблемы в высокоуровневых терминах. У нас есть разделяемый ресурс — место на экране. Мы можем показывать в один момент времени только одно предложение. Следовательно, если какому-то актору требуется длящийся доступ к панели, он должен этот доступ явно получить. Отсюда следует, что, во-первых, флаг такого доступа должен именоваться явно (например, `offerFullViewLocked`, а не `isDataLoading`) и контролироваться `Composer`-ом, но никак не самой панелью предложения (ещё и потому, что подготовка данных для показа — также ответственность `Composer`-а).
```
searchBox.on('selectOffer', (offer) {
searchBox.offerPanel.render(offer, {
left: screenWidth
});
searchBox.state.
isInnerAreaLocked = true;
await searchBox.offerPanel
.view.animate('left', 0, '1s');
searchBox.state.
isInnerAreaLocked = false;
});
searchBox.on('scroll', (event) => {
// Если сейчас происходит анимация
if (searchBox.state
.isInnerAreaLocked) {
// Запретить действие
return false;
```typescript
class SearchBoxComposer {
constructor () {
this.offerFullViewLocked = false;
}
});
```
Такой подход улучшает семантику операций, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его *захвата* (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):
```
try {
const lock = await searchBox
.state.acquireLock('innerArea', '2s');
await searchBox.offerPanel
.view.animate('left', 0, '1s');
lock.release();
} catch (e) {
// Какая-то логика обработки
// невозможности захвата ресурса
selectOffer (offer) {
if (this.offerFullViewLocked) {
return;
}
this.offerFullViewLocked = true;
let fullData = await api
.getFullOfferData(offer);
this.events.emit(
'offerFullViewChange',
this.generateOfferFullView(fullData)
);
this.offerFullViewLocked = false;
}
}
```
**NB**: вторым параметром в `acquireLock` мы передали время жизни блокировки — 2 секунды. Если в течение двух секунд блокировка не снята, она будет отменена автоматически.
В таком подходе мы можем реализовать не только блокировки, но и программируемые прерывания и реакцию на них:
Такой подход улучшает читабельность, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его *захвата* (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):
```typescript
class SearchBoxComposer {
selectOffer (offer) {
let lock;
try {
// Пытаемся захватить ресурс
// `offerFullView`
lock = this.acquireLock(
'offerFullView', '10s'
);
let fullData = await api
.getFullOfferData(offer);
this.events.emit(
'offerFullViewChange',
this.generateOfferFullView(fullData)
);
lock.release();
} catch (e) {
// Если получить доступ не удалось
return;
} finally {
// Не забываем освободить ресурс
// в случае ошибки
if (lock) {
lock.release();
}
}
}
}
```
const lock = await searchBox
.state.acquireLock(
'innerArea',
'2s', {
**NB**: вторым параметром в `acquireLock` мы передали максимальное время жизни блокировки — 10 секунд. Если в течение этого времени блокировка не снята (например, в случае, если ), она будет отменена автоматически.
В таком подходе мы можем реализовать не только блокировки, но и различные сценарии, которые позволяют нам более гибко ими управлять. Добавим в функцию захвата ресурса дополнительные данные о целях захвата:
```typescript
lock = this.acquireLock(
'offerFullView', '10s', {
// Добавляем описание,
// кто и зачем пытается
// выполнить блокировку
reason: 'selectOffer',
reason: 'userSelectOffer',
offer
}
);
}
);
```
lock.on('lost', () => {
// Если у нас забрали блокировку,
// отменяем анимацию
searchBox.offerPanel.view
.cancelAnimation();
})
Тогда текущий владелец ресурса (или диспетчер блокировок) может, в зависимости от ситуации, отдавать владение ресурсом или, наоборот, запрещать перехват. Скажем, если открытие панели инициировано программистом через вызов API компонента (а не пользователем через выбор предложения в списке), оно может иметь более высокий приоритет и быть разрешено:
// Если другой актор пытается
// перехватить блокировку
lock.on('tryLock', (sender) => {
// Если это другое предложение,
// разрешааем перехват и отменяем
// текущую блокировку
if (sender.reason == 'selectOffer') {
```typescript
lock.events.on('tryAcquire', (actor) => {
if (sender.reason == 'apiSelectOffer') {
lock.release();
} else {
// Иначе запрещаем перехват
return false;
}
})
await searchBox.offerPanel
.view.animate('left', 0, '1s');
lock.release();
```
**NB**: хотя пример выше выглядит крайне переусложнённым, в нём не учтено ещё множество нюансов:
* `offerPanel` тоже должен стать разделяемым ресурсом, и его точно так же надо захватывать;
* при перехвате блокировки должна останавливаться не анимация вообще, а та конкретная операция, которая была запущена в рамках конкретной блокировки.
Дополнительно мы можем ввести и обработку потери контроля ресурса — например, отменить загрузку данных, которые больше не нужны.
Упражнение «найти все разделяемые ресурсы и дополнить пример корректной работой с ними» мы оставим читателю.
```typescript
lock.events.on('lost', () => {
this.cancelFullDataLoad();
})
```
Паттерн контроля разделяемых ресурсов также хорошо сочетается с паттерном «модель»: акторы могут захватывать доступ на чтение и/или изменение свойств или групп свойств модели.
**NB**: мы могли бы решить проблему подгрузки данных иначе:
* открыть панель предложения;
* вместо настоящих данных отобразить спиннер или какую-то другую индикацию загрузки;
* асинхронно обновить отображение при получении ответа от сервера.
Однако в постановке проблемы это ничего не меняет: нам всё ещё нужно разработать политику разрешения конфликтов для случая, если какой-то актор пытается открыть панель предложения, пока загрузка данных ещё не закончена, для чего нам вновь нужны разделяемые ресурсы и их захват.
Отметим, что в современном фронтенде (к нашему большому сожалению) подобные упражнения с захватом контроля на время загрузки данных или анимации компонентов практически не производятся (считается, что такие асинхронные операции происходят быстро, и коллизии доступа не представляют собой проблемы). Однако, если асинхронные операции выполняются долго (происходят длительные или многоступенчатые загрузки данных, сложные анимации), пренебрежение организацией доступа может быть очень серьёзной UX-проблемой.