Novidades sobre produtos

Como melhorar a reprodução de mídia: um estudo detalhado sobre o PreloadManager do Media3 – Parte 2

Leitura de 9 minutos
Mayuri Khinvasara Khabya
Engenheira de relações com desenvolvedores

Bem-vindo à segunda parte da nossa série de três partes sobre o pré-carregamento de mídia com o Media3. Esta série foi criada para orientar você no processo de criação de experiências de mídia altamente responsivas e de baixa latência nos seus apps Android.

  • Parte 1: introdução ao pré-carregamento com o Media3 abordou os fundamentos. Exploramos a distinção entre PreloadConfiguration para playlists simples e o DefaultPreloadManager mais avançado para interfaces dinâmicas. Você aprendeu a implementar o ciclo de vida básico da API: adicionar mídia com add(), extrair uma MediaSource preparada com getMediaSource(), gerenciar prioridades com setCurrentPlayingIndex() e invalidate() e liberar recursos com remove() e release().
  • Parte 2 (esta postagem): neste blog, vamos explorar os recursos avançados do DefaultPreloadManager. Vamos abordar como gerar insights com PreloadManagerListener, implementar práticas recomendadas prontas para produção, como compartilhar componentes principais com o ExoPlayer, e dominar o padrão de janela deslizante para gerenciar a memória de maneira eficaz.
  • Parte 3: a parte final desta série vai abordar a integração do PreloadManager com um cache em disco persistente, permitindo que você reduza o consumo de dados com o gerenciamento de recursos e ofereça uma experiência perfeita.

Se você não conhece o pré-carregamento no Media3, recomendamos que leia a Parte 1 antes de continuar. Para quem está pronto para ir além do básico, vamos explorar como melhorar a implementação da reprodução de mídia.

Ouvindo: extrair análises com PreloadManagerListener

Ao lançar um recurso na produção, como desenvolvedor de apps, você também quer entender e capturar as análises por trás dele. Como você pode ter certeza de que sua estratégia de pré-carregamento é eficaz em um ambiente real? Para responder a essa pergunta, é necessário ter dados sobre taxas de sucesso, falhas e desempenho. A interface PreloadManagerListener é o mecanismo principal para coletar esses dados.

O PreloadManagerListener oferece dois callbacks essenciais que fornecem insights importantes sobre o processo e o status do pré-carregamento.

  • onCompleted(MediaItem mediaItem): esse callback é invocado após a conclusão bem-sucedida de uma solicitação de pré-carregamento, conforme definido pelo TargetPreloadStatusControl.
  • onError(PreloadException error): esse callback pode ser útil para depuração e monitoramento. Ele é invocado quando um pré-carregamento falha, fornecendo a exceção associada.

É possível registrar um listener com uma única chamada de método, conforme mostrado no exemplo de código a seguir:

val preloadManagerListener = object : PreloadManagerListener {
    override fun onCompleted(mediaItem: MediaItem) {
        // Log success for analytics. 
        Log.d("PreloadAnalytics", "Preload completed for $mediaItem")
    }

    override fun onError( preloadError: PreloadException) {
        // Log the specific error for debugging and monitoring.
        Log.e("PreloadAnalytics", "Preload error ", preloadError)
    }
}

preloadManager.addListener(preloadManagerListener)

Extrair insights do listener

Esses callbacks do listener podem ser conectados ao pipeline de análise. Ao encaminhar esses eventos para o mecanismo de análise, você pode responder a perguntas importantes, como:

  • Qual é nossa taxa de sucesso de pré-carregamento? (proporção de eventos onCompleted para o total de tentativas de pré-carregamento)
  • Quais CDNs ou formatos de vídeo apresentam as maiores taxas de erro? (analisando as exceções de onError)
  • Qual é nossa taxa de erros de pré-carregamento? (proporção de eventos onError para o total de tentativas de pré-carregamento)

Esses dados podem fornecer feedback quantitativo sobre sua estratégia de pré-carregamento, permitindo testes A/B e melhorias orientadas por dados na experiência do usuário. Esses dados também podem ajudar você a ajustar de maneira inteligente as durações de pré-carregamento e o número de vídeos que você quer pré-carregar, bem como os buffers alocados.

Além da depuração: usar onError para fallback de interface otimizado

