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