Usa complementos de la biblioteca de Car UI para crear implementaciones completas de personalizaciones de componentes en la biblioteca de Car UI en lugar de usar superposiciones de recursos en tiempo de ejecución (RRO). Los RRO te permiten cambiar solo los recursos XML de los componentes de la biblioteca de Car UI, lo que limita el alcance de lo que puedes personalizar.
Crea un complemento
Un complemento de la biblioteca de Car UI es un APK que contiene clases que implementan un conjunto de APIs de complementos. Las APIs de complementos se pueden compilar en un complemento como una biblioteca estática.
Consulta los ejemplos en Soong y Gradle:
Soong
Considera este ejemplo 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
Consulta este archivo 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')
El complemento debe tener un proveedor de contenido declarado en su manifiesto que tenga los siguientes atributos:
android:authorities="com.android.car.ui.plugin"
android:enabled="true"
android:exported="true"
android:authorities="com.android.car.ui.plugin"
hace que la biblioteca de la IU del vehículo pueda detectar el complemento. El proveedor debe exportarse para que se pueda consultar en el tiempo de ejecución. Además, si el atributo enabled
se establece en false
, se usará la implementación predeterminada en lugar de la implementación del complemento. No es necesario que exista la clase del proveedor de contenido. En ese caso, asegúrate de agregar tools:ignore="MissingClass"
a la definición del proveedor. Consulta la siguiente entrada de manifiesto de muestra:
<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 último, como medida de seguridad, firma tu app.
Complementos como biblioteca compartida
A diferencia de las bibliotecas estáticas de Android, que se compilan directamente en las apps, las bibliotecas compartidas de Android se compilan en un APK independiente al que hacen referencia otras apps en el tiempo de ejecución.
Los complementos que se implementan como una biblioteca compartida de Android tienen sus clases agregadas automáticamente al cargador de clases compartido entre las apps. Cuando una app que usa la biblioteca de Car UI especifica una dependencia de tiempo de ejecución en la biblioteca compartida del complemento, su cargador de clases puede acceder a las clases de la biblioteca compartida del complemento. Los complementos implementados como apps para Android normales (no como una biblioteca compartida) pueden afectar de forma negativa los tiempos de inicio en frío de la app.
Implementa y compila bibliotecas compartidas
El desarrollo con bibliotecas compartidas de Android es muy similar al de las apps normales para Android, pero con algunas diferencias clave.
- Usa la etiqueta
library
debajo de la etiquetaapplication
con el nombre del paquete del complemento en el manifiesto de la app del complemento:
<application>
<library android:name="com.chassis.car.ui.plugin" />
...
</application>
- Configura tu regla de compilación de Soong
android_app
(Android.bp
) con la marca de AAPTshared-lib
, que se usa para compilar una biblioteca compartida:
android_app {
...
aaptflags: ["--shared-lib"],
...
}
Dependencias de bibliotecas compartidas
Para cada app del sistema que use la biblioteca de Car UI, incluye la etiqueta uses-library
en el manifiesto de la app debajo de la etiqueta application
con el nombre del paquete del complemento:
<manifest>
<application
android:name=".MyApp"
...>
<uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
...
</application>
</manifest>
Instala un complemento
Los complementos DEBEN estar preinstalados en la partición del sistema. Para ello, se debe incluir el módulo en PRODUCT_PACKAGES
. El paquete preinstalado se puede actualizar de manera similar a cualquier otra app instalada.
Si actualizas un complemento existente en el sistema, se cerrarán automáticamente todas las apps que usen ese complemento. Una vez que el usuario la vuelva a abrir, verá los cambios actualizados. Si la app no se estaba ejecutando, la próxima vez que se inicie tendrá el complemento actualizado.
Cuando instalas un complemento con Android Studio, debes tener en cuenta algunas consideraciones adicionales. En el momento de escribir este documento, hay un error en el proceso de instalación de la app de Android Studio que hace que las actualizaciones de un complemento no surtan efecto. Para corregir este problema, selecciona la opción Always install with package manager (disables deploy optimizations on Android 11 and later) en la configuración de compilación del complemento.
Además, cuando se instala el complemento, Android Studio informa un error que indica que no puede encontrar una actividad principal para iniciar. Esto es normal, ya que el complemento no tiene ninguna actividad (excepto el intent vacío que se usa para resolver un intent). Para eliminar el error, cambia la opción Launch a Nothing en la configuración de compilación.
Figura 1: Configuración del complemento de Android Studio
Complemento de proxy
La personalización de apps que usan la biblioteca de Car UI requiere un RRO que se oriente a cada app específica que se modificará, incluso cuando las personalizaciones sean idénticas en todas las apps. Esto significa que se requiere un RRO por app. Consulta qué apps usan la biblioteca de Car UI.
El complemento de proxy de la biblioteca de Car UI es un ejemplo de biblioteca de complementos compartida que delega sus implementaciones de componentes a la versión estática de la biblioteca de Car UI. Este complemento se puede segmentar con un RRO, que se puede usar como un único punto de personalización para las apps que usan la biblioteca de Car UI sin necesidad de implementar un complemento funcional. Para obtener más información sobre las RRO, consulta Cómo cambiar el valor de los recursos de una app en el tiempo de ejecución.
El complemento de proxy es solo un ejemplo y un punto de partida para realizar personalizaciones con un complemento. Para una personalización más allá de los RRO, se puede implementar un subconjunto de componentes de complementos y usar el complemento de proxy para el resto, o bien implementar todos los componentes de complementos desde cero.
Aunque el complemento de proxy proporciona un solo punto de personalización de RRO para las apps, las apps que decidan no usar el complemento seguirán necesitando un RRO que se dirija directamente a la app.
Implementa las APIs del complemento
El punto de entrada principal del complemento es la clase com.android.car.ui.plugin.PluginVersionProviderImpl
. Todos los complementos deben incluir una clase con este nombre y nombre de paquete exactos. Esta clase debe tener un constructor predeterminado y debe implementar la interfaz PluginVersionProviderOEMV1
.
Los complementos de CarUi deben funcionar con apps más antiguas o más nuevas que el complemento. Para facilitar esto, todas las APIs de complementos tienen una versión con un V#
al final de su nombre de clase. Si se lanza una nueva versión de la biblioteca de Car UI con funciones nuevas, estas formarán parte de la versión V2
del componente. La biblioteca de Car UI hace todo lo posible para que las funciones nuevas funcionen dentro del alcance de un componente de complemento anterior.
Por ejemplo, convertir un nuevo tipo de botón en la barra de herramientas en MenuItems
Sin embargo, una app con una versión anterior de la biblioteca de Car UI no puede adaptarse a un nuevo complemento escrito para APIs más recientes. Para resolver este problema, permitimos que los complementos devuelvan diferentes implementaciones de sí mismos según la versión de la API del OEM que admitan las apps.
PluginVersionProviderOEMV1
tiene un método:
Object getPluginFactory(int maxVersion, Context context, String packageName);
Este método devuelve un objeto que implementa la versión más alta de PluginFactoryOEMV#
compatible con el complemento, sin dejar de ser inferior o igual a maxVersion
. Si un complemento no tiene una implementación de PluginFactory
tan antigua, puede devolver null
, en cuyo caso se usa la implementación vinculada de forma estática de los componentes de CarUi.
Para mantener la retrocompatibilidad con las apps que se compilan con versiones anteriores de la biblioteca estática de Car Ui, se recomienda admitir maxVersion
s de 2, 5 y superiores desde la implementación de la clase PluginVersionProvider
de tu complemento. No se admiten las versiones 1, 3 y 4. Para obtener más información, consulta PluginVersionProviderImpl
.
PluginFactory
es la interfaz que crea todos los demás componentes de CarUi. También define qué versión de sus interfaces se debe usar. Si el complemento no busca implementar ninguno de estos componentes, puede devolver null
en su función de creación (con la excepción de la barra de herramientas, que tiene una función customizesBaseLayout()
independiente).
pluginFactory
limita las versiones de los componentes de CarUi que se pueden usar en conjunto. Por ejemplo, nunca habrá un pluginFactory
que pueda crear la versión 100 de un Toolbar
y también la versión 1 de un RecyclerView
, ya que habría poca garantía de que una amplia variedad de versiones de componentes funcionarían juntas. Para usar la versión 100 de la barra de herramientas, se espera que los desarrolladores proporcionen una implementación de una versión de pluginFactory
que cree una versión 100 de la barra de herramientas, lo que limita las opciones en las versiones de otros componentes que se pueden crear. Es posible que las versiones de otros componentes no sean iguales, por ejemplo, un pluginFactoryOEMV100
podría crear un ToolbarControllerOEMV100
y un RecyclerViewOEMV70
.
Barra herram.
Diseño básico
La barra de herramientas y el "diseño base" están muy relacionados, por lo que la función que crea la barra de herramientas se llama installBaseLayoutAround
. El diseño base es un concepto que permite colocar la barra de herramientas en cualquier lugar alrededor del contenido de la app, lo que permite tener una barra de herramientas en la parte superior o inferior de la app, verticalmente a lo largo de los lados o incluso una barra de herramientas circular que encierre toda la app. Esto se logra pasando una vista a installBaseLayoutAround
para que la barra de herramientas o el diseño base se ajusten a ella.
El complemento debe tomar la vista proporcionada, separarla de su elemento principal, inflar el diseño propio del complemento en el mismo índice del elemento principal y con el mismo LayoutParams
que la vista que se acaba de separar, y, luego, volver a adjuntar la vista en algún lugar dentro del diseño que se acaba de inflar. El diseño inflado contendrá la barra de herramientas, si la app la solicita.
La app puede solicitar un diseño base sin una barra de herramientas. Si es así, installBaseLayoutAround
debe devolver un valor nulo. Para la mayoría de los complementos, eso es todo lo que debe suceder, pero si el autor del complemento desea aplicar, p.ej., una decoración alrededor del borde de la app, eso aún se podría hacer con un diseño base. Estas decoraciones son especialmente útiles para dispositivos con pantallas no rectangulares, ya que pueden insertar la app en un espacio rectangular y agregar transiciones limpias al espacio no rectangular.
También se pasa un Consumer<InsetsOEMV1>
a installBaseLayoutAround
. Este consumidor se puede usar para comunicar a la app que el complemento cubre parcialmente el contenido de la app (con la barra de herramientas o de otro modo). La app sabrá que debe seguir dibujando en este espacio, pero mantendrá fuera de él los componentes críticos con los que el usuario puede interactuar. Este efecto se usa en nuestro diseño de referencia para que la barra de herramientas sea semitransparente y las listas se desplacen debajo de ella. Si esta función no se hubiera implementado, el primer elemento de una lista quedaría atascado debajo de la barra de herramientas y no se podría hacer clic en él. Si no se necesita este efecto, el complemento puede ignorar el Consumer.
Figura 2: Desplazamiento de contenido debajo de la barra de herramientas
Desde la perspectiva de la app, cuando el complemento envía nuevas inserciones, las recibirá de cualquier actividad o fragmento que implemente InsetsChangedListener
. Si una actividad o un fragmento no implementan InsetsChangedListener
, la biblioteca de Car Ui controlará las inserciones de forma predeterminada aplicando las inserciones como relleno al Activity
o FragmentActivity
que contiene el fragmento. La biblioteca no aplica las inserciones de forma predeterminada a los fragmentos. A continuación, se muestra un fragmento de ejemplo de una implementación que aplica las inserciones como relleno en un RecyclerView
de la 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 último, se le proporciona al complemento una sugerencia fullscreen
, que se usa para indicar si la vista que se debe ajustar ocupa toda la app o solo una sección pequeña.
Esto se puede usar para evitar aplicar algunas decoraciones a lo largo del borde que solo tienen sentido si aparecen a lo largo del borde de toda la pantalla. Un ejemplo de una app que usa diseños base no de pantalla completa es Configuración, en la que cada panel del diseño de doble panel tiene su propia barra de herramientas.
Dado que se espera que installBaseLayoutAround
muestre un valor nulo cuando toolbarEnabled
es false
, para que el complemento indique que no desea personalizar el diseño base, debe mostrar false
desde customizesBaseLayout
.
El diseño base debe contener un FocusParkingView
y un FocusArea
para admitir por completo los controles rotatorios. Estas vistas se pueden omitir en dispositivos que no admiten el control rotatorio. Los FocusParkingView/FocusAreas
se implementan en la biblioteca estática de CarUi, por lo que se usa un setRotaryFactories
para proporcionar fábricas que creen las vistas a partir de contextos.
Los contextos que se usan para crear vistas de Focus deben ser el contexto de la fuente, no el del complemento. El FocusParkingView
debe estar lo más cerca posible de la primera vista en el árbol, ya que es lo que se enfoca cuando no debería haber ningún enfoque visible para el usuario. El FocusArea
debe envolver la barra de herramientas en el diseño base para indicar que es una zona de nudging rotatorio. Si no se proporciona FocusArea
, el usuario no podrá navegar a ningún botón de la barra de herramientas con el controlador rotatorio.
Controlador de la barra de herramientas
El ToolbarController
real que se devuelve debería ser mucho más sencillo de implementar que el diseño base. Su trabajo es tomar la información que se pasa a sus métodos de configuración y mostrarla en el diseño base. Consulta Javadoc para obtener información sobre la mayoría de los métodos. A continuación, se analizan algunos de los métodos más complejos.
getImeSearchInterface
se usa para mostrar los resultados de la búsqueda en la ventana del IME (teclado). Esto puede ser útil para mostrar o animar los resultados de la búsqueda junto con el teclado, por ejemplo, si el teclado solo ocupa la mitad de la pantalla. La mayor parte de la funcionalidad se implementa en la biblioteca estática de CarUi. La interfaz de búsqueda del complemento solo proporciona métodos para que la biblioteca estática obtenga las devoluciones de llamada TextView
y onPrivateIMECommand
. Para admitir esto, el complemento debe usar una subclase TextView
que anule onPrivateIMECommand
y pase la llamada al objeto de escucha proporcionado como el TextView
de la barra de búsqueda.
setMenuItems
simplemente muestra MenuItems en la pantalla, pero se llamará con una frecuencia sorprendente. Dado que la API de complementos para MenuItems es inmutable, cada vez que se cambia un MenuItem, se realizará una llamada setMenuItems
completamente nueva. Esto podría ocurrir por algo tan trivial como que un usuario hizo clic en un elemento de menú de interruptor y ese clic provocó que el interruptor se activara o desactivara. Por lo tanto, tanto por motivos de rendimiento como de animación, se recomienda calcular la diferencia entre la lista de elementos de menú anterior y la nueva, y solo actualizar las vistas que realmente cambiaron. Los MenuItems proporcionan un campo key
que puede ayudar con esto, ya que la clave debe ser la misma en diferentes llamadas a setMenuItems
para el mismo MenuItem.
AppStyledView
El elemento AppStyledView
es un contenedor para una vista que no está personalizada en absoluto. Se puede usar para proporcionar un borde alrededor de esa vista que la haga destacar del resto de la app y para indicarle al usuario que se trata de un tipo diferente de interfaz. La vista que AppStyledView contiene se proporciona en setContent
. El AppStyledView
también puede tener un botón de atrás o de cerrar, según lo solicite la app.
AppStyledView
no inserta sus vistas inmediatamente en la jerarquía de vistas como lo hace installBaseLayoutAround
, sino que devuelve su vista a la biblioteca estática a través de getView
, que luego realiza la inserción. La posición y el tamaño de AppStyledView
también se pueden controlar implementando getDialogWindowLayoutParam
.
Contextos
El complemento debe tener cuidado cuando usa contextos, ya que existen contextos de complemento y de "fuente". El contexto del complemento se proporciona como argumento a getPluginFactory
y es el único contexto que contiene los recursos del complemento. Esto significa que es el único contexto que se puede usar para inflar diseños en el complemento.
Sin embargo, es posible que el contexto del complemento no tenga establecida la configuración correcta. Para obtener la configuración correcta, proporcionamos contextos de origen en los métodos que crean componentes. Por lo general, el contexto de origen es una actividad, pero, en algunos casos, también puede ser un servicio o algún otro componente de Android. Para usar la configuración del contexto de origen con los recursos del contexto del complemento, se debe crear un contexto nuevo con createConfigurationContext
. Si no se usa la configuración correcta, habrá un incumplimiento del modo estricto de Android y es posible que las vistas infladas no tengan las dimensiones correctas.
Context layoutInflationContext = pluginContext.createConfigurationContext(
sourceContext.getResources().getConfiguration());
Cambios de modo
Algunos complementos pueden admitir varios modos para sus componentes, como un modo deportivo y un modo ecológico que se ven visualmente distintos. CarUi no admite esta funcionalidad de forma integrada, pero nada impide que el complemento la implemente de forma interna. El complemento puede supervisar las condiciones que desee para determinar cuándo cambiar de modo, como escuchar transmisiones. El complemento no puede activar un cambio de configuración para cambiar de modo, pero no se recomienda depender de los cambios de configuración de todos modos, ya que actualizar manualmente la apariencia de cada componente es más fluido para el usuario y también permite transiciones que no son posibles con los cambios de configuración.
Jetpack Compose
Los complementos se pueden implementar con Jetpack Compose, pero esta es una función de nivel alfa y no se debe considerar estable.
Los complementos pueden usar ComposeView
para crear una superficie habilitada para Compose en la que se pueda renderizar. Este ComposeView
sería lo que se devuelve a la app desde el método getView
en los componentes.
Un problema importante con el uso de ComposeView
es que establece etiquetas en la vista raíz del diseño para almacenar variables globales que se comparten en diferentes ComposeViews de la jerarquía. Dado que los IDs de recursos del complemento no tienen un espacio de nombres separado de los de la app, esto podría causar conflictos cuando tanto la app como el complemento establezcan etiquetas en la misma vista. A continuación, se proporciona un ComposeViewWithLifecycle
personalizado que traslada estas variables globales al ComposeView
. Una vez más, esto no debe considerarse estable.
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)
// }
}