Um pré-carregamento com falha é um forte indicador de um evento de bufferização para o usuário. O callback onError permite que você responda de forma reativa. Em vez de apenas registrar o erro, você pode adaptar a interface. Por exemplo, se o vídeo a seguir não for pré-carregado, o aplicativo poderá desativar a reprodução automática para o próximo deslize, exigindo que o usuário toque para iniciar a reprodução.

Além disso, ao inspecionar o tipo PreloadException, você pode definir uma estratégia de nova tentativa mais inteligente. Um app pode optar por remover imediatamente uma origem com falha do gerenciador com base na mensagem de erro ou no código de status HTTP. O item precisaria ser removido do stream da interface de acordo com isso para não vazar problemas de carregamento para a experiência do usuário. Você também pode receber dados mais granulares de PreloadException, como HttpDataSourceException, para investigar melhor os erros. Leia mais sobre a solução de problemas do ExoPlayer.

O sistema de amigos: por que é necessário compartilhar componentes com o ExoPlayer?

O DefaultPreloadManager e o ExoPlayer foram criados para funcionar juntos. Para garantir a estabilidade e a eficiência, eles precisam compartilhar vários componentes principais. Se eles operarem com componentes separados e não coordenados, isso poderá afetar a segurança de linhas de execução e a usabilidade das faixas pré-carregadas no player, já que precisamos garantir que as faixas pré-carregadas sejam reproduzidas no player correto. Os componentes separados também podem competir por recursos limitados, como largura de banda de rede e memória, o que pode levar à degradação do desempenho. Uma parte importante do ciclo de vida é o descarte adequado. A ordem recomendada de descarte é liberar o PreloadManager primeiro, seguido pelo ExoPlayer.

O DefaultPreloadManager.Builder foi criado para facilitar esse compartilhamento e tem APIs para instanciar o PreloadManager e uma instância de player vinculada. Vamos ver por que componentes como BandwidthMeter, LoadControl, TrackSelector e Looper precisam ser compartilhados. Confira a representação visual de como esses componentes interagem com a reprodução do ExoPlayer.

preloadManager2.png

Como evitar conflitos de largura de banda com um BandwidthMeter compartilhado

O BandwidthMeter fornece uma estimativa da largura de banda de rede disponível com base nas taxas de transferência históricas. Se o PreloadManager e o player usarem instâncias separadas, eles não vão reconhecer a atividade de rede um do outro, o que pode levar a cenários de falha. Por exemplo, considere o cenário em que um usuário está assistindo a um vídeo, a conexão de rede é degradada e o MediaSource de pré-carregamento inicia simultaneamente um download agressivo para um vídeo futuro. A atividade do MediaSource de pré-carregamento consumiria a largura de banda necessária para o player ativo, causando a interrupção do vídeo atual. Uma interrupção durante a reprodução é uma falha significativa na experiência do usuário.

Ao compartilhar um único BandwidthMeter, o TrackSelector pode selecionar faixas da mais alta qualidade, considerando as condições de rede atuais e o estado do buffer, durante o pré-carregamento ou a reprodução. Em seguida, ele pode tomar decisões inteligentes para proteger a sessão de reprodução ativa e garantir uma experiência tranquila.

preloadManagerBuilder.setBandwidthMeter(customBandwidthMeter)

Como garantir a consistência com os componentes LoadControl, TrackSelector e Renderer compartilhados do ExoPlayer

  • LoadControl: esse componente determina a política de bufferização, como a quantidade de dados a serem armazenados em buffer antes de iniciar a reprodução e quando iniciar ou interromper o carregamento de mais dados. O compartilhamento do LoadControl garante que o consumo de memória do player e do PreloadManager seja orientado por uma única estratégia de bufferização coordenada em mídias pré-carregadas e ativas, evitando a disputa de recursos. Você precisará alocar o tamanho do buffer de maneira inteligente, coordenando quantos itens e por quanto tempo você está pré-carregando para garantir a consistência. Em momentos de disputa, o player vai priorizar a reprodução do item atual exibido na tela. Com um LoadControl compartilhado, o gerenciador de pré-carregamento vai continuar pré-carregando enquanto os bytes de buffer de destino alocados para pré-carregamento não atingirem o limite máximo. Ele não espera até que o carregamento para reprodução seja concluído.

Observação: o compartilhamento do LoadControl na versão mais recente do Media3 (1.8) garante que o alocador possa ser compartilhado corretamente com o PreloadManager e o player. O uso do LoadControl para controlar o pré-carregamento de maneira eficaz é um recurso que estará disponível na próxima versão do Media3 1.9.

