Plug-ins de IU de carro

Use plugins car-ui-lib para criar implementações completas de customizações de componentes em car-ui-lib em vez de usar sobreposições de recursos de tempo de execução (RROs). Os RROs permitem alterar apenas os recursos XML dos componentes car-ui-lib , o que limita a extensão do que você pode personalizar.

Criando um plug-in

Um plug-in car-ui-lib é um APK que contém classes que implementam um conjunto de APIs de plug -in . As APIs de plug-in estão localizadas em packages/apps/Car/libs/car-ui-lib/oem-apis e podem ser compiladas em um plug-in como uma biblioteca estática.

Veja os exemplos Soong e em Gradle abaixo:

Logo

Considere este exemplo Soong:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    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",
    ],

    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 skip the 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 plug-in detectável para car-ui-lib . 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 plug-in. 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>

Por fim, como medida de segurança, Assine seu aplicativo .

Instalando um plug-in

Depois de criar o plug-in, ele pode ser instalado como qualquer outro aplicativo, como adicioná-lo a PRODUCT_PACKAGES ou usar adb install . No entanto, se esta for uma nova instalação de um plug-in, os aplicativos devem ser reiniciados para que as alterações entrem em vigor. Isso pode ser feito executando uma adb reboot ou adb shell am force-stop package.name para um aplicativo específico.

Se você estiver atualizando um plug-in car-ui-lib existente no sistema, todos os aplicativos que usam esse plug-in serão fechados automaticamente e, uma vez reabertos pelo usuário, terão as alterações atualizadas. Isso parece uma falha se os aplicativos estiverem em primeiro plano no momento. Se o aplicativo não estava em execução, na próxima vez que for iniciado, ele terá o plug-in atualizado.

