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

Комментарии (7)


  1. miscusi
    24.06.2022 04:21
    +2

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


    1. kubk
      24.06.2022 08:11
      +1

      get 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;
      }

      Вот минимальное количество кода. Так бы это выглядело на Mobx. Обратите внимание - в коде нет ничего нового специфичного для библиотеки, в отличии от RxJS с его combineLatest. Более того Mobx ещё и закеширует computed значение, а в RxJS для этого нужно добавлять distinctUntilChanged.


      1. dopusteam
        24.06.2022 08:33

        А как он закэширует, если тут нет параметров, но есть зависимости и функция не чистая?

        Понял, но выглядит как придирка, явно distinct лучше может быть, чем неявный

        Обратите внимание - в коде нет ничего нового специфичного для библиотеки, в отличии от RxJS с его combineLatest

        А чем это хорошо? Так хотя бы явно, в отличие от декорированных свойств.


        1. kubk
          24.06.2022 08:47

          Хороший вопрос. Это называется transparent reactivity. Подробности.
          Похожая система, к примеру, реализована во Vue. Идея в том, что библиотека может сама запоминать зависимости наблюдаемых и вычисляемых значений. Это даёт такие преимущества:
          - Не нужно указывать зависимости вручную, библиотека определит за вас. Как следствие более удобный рефакторинг - нельзя забыть добавить зависимость, нельзя забыть убрать зависимость когда она уже не требуется для вычисления
          - Можно работать с объектами большой вложенности как есть, без необходимости нормализовывать состояние.
          - Отписка автоматическая, невозможно забыть отписаться как в RxJS и получить утечки памяти: https://www.youtube.com/watch?v=7806msvJ1HE&t=2s

          Примерный принцип работы (в очень упрощённом виде) описан в этой статье.

          Ну и Mobx отлично подходит к Angular с его ООП и DI.


          1. miscusi
            24.06.2022 10:29

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

            из минусов это необходимость разобраться с rx, многим это дается трудно или используют просто подход "подписался-отписался", без операторов вовсе. Впрочем в основе ангуляра и используется либа, поэтому нужно знать. Да и сложно применима в других реактоподобных UI библиотеках

            и да, вовсе необязательно подписываться или завершать потоки открытые потоки, это все делает пайп async или сборщик мусора автоматически. Точнее в 90% случаев можно отказаться от подписок, а при ревью везде где есть subscribe очень легко обнаружить потенциальную утечку


      1. obenjiro Автор
        24.06.2022 13:32

        На самом деле исследовал этот вопрос еще со времен Angular RC. Не нашел хороших wrappers для Mobx. Самый популярный, что есть (mobx-angular) увы имеет свои проблемы https://github.com/mobxjs/mobx-angular/issues/38 и это только 1 пример (проблем на самом деле больше если капнуть)

        Mobx очень крут, но у меня лично так и не хватило времени написать свой wrapper :/


  1. 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(...))