Desenvolvimento

27 fev, 2017

Criando uma interface de usuário de TV de alto desempenho usando React

Publicidade

A interface da Netflix TV está em constante evolução, junto com os esforços para descobrir a melhor experiência para os milhões de usuários. Por exemplo, após testes A / B, pesquisa de rastreamento ocular e comentários de clientes, foram lançadas recentemente visualizações de vídeo para ajudar os membros a tomar melhores decisões sobre o que assistir. Em artigos anteriores da Netflix, já foi relatado que o seu aplicativo de TV consiste em um SDK instalado nativamente no dispositivo, um aplicativo JavaScript que pode ser atualizado a qualquer momento e uma camada de renderização conhecida como Gibbon. Neste artigo, o pessoal da engenharia da Netflix destacou algumas das estratégias utilizadas ao longo do caminho para otimizar o desempenho do aplicativo JavaScript.

React-Gibbon

Em 2015, a Netflix começou uma reescrita e modernização da sua arquitetura de UI de TV. Eles decidiram usar o React porque seu fluxo de dados unidirecional e abordagem declarativa para o desenvolvimento de UI tornam mais fácil raciocinar sobre o aplicativo. Eles perceberam que precisariam do seu próprio React, uma vez que naquela época era visado apenas o DOM. Então, criou-se um protótipo que visava o Gibbon muito rapidamente. Esse protótipo eventualmente evoluiu para React-Gibbon, e eles começaram a trabalhar na construção da nova UI baseada em React.

Eles acreditavam que a API da React-Gibbon seria bastante familiar para quem havia trabalhado com React-DOM. A principal diferença é que, em vez de divs, spans, entradas etc., eles teriam um único desenho “widget” primitivo que suporta o estilo inline.

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

O desempenho é um desafio fundamental

O aplicativo da Netflix é executado em centenas de dispositivos diferentes, desde os mais recentes consoles de jogos, como o PS4 Pro, até os dispositivos eletrônicos de consumo de orçamento com memória e poder de processamento limitados. As máquinas low-end que eles já tiveram como alvo podem muitas vezes têm sub-GHz CPUs single core, memória baixa e aceleração de gráficos limitada. Para tornar as coisas ainda mais desafiadoras, o ambiente JavaScript da Netflix é uma versão não-JIT mais antiga do JavaScriptCore. Essas restrições fazem experiências 60fps super responsivas especialmente complicadas e condicionam muitas das diferenças entre React-Gibbon e React-DOM.

Medir, medir, medir

Primeiramente, é importante abordar a otimização de desempenho para poder identificar as métricas que você usará para medir o sucesso de seus esforços. Foram utilizadas as métricas a seguir para avaliar o desempenho geral do aplicativo:

  • Key Input Responsiveness – o tempo necessário para processar uma alteração na resposta a uma tecla pressionada
  • Tempo para interatividade – o tempo para iniciar o aplicativo
  • Quadros por segundo – a consistência e suavidade de nossas animações
  • Uso de memória

As estratégias descritas abaixo destinam-se principalmente a melhorar a capacidade de resposta de entrada de chave. Todas elas foram identificadas, testadas e medidas nos dispositivos e não são necessariamente aplicáveis em outros ambientes. Como com todas as sugestões de “melhores práticas”, era importante para o time de engenharia da Netflix ser cético e verificar se elas funcionavam em seu ambiente e para seu caso de uso. No início, eles usaram ferramentas de criação de perfil para identificar quais caminhos de código estavam sendo executados e qual era a sua parcela do tempo total de renderização; isso os levou a algumas observações interessantes.

Observação: React.createElement tem um custo

Quando Babel transpõe o JSX, ele o converte em um número de chamadas de função React.createElement que, quando avaliadas, produzem uma descrição do próximo Componente a ser processado. Se foi possível prever o que a função createElement iria produzir, eles puderam fazer a chamada no código com o resultado esperado no tempo de compilação em vez de em tempo de execução.

// 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'
    };
}

Como podemos observar, eles removeram o custo da chamada createElement completamente, uma grande conquista para a escola de otimização de software “can we just not?”.