Ao instalar um plug-in com o Android Studio, há algumas considerações adicionais a serem consideradas. No momento da redação deste artigo, há um bug no processo de instalação do aplicativo Android Studio que faz com que as atualizações de um plug-in não tenham efeito. Isso pode ser corrigido selecionando a opção Sempre instalar com gerenciador de pacotes (desativa as 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 de que não consegue encontrar uma atividade principal para iniciar. Isso é esperado, pois o plug-in não possui nenhuma atividade (exceto o intent vazio usado para resolver um intent). Para eliminar o erro, altere a opção Launch para Nothing na configuração de compilação.

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

Implementando as APIs do plug-in

O ponto de entrada principal para o plugin é a classe com.android.car.ui.plugin.PluginVersionProviderImpl . Todos os plugins devem incluir uma classe com este nome exato e nome de pacote. Essa 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 plug-in são versionadas com um V# no final de seu nome de classe. Se uma nova versão do car-ui-lib for lançada com novos recursos, eles farão parte da versão V2 do componente. car-ui-lib faz o possível para que novos recursos funcionem dentro do 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 antigo com uma versão antiga do car-ui-lib 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 tem um método nele:

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

Este método retorna um objeto que implementa a versão mais alta de PluginFactoryOEMV# suportada pelo plugin, enquanto ainda é menor ou igual a maxVersion . Se um plugin não tem uma implementação de um PluginFactory tão antigo, ele pode retornar null , neste caso a implementação estaticamente-nked de componentes CarUi são usadas.

O PluginFactory é a interface que cria todos os outros componentes do CarUi. Também define qual versão de suas interfaces deve ser usada. Caso o plugin não busque implementar nenhum desses componentes, ele pode 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 de ferramentas e também a versão 1 de um RecyclerView , pois haveria pouca garantia de que uma ampla variedade de versões de componentes funcionaria em conjunto. Para usar a versão 100 da barra de ferramentas, espera-se que os desenvolvedores forneçam uma implementação de uma versão do pluginFactory que cria uma versão 100 da barra de ferramentas, 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 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 base é 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 View para installBaseLayoutAround para que o layout da barra de ferramentas/base seja contornado.

O plugin deve pegar a View fornecida, desanexá-la de seu pai, inflar o layout do próprio plugin no mesmo índice do pai e com o mesmo LayoutParams da view que acabou de ser desanexada, e então reanexar a View 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 uma barra de ferramentas. Se isso acontecer, installBaseLayoutAround deve 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 pode ser feito com um layout básico. Essas decorações são particularmente úteis para dispositivos com telas não retangulares, pois podem colocar o aplicativo em um espaço retangular e adicionar transições limpas no espaço não retangular.

installBaseLayoutAround também recebe um Consumer<InsetsOEMV1> . Esse consumidor pode ser usado para comunicar ao aplicativo que o plug-in está cobrindo parcialmente o conteúdo do aplicativo (com a barra de ferramentas ou de outra forma). O aplicativo saberá continuar desenhando nesse espaço, mas manterá todos os componentes críticos de interação 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 embaixo da barra de ferramentas e não seria clicável. Se este efeito não for necessário, o plugin pode ignorar o Consumer.

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

Do ponto de vista do aplicativo, quando o plug-in enviar novas inserções, ele as receberá por meio de quaisquer atividades/fragmentos que implementem InsetsChangedListener . 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 plug-in recebe uma dica de fullscreen , que é usada para indicar se a Visualização que deve ser encapsulada 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 de toda a tela. Um aplicativo de exemplo que usa layouts básicos que não são de tela cheia é o Configurações, no qual 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 plug-in indique que não deseja personalizar 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 exibições podem ser omitidas em dispositivos que não suportam rotação. O 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 plug-in. O FocusParkingView deve ser o mais próximo possível da primeira visualização na árvore, pois é o foco quando não deve haver foco visível para o usuário. A FocusArea deve envolver a barra de ferramentas no layout base para indicar que é uma zona de deslocamento rotativo. Se o FocusArea não for fornecido, o usuário não poderá navegar para nenhum botão na barra de ferramentas com o controlador rotativo.

Controlador da barra de ferramentas

O ToolbarController real retornado deve ser muito mais simples de implementar do que o layout base. Seu trabalho é pegar as informações passadas para 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 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 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 substitui onPrivateIMECommand e passa a chamada para o ouvinte fornecido como TextView da 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 clicou em uma opção MenuItem e esse clique fez com que a opção fosse alternada. Por motivos de desempenho e animação, é recomendável calcular a diferença entre a lista de MenuItems antiga e a nova e atualizar apenas as exibições que realmente foram alteradas. Os MenuItems fornecem um campo de 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 um View que não é personalizado. Ele pode ser usado para fornecer uma borda em torno dessa visualização que a destaca do restante do aplicativo e indicar ao usuário que esse é um tipo diferente de interface. A View que é encapsulada 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 Views na hierarquia de Views como installBaseLayoutAround , em vez disso, apenas retorna sua view 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 plug-in deve ter cuidado ao usar Contextos, pois existem contextos de plug -in e "fonte". O contexto do plug-in é fornecido como um argumento para getPluginFactory e é o único contexto que garante ter os recursos do plug-in nele. Isso significa que é o único contexto que pode ser usado para inflar layouts no plugin.

No entanto, o contexto do plug-in pode não ter a configuração correta definida nele. Para obter a configuração correta, fornecemos contextos de origem em métodos que criam componentes. O contexto de origem geralmente é uma Activity, mas em alguns casos também pode ser um Service ou outro componente do Android. Para usar a configuração do contexto de origem com os recursos do contexto do plug-in, 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 podem não ter as dimensões corretas.

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

Mudanças de modo

Alguns plugins podem suportar vários modos para seus componentes, como um modo esportivo ou um modo econômico que parecem visualmente distintos. Não há suporte embutido para tal funcionalidade no CarUi, mas não há nada que impeça o plugin de implementá-lo inteiramente internamente. O plug-in 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 é recomendável 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 o ComposeView para criar uma superfície habilitada para Compose para renderização. Este ComposeView seria o que é retornado para o aplicativo do método getView em componentes.

Um grande problema com o uso de ComposeView é que ele define tags na visualização raiz no layout para armazenar variáveis ​​globais que são compartilhadas em 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, 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)
//  }
}