Plug-ins de interface do carro

Use 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 tempo de execução (RROs). Os RROs permitem que você altere apenas os recursos XML dos componentes da biblioteca Car UI, o que limita a extensão do que você pode personalizar.

Crie um plug-in

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

Veja exemplos em Soong e Gradle:

Em breve

Considere este exemplo de 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

Veja 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 plugin deve ter um provedor de conteúdo declarado em seu manifesto que possua 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 plugin detectável na biblioteca Car UI. O provedor deve ser exportado para que possa ser consultado em 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 plugin. A classe do provedor de conteúdo não precisa existir. Nesse caso, certifique-se de adicionar tools:ignore="MissingClass" à definição do provedor. Veja o exemplo de entrada do 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>

Finalmente, como medida de segurança, assine seu aplicativo .

Plugins como uma biblioteca compartilhada

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

Plugins implementados como uma biblioteca compartilhada do Android têm suas classes adicionadas automaticamente ao carregador de classe compartilhado entre aplicativos. Quando um aplicativo que usa a biblioteca Car UI especifica uma dependência de tempo de execução na biblioteca compartilhada do plugin, seu carregador de classe pode acessar as classes da biblioteca compartilhada do plugin. Plugins implementados como aplicativos Android normais (não como uma biblioteca compartilhada) podem impactar negativamente os tempos de inicialização a frio do aplicativo.

Implementar e construir bibliotecas compartilhadas

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

  • Use a tag library abaixo da tag application com o nome do pacote do plug-in no manifesto do aplicativo do seu plug-in:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configure sua regra de compilação android_app do Soong ( Android.bp ) com o sinalizador AAPT shared-lib , que é usado para criar uma biblioteca compartilhada:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dependências de bibliotecas compartilhadas

Para cada aplicativo no sistema que usa a biblioteca Car UI, inclua a tag uses-library no manifesto do aplicativo sob a 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>

Instale um plug-in

Os plug-ins DEVEM 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 aplicativo instalado.

Se você estiver atualizando um plug-in existente no sistema, todos os aplicativos que usam esse plug-in serão fechados automaticamente. Depois de reabertos pelo usuário, eles terão as alterações atualizadas. Se o aplicativo não estiver em execução, na próxima vez que for iniciado, ele terá o plugin atualizado.

Ao instalar um plugin com Android Studio, há algumas considerações adicionais a serem levadas em consideração. No momento em que este artigo foi escrito, havia um bug no processo de instalação do aplicativo Android Studio que fazia com que as atualizações de um plug-in não entrassem em vigor. Isso pode ser corrigido selecionando a opção Sempre instalar com gerenciador de pacotes (desativa otimizações de implantação no Android 11 e posterior) na configuração de compilação do plug-in.

Além disso, ao instalar o plug-in, o Android Studio relata um erro informando que não consegue encontrar uma atividade principal para iniciar. Isso é esperado, pois o plugin não possui nenhuma atividade (exceto a intenção vazia usada para resolver uma intenção). Para eliminar o erro, altere a opção Launch para Nothing na configuração de compilação.

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

Plug-in proxy

A personalização de aplicativos usando a biblioteca Car UI requer um RRO direcionado a cada aplicativo específico a ser modificado, inclusive quando as personalizações são idênticas entre os aplicativos. Isso significa que é necessário um RRO por aplicativo. Veja quais aplicativos usam a biblioteca Car UI.

O plug-in proxy da biblioteca Car UI é um exemplo de biblioteca compartilhada de plug-ins que delega suas implementações de componentes à versão estática da biblioteca Car UI. Este plugin pode ser direcionado com um RRO, que pode ser usado como um ponto único de customização para aplicativos que utilizam a biblioteca Car UI sem a necessidade de implementar um plugin funcional. Para obter mais informações sobre RROs, consulte Alterar o valor dos recursos de um aplicativo em tempo de execução .

O plugin proxy é apenas um exemplo e ponto de partida para fazer customização usando um plugin. Para personalização além dos RROs, pode-se implementar um subconjunto de componentes do plugin e usar o plugin proxy para o resto, ou implementar todos os componentes do plugin inteiramente do zero.

Embora o plug-in de proxy forneça um ponto único de personalização de RRO para aplicativos, os aplicativos que optarem por não usar o plug-in ainda exigirão um RRO direcionado diretamente ao próprio aplicativo.

Implementar as APIs do plugin

O principal ponto de entrada para o plugin é a classe com.android.car.ui.plugin.PluginVersionProviderImpl . Todos os plugins devem incluir uma classe com este nome exato e nome do pacote. Esta classe deve ter um construtor padrão e implementar a interface PluginVersionProviderOEMV1 .