Mas eles precisavam de mais, então ele queriam saber se seria possível aplicar essa técnica em toda a aplicação e evitar chamar createElement inteiramente. O que eles descobriram foi que, se usassem um ref em seus elementos, o createElement precisaria ser chamado para conectar o proprietário no tempo de execução. Isso também se aplica se você estiver usando o operador de propagação que pode conter um valor ref (voltaremos a isso mais tarde).

Foi utilizado um plugin Babel personalizado para o elemento inlining, mas existe um plugin oficial que você pode usar agora. Em vez de um objeto literal, o plugin oficial irá emitir uma chamada para uma função auxiliar que provavelmente desaparecerá graças à magia da função inlining V8. Depois de aplicar o plugin da Netflix, ainda havia alguns componentes que não estavam no código, especificamente Componentes de Ordem Superior que compõem uma parcela decente dos componentes totais sendo renderizados em seu aplicativo.

Problema: Componentes de Ordem Superior não podem usar Inlining

O time de engenharia da Netflix utiliza muito os Componentes de Ordem Superior (HOCs) como uma alternativa aos mixins. Segundo eles, HOCs facilitam a camada sobre o comportamento, mantendo uma separação de preocupações. Eles queriam tirar proveito de inlining em seus HOCs, mas encontraram um problema: HOCs geralmente agem como um pass-through para seus adereços. Isso naturalmente leva ao uso do operador de propagação, o que impede que o plug-in Babel seja capaz de entrar no código.

Quando foi iniciado o processo de reescrever seu aplicativo, foi decidido que todas as interações com a camada de renderização passariam por APIs declarativas. Por exemplo, em vez de fazer:

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

Para mover o foco da aplicação para um determinado Widget, em vez disso, foi implementado uma API de foco declarativo que permitiu descrever o que deve ser focalizado durante a renderização da seguinte forma:

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

Isso teve o afortunado efeito colateral de permitir evitar o uso de refs em toda a aplicação. Como resultado, eles foram capazes de aplicar no código independentemente se o código utilizou um spread ou não.

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

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

Isso reduziu muito a quantidade de chamadas de função e fusão de propriedade que eles estavam tendo que fazer anteriormente, mas não as eliminou completamente.

Problema: A interceptação de propriedade ainda requer uma mesclagem

Depois do sucesso de gerenciar no código os seus componentes, seu aplicativo ainda estava gastando muito tempo mesclando propriedades dentro dos HOCs. Isso não foi surpreendente, como HOCs muitas vezes interceptam adereços de entrada, a fim de adicionar a sua própria ou alterar o valor de um determinado prop antes de encaminhar para o componente envolto.

Foi realizada uma análise de como as pilhas de HOCs escalavam com contagem de prop e profundidade de componente em um dos seus dispositivos, e os resultados foram informativos.

Eles mostraram que existe uma relação aproximadamente linear entre o número de props movendo-se através da pilha e o tempo de renderização para uma determinada profundidade do componente.

Morte por mil props

Com base em nessas descobertas, foi percebido que eles poderiam melhorar o desempenho de seu aplicativo substancialmente, limitando o número de props que passam ​​pela pilha. Assim, eles perceberam que grupos de props eram frequentemente relacionados e sempre alterados ao mesmo tempo. Nesses casos, fazia sentido agrupar esses props relacionados sob um único prop “namespace”. Se um prop de namespace pode ser modelado como um valor imutável, chamadas subsequentes para chamadas shouldComponentUpdate podem ser otimizadas ainda mais verificando a igualdade referencial, em vez de fazer uma comparação profunda. Isso deu algumas boas vitórias, mas finalmente eles perceberam que precisariam reduzir a contagem prop tanto quanto era possível. Agora era hora de recorrer a medidas mais extremas.

Mesclando props sem iteração de chave

Advertindo, aqui sejam dragões! Isso não é recomendado e muito provavelmente vai quebrar muitas coisas de maneiras estranhas e inesperadas.

Depois de reduzir os props movendo-se através do seu app, eles resolveram experimentar outras maneiras de reduzir o tempo gasto na fusão de props entre HOCs. Perceberam então que poderiam usar a cadeia de protótipos para atingir os mesmos objetivos, evitando a iteração de chave.

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

