Автор Ян МакКей

Интерфейс Netflix TV постоянно развивается, поскольку мы стремимся сделать все возможное для наших участников. Например, после A / B-тестирования, исследования глаз и отзывов клиентов мы недавно развернули превью видео, чтобы помочь участникам принимать более обоснованные решения о том, что смотреть. Мы писали раньше о том, как наше телевизионное приложение состоит из SDK, изначально установленного на устройстве, приложения JavaScript, которое можно обновить в любое время, и слоя рендеринга, известного как Gibbon. В этом посте мы расскажем о некоторых стратегиях, которые мы использовали на этом пути для оптимизации производительности нашего JavaScript-приложения.

Реакция-Гиббон

В 2015 году мы приступили к полному переписыванию и модернизации архитектуры пользовательского интерфейса телевизора. Мы решили использовать React, потому что его односторонний поток данных и декларативный подход к разработке пользовательского интерфейса упрощают рассуждение о нашем приложении. Очевидно, нам понадобится наш собственный вариант React, поскольку в то время он был нацелен только на DOM. Нам удалось довольно быстро создать прототип, нацеленный на Гиббона. Этот прототип в конечном итоге превратился в React-Gibbon, и мы начали работать над созданием нашего нового пользовательского интерфейса на основе React.

API React-Gibbon будет хорошо знаком любому, кто работал с React-DOM. Основное отличие состоит в том, что вместо div, span, input и т. Д. У нас есть один примитив рисования «виджета», который поддерживает встроенные стили.

React.createClass({
  render() {
    return <Widget style={{ text: 'Hello World', textSize: 20 }} />;
  }
});

Производительность - ключевой вызов

Наше приложение работает на сотнях различных устройств, от новейших игровых консолей, таких как PS4 Pro, до бюджетных устройств бытовой электроники с ограниченной памятью и вычислительной мощностью. Младшие машины, на которые мы нацелены, часто могут иметь одноядерные процессоры с тактовой частотой ниже ГГц, небольшой объем памяти и ограниченное ускорение графики. Что еще более усложняет задачу, наша среда JavaScript представляет собой более старую версию JavaScriptCore без JIT. Эти ограничения делают сверхчувствительные 60 кадров в секунду особенно сложными и определяют многие различия между React-Gibbon и React-DOM.

Измерять, измерять, измерять

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

  • Скорость реакции при вводе клавиш - время, затрачиваемое на отображение изменения в ответ на нажатие клавиши.
  • Time To Interactivity - время запустить приложение
  • Кадров в секунду - последовательность и плавность нашей анимации.
  • Использование памяти

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

Наблюдение: React.createElement имеет стоимость

Когда Babel транспилирует JSX, он преобразует его в ряд вызовов функции React.createElement, которые при оценке создают описание следующего компонента для рендеринга. Если мы можем предсказать, что создаст функция createElement, мы можем встроить вызов с ожидаемым результатом во время сборки, а не во время выполнения.

// JSX
render() {
  return <MyComponent key='mykey' prop1='foo' prop2='bar' />;
}
// Transpiled
render() {
  return React.createElement(MyComponent, { key: 'mykey', prop1: 'foo', prop2: 'bar' });
}
// With inlining
render() {
  return {
    type: MyComponent,
    props: {
      prop1: 'foo',
      prop2: 'bar'
    },
    key: 'mykey'
  };
}

Как вы можете видеть, мы полностью убрали стоимость вызова createElement, что стало триумфом «а можем ли мы просто не делать?» школа оптимизации программного обеспечения.

Мы задались вопросом, можно ли применить эту технику ко всему нашему приложению и полностью избежать вызова createElement. Мы обнаружили, что если мы использовали ссылку на наши элементы, необходимо вызвать createElement, чтобы подключить владельца во время выполнения. Это также применимо, если вы используете оператор распространения, который может содержать значение ссылки (мы вернемся к этому позже).

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

Проблема: компоненты более высокого порядка не могут использовать встраивание

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

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

componentDidMount() {
  this.refs.someWidget.focus()
}

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

render() {
  return <Widget focused={true} />;
}

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

// before inlining
render() {
  return <MyComponent {...this.props} />;
}
// after inlining
render() {
  return {
    type: MyComponent,
    props: this.props
  };
}

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

Проблема: перехват собственности по-прежнему требует слияния

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

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

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

Смерть от тысячи опор

