Plug-ins de IU de carro

Use plug-ins car-ui-lib para criar implementações completas de personalizaçõ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 do 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 in Gradle abaixo:

logo

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

GradleName

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 plug-in 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 deverão ser reiniciados para que as alterações entrem em vigor. Isso pode ser feito executando uma adb reboot completa 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 fecham automaticamente e, uma vez reabertos pelo usuário, têm as alterações atualizadas. Isso parecerá uma falha se os aplicativos estiverem em primeiro plano no momento. Se o aplicativo não estiver 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 entrem em vigor. Isso pode ser corrigido selecionando a opção Sempre instalar com o 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 a intenção vazia usada para resolver uma intenção). Para eliminar o erro, altere a opção Iniciar para Nothing na configuração de compilação.

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

Implementando as APIs do plug-in

O principal ponto de entrada para o plug-in é a classe com.android.car.ui.plugin.PluginVersionProviderImpl . Todos os plugins devem incluir uma classe com este nome exato e nome do 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 fazer com 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 plug-in, enquanto ainda é menor ou igual a maxVersion . Se um plug-in não tiver uma implementação de uma PluginFactory tão antiga, ele poderá retornar null , caso em que a implementação estaticamente marcada dos componentes CarUi será usada.

O PluginFactory é a interface que cria todos os outros componentes CarUi. Também define qual versão de suas interfaces deve ser usada. Se o plug-in não busca 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 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 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 então 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 poderia criar um ToolbarControllerOEMV100 e um RecyclerViewOEMV70 .

barra de ferramentas

Layout base

A barra de ferramentas e o "layout base" estão intimamente relacionados, por isso 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 um View para installBaseLayoutAround para que a barra de ferramentas/layout básico seja agrupado.

O plug-in deve pegar a View 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 exibição que acabou de desanexar e, em seguida, 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. Em caso afirmativo, installBaseLayoutAround deve 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 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 ao 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á então continuar desenhando neste espaço, mas manterá quaisquer componentes críticos de interação com o usuário fora dele. Esse 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 plug-in pode ignorar o Consumer.

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 plug-in enviar novos inserts, ele os 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 exibiçã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 não em tela cheia é o Settings, 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 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 giratórios. Essas exibições podem ser omitidas em dispositivos que não suportam rotação. As FocusParkingView/FocusAreas são implementadas na biblioteca estática CarUi, portanto, um setRotaryFactories é usado para fornecer fábricas para criar as exibições a partir de contextos.

Os contextos usados ​​para criar exibiçõ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 exibição na árvore, pois é o que está em foco quando não deve haver foco visível para o usuário. A FocusArea deve agrupar a barra de ferramentas no layout base para indicar que é uma zona de deslocamento giratório. Se a FocusArea não for fornecida, 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 verdadeiro ToolbarController retornado deve ser muito mais simples de implementar do que o layout básico. Sua função é 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 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 apenas fornece métodos para a biblioteca estática obter os retornos de chamada TextView e onPrivateIMECommand . Para oferecer suporte a 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 frequência surpreendente. Como a API do plug-in 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 fizer com que o botão alterne. 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 key que pode ajudar com isso, pois a chave deve ser a mesma em diferentes chamadas para setMenuItems para o mesmo MenuItem.

AppStyledViewName

O AppStyledView é um contêiner para uma exibição que não é personalizada. 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 exibição que é 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 exibições na hierarquia de exibição como installBaseLayoutAround faz, em vez disso, apenas retorna sua exibição para a biblioteca estática por meio 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 de "fonte". O contexto do plug-in é fornecido como um argumento para getPluginFactory e é o único contexto com garantia de ter os recursos do plug-in nele. Isso significa que é 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, 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 exibiçõ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 seus componentes, como um modo esportivo ou um modo econômico que parecem visualmente distintos. Não há suporte integrado para tal funcionalidade no CarUi, mas não há nada que impeça o plug-in de implementá-lo totalmente 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 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.

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 habilitada para Compose na qual renderizar. Este ComposeView seria o que é retornado para app do método getView em components.

Um grande problema com o uso ComposeView é que ele define tags na exibição raiz no 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 exibiçã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)
//  }
}