Os plug-ins CarUi devem funcionar com aplicativos mais antigos ou mais recentes que o plug-in. Para facilitar isso, todas as APIs de plugins são versionadas 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 fazer com que 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 aplicativo com uma versão mais antiga da biblioteca Car UI não pode se adaptar a um novo plug-in escrito em APIs mais recentes. Para resolver esse problema, permitimos que os plug-ins retornem diferentes implementações de si mesmos com base na versão da API OEM suportada pelos aplicativos.

PluginVersionProviderOEMV1 possui um método:

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

Este método retorna um objeto que implementa a versão mais recente de PluginFactoryOEMV# suportada pelo plugin, embora ainda seja menor ou igual a maxVersion . Se um plugin não tiver uma implementação de um PluginFactory tão antiga, ele poderá retornar null , caso em que a implementação vinculada estaticamente dos componentes CarUi será usada.

Para manter a compatibilidade retroativa com aplicativos compilados em versões mais antigas da biblioteca estática Car Ui, é recomendado oferecer suporte maxVersion s de 2, 5 e superiores na implementação da classe PluginVersionProvider do seu plug-in. As versões 1, 3 e 4 não são suportadas. Para obter mais informações, consulte PluginVersionProviderImpl .

O PluginFactory é a interface que cria todos os outros componentes do CarUi. Também define qual versão de suas interfaces deve ser utilizada. Caso o plugin não busque implementar nenhum desses componentes, ele poderá retornar null em sua função de criação (com exceção da barra de ferramentas, que possui uma função customizesBaseLayout() separada).

O pluginFactory limita quais versões dos componentes CarUi podem ser usadas juntas. Por exemplo, nunca haverá um pluginFactory que possa criar a versão 100 de uma Toolbar e também a versão 1 de um RecyclerView , pois haveria pouca garantia de que uma grande variedade de versões de componentes funcionariam juntas. Para usar a barra de ferramentas versão 100, espera-se que os desenvolvedores forneçam uma implementação de uma versão do pluginFactory que crie uma barra de ferramentas versão 100, que então limite 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 poderia criar um ToolbarControllerOEMV100 e um RecyclerViewOEMV70 .

Barra de ferramentas

Layout básico

A barra de ferramentas e o "layout base" estão intimamente relacionados, portanto a função que cria a barra de ferramentas é chamada installBaseLayoutAround . O layout básico é um conceito que permite que a barra de ferramentas seja posicionada em qualquer lugar ao redor do conteúdo do aplicativo, para permitir uma barra de ferramentas na parte superior/inferior do aplicativo, verticalmente nas laterais ou até mesmo uma barra de ferramentas circular envolvendo todo o aplicativo. Isso é feito passando uma visualização para installBaseLayoutAround para que a barra de ferramentas/layout base seja contornada.

O plug-in deve pegar a visualização fornecida, desanexá-la de seu pai, inflar o próprio layout do plug-in no mesmo índice do pai e com os mesmos LayoutParams da visualização que acabou de ser desanexada e, em seguida, reanexar a visualização em algum lugar dentro do layout que foi apenas inflado. O layout inflado conterá a barra de ferramentas, se solicitado pelo aplicativo.

O aplicativo pode solicitar um layout básico sem barra de ferramentas. Se isso acontecer, installBaseLayoutAround deverá retornar nulo. Para a maioria dos plugins, isso é tudo o que precisa acontecer, mas se o autor do plugin quiser aplicar, por exemplo, uma decoração ao redor da borda do aplicativo, isso ainda poderá ser feito com um layout base. Essas decorações são particularmente úteis para dispositivos com telas não retangulares, pois podem empurrar o aplicativo para um espaço retangular e adicionar transições limpas ao espaço não retangular.

installBaseLayoutAround também recebe um Consumer<InsetsOEMV1> . Este consumidor pode ser usado para comunicar ao aplicativo que o plugin está cobrindo parcialmente o conteúdo do aplicativo (com a barra de ferramentas ou de outra forma). O aplicativo saberá então que deve continuar desenhando neste espaço, mas manterá quaisquer componentes críticos interagindo com o usuário fora dele. Este efeito é usado em nosso design de referência para tornar a barra de ferramentas semitransparente e fazer com que as listas rolem 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 este efeito não for necessário, o plugin pode ignorar o Consumidor.

Rolagem de conteúdo abaixo da barra de ferramentas Figura 2. Rolagem de conteúdo abaixo da barra de ferramentas