Основываясь на наших выводах, мы поняли, что можем существенно улучшить производительность нашего приложения, ограничив количество пропсов, проходящих через стек. Мы обнаружили, что группы реквизита часто связаны и всегда меняются одновременно. В этих случаях имело смысл сгруппировать эти связанные свойства в одном «пространстве имен». Если свойство пространства имен может быть смоделировано как неизменяемое значение, последующие вызовы вызовов shouldComponentUpdate могут быть дополнительно оптимизированы путем проверки ссылочного равенства, а не глубокого сравнения. Это дало нам несколько хороших результатов, но в конце концов мы обнаружили, что уменьшили количество опор настолько, насколько это было возможно. Пришло время прибегнуть к более крайним мерам.

Слияние реквизита без ключевой итерации

Предупреждение, вот драконы! Это не рекомендуется, и, скорее всего, это может привести к неожиданным и странным выходам из строя.

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

// before proto merge
render() {
  const newProps = Object.assign({}, this.props, { prop1: ‘foo’ })
  return <MyComponent {…newProps} />;
}
// after proto merge
render() {
  const newProps = { prop1: ‘foo’ };
  newProps.__proto__ = this.props;
  return {
    type: MyComponent,
    props: newProps
  };
}

В приведенном выше примере мы уменьшили время рендеринга для случая 100 depth 100 prop с ~ 500 мс до ~ 60 мс. Имейте в виду, что использование этого подхода привело к появлению некоторых интересных ошибок, а именно в том случае, если this.props является замороженным объектом. Когда это происходит, подход цепочки прототипов работает только в том случае, если __proto__ назначается после создания объекта newProps. Излишне говорить, что если вы не являетесь владельцем newProps, было бы неразумно назначать прототип вообще.

Проблема: "Различие" стилей происходило медленно

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

Разделяйте стили реквизита в зависимости от того, что может измениться

Мы обнаружили, что часто многие из установленных нами значений стиля никогда не менялись. Например, скажем, у нас есть виджет, используемый для отображения некоторого динамического текстового значения. У него есть свойства text, textSize, textWeight и textColor. Свойство text будет изменяться в течение срока службы этого виджета, но мы хотим, чтобы остальные свойства оставались такими же. Стоимость изменения 4 свойств стиля виджета тратится на каждый рендер. Мы можем уменьшить это, отделив то, что может измениться, от того, что не изменится.

const memoizedStylesObject = { textSize: 20, textWeight: ‘bold’, textColor: ‘blue’ };
<Widget staticStyle={memoizedStylesObject} style={{ text: this.props.text }} />

Если мы тщательно запомнили объект memoizedStylesObject, React-Gibbon может затем проверить ссылочное равенство и сравнить его значения только в том случае, если эта проверка окажется ложной. Это не влияет на время, необходимое для монтирования виджета, но окупается при каждой последующей визуализации.

Почему бы не избежать итерации целиком?

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

// This function is written by the static analysis plugin
function __update__(widget, nextProps, prevProps) {
  var style = nextProps.style,
      prev_style = prevProps && prevProps.style;
  if (prev_style) {
    var text = style.text;
    if (text !== prev_style.text) {
      widget.text = text;
    }
  } else {
    widget.text = style.text;
  }
}
React.createClass({
  render() {
    return (
      <Widget __update__={__update__} style={{ text: this.props.title }} />
    );
  }
});

Внутренне React-Gibbon проверяет наличие «специального» свойства __update__ и пропускает обычную итерацию по предыдущему и следующему стилю, вместо этого применяя свойства непосредственно к виджету, если они изменились. Это оказало огромное влияние на время рендеринга за счет увеличения размера распространяемого файла.

Производительность - это особенность

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

  • Пользовательский составной компонент - гипероптимизирован для нашей платформы
  • Предварительно устанавливаемые экраны для уменьшения воспринимаемого времени перехода
  • Объединение компонентов в списки
  • Мемоизация дорогостоящих вычислений

Создание пользовательского интерфейса Netflix TV, который может работать на различных поддерживаемых нами устройствах, - это увлекательная задача. Мы воспитываем в команде культуру, ориентированную на производительность, и постоянно пытаемся улучшить впечатления для всех, независимо от того, используют ли они Xbox One S, Smart TV или потоковую передачу. Присоединяйтесь к нам, если это похоже на ваше варенье!

Смотрите также:







Первоначально опубликовано на сайте techblog.netflix.com 12 января 2017 г.