Usa los complementos de la biblioteca de la IU del vehículo para crear implementaciones completas de personalizaciones de componentes en la biblioteca de la IU del vehículo en lugar de usar superposiciones de recursos de tiempo de ejecución (RRO). Los RRO te permiten cambiar solo los recursos XML de los componentes de la biblioteca de la IU del vehículo, lo que limita el grado de personalización.
Crea un complemento
Un complemento de la biblioteca de la IU del vehículo 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 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 el complemento sea detectable para la biblioteca de la IU del vehículo. El proveedor se debe exportar 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.
Plugins como biblioteca compartida
A diferencia de las bibliotecas estáticas de Android, que se compilan directamente en apps, las bibliotecas compartidas de Android se compilan en un APK independiente al que otras apps hacen referencia durante el tiempo de ejecución.
Las clases de los complementos que se implementan como una biblioteca compartida de Android se agregan automáticamente al cargador de clases compartido entre apps. Cuando una app que usa la biblioteca de la IU del vehículo especifica una dependencia del 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 una biblioteca compartida) pueden afectar de forma negativa los tiempos de inicio en frío de la app.
Implementa y compila bibliotecas compartidas
Desarrollar con bibliotecas compartidas de Android es muy similar al desarrollo de apps normales para Android, 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
android_app
de Soong (Android.bp
) con la marcashared-lib
de AAPT, 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 la IU de Car, 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, incluye 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, todas las apps que lo usen se cerrarán automáticamente. Una vez que el usuario lo 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 artículo, hay un error en el proceso de instalación de la app de Android Studio que hace que las actualizaciones de un complemento no se apliquen. Para solucionar 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 esperable, 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 de Android Studio del complemento
Complemento de proxy
La personalización de apps con la biblioteca de la IU de Car requiere un RRO que se segmente para 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 IU del vehículo.
El complemento de proxy de la biblioteca de la IU del vehículo es un ejemplo de biblioteca compartida de complementos que delega sus implementaciones de componentes a la versión estática de la biblioteca de la IU del vehículo. 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 la IU de Car 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 la personalización con un complemento. Para la personalización más allá de los RRO, se puede implementar un subconjunto de componentes del complemento y usar el complemento de proxy para el resto, o implementar todos los componentes del complemento desde cero.
Aunque el complemento de proxy proporciona un único punto de personalización de RRO para las apps, las apps que inhabiliten el uso del complemento aún requerirán un RRO que se segmente directamente a la app.
Implementa las APIs de complementos
El punto de entrada principal al 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, además, implementar la interfaz PluginVersionProviderOEMV1
.
Los complementos de CarUi deben funcionar con apps anteriores o posteriores al 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 IU del vehículo con funciones nuevas, estas forman parte de la versión V2
del componente. La biblioteca de la IU del vehículo hace todo lo posible para que las funciones nuevas funcionen dentro del alcance de un componente de complemento más antiguo.
Por ejemplo, convirtiendo 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 la IU del vehículo no se puede adaptar a un complemento nuevo escrito para APIs más recientes. Para resolver este problema, permitimos que los complementos muestren diferentes implementaciones de sí mismos según la versión de la API del OEM que admiten las apps.
PluginVersionProviderOEMV1
tiene un método:
Object getPluginFactory(int maxVersion, Context context, String packageName);
Este método muestra un objeto que implementa la versión más alta de PluginFactoryOEMV#
que admite el complemento, sin dejar de ser inferior o igual a maxVersion
. Si un complemento no tiene una implementación de un PluginFactory
tan antiguo, puede mostrar 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
de 2, 5 y versiones posteriores 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 mostrar 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 componentes de CarUi que se pueden usar
juntos. 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 pocas garantías de que una amplia variedad de versiones de componentes funcionen en conjunto. 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 de 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 de herramientas
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 que la barra de herramientas se posicione en cualquier lugar del contenido de la app, para permitir 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. Para ello, se pasa una vista a installBaseLayoutAround
para que la barra de herramientas o el diseño base se unan.
El complemento debe tomar la vista proporcionada, separarla de su elemento superior, aumentar el diseño del complemento en el mismo índice del elemento superior y con el mismo LayoutParams
que la vista que se acaba de separar y, luego, volver a conectar la vista en algún lugar dentro del diseño que se acaba de aumentar. El diseño aumentado contendrá la barra de herramientas, si la app lo solicita.
La app puede solicitar un diseño base sin una barra de herramientas. Si es así, installBaseLayoutAround
debería mostrar un valor nulo. Para la mayoría de los complementos, eso es todo lo que se necesita, pero si el autor del complemento desea aplicar, por ejemplo, una decoración alrededor del borde de la app, eso se puede hacer con un diseño base. Estas decoraciones son particularmente útiles para dispositivos con pantallas no rectangulares, ya que pueden colocar la app en un espacio rectangular y agregar transiciones claras al espacio no rectangular.
También se pasa un Consumer<InsetsOEMV1>
a installBaseLayoutAround
. Este consumidor se puede usar para comunicarle a la app que el complemento cubre parcialmente su contenido (con la barra de herramientas o de otra manera). La app entonces sabrá que debe seguir dibujando en este espacio, pero no debe incluir componentes críticos con los que el usuario pueda 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 no se implementara esta función, el primer elemento de una lista quedaría 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 al consumidor.
Figura 2: Desplazamiento del contenido debajo de la barra de herramientas
Desde la perspectiva de la app, cuando el complemento envíe nuevos inserciones, los recibirá de cualquier actividad o fragmento que implemente InsetsChangedListener
. Si una actividad o un fragmento no implementan InsetsChangedListener
, la biblioteca de IU de Car controlará los rellenos de forma predeterminada aplicando los rellenos como padding al Activity
o FragmentActivity
que contiene el fragmento. La biblioteca no aplica los rellenos de forma predeterminada a los fragmentos. A continuación, se muestra un fragmento de ejemplo de una implementación que aplica los rellenos como padding en un RecyclerView
en 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 unir ocupa toda la app o solo una pequeña sección.
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. Una app de ejemplo que usa diseños básicos que no son de pantalla completa es Configuración, en la que cada panel del diseño de dos paneles tiene su propia barra de herramientas.
Dado que se espera que installBaseLayoutAround
muestre un valor nulo cuando toolbarEnabled
sea 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 rotativos. Estas vistas se pueden omitir en dispositivos que no admiten dispositivos rotativos. Los FocusParkingView/FocusAreas
se implementan en la biblioteca CarUi estática, por lo que se usa un setRotaryFactories
para proporcionar fábricas para crear las vistas a partir de contextos.
Los contextos que se usan para crear vistas de enfoque deben ser el contexto de la fuente, no el del complemento. El FocusParkingView
debe ser lo más cercano posible a 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 unir la barra de herramientas en el diseño base para indicar que es una zona de empuje rotativo. Si no se proporciona FocusArea
, el usuario no puede navegar a ningún botón de la barra de herramientas con el control rotativo.
Controlador de la barra de herramientas
El ToolbarController
real que se muestra debería ser mucho más fácil de implementar que el diseño base. Su trabajo es tomar la información que se pasa a sus set 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 en el complemento solo proporciona métodos para que la biblioteca estática obtenga las devoluciones de llamadas 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 TextView
de su barra de búsqueda.
setMenuItems
simplemente muestra MenuItems en la pantalla, pero se lo llamará con una frecuencia sorprendente. Dado que la API del complemento 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 MenuItem de interruptor y ese clic hizo que el interruptor se activara o desactivara. Por motivos de rendimiento y animación, se recomienda calcular la diferencia entre la lista de MenuItems 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
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 destacarse del resto de la app y le indique al usuario que se trata de un tipo diferente de interfaz. La vista que une AppStyledView se proporciona en setContent
. AppStyledView
también puede tener un botón Atrás o Cerrar según lo solicite la app.
AppStyledView
no inserta sus vistas de inmediato en la jerarquía de vistas como lo hace installBaseLayoutAround
, sino que solo muestra 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 mediante la implementación de getDialogWindowLayoutParam
.
Contextos
El complemento debe tener cuidado cuando use Contexts, ya que hay contextos de complemento y de “fuente”. El contexto del complemento se proporciona como argumento a getPluginFactory
y es el único contexto que tiene 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 la configuración correcta establecida. Para obtener la configuración correcta, proporcionamos contextos de origen en métodos que crean componentes. El contexto de origen suele ser 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, se producirá una infracción del modo estricto de Android, y es posible que las vistas aumentadas no tengan las dimensiones correctas.
Context layoutInflationContext = pluginContext.createConfigurationContext(
sourceContext.getResources().getConfiguration());
Cambios de modo
Algunos complementos admiten varios modos para sus componentes, como un modo deportivo o un modo ecológico que se ven visualmente distintos. No hay compatibilidad integrada para esa funcionalidad en CarUi, pero no hay nada que impida que el complemento la implemente por completo de forma interna. El complemento puede supervisar cualquier condición 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, ya que actualizar manualmente la apariencia de cada componente es más fluida 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 renderizar. Este ComposeView
sería lo que se muestra 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 en el diseño para almacenar variables globales que se comparten entre diferentes ComposeViews en la jerarquía. Dado que los IDs de recursos del complemento no tienen espacio de nombres por separado del de la app, esto podría causar conflictos cuando la app y el complemento establezcan etiquetas en la misma vista. A continuación, se proporciona un ComposeViewWithLifecycle
personalizado que mueve estas variables globales hacia abajo al ComposeView
. Una vez más, esto no se debe considerar 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)
// }
}