Complementos de la IU del vehículo

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 etiqueta application 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 marca shared-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.

Configuración de Android Studio del complemento 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.

Desplazamiento del contenido debajo de la barra de herramientas 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)
//  }
}