Do ponto de vista do aplicativo, quando o plugin enviar novos insets, ele os receberá de quaisquer atividades ou fragmentos que implementem InsetsChangedListener . Se uma atividade ou fragmento não implementar InsetsChangedListener , a biblioteca Car Ui manipulará inserções por padrão aplicando as inserções como preenchimento à Activity ou FragmentActivity que contém o fragmento. A biblioteca não aplica as inserções por padrão aos fragmentos. Aqui está um trecho de exemplo de uma implementação que aplica as inserções como preenchimento em um RecyclerView no aplicativo:

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 plugin recebe uma dica fullscreen , que é usada para indicar se a visualização que deve ser agrupada ocupa todo o aplicativo 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 da tela inteira. Um aplicativo de exemplo que usa layouts básicos que não são de tela cheia são Configurações, em que cada painel do layout de painel duplo tem sua própria barra de ferramentas.

Como é esperado que installBaseLayoutAround retorne null quando toolbarEnabled for false , para que o plugin indique que não deseja customizar o layout base, ele deve retornar false de customizesBaseLayout .

O layout base deve conter um FocusParkingView e um FocusArea para oferecer suporte total aos controles rotativos. Essas visualizações podem ser omitidas em dispositivos que não suportam rotação. Os FocusParkingView/FocusAreas são implementados 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 Focus devem ser o contexto de origem, não o contexto do plugin. O FocusParkingView deve ser o mais próximo possível da primeira visualização na árvore, pois é o que está em foco quando não deveria haver nenhum foco visível para o usuário. A FocusArea deve envolver a barra de ferramentas no layout base para indicar que é uma zona de ajuste rotativo. Se FocusArea não for fornecido, o usuário não poderá navegar para nenhum botão na barra de ferramentas com o controlador giratório.

Controlador da barra de ferramentas

O ToolbarController real retornado deve ser muito mais simples de implementar do que o layout base. Sua função é pegar as informações passadas aos seus setters e exibi-las no layout base. Consulte o Javadoc para obter informações sobre a maioria dos métodos. Alguns dos métodos mais complexos são discutidos abaixo.

getImeSearchInterface é usado para mostrar os resultados da pesquisa na janela IME (teclado). Isso pode ser útil para exibir/animar resultados de pesquisa junto com o 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 apenas fornece métodos para a biblioteca estática obter os retornos de chamada TextView e onPrivateIMECommand . Para suportar isso, o plug-in deve usar uma subclasse TextView que substitua onPrivateIMECommand e passe a chamada para o ouvinte fornecido como TextView de sua barra de pesquisa.

setMenuItems simplesmente exibe MenuItems na tela, mas será chamado com uma frequência surpreendente. Como a API do plugin para MenuItems é imutável, sempre que um MenuItem for alterado, uma nova chamada setMenuItems acontecerá. Isso pode acontecer para algo tão trivial quanto um usuário clicar em um botão MenuItem e esse clique fazer com que o botão seja alternado. Por motivos de desempenho e animação, portanto, é recomendável calcular a diferença entre a lista antiga e a nova de MenuItems e atualizar apenas as visualizações que realmente foram alteradas. Os MenuItems fornecem um campo key que pode ajudar com isso, pois a chave deve 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 dessa visualização que a destaca do resto do aplicativo e indicar ao usuário que este é um tipo diferente de interface. A visualização agrupada pelo AppStyledView é fornecida em setContent . O AppStyledView também pode ter um botão voltar ou fechar conforme solicitado pelo aplicativo.

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

Contextos

O plugin deve ter cuidado ao usar Contextos, pois existem contextos de plugin e de "fonte". O contexto do plugin é fornecido como argumento para getPluginFactory e é o único contexto que contém os recursos do plugin. Isso significa que é o único contexto que pode ser usado para aumentar os layouts no plugin.

No entanto, o contexto do plugin 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 utilizar a configuração do contexto de origem com os recursos do contexto do plugin, um novo contexto deve ser criado 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 suportar vários modos para seus componentes, como modo esportivo ou modo ecológico que parecem visualmente distintos. Não há suporte integrado para tal funcionalidade no CarUi, mas nada impede o plugin de implementá-lo inteiramente internamente. O plugin pode monitorar quaisquer condições que desejar para descobrir quando alternar os modos, como ouvir transmissões. O plug-in não pode acionar uma alteração de configuração para alterar os modos, mas não é recomendado confiar em alterações de configuração de qualquer maneira, pois 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 alterações de configuração.

Composição do Jetpack

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 habilitada para o Compose para renderização. Este ComposeView seria o que seria retornado para o aplicativo a partir do método getView nos componentes.

Um grande problema com o uso ComposeView é que ele define tags na visualização raiz do layout para armazenar variáveis ​​globais que são compartilhadas entre diferentes ComposeViews na hierarquia. Como os IDs de recursos do plug-in não têm namespaces separados dos do aplicativo, isso pode causar conflitos quando o aplicativo 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, isto 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)
//  }
}