No exemplo acima, eles reduziram o caso de prop de 100 e profundidade de 100 de um tempo de renderização de ~ 500ms para ~ 60ms. Que fique claro: o uso dessa abordagem introduziu alguns bugs interessantes, por exemplo, no caso de this.props ser um objeto congelado. Quando isso acontece, a abordagem de cadeia de protótipos só funciona se o __proto__ é atribuído depois que o objeto newProps é criado. Não é preciso dizer que, se você não é o proprietário de newProps, não seria sábio atribuir o protótipo em tudo.

Problema: estilo “Diffing” foi lento

Uma vez que o React sabe os elementos que ele precisa renderizar, deve então diff-los com os valores anteriores, a fim de determinar as mudanças mínimas que devem ser aplicadas aos elementos reais DOM. Através de perfis, foi descoberto que esse processo era caro, especialmente durante a montagem – em parte devido à necessidade de iterar sobre um grande número de propriedades de estilo.

Separe os props de estilo com base no que é provável que mude

Assim, muitas vezes, muitos dos valores de estilo que estavam sendo definidos nunca foram realmente alterados. Por exemplo, digamos que temos um Widget usado para exibir algum valor de texto dinâmico. Ele tem as propriedades text, textSize, textWeight e textColor. A propriedade text mudará durante o tempo de vida desse Widget, mas o objetivo é  que as propriedades restantes permaneçam as mesmas. O custo de diferenciar os 4 props de estilo de widget é gasto em cada renderização. É possível então reduzir isso separando as coisas que poderiam mudar das coisas que não.

const memoizedStylesObject = { textSize: 20, textWeight: ‘bold’, textColor: ‘blue’ };


<Widget staticStyle={memoizedStylesObject} style={{ text: this.props.text }} />

Se tivermos o cuidado de memorizar o objeto memoizedStylesObject, o React-Gibbon poderá, então, verificar a igualdade referencial e apenas diff seus valores se essa verificação for falsa. Isso não tem efeito no tempo que leva para montar o widget, mas vale a pena em cada renderização subsequente.

Por que não evitar a iteração todos juntos?

Levando essa ideia mais diante, se eles souberem que os props do estilo estão sendo ajustados em um widget particular, seria possível escrever uma função que faça o mesmo trabalho sem ter que iterar sobre todas as chaves. Eles escreveram um plugin Babel personalizado que realizava análise estática em métodos de renderização de componentes. Ele determina quais estilos serão aplicados e cria uma função personalizada diff-and-apply que é, então, anexada aos props do widget.

// 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 }}  />
        );
    }
});

Internamente, React-Gibbon procura a presença do prop __update__ “especial” e irá ignorar a iteração usual sobre os props de estilo anteriores e próximos, em vez de aplicar as propriedades diretamente ao widget se eles tiverem sido alterados. Isso teve um enorme impacto em seus tempos de renderização ao custo de aumentar o tamanho do distribuível.

O desempenho é um recurso

O ambiente da Netflix é único, mas as técnicas utilizadas para identificar oportunidades de melhoria de desempenho não são. Foram realizadas medições, testes e verificadas todas as suas alterações em dispositivos reais. Essas investigações levaram a descobrir um tema comum: a iteração chave era cara. Como resultado, eles quiseram identificar a fusão na sua aplicação e determinar se poderiam ser otimizadas. Aqui está uma lista de algumas das outras coisas que foram realizadas em sua busca para melhorar o desempenho:

  • Componente Composto Personalizado – hiper otimizado para a plataforma
  • Telas pré-montadas para melhorar o tempo de transição percebido
  • Agrupamento de componentes em Listas
  • Memorização de cálculos caros

Construir uma experiência de UI do Netflix TV que possa ser executada em uma variedade de dispositivos que eles apoiam é um desafio divertido, segundo os engenheiros. Eles cultivam uma cultura orientada para o desempenho na equipe e estão constantemente tentando melhorar as experiências para todos, estejam eles usando o Xbox One S, uma Smart TV ou um stick de streaming.

***

Fonte: http://techblog.netflix.com/2017/01/crafting-high-performance-tv-user.html