Plug-ins da interface do carro

Use os plug-ins 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 execução (RROs, na sigla em inglês). Os RROs permitem mudar apenas os recursos XML dos componentes da biblioteca Car UI, o que limita a extensão do que você pode personalizar.

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-ins podem ser compiladas em um plug-in como uma biblioteca estática.

Confira exemplos no Soong e no Gradle:

Soong

Confira 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"

android:authorities="com.android.car.ui.plugin" torna o plug-in detectável para a biblioteca Car UI. O provedor precisa ser exportado para que possa ser consultado no tempo de execução. Além disso, se o atributo enabled for 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, assinale 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 autônomo que é 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 os apps. Quando um app que usa a biblioteca da interface do carro especifica uma dependência de tempo de execução na biblioteca compartilhada do plug-in, o classloader dele pode acessar as classes da biblioteca compartilhada do plug-in. Plugins 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 do plug-in:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configure a regra de build android_app do Soong (Android.bp) com a flag shared-lib do AAPT, 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 App, 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 de forma semelhante a 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 a página, as mudanças atualizadas vão aparecer. Se o app não estava em execução, na próxima vez que ele for iniciado, ele terá o plug-in atualizado.

Ao instalar um plug-in com o Android Studio, é preciso considerar outros aspectos. No momento da redação deste artigo, há um bug no processo de instalação do app do Android Studio que faz com que as atualizações de um plug-in não entrem em vigor. Para corrigir isso, selecione a opção Sempre instalar com o gerenciador de pacotes (desativa os 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 que não consegue encontrar uma atividade principal para iniciar. Isso é esperado, porque o plug-in não tem atividades (exceto a intent vazia usada para resolver uma intent). Para eliminar o erro, mude a opção Launch para Nothing 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 que usam a biblioteca de interface para carros requer um RRO direcionado a cada app específico que será modificado, inclusive quando as personalizações forem idênticas entre os apps. Isso significa que uma RRO por app é necessária. Saiba quais apps usam a biblioteca Car UI.

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

O plug-in de proxy é apenas um exemplo e ponto de partida para fazer personalização usando um plug-in. Para personalizar além dos 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 não usam o plug-in ainda vão precisar de uma RRO que direcione diretamente para 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. Para facilitar isso, todas as APIs de plug-in são controladas por versões com um V# no final da classe. Se uma nova versão da biblioteca Car UI for lançada com novos recursos, eles vão fazer parte da versão V2 do componente. A biblioteca de interface do carro faz o melhor para que os novos recursos funcionem no escopo de um componente de plug-in 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 criado com base em APIs mais recentes. Para resolver esse problema, permitimos que os plug-ins retornassem implementações diferentes com base na versão da API OEM compatível com os apps.

O 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# com suporte do plug-in, sendo 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 estáticamente dos componentes da CarUi será usada.

Para manter a compatibilidade com versões anteriores de apps compilados para versões mais antigas da biblioteca estática de interface do carro, é recomendável oferecer suporte a maxVersions de 2, 5 e mais recentes na implementação da classe PluginVersionProvider do plug-in. 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 da 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, com exceção da barra de ferramentas, que tem uma função customizesBaseLayout() separada.

O pluginFactory limita quais versões dos componentes da CarUi podem ser usadas juntos. 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, porque não haveria garantia de que uma grande variedade de versões de componentes funcionariam juntas. Para usar a versão 100 da barra de ferramentas, os desenvolvedores precisam fornecer uma implementação de uma versão de pluginFactory que cria uma versão 100 da barra de ferramentas, o que limita as opções nas versões de outros componentes que podem ser criadas. As versões de outros componentes podem não ser iguais. Por exemplo, um pluginFactoryOEMV100 pode criar um ToolbarControllerOEMV100 e um RecyclerViewOEMV70.

Barra de ferramentas

Layout básico

A barra de ferramentas e o "layout base" estão intimamente relacionados. Por isso, a função que cria a barra de ferramentas é chamada de installBaseLayoutAround. O layout base é um conceito que permite posicionar a barra de ferramentas em qualquer lugar ao redor do conteúdo do app, para permitir uma barra de ferramentas na parte superior/inferior do app, verticalmente ao longo dos lados ou até mesmo uma barra de ferramentas circular que envolve todo o app. Isso é conseguido transmitindo uma visualização para installBaseLayoutAround para que o layout da barra de ferramentas/base seja aplicado.

O plug-in precisa pegar a visualização fornecida, separá-la do pai, inflar o 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 base sem uma barra de ferramentas. Se isso acontecer, installBaseLayoutAround vai retornar 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 pode ser feito com um layout básico. Essas decorações são particularmente úteis para dispositivos com telas não retangulares, porque elas podem empurrar o app para um espaço retangular e adicionar transições limpas ao espaço não retangular.

installBaseLayoutAround também recebe um Consumer<InsetsOEMV1>. Esse consumidor pode ser usado para comunicar ao app que o plug-in está parcialmente cobrindo 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 que podem ser usados pelo usuário. Esse efeito é usado no nosso design de referência para tornar a barra de ferramentas semitransparente e fazer as listas rolarem abaixo dela. Se esse recurso não fosse implementado, o primeiro item de uma lista ficaria preso abaixo 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.

Conteúdo rolando abaixo da barra de ferramentas Figura 2. Conteúdo rolando abaixo da barra de ferramentas

Do ponto de vista do app, quando o plug-in envia novos insets, ele os recebe de qualquer atividade ou fragmento que implementa InsetsChangedListener. Se uma atividade ou um fragmento não implementar InsetsChangedListener, a biblioteca de interface do carro vai processar os insets por padrão, aplicando-os como padding ao Activity ou FragmentActivity que contém o fragmento. A biblioteca não aplica os insetos por padrão aos fragmentos. Confira um exemplo de snippet de uma implementação que aplica os insets 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 sugestão fullscreen, que é usada para indicar se a visualização que precisa ser agrupada 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 app de exemplo que usa layouts básicos que não são de tela cheia é o app Configurações, em que cada painel do layout de dois painéis tem a própria barra de ferramentas.

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

O layout de base precisa conter um FocusParkingView e um FocusArea para oferecer suporte total a controles rotativos. Essas visualizações podem ser omitidas em dispositivos que não oferecem suporte a rotary. O FocusParkingView/FocusAreas é implementado na biblioteca estática CarUi. Portanto, um setRotaryFactories é usado para fornecer fábricas para criar as visualizações a partir de contextos.

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

Controlador da barra de ferramentas

O ToolbarController real retornado precisa ser muito mais simples de implementar do que o layout básico. A função dele é receber as informações transmitidas para os setters e exibi-las no layout 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 de pesquisa na janela do IME (teclado). Isso pode ser útil para mostrar/animar os resultados da pesquisa ao lado do teclado, por exemplo, se o teclado ocupar apenas metade da tela. A maior parte da funcionalidade é implementada na biblioteca estática CarUi. A interface de pesquisa no plug-in fornece apenas métodos para a biblioteca estática receber os callbacks TextView e onPrivateIMECommand. Para oferecer suporte a isso, o plug-in precisa usar uma subclasse TextView que substitua onPrivateIMECommand e transmita a chamada para o listener fornecido como TextView da barra de pesquisa.

O setMenuItems simplesmente exibe MenuItems na tela, mas será chamado com frequência surpreendente. Como a API do 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 chave, e esse clique fez com que a chave fosse alternada. Por motivos de desempenho e animação, recomendamos calcular a diferença entre a lista de itens de menu antiga e a nova e atualizar apenas as visualizações que realmente mudaram. Os MenuItems oferecem um campo key que pode ajudar nisso, já que a chave precisa ser a mesma em diferentes chamadas para setMenuItems para o 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 da visualização, destacando-a do resto do app e indicando ao usuário que esse é um tipo diferente de interface. A visualização que é agrupada pelo AppStyledView é fornecida em setContent. O AppStyledView também pode ter um botão "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 retorna a visualização para a biblioteca estática usando getView, que faz a inserção. A posição e o tamanho do AppStyledView também podem ser controlados com a implementação do getDialogWindowLayoutParam.

Contextos

O plug-in precisa ter cuidado ao usar contextos, porque há contextos plug-in e "fonte". 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 obter 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 restrito do Android, e as visualizações infladas podem 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 esportivo ou um modo econômico que são visualmente distintos. Não há suporte integrado para essa funcionalidade no CarUi, mas nada impede que o plug-in a implemente totalmente internamente. O plug-in pode monitorar qualquer condição que ele queira 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 os modos, mas não é recomendável depender de mudanças de configuração de qualquer maneira, já que atualizar manualmente a aparência de cada componente é mais fácil 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 do método getView nos componentes.

Um dos principais problemas com o uso de ComposeView é que ele define tags na visualização raiz no layout para armazenar variáveis globais compartilhadas em diferentes ComposeViews na hierarquia. Como os IDs de recursos do plug-in não têm espaço de nome separado do app, isso pode causar conflitos quando o app e o plug-in definem tags na mesma visualização. Uma ComposeViewWithLifecycle personalizada que move essas variáveis globais para o ComposeView é fornecida 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)
//  }
}