preloadManagerBuilder.setLoadControl(customLoadControl)

  • TrackSelector: esse componente é responsável por selecionar quais faixas (por exemplo, vídeo de uma determinada resolução, áudio em um idioma específico) carregar e reproduzir. O compartilhamento garante que as faixas selecionadas durante o pré-carregamento sejam as mesmas que o player vai usar. Isso evita um cenário desperdiçador em que uma faixa de vídeo de 480p é pré-carregada, apenas para o player descartá-la imediatamente e buscar uma faixa de 720p na reprodução.< br /> O gerenciador de pré-carregamento NÃO deve compartilhar a mesma instância do TrackSelector com o player. Em vez disso, eles precisam usar a instância diferente do TrackSelector, mas da mesma implementação. É por isso que definimos o TrackSelectorFactory em vez de um TrackSelector no DefaultPreloadManager.Builder.

preloadManagerBuilder.setTrackSelectorFactory(customTrackSelectorFactory)

  • Renderer: esse componente é responsável por entender os recursos do player sem criar os renderizadores completos. Ele verifica esse blueprint para ver quais formatos de vídeo, áudio e texto o player final vai oferecer suporte. Isso permite que ele selecione e faça o download de maneira inteligente apenas da faixa de mídia compatível e evita o desperdício de largura de banda em conteúdo que o player não pode reproduzir.

preloadManagerBuilder.setRenderersFactory(customRenderersFactory)

Leia mais sobre os componentes do Exoplayer.

A regra de ouro: um Looper de reprodução comum para governar todos

A linha de execução em que uma instância do ExoPlayer pode ser acessada pode ser especificada explicitamente transmitindo um Looper ao criar o player. O Looper da linha de execução em que o player precisa ser acessado pode ser consultado usando Player.getApplicationLooper. Ao manter um Looper compartilhado entre o player e o PreloadManager, é garantido que todas as operações nesses objetos de mídia compartilhados sejam serializadas na fila de mensagens de uma única linha de execução. Isso pode reduzir os bugs de simultaneidade.

Todas as interações entre o PreloadManager e o player com origens de mídia a serem carregadas ou pré-carregadas precisam acontecer na mesma linha de execução de reprodução. O compartilhamento do Looper é obrigatório para a segurança da linha de execução. Portanto, precisamos compartilhar o PlaybackLooper entre o PreloadManager e o player.

O PreloadManager prepara um objeto MediaSource com estado em segundo plano. Quando o código da interface chama player.setMediaSource(mediaSource), você está realizando uma transferência desse objeto complexo e com estado do MediaSource de pré-carregamento para o player. Nesse cenário, todo o PreloadMediaSource é movido do gerenciador para o player. Todas essas interações e transferências precisam ocorrer no mesmo PlaybackLooper.

Se o PreloadManager e o ExoPlayer estivessem operando em linhas de execução diferentes, uma disputa poderia ocorrer. A linha de execução do PreloadManager poderia estar modificando o estado interno do MediaSource (por exemplo, gravando novos dados em um buffer) no momento exato em que a linha de execução do player está tentando ler. Isso leva a um comportamento imprevisível, IllegalStateException, que é difícil de depurar.

preloadManagerBuilder.setPreloadLooper(playbackLooper)

Vamos ver como você pode compartilhar todos os componentes acima entre o ExoPlayer e o DefaultPreloadManager na própria configuração.

val preloadManagerBuilder =
DefaultPreloadManager.Builder(context, targetPreloadStatusControl)

// Optional - Share components between ExoPlayer and DefaultPreloadManager
preloadManagerBuilder
     .setBandwidthMeter(customBandwidthMeter)
     .setLoadControl(customLoadControl)
     .setMediaSourceFactory(customMediaSourceFactory)
     .setTrackSelectorFactory(customTrackSelectorFactory)
     .setRenderersFactory(customRenderersFactory)
     .setPreloadLooper(playbackLooper)

val preloadManager = val preloadManagerBuilder.build()

Dica: se você usar os componentes padrão no ExoPlayer, como DefaultLoadControl, etc., não será necessário compartilhá-los explicitamente com o DefaultPreloadManager. Ao criar a instância do ExoPlayer usando buildExoPlayer do DefaultPreloadManager.Builder, esses componentes são referenciados automaticamente uns aos outros, se você usar as implementações padrão com configurações padrão. No entanto, se você usar componentes ou configurações personalizadas, notifique o DefaultPreloadManager sobre eles de forma explícita pelas APIs acima.

Pré-carregamento pronto para produção: o padrão de janela deslizante

Em um feed dinâmico, um usuário pode rolar por uma quantidade praticamente infinita de conteúdo. Se você adicionar vídeos continuamente ao DefaultPreloadManager sem uma estratégia de remoção correspondente, inevitavelmente causará um OutOfMemoryError. Cada MediaSource pré-carregado mantém uma SampleQueue que aloca buffers de memória. À medida que eles se acumulam, podem esgotar o espaço de heap do aplicativo. A solução é um algoritmo que você já conhece, chamado de janela deslizante. O padrão de janela deslizante mantém um conjunto pequeno e gerenciável de itens na memória que são logicamente adjacentes à posição atual do usuário no feed. À medida que o usuário rola, essa "janela" de itens gerenciados desliza com ele, adicionando novos itens que aparecem e também removendo itens que agora estão distantes.

slidingwindow.png

Implementar o padrão de janela deslizante

É essencial entender que o PreloadManager não fornece um método setWindowSize() integrado. A janela deslizante é um padrão de design que você, o desenvolvedor, é responsável por implementar usando os métodos primitivos add() e remove(). A lógica do aplicativo precisa conectar eventos da interface, como uma rolagem ou mudança de página, a essas chamadas de API. Se você quiser uma referência de código para isso, temos esse padrão de janela deslizante implementado no socialite exemplo, que também inclui um PreloadManagerWrapper que imita uma janela deslizante.

Não se esqueça de adicionar preloadManager.remove(mediaItem) à implementação quando o item não for mais provável de aparecer em breve na visualização do usuário. A falha ao remover itens que não estão mais próximos do usuário é a principal causa de problemas de memória em implementações de pré-carregamento. A chamada remove() garante que os recursos sejam liberados, o que ajuda a manter o uso da memória do app limitado e estável.

Ajustar uma estratégia de pré-carregamento categorizada com TargetPreloadStatusControl

Agora que definimos o que pré-carregar (os itens na nossa janela), podemos aplicar uma estratégia bem definida para a quantidade de pré-carregamento de cada item. Já vimos como alcançar essa granularidade com a configuração TargetPreloadStatusControl em Parte 1.

Para lembrar, um item na posição +/- 1 pode ter uma probabilidade maior de ser reproduzido do que um item na posição +/- 4. Você pode alocar mais recursos (rede, CPU, memória) para itens que o usuário provavelmente vai visualizar em seguida. Isso cria uma estratégia de "pré-carregamento" com base na proximidade, que é a chave para equilibrar a reprodução imediata com o uso eficiente de recursos.

Você pode usar dados de análise pelo PreloadManagerListener, conforme discutido nas seções anteriores, para decidir sua estratégia de duração de pré-carregamento.

Conclusão e próximas etapas

Agora você tem o conhecimento avançado para criar feeds de mídia rápidos, estáveis e com uso eficiente de recursos usando o DefaultPreloadManager do Media3.

Vamos recapitular os principais aprendizados:

  • Use o PreloadManagerListener para coletar insights de análise e implementar um tratamento de erros robusto.
  • Sempre use um único DefaultPreloadManager.Builder para criar instâncias do gerenciador e do player para garantir que os componentes importantes sejam compartilhados.
  • Implemente o padrão de janela deslizante gerenciando ativamente as chamadas add() e remove() para evitar OutOfMemoryError.
  • Use o TargetPreloadStatusControl para criar uma estratégia de pré-carregamento inteligente e em camadas que equilibre o desempenho e o consumo de recursos.

O que vem a seguir na Parte 3: armazenamento em cache com mídia pré-carregada

O pré-carregamento de dados na memória oferece um benefício de desempenho imediato, mas pode ter compensações. Depois que o aplicativo é fechado ou a mídia pré-carregada é removida do gerenciador, os dados desaparecem. Para alcançar um nível mais persistente de otimização, podemos combinar o pré-carregamento com o armazenamento em cache de disco. Esse recurso está em desenvolvimento ativo e será lançado em breve, em alguns meses.

Você tem algum feedback para compartilhar? Queremos saber sua opinião.

Fique atento e acelere a reprodução de vídeo! 🚀

Escrito por:

Continuar lendo