Привет, Хабр! Меня зовут Алексей Охрименко, я 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. Эта фича экономит время и позволяет по минимуму отвлекаться от просмотра. Особенно она актуальна для сериалов, где заставка зачастую повторяется из серии в серию, а титры так и вовсе одинаковые. И (будем честны) их обычно никто не смотрит до конца, зрители просто включают следующую серию.

Казалось бы, все что нужно для реализации фичи – прислать отметки времени, на которых есть титры, и просто показать кнопки для пропуска титров. Но не тут-то было :)
Итак, попробуем решить задачу топорно. Делаем две кнопки «пропустить» – для начальных и финальных титров.

Реализация «в лоб»
Представим, что у нас есть сущность 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 и флагов.

Причем попали мы в данную ситуацию, выполняя совершенно логичные и последовательные действия. Об этой проблеме написано довольно много статей, например, вот эти.
Какие есть варианты
Обычно проблема решается уходом от компонентной разработки в 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; }
Но это очень плохая практика, так как она приводит к существенному падению производительности приложения.

Мы можем эмулировать 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. А если у вас уже есть, что рассказать на эти темы – сделайте это в комментариях! Вопросы жду там же.