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 tagapplication
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 AAPTshared-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.
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
maxVersion
s 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.
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)
// }
}