Plug-ins da interface do carro

Use plugins da biblioteca Car UI para criar implementações completas de personalizações de componentes na biblioteca Car UI em vez de usar sobreposições de recursos de tempo de execução (RROs). As RROs permitem mudar apenas os recursos XML dos componentes da biblioteca Car UI, o que limita o que pode ser personalizado.

Criar um plug-in

Um plug-in da biblioteca da interface do carro é um APK que contém classes que implementam um conjunto de APIs de plug-in. As APIs de plug-in podem ser compiladas em um plug-in como uma biblioteca estática.

Confira exemplos em Soong e Gradle:

Soong

Considere este exemplo do Soong:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    aaptflags: ["--shared-lib"],
    sdk_version: "current",

    manifest: "src/main/AndroidManifest.xml",
    srcs: ["src/main/java/**/*.java"],
    resource_dirs: ["src/main/res"],
    static_libs: [
        "car-ui-lib-oem-apis",
    ],
    // Disable optimization is mandatory to prevent R.java class from being
    // stripped out
    optimize: {
        enabled: false,
    },

    certificate: ":my-plugin-certificate",
}

Gradle

Consulte este arquivo build.gradle:

apply plugin: 'com.android.application'

android {
  compileSdkVersion 30

  defaultConfig {
    minSdkVersion 28
    targetSdkVersion 30
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  signingConfigs {
    debug {
      storeFile file('chassis_upload_key.jks')
      storePassword 'chassis'
      keyAlias 'chassis'
      keyPassword 'chassis'
    }
  }
}

dependencies {
  implementation project(':oem-apis')
  // Or use the following if you'd like to use the maven artifact
  // implementation 'com.android.car.ui:car-ui-lib-plugin-apis:1.0.0'
}

Settings.gradle:

// You can remove the ':oem-apis' if you're using the maven artifact.
include ':oem-apis'
project(':oem-apis').projectDir = new File('./path/to/oem-apis')
include ':my-plugin'
project(':my-plugin').projectDir = new File('./my-plugin')

O plug-in precisa ter um provedor de conteúdo declarado no manifesto com os seguintes atributos:

  android:authorities="com.android.car.ui.plugin"
  android:enabled="true"
  android:exported="true"

O android:authorities="com.android.car.ui.plugin" torna o plugin detectável para a biblioteca Car UI. O provedor precisa ser exportado para que possa ser consultado em tempo de execução. Além disso, se o atributo enabled estiver definido como false, a implementação padrão será usada em vez da implementação do plug-in. A classe do provedor de conteúdo não precisa existir. Nesse caso, adicione tools:ignore="MissingClass" à definição do provedor. Confira o exemplo de entrada de manifesto abaixo:

    <application>
        <provider
            android:name="com.android.car.ui.plugin.PluginNameProvider"
            android:authorities="com.android.car.ui.plugin"
            android:enabled="false"
            android:exported="true"
            tools:ignore="MissingClass"/>
    </application>

Por fim, como medida de segurança, assine seu app.

Plug-ins como uma biblioteca compartilhada

Ao contrário das bibliotecas estáticas do Android, que são compiladas diretamente em apps, as bibliotecas compartilhadas do Android são compiladas em um APK independente referenciado por outros apps durante a execução.

Os plug-ins implementados como uma biblioteca compartilhada do Android têm as classes adicionadas automaticamente ao carregador de classes compartilhado entre apps. Quando um app que usa a biblioteca Car UI especifica uma dependência de tempo de execução na biblioteca compartilhada do plugin, o classloader pode acessar as classes da biblioteca compartilhada do plugin. Os plug-ins implementados como apps Android normais (não uma biblioteca compartilhada) podem afetar negativamente os tempos de inicialização a frio do app.

Implementar e criar bibliotecas compartilhadas

O desenvolvimento com bibliotecas compartilhadas do Android é muito parecido com o de apps Android normais, com algumas diferenças importantes.

  • Use a tag library na tag application com o nome do pacote do plug-in no manifesto do app dele:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configure sua regra de build android_app do Soong (Android.bp) com a flag do AAPT shared-lib, que é usada para criar uma biblioteca compartilhada:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dependências de bibliotecas compartilhadas

Para cada app no sistema que usa a biblioteca Car UI, inclua a tag uses-library no manifesto do app na tag application com o nome do pacote do plug-in:

<manifest>
  <application
      android:name=".MyApp"
      ...>
    <uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
    ...
  </application>
</manifest>

Instalar um plug-in

Os plug-ins PRECISAM ser pré-instalados na partição do sistema incluindo o módulo em PRODUCT_PACKAGES. O pacote pré-instalado pode ser atualizado da mesma forma que qualquer outro app instalado.

Se você estiver atualizando um plug-in no sistema, todos os apps que usam esse plug-in serão fechados automaticamente. Quando o usuário reabrir o arquivo, as mudanças atualizadas vão aparecer. Se o app não estava em execução, ele terá o plug-in atualizado na próxima vez que for iniciado.

Ao instalar um plug-in com o Android Studio, há algumas considerações adicionais a serem levadas em conta. No momento da redação deste artigo, há um bug no processo de instalação do app Android Studio que impede que as atualizações de um plug-in entrem em vigor. Para corrigir isso, selecione a opção Sempre instalar com o gerenciador de pacotes (desativa as otimizações de implantação no Android 11 e versões mais recentes) na configuração de build do plug-in.

Além disso, ao instalar o plug-in, o Android Studio informa um erro de que não é possível encontrar uma atividade principal para iniciar. Isso é esperado, já que o plug-in não tem atividades (exceto a intent vazia usada para resolver uma intent). Para eliminar o erro, mude a opção Iniciar para Nada na configuração do build.

Configuração do plug-in do Android Studio Figura 1. Configuração do plug-in do Android Studio

Plug-in de proxy

A personalização de apps usando a biblioteca Car UI exige um RRO que tenha como destino cada app específico a ser modificado, mesmo quando as personalizações são idênticas em todos os apps. Isso significa que é necessário um RRO por app. Confira quais apps usam a biblioteca Car UI.

O plugin proxy da biblioteca Car UI é um exemplo de biblioteca compartilhada de plugin que delega as implementações de componentes à versão estática da biblioteca Car UI. Esse plug-in pode ser segmentado com uma RRO, que pode ser usada como um único ponto de personalização para apps que usam a biblioteca Car UI sem a necessidade de implementar um plug-in funcional. Para mais informações sobre RROs, consulte Mudar o valor dos recursos de um app durante a execução.

O plug-in de proxy é apenas um exemplo e um ponto de partida para fazer personalizações usando um plug-in. Para personalização além das RROs, é possível implementar um subconjunto de componentes de plug-in e usar o plug-in de proxy para o restante ou implementar todos os componentes de plug-in do zero.

Embora o plug-in de proxy forneça um único ponto de personalização de RRO para apps, os apps que desativam o uso do plug-in ainda exigem um RRO que tenha como destino direto o próprio app.

Implementar as APIs do plug-in

O ponto de entrada principal do plug-in é a classe com.android.car.ui.plugin.PluginVersionProviderImpl. Todos os plug-ins precisam incluir uma classe com esse nome e nome de pacote exatos. Essa classe precisa ter um construtor padrão e implementar a interface PluginVersionProviderOEMV1.

Os plug-ins da CarUi precisam funcionar com apps mais antigos ou mais recentes que o plug-in. Para facilitar isso, todas as APIs de plug-in têm controle de versões com um V# no final do nome da classe. Se uma nova versão da biblioteca Car UI for lançada com novos recursos, eles farão parte da versão V2 do componente. A biblioteca Car UI faz o possível para que novos recursos funcionem no escopo de um componente de plugin mais antigo. Por exemplo, convertendo um novo tipo de botão na barra de ferramentas em MenuItems.

No entanto, um app com uma versão mais antiga da biblioteca Car UI não pode se adaptar a um novo plug-in escrito com APIs mais recentes. Para resolver esse problema, permitimos que os plug-ins retornem diferentes implementações deles mesmos com base na versão da API do OEM compatível com os apps.

PluginVersionProviderOEMV1 tem um método:

Object getPluginFactory(int maxVersion, Context context, String packageName);

Esse método retorna um objeto que implementa a versão mais recente de PluginFactoryOEMV# compatível com o plug-in, mas ainda é menor ou igual a maxVersion. Se um plug-in não tiver uma implementação de um PluginFactory tão antigo, ele poderá retornar null. Nesse caso, a implementação vinculada estaticamente dos componentes do CarUi será usada.

Para manter a compatibilidade com versões anteriores de apps compilados com versões mais antigas da biblioteca estática Car Ui, é recomendável oferecer suporte a maxVersions 2, 5 e mais recentes na implementação do plug-in da classe PluginVersionProvider. As versões 1, 3 e 4 não são compatíveis. Para mais informações, consulte PluginVersionProviderImpl.

O PluginFactory é a interface que cria todos os outros componentes do CarUi. Ele também define qual versão das interfaces deve ser usada. Se o plug-in não tentar implementar nenhum desses componentes, ele poderá retornar null na função de criação (exceto a barra de ferramentas, que tem uma função customizesBaseLayout() separada).

O pluginFactory limita quais versões de componentes do CarUi podem ser usadas juntas. Por exemplo, nunca haverá um pluginFactory que possa criar a versão 100 de um Toolbar e também a versão 1 de um RecyclerView, já que haveria pouca garantia de que uma grande variedade de versões de componentes funcionaria em conjunto. Para usar a barra de ferramentas versão 100, os desenvolvedores precisam fornecer uma implementação de uma versão do pluginFactory que crie uma barra de ferramentas versão 100, o que limita as opções nas versões de outros componentes que podem ser criados. As versões de outros componentes podem não ser iguais. Por exemplo, um pluginFactoryOEMV100 pode criar um ToolbarControllerOEMV100 e um RecyclerViewOEMV70.

Barra de Ferr.

Layout básico

A barra de ferramentas e o "layout básico" estão muito relacionados. Por isso, a função que cria a barra de ferramentas é chamada de installBaseLayoutAround. O layout de base é um conceito que permite posicionar a barra de ferramentas em qualquer lugar ao redor do conteúdo do app, na parte de cima/de baixo, verticalmente ao longo das laterais ou até mesmo uma barra de ferramentas circular envolvendo todo o app. Isso é feito transmitindo uma visualização para installBaseLayoutAround para que a barra de ferramentas/layout de base seja envolvida.

O plug-in precisa pegar a visualização fornecida, separar do pai, inflar o próprio layout do plug-in no mesmo índice do pai e com o mesmo LayoutParams da visualização que acabou de ser separada e, em seguida, anexar a visualização em algum lugar dentro do layout que acabou de ser inflado. O layout inflado vai conter a barra de ferramentas, se solicitado pelo app.

O app pode solicitar um layout básico sem uma barra de ferramentas. Se isso acontecer, installBaseLayoutAround vai retornar um valor nulo. Para a maioria dos plug-ins, isso é tudo o que precisa acontecer, mas se o autor do plug-in quiser aplicar, por exemplo, uma decoração na borda do app, isso ainda poderá ser feito com um layout básico. Essas decorações são particularmente úteis para dispositivos com telas não retangulares, já que podem inserir o app em um espaço retangular e adicionar transições limpas ao espaço não retangular.

Um Consumer<InsetsOEMV1> também é transmitido para installBaseLayoutAround. Esse consumidor pode ser usado para comunicar ao app que o plug-in está cobrindo parcialmente o conteúdo do app (com a barra de ferramentas ou de outra forma). O app vai saber que precisa continuar desenhando nesse espaço, mas manter fora dele os componentes críticos com que o usuário pode interagir. Esse efeito é usado no nosso design de referência para tornar a barra de ferramentas semitransparente e fazer com que as listas rolem por baixo dela. Se esse recurso não fosse implementado, o primeiro item de uma lista ficaria preso embaixo da barra de ferramentas e não seria clicável. Se esse efeito não for necessário, o plug-in poderá ignorar o Consumer.

O conteúdo rola para baixo da barra de ferramentas Figura 2. O conteúdo rola para baixo da barra de ferramentas

Do ponto de vista do app, quando o plug-in envia novos encartes, ele os recebe de qualquer atividade ou fragmento que implemente InsetsChangedListener. Se uma atividade ou um fragmento não implementar InsetsChangedListener, a biblioteca Car Ui vai processar os encartes por padrão aplicando-os como padding ao Activity ou FragmentActivity que contém o fragmento. A biblioteca não aplica encartes por padrão aos fragmentos. Confira um exemplo de snippet de uma implementação que aplica encartes como padding em um RecyclerView no app:

public class MainActivity extends Activity implements InsetsChangedListener {
  @Override
  public void onCarUiInsetsChanged(Insets insets) {
    CarUiRecyclerView rv = requireViewById(R.id.recyclerview);
    rv.setPadding(insets.getLeft(), insets.getTop(),
                  insets.getRight(), insets.getBottom());
  }
}

Por fim, o plug-in recebe uma dica fullscreen, que é usada para indicar se a visualização que deve ser encapsulada ocupa todo o app ou apenas uma pequena seção. Isso pode ser usado para evitar a aplicação de algumas decorações ao longo da borda que só fazem sentido se aparecerem ao longo da borda de toda a tela. Um exemplo de app que usa layouts de base não em tela cheia é o app Configurações, em que cada painel do layout de dois painéis tem a própria barra de ferramentas.

Como é esperado que installBaseLayoutAround retorne nulo quando toolbarEnabled for false, para que o plug-in indique que não quer personalizar o layout básico, ele precisa retornar false de customizesBaseLayout.

O layout de base precisa conter um FocusParkingView e um FocusArea para oferecer suporte total aos controles rotativos. Essas visualizações podem ser omitidas em dispositivos que não oferecem suporte a rotação. Os FocusParkingView/FocusAreas são implementados na biblioteca estática CarUi. Por isso, um setRotaryFactories é usado para fornecer fábricas que criam as visualizações com base em contextos.

Os contextos usados para criar visualizações de foco precisam ser o contexto de origem, não o do plug-in. O FocusParkingView precisa estar o mais próximo possível da primeira visualização na árvore, já que é o que recebe o foco quando não há foco visível para o usuário. O FocusArea precisa envolver a barra de ferramentas no layout base para indicar que é uma zona de toque rotativo. Se o FocusArea não for fornecido, o usuário não poderá navegar até nenhum botão na barra de ferramentas com o controlador rotativo.

Controlador da barra de ferramentas

O ToolbarController real retornado precisa ser muito mais simples de implementar do que o layout básico. O trabalho dele é receber as informações transmitidas aos setters e mostrá-las no layout de base. Consulte o Javadoc para informações sobre a maioria dos métodos. Alguns dos métodos mais complexos são discutidos abaixo.

getImeSearchInterface é usado para mostrar resultados da pesquisa na janela do IME (teclado). Isso pode ser útil para mostrar/animar resultados da pesquisa ao lado do teclado, por exemplo, se ele ocupar apenas metade da tela. A maior parte da funcionalidade é implementada na biblioteca estática CarUi. A interface de pesquisa no plug-in apenas fornece métodos para que a biblioteca estática receba os callbacks TextView e onPrivateIMECommand. Para oferecer suporte a isso, o plug-in precisa usar uma subclasse TextView que substitui onPrivateIMECommand e transmite a chamada ao listener fornecido como o TextView da barra de pesquisa.

setMenuItems apenas mostra MenuItems na tela, mas é chamado com uma frequência surpreendente. Como a API de plug-in para MenuItems é imutável, sempre que um MenuItem é alterado, uma nova chamada setMenuItems é feita. Isso pode acontecer por algo tão trivial quanto um usuário clicar em um MenuItem de alternância, e esse clique fazer com que a alternância seja ativada ou desativada. Por motivos de desempenho e animação, é recomendável calcular a diferença entre as listas de MenuItem antigas e novas e atualizar apenas as visualizações que realmente mudaram. Os MenuItems fornecem um campo key que pode ajudar com isso, já que a chave precisa ser a mesma em diferentes chamadas para setMenuItems do mesmo MenuItem.

AppStyledView

O AppStyledView é um contêiner para uma visualização que não é personalizada. Ele pode ser usado para fornecer uma borda ao redor dessa visualização, destacando-a do restante do app e indicando ao usuário que essa é uma interface diferente. A visualização encapsulada pelo AppStyledView é fornecida em setContent. O AppStyledView também pode ter um botão de voltar ou fechar, conforme solicitado pelo app.

O AppStyledView não insere imediatamente as visualizações na hierarquia de visualização como o installBaseLayoutAround. Em vez disso, ele apenas retorna a visualização para a biblioteca estática por getView, que faz a inserção. A posição e o tamanho do AppStyledView também podem ser controlados implementando getDialogWindowLayoutParam.

Contextos

O plug-in precisa ter cuidado ao usar contextos, já que há contextos de plug-in e de "origem". O contexto do plug-in é fornecido como um argumento para getPluginFactory e é o único contexto que tem os recursos do plug-in. Isso significa que ele é o único contexto que pode ser usado para inflar layouts no plug-in.

No entanto, o contexto do plug-in pode não ter a configuração correta definida. Para receber a configuração correta, fornecemos contextos de origem em métodos que criam componentes. O contexto de origem geralmente é uma atividade, mas em alguns casos também pode ser um serviço ou outro componente do Android. Para usar a configuração do contexto de origem com os recursos do contexto do plug-in, é necessário criar um novo contexto usando createConfigurationContext. Se a configuração correta não for usada, haverá uma violação do modo estrito do Android, e as visualizações infladas poderão não ter as dimensões corretas.

Context layoutInflationContext = pluginContext.createConfigurationContext(
        sourceContext.getResources().getConfiguration());

Mudanças de modo

Alguns plug-ins podem oferecer suporte a vários modos para os componentes, como um modo esporte e um modo econômico com aparência visualmente distinta. Não há suporte integrado para essa funcionalidade na CarUi, mas nada impede que o plug-in a implemente totalmente de forma interna. O plug-in pode monitorar qualquer condição para descobrir quando mudar de modo, como ouvir transmissões. O plug-in não pode acionar uma mudança de configuração para mudar de modo, mas não é recomendado confiar em mudanças de configuração de qualquer maneira, já que atualizar manualmente a aparência de cada componente é mais suave para o usuário e também permite transições que não são possíveis com mudanças de configuração.

Jetpack Compose

Os plug-ins podem ser implementados usando o Jetpack Compose, mas esse é um recurso de nível alfa e não deve ser considerado estável.

Os plug-ins podem usar ComposeView para criar uma superfície compatível com o Compose para renderização. Esse ComposeView seria o que é retornado para o app pelo método getView nos componentes.

Um problema grave ao usar ComposeView é que ele define tags na visualização raiz no layout para armazenar variáveis globais compartilhadas entre diferentes ComposeViews na hierarquia. Como os IDs de recursos do plug-in não têm namespace separado do app, isso pode causar conflitos quando o app e o plug-in definem tags na mesma visualização. Um ComposeViewWithLifecycle personalizado que move essas variáveis globais para o ComposeView é fornecido abaixo. Novamente, isso não deve ser considerado estável.

ComposeViewWithLifecycle:

class ComposeViewWithLifecycle @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
    LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {

  private val lifeCycle = LifecycleRegistry(this)
  private val modelStore = ViewModelStore()
  private val savedStateRegistryController = SavedStateRegistryController.create(this)
  private var composeView: ComposeView? = null
  private var content = @Composable {}

  init {
    ViewTreeLifecycleOwner.set(this, this)
    ViewTreeViewModelStoreOwner.set(this, this)
    ViewTreeSavedStateRegistryOwner.set(this, this)
    compositionContext = createCompositionContext()
  }

  fun setContent(content: @Composable () -> Unit) {
    this.content = content
    composeView?.setContent(content)
  }

  override fun getLifecycle(): Lifecycle {
    return lifeCycle
  }

  override fun getViewModelStore(): ViewModelStore {
    return modelStore
  }

  override fun getSavedStateRegistry(): SavedStateRegistry {
    return savedStateRegistryController.savedStateRegistry
  }

  override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    savedStateRegistryController.performRestore(Bundle())
    lifeCycle.currentState = Lifecycle.State.RESUMED
    composeView = ComposeView(context)
    composeView?.setContent(content)
    addView(composeView, LayoutParams(
      LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
  }

  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    lifeCycle.currentState = Lifecycle.State.DESTROYED
    modelStore.clear()
    removeAllViews()
    composeView = null
  }

  // Exact copy of View.createCompositionContext() in androidx's WindowRecomposer.android.kt
  private fun createCompositionContext(): CompositionContext {
    val currentThreadContext = AndroidUiDispatcher.CurrentThread
    val pausableClock = currentThreadContext[MonotonicFrameClock]?.let {
      PausableMonotonicFrameClock(it).apply { pause() }
    }
    val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
    val recomposer = Recomposer(contextWithClock)
    val runRecomposeScope = CoroutineScope(contextWithClock)
    val viewTreeLifecycleOwner = checkNotNull(ViewTreeLifecycleOwner.get(this)) {
      "ViewTreeLifecycleOwner not found from $this"
    }
    viewTreeLifecycleOwner.lifecycle.addObserver(
      LifecycleEventObserver { _, event ->
        @Suppress("NON_EXHAUSTIVE_WHEN")
        when (event) {
          Lifecycle.Event.ON_CREATE ->
            // Undispatched launch since we've configured this scope
            // to be on the UI thread
            runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
              recomposer.runRecomposeAndApplyChanges()
            }
          Lifecycle.Event.ON_START -> pausableClock?.resume()
          Lifecycle.Event.ON_STOP -> pausableClock?.pause()
          Lifecycle.Event.ON_DESTROY -> {
            recomposer.cancel()
          }
        }
      }
    )
    return recomposer
  }

//  TODO: ComposeViewWithLifecycle should handle saving state and other lifecycle things
//  override fun onSaveInstanceState(): Parcelable? {
//    val superState = super.onSaveInstanceState()
//    val bundle = Bundle()
//    savedStateRegistryController.performSave(bundle)
//  }
}