Привет, Хабр! Меня зовут Алексей Охрименко, я TechLead вертикали Ai/Voices онлайн-кинотеатра KION в МТС Digital, автор русскоязычной документации по Angular и популярного плагина для рефакторинга Angular-компонентов.
Мой коллега Алексей Мельников уже рассказывал про фичу пропуска титров в KION, про ее бизнес- и tech-составляющие. Я же остановлюсь на том, какие у нас проблемы возникли в процессе реализации фичи и как мы их решили с помощью Computed Properties в Angular*.
Маленькое уточнение о Computed Properties в Angular
В самом начале уточню, что никаких Computed Properties в самом Angular нет, что-то подобное есть в RxJS, который идет с ним в комплекте.
Angular жив
Да, вы все правильно прочитали: вебсайт kion.ru и приложение для SmartTV (Samsung, LG) написаны на Angular. Почему Angular это хороший выбор для SmartTV? Эта тема достойна отдельной публикации.
А сейчас предлагаю прекратить открывать эти секции со спойлерами и перейти к статье :)
Напомню, что такое пропуск титров в KION. Эта фича экономит время и позволяет по минимуму отвлекаться от просмотра. Особенно она актуальна для сериалов, где заставка зачастую повторяется из серии в серию, а титры так и вовсе одинаковые. И (будем честны) их обычно никто не смотрит до конца, зрители просто включают следующую серию.
![](https://habrastorage.org/getpro/habr/upload_files/89c/235/191/89c235191192bc1f6e99dd1419522ba1.jpg)
Казалось бы, все что нужно для реализации фичи – прислать отметки времени, на которых есть титры, и просто показать кнопки для пропуска титров. Но не тут-то было :)
Итак, попробуем решить задачу топорно. Делаем две кнопки «пропустить» – для начальных и финальных титров.
![](https://habrastorage.org/getpro/habr/upload_files/896/9a7/8b4/8969a78b4f5177e4e5e72b60b7b64792.jpeg)
Реализация «в лоб»
Представим, что у нас есть сущность player (непосредственно проигрывает фильм) и player-ui (агрегирует в себе все UI-компоненты плеера).
В самом начале мы подписываемся на изменения состояния плеера в ngAfterViewInit:
@Component({
selector: 'lib-player-ui',
templateUrl: './player-ui.component.html',
styleUrls: ['./player-ui.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerUIComponent {
// Здесь подписываемся на события плеера
ngAfterViewInit(): void {
this.player.registerStateChangeHandler((event: EventInfo) => {
switch (event.state) {
case ListenerEnums.timeupdate:
// Событие приходит в процессе проигрывания видео
break;
case ListenerEnums.seeking:
// Событие приходит при перемотке видео
break;
case ListenerEnums.ended:
// Событие приходит когда данное видео закончилось
// либо когда мы переключились на другое видео
break;
default:
break;
}
});
}
}
Пока все выглядит просто и очевидно. Добавим кнопку пропуска финальных титров. Покажем ее, когда будет приходить событие timeupdate (когда мы смотрим фильм), прячем на события seeking (приходит, когда мы пропускаем тот или иной отрезок времени) и ended (когда мы завершили просмотр). Назовем эту кнопку SkipTail.
@Component({
selector: 'lib-player-ui',
templateUrl: './player-ui.component.html',
styleUrls: ['./player-ui.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerSmartVodComponent {
// Здесь подписываемся на события плеера
ngAfterViewInit(): void {
this.player.registerStateChangeHandler((event: EventInfo) => {
switch (event.state) {
case ListenerEnums.timeupdate:
const currentChapter = this.durationSeconds[Math.ceil(+event.currentTime)];
this.handleChapter(currentChapter);
break;
case ListenerEnums.seeking:
this.clearChapter();
break;
case ListenerEnums.ended: {
this.clearChapter();
break;
}
default:
break;
}
});
}
// проверяем есть ли информация о титрах (MovieChapter)
private handleChapter(chapter: MovieChapter): void {
switch (chapter?.title) {
case ChapterTitleEnum.TAIL_CREDIT:
this.showSkipTailButton();
break;
}
}
// прячем кнопку
private clearChapter(): void {
this.isShowSkipTail = false;
}
// показываем кнопку пропуска финальных титров
private showSkipTailButton(): void {
this.isShowSkipTail = true;
}
}
Вроде все последовательно и логично, хотя опытный инженер уже здесь чувствует Code Smell (но об этом попозже). Теперь добавим последний недостающий элемент – кнопку пропуска начальных титров SkipHead:
// проверяем есть ли информация о титрах (MovieChapter)
private handleChapter(chapter: MovieChapter): void {
switch (chapter?.title) {
case ChapterTitleEnum.HEAD_CREDIT:
this.showSkipHeadButton();
break;
case ChapterTitleEnum.TAIL_CREDIT:
this.showSkipTailButton();
break;
}
}
// прячем кнопку
private clearChapter(): void {
this.isShowSkipHead = false;
this.isShowSkipTail = false;
}
// показываем кнопку пропуска начальных титров
private showSkipHeadButton(): void {
this.isShowSkipHead = true;
}
// показываем кнопку пропуска финальных титров
private showSkipTailButton(): void {
this.isShowSkipTail = true;
}
И все! Можно спокойно отдавать код на тестирование. А там как раз вскроются проблемы, побудившие меня написать эту статью.
С чем мы столкнулись
Проблем тут несколько. Начнем с самой простой – код очень резко начинает обрастать «нюансами». Пользователь может перемотать с начальных титров на финальные, в результате у нас появится 2 кнопки. Поэтому вызовем clearChapter прежде, чем показывать какую-то кнопку:
case ListenerEnums.timeupdate:
this.clearChapter();
const currentChapter = this.durationSeconds[Math.ceil(+event.currentTime)];
this.handleChapter(currentChapter);
break;
А теперь узнаем другой нюанс. Событие seeking, которое приходит в момент перемотки, может прийти раньше, чем событие timeupdate. Это приведет к тому, что мы сначала покажем кнопку на долю секунды, а потом ее спрячем. Еще у нас есть множество других фич, которые так или иначе связаны с нашей. Это приводит к комбинаторному взрыву из if/else и флагов.
![](https://habrastorage.org/getpro/habr/upload_files/82e/0de/379/82e0de3797b5c3d6d50e68ae1009f292.jpeg)
Причем попали мы в данную ситуацию, выполняя совершенно логичные и последовательные действия. Об этой проблеме написано довольно много статей, например, вот эти.
Какие есть варианты
Обычно проблема решается уходом от компонентной разработки в cторону StateManagers. Там есть Selectors, позволяющие получать сложное/комбинированное состояние. Но классические StateManagers не слишком хорошо оптимизированы под очень критичные к производительности приложения. Читателям наверняка хочется оспорить это утверждение, так как нет такой среды для JS, в которой StateManagers тормозят. Увы, платформы WebOS (LG) и Tizen (Samsung) – это досадные исключения. Мы обязательно обсудим производительность JS на телевизорах, но в отдельной статье.
Помимо производительности у нас есть еще одно ограничение – существующая кодовая база, которую не так-то легко переписать. Так что пока закроем вопрос со State Managers и вернемся к проблеме. Попробуем решить ее локально, не переписывая всю кодовую базу.
В статьях выше предлагаются решения из мира ООП. Но я хочу рассказать об одном решении из мира функционального программирования, а именно Реактивное Программирование или точнее Computed Properties
Реактивность – это способ автоматически обновлять систему в зависимости от изменения потока данных. Поток данных – любая последовательность событий из любого источника, упорядоченная во времени.
Возьмем простой пример:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // Все еще 3 :/
Когда мы меняем A0, значение A2 не меняется автоматически. Мы можем обойти эту проблему в таких фреймворках, как VueJS, с помощью специальных примитивов ref, computed.
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
Этот код дает уверенность в том, что при изменении A0 мы автоматически обновим A2. Есть ли что-то подобное в Angular? К сожалению, сам фреймворк не поддерживает Computed Properties «из коробки». Но в Angular есть RxJS!
const A0$ = new BehaviorSubject('Larry');
const A1$ = new BehaviorSubject('Wachowski');
const A2$ = combineLatest(
A0$,
A1$,
([A0_val, A1_val]) => A0_val + A1_val
);
A0$.next(2);
Переписав код подобным образом, мы сможем получить более чистую и понятную логику показа кнопок пропуска титров.
const isShowSkipHead$ = combineLatest(
time$,
chapters$,
isSeeking$,
(time, chapters, isSeeking) => {
if (isSeeking) return false;
const currentTime = Math.ceil(time / 1000);
const currentChapter = chapters[currentTime];
if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
return true;
}
return false;
}
);
А в коде с помощью async pipe можно использовать данные Observable:
[isShowSkipHead]="isShowSkipHead$ | async"
Какие еще есть варианты?
Как я говорил выше – в Angular нет поддержки computed properties «из коробки». Над этим уже работают авторы фреймворка, но пока статус – under consideration.
https://github.com/angular/angular/issues/20472
https://github.com/angular/angular/issues/43485
Самый очевидный вариант – просто написать метод в теле нашего компонента и вызвать его в шаблоне:
isShowSkipHead(): boolean {
const currentTime = Math.ceil(this.currentTime / 1000);
const currentChapter = this.durationSeconds[currentTime];
if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
return true;
}
return false;
}
Но это очень плохая практика, так как она приводит к существенному падению производительности приложения.
![](https://habrastorage.org/getpro/habr/upload_files/f6b/c3a/dfc/f6bc3adfc8dea43541151e459b628a7e.gif)
Мы можем эмулировать Computed Properties код с помощью Angular Pipe:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'is-show-head'
})
export class isShowSkipHeadPipe implements PipeTransform {
transform(time: any, chapters: any): any {
const currentTime = Math.ceil(time / 1000);
const currentChapter = chapters[currentTime];
if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
return true;
}
return false;
}
}
Или можем вручную вычислять значение на каждый ngOnChanges:
ngOnChanges(changes: SimpleChanges) {
if (changes.time || changes.chapter) {
this.isShowSkipHead = this.calculateIsShowSkipHead();
}
}
Еще есть умельцы, которые прямо в Angular используют примитивы VueJS :D
Вместо выводов
Мы не стали идти не по одному из вышеперечисленных альтернативных путей, не стали переписывать все на Redux/Mobx/Akita, а выбрали подход с RxJS. Увы, я не смогу показать главную причину такого решения. Просто потому что разных условий и событий очень много и, чтобы продемонстрировать их, придется показать большой кусок кодовой базы.
Если вкратце – подход с RxJS позволяет нам разделять бизнес-логику на отдельные атомарные и логичные куски, объединять их в любом порядке, сохраняя при этом чистоту кода. С его помощью нам удалось переписать сложный модуль приложения без изменения логики всего приложения и других его частей. А еще так можно сократить время разработки и убрать назойливые баги, вызванные комбинаторным взрывом.
Для понимания Reactive Programming с помощью Observable советую посмотреть вот это видео (осторожно, очень много computer science!), разбор RxJS и этот доклад.
Вот и все. Надеюсь, что наш опыт вам пригодится и вы заинтересовались реактивным программированием и RxJS. А если у вас уже есть, что рассказать на эти темы – сделайте это в комментариях! Вопросы жду там же.
Комментарии (7)
siandreev
24.06.2022 09:42+1Используемая в RxJS решении сигнатура combineLatest -- deprecated
https://github.com/ReactiveX/rxjs/blob/881cacdc99a7ebae219b595004c632f7358f730d/src/internal/observable/combineLatest.ts#L33
Лучше передавать в combineLatest массив Observable-ов, а дальше использовать pipe(map(...))
miscusi
я считаю в этом и есть мощь ангуляра, rxjs в связке с DI и пайпами в шаблонах, позволяет решать некоторые задачи просто по щелчку пальцев минимальным кол-вом кода, и убивает мысли использования каких либо лапшой стейт менеджеров
kubk
Вот минимальное количество кода. Так бы это выглядело на Mobx. Обратите внимание - в коде нет ничего нового специфичного для библиотеки, в отличии от RxJS с его combineLatest. Более того Mobx ещё и закеширует computed значение, а в RxJS для этого нужно добавлять distinctUntilChanged.
dopusteam
А как он закэширует, если тут нет параметров, но есть зависимости и функция не чистая?Понял, но выглядит как придирка, явно distinct лучше может быть, чем неявный
А чем это хорошо? Так хотя бы явно, в отличие от декорированных свойств.
kubk
Хороший вопрос. Это называется transparent reactivity. Подробности.
Похожая система, к примеру, реализована во Vue. Идея в том, что библиотека может сама запоминать зависимости наблюдаемых и вычисляемых значений. Это даёт такие преимущества:
- Не нужно указывать зависимости вручную, библиотека определит за вас. Как следствие более удобный рефакторинг - нельзя забыть добавить зависимость, нельзя забыть убрать зависимость когда она уже не требуется для вычисления
- Можно работать с объектами большой вложенности как есть, без необходимости нормализовывать состояние.
- Отписка автоматическая, невозможно забыть отписаться как в RxJS и получить утечки памяти: https://www.youtube.com/watch?v=7806msvJ1HE&t=2s
Примерный принцип работы (в очень упрощённом виде) описан в этой статье.
Ну и Mobx отлично подходит к Angular с его ООП и DI.
miscusi
вы путаете мягкое с теплым, в rx совсем другой подход и решает другие задачи. На простых примерах, как в статье или вашей особой выгоды нет, все прекрасно решается и без библиотек. Вся красота раскрывается в сложных решениях, а именно в комбинациях потоков и цепочек, когда один зависит от второго, а второй от 3х других и т.д, это могут быть не только данные но и события, именно операторы и производят всю эту магию. Совершенно другой подход с минимальным кол-вом кода, причем читаемым
из минусов это необходимость разобраться с rx, многим это дается трудно или используют просто подход "подписался-отписался", без операторов вовсе. Впрочем в основе ангуляра и используется либа, поэтому нужно знать. Да и сложно применима в других реактоподобных UI библиотеках
и да, вовсе необязательно подписываться или завершать потоки открытые потоки, это все делает пайп async или сборщик мусора автоматически. Точнее в 90% случаев можно отказаться от подписок, а при ревью везде где есть subscribe очень легко обнаружить потенциальную утечку
obenjiro Автор
На самом деле исследовал этот вопрос еще со времен Angular RC. Не нашел хороших wrappers для Mobx. Самый популярный, что есть (mobx-angular) увы имеет свои проблемы https://github.com/mobxjs/mobx-angular/issues/38 и это только 1 пример (проблем на самом деле больше если капнуть)
Mobx очень крут, но у меня лично так и не хватило времени написать свой wrapper :/