Complementos de la interfaz de usuario del automóvil

Utilice complementos de la biblioteca Car UI para crear implementaciones completas de personalizaciones de componentes en la biblioteca Car UI en lugar de utilizar superposiciones de recursos de tiempo de ejecución (RRO). Los RRO le permiten cambiar solo los recursos XML de los componentes de la biblioteca Car UI, lo que limita el alcance de lo que puede personalizar.

Crear un complemento

Un complemento de la biblioteca Car UI es un APK que contiene clases que implementan un conjunto de API de complementos . Las API de complementos se pueden compilar en un complemento como una biblioteca estática.

Ver ejemplos en Soong y Gradle:

pronto

Considere 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

Vea 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 reconocible en la biblioteca Car UI. El proveedor debe exportarse para poder consultarlo en tiempo de ejecución. Además, si el atributo enabled se establece en false , se utilizará la implementación predeterminada en lugar de la implementación del complemento. La clase de proveedor de contenido no tiene por qué existir. En cuyo caso, asegúrese de agregar tools:ignore="MissingClass" a la definición del proveedor. Vea la entrada de manifiesto de muestra a continuación:

    <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>

Finalmente, como medida de seguridad, firma tu aplicación .

Complementos como biblioteca compartida

A diferencia de las bibliotecas estáticas de Android que se compilan directamente en aplicaciones, las bibliotecas compartidas de Android se compilan en un APK independiente al que otras aplicaciones hacen referencia en 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 aplicaciones. Cuando una aplicación que utiliza la biblioteca 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 aplicaciones normales de Android (no como una biblioteca compartida) pueden afectar negativamente los tiempos de inicio en frío de la aplicación.

Implementar y construir bibliotecas compartidas

El desarrollo con bibliotecas compartidas de Android es muy parecido al de las aplicaciones normales de Android, con algunas diferencias clave.

  • Utilice la etiqueta library debajo de la etiqueta application con el nombre del paquete del complemento en el manifiesto de la aplicación de su complemento:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configure su regla de compilación de Soong android_app ( Android.bp ) con el indicador shared-lib , que se utiliza para crear una biblioteca compartida:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dependencias de bibliotecas compartidas

Para cada aplicación en el sistema que usa la biblioteca Car UI, incluya la etiqueta uses-library en el manifiesto de la aplicación 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>

Instalar un complemento

Los complementos DEBEN estar preinstalados en la partición del sistema incluyendo el módulo en PRODUCT_PACKAGES . El paquete preinstalado se puede actualizar de manera similar a cualquier otra aplicación instalada.

Si está actualizando un complemento existente en el sistema, todas las aplicaciones que utilicen ese complemento se cerrarán automáticamente. Una vez reabierto por el usuario, tiene los cambios actualizados. Si la aplicación no se estaba ejecutando, la próxima vez que se inicie tendrá el complemento actualizado.

Al instalar un complemento con Android Studio, hay algunas consideraciones adicionales a tener en cuenta. Al momento de escribir este artículo, hay un error en el proceso de instalación de la aplicación Android Studio que hace que las actualizaciones de un complemento no surtan efecto. Esto se puede solucionar seleccionando la opción Instalar siempre con el administrador de paquetes (deshabilita las optimizaciones de implementación en Android 11 y versiones posteriores) en la configuración de compilación del complemento.

Además, al instalar el complemento, Android Studio informa un error que indica que no puede encontrar una actividad principal para iniciar. Esto es de esperarse, ya que el complemento no tiene ninguna actividad (excepto la intención vacía utilizada para resolver una intención). Para eliminar el error, cambie la opción Iniciar a Nada en la configuración de compilación.

Configuración del complemento Android Studio Figura 1. Configuración del complemento de Android Studio

Complemento de proxy

La personalización de aplicaciones utilizando la biblioteca Car UI requiere un RRO que se dirija a cada aplicación específica que se va a modificar, incluso cuando las personalizaciones son idénticas en todas las aplicaciones. Esto significa que se requiere un RRO por aplicación. Vea qué aplicaciones utilizan la biblioteca Car UI.

El complemento proxy de la biblioteca Car UI es una biblioteca compartida de complementos de ejemplo que delega las implementaciones de sus componentes a la versión estática de la biblioteca Car UI. Este complemento se puede orientar con un RRO, que se puede usar como un punto único de personalización para aplicaciones que usan la biblioteca Car UI sin la necesidad de implementar un complemento funcional. Para obtener más información sobre las RRO, consulte Cambiar el valor de los recursos de una aplicación en tiempo de ejecución .

El complemento de proxy es solo un ejemplo y un punto de partida para realizar la personalización mediante 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 proxy para el resto, o implementar todos los componentes de complementos completamente desde cero.

Aunque el complemento proxy proporciona un único punto de personalización de RRO para aplicaciones, las aplicaciones que opten por no usar el complemento seguirán necesitando un RRO que apunte directamente a la aplicación misma.

Implementar las API del complemento

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 exacto y nombre de paquete. Esta clase debe tener un constructor predeterminado e implementar la interfaz PluginVersionProviderOEMV1 .

Los complementos de CarUi deben funcionar con aplicaciones que sean más antiguas o más nuevas que el complemento. Para facilitar esto, todas las API 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 Car UI con nuevas funciones, forman parte de la versión V2 del componente. La biblioteca Car UI hace todo lo posible para que las nuevas funciones 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 aplicación con una versión anterior de la biblioteca Car UI no puede adaptarse a un nuevo complemento escrito con API más nuevas. Para resolver este problema, permitimos que los complementos devuelvan diferentes implementaciones de sí mismos según la versión de la API OEM compatible con las aplicaciones.

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# admitida por el complemento, sin dejar de ser menor o igual que maxVersion . Si un complemento no tiene una implementación de PluginFactory tan antigua, puede devolver null , en cuyo caso se utiliza la implementación vinculada estáticamente de los componentes CarUi.

Para mantener la compatibilidad con aplicaciones que se compilan con versiones anteriores de la biblioteca estática Car Ui, se recomienda admitir maxVersion s de 2, 5 y superiores desde la implementación de su complemento de la clase PluginVersionProvider . Las versiones 1, 3 y 4 no son compatibles. Para obtener más información, consulte 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 utilizar. 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() separada).

pluginFactory limita qué versiones de los componentes CarUi se pueden usar juntas. Por ejemplo, nunca habrá un pluginFactory que pueda crear la versión 100 de una 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 juntas. Para utilizar 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, que luego limita las opciones sobre las versiones de otros componentes que se pueden crear. Las versiones de otros componentes pueden no ser iguales, por ejemplo un pluginFactoryOEMV100 podría crear un ToolbarControllerOEMV100 y un RecyclerViewOEMV70 .

Barra de herramientas

Disposición básica

La barra de herramientas y el "diseño base" están muy relacionados, de ahí que la función que crea la barra de herramientas se llame installBaseLayoutAround . El diseño base es un concepto que permite colocar la barra de herramientas en cualquier lugar alrededor del contenido de la aplicación, para permitir una barra de herramientas en la parte superior/inferior de la aplicación, verticalmente a lo largo de los lados o incluso una barra de herramientas circular que encierre toda la aplicación. Esto se logra pasando una vista a installBaseLayoutAround para que la barra de herramientas/diseño base se ajuste.

El complemento debe tomar la vista proporcionada, separarla de su padre, inflar el diseño propio del complemento en el mismo índice del padre y con los mismos 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 simplemente inflado. El diseño inflado contendrá la barra de herramientas, si la aplicación lo solicita.

La aplicación puede solicitar un diseño base sin barra de herramientas. Si es así, installBaseLayoutAround debería devolver nulo. Para la mayoría de los complementos, eso es todo lo que debe suceder, pero si el autor del complemento desea aplicar, por ejemplo, una decoración alrededor del borde de la aplicación, aún podría hacerlo con un diseño base. Estas decoraciones son particularmente útiles para dispositivos con pantallas no rectangulares, ya que pueden empujar la aplicación a un espacio rectangular y agregar transiciones limpias al espacio no rectangular.

installBaseLayoutAround también se le pasa un Consumer<InsetsOEMV1> . Este consumidor se puede utilizar para comunicar a la aplicación que el complemento cubre parcialmente el contenido de la aplicación (con la barra de herramientas o de otra manera). La aplicación sabrá entonces que debe seguir dibujando en este espacio, pero mantendrá fuera de él cualquier componente crítico con el que pueda interactuar el usuario. Este efecto se utiliza en nuestro diseño de referencia para hacer que la barra de herramientas sea semitransparente y que las listas se desplacen debajo de ella. Si esta característica no se implementara, el primer elemento de una lista quedaría atrapado debajo de la barra de herramientas y no se podría hacer clic. Si este efecto no es necesario, el complemento puede ignorar al Consumidor.

Contenido desplazándose debajo de la barra de herramientas Figura 2. Contenido desplazándose debajo de la barra de herramientas

Desde la perspectiva de la aplicación, cuando el complemento envía nuevos insertos, los recibirá de cualquier actividad o fragmento que implemente InsetsChangedListener . Si una actividad o fragmento no implementa InsetsChangedListener , la biblioteca Car Ui manejará las inserciones de forma predeterminada aplicándolas como relleno a la Activity o FragmentActivity que contiene el fragmento. La biblioteca no aplica las inserciones de forma predeterminada a los fragmentos. Aquí hay un fragmento de muestra de una implementación que aplica los insertos como relleno en un RecyclerView en la aplicación:

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());
  }
}

Finalmente, el complemento recibe una sugerencia fullscreen , que se utiliza para indicar si la vista que se debe ajustar ocupa toda la aplicación o solo una pequeña sección. Esto se puede utilizar para evitar aplicar algunas decoraciones a lo largo del borde que sólo tienen sentido si aparecen a lo largo del borde de toda la pantalla. Una aplicación de ejemplo que utiliza diseños básicos que no son 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 devuelva null cuando toolbarEnabled sea false , para que el complemento indique que no desea personalizar el diseño base, debe devolver false de customizesBaseLayout .

El diseño base debe contener un FocusParkingView y un FocusArea para admitir completamente los controles giratorios. Estas vistas se pueden omitir en dispositivos que no admiten rotación. FocusParkingView/FocusAreas se implementan en la biblioteca estática CarUi, por lo que se utiliza setRotaryFactories para proporcionar fábricas para crear las vistas a partir de contextos.

Los contextos utilizados para crear vistas de Focus deben ser el contexto de origen, no el contexto del complemento. FocusParkingView debe ser lo más cercano posible a la primera vista del árbol, ya que es lo que se enfoca cuando no debería haber ningún foco visible para el usuario. El FocusArea debe envolver la barra de herramientas en el diseño base para indicar que es una zona de desplazamiento giratorio. Si no se proporciona FocusArea , el usuario no puede navegar a ningún botón en la barra de herramientas con el controlador giratorio.

Controlador de barra de herramientas

El ToolbarController real devuelto debería ser mucho más sencillo de implementar que el diseño base. Su trabajo es tomar la información pasada a sus configuradores y mostrarla en el diseño base. Consulte el Javadoc para obtener información sobre la mayoría de los métodos. Algunos de los métodos más complejos se analizan a continuación.

getImeSearchInterface se utiliza para mostrar resultados de búsqueda en la ventana IME (teclado). Esto puede resultar útil para mostrar/animar resultados de búsqueda junto al 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 CarUi, la interfaz de búsqueda en el 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 oyente proporcionado como TextView de su barra de búsqueda.

setMenuItems simplemente muestra MenuItems en la pantalla, pero se llamará sorprendentemente a menudo. Dado que la API del complemento para MenuItems es inmutable, cada vez que se cambia un MenuItem, se producirá una llamada setMenuItems completamente nueva. Esto podría suceder por algo tan trivial como que un usuario hiciera clic en un interruptor de elemento de menú y ese clic provocara que el interruptor alternara. Por razones tanto de rendimiento como de animación, se recomienda calcular la diferencia entre la lista de elementos de menú antiguos y nuevos, y actualizar solo 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.

Vista de estilo de aplicación

AppStyledView es un contenedor para una vista que no está personalizada en absoluto. Se puede utilizar para proporcionar un borde alrededor de esa vista que la haga destacar del resto de la aplicación e indicarle al usuario que se trata de un tipo diferente de interfaz. La vista envuelta por AppStyledView se proporciona en setContent . AppStyledView también puede tener un botón de retroceso o de cierre según lo solicite la aplicación.

AppStyledView no inserta inmediatamente sus vistas en la jerarquía de vistas como lo hace installBaseLayoutAround , sino que simplemente 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 al usar contextos, ya que existen contextos tanto de complemento como de "fuente". El contexto del complemento se proporciona como argumento para getPluginFactory y es el único contexto que contiene los recursos del complemento. Esto significa que es el único contexto que se puede utilizar para inflar diseños en el complemento.

Sin embargo, es posible que el contexto del complemento no tenga la configuración correcta. Para obtener la configuración correcta, proporcionamos contextos de origen en los métodos que crean componentes. El contexto de origen suele ser una actividad, pero en algunos casos también puede ser un servicio u otro componente de Android. Para usar la configuración del contexto de origen con los recursos del contexto del complemento, se debe crear un nuevo contexto usando createConfigurationContext . Si no se utiliza la configuración correcta, se producirá una infracción 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 múltiples modos para sus componentes, como un modo deportivo o un modo ecológico que se ven visualmente distintos. No hay soporte integrado para dicha funcionalidad en CarUi, pero nada impide que el complemento la implemente completamente internamente. El complemento puede monitorear 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 los modos, pero no se recomienda confiar en los cambios de configuración de todos modos, ya que actualizar manualmente la apariencia de cada componente es más sencillo para el usuario y también permite transiciones que no son posibles con los cambios de configuración.

Componer Jetpack

Los complementos se pueden implementar usando Jetpack Compose, pero esta es una característica de nivel alfa y no debe considerarse estable.

Los complementos pueden usar ComposeView para crear una superficie habilitada para Compose en la que renderizar. Este ComposeView sería lo que se devuelve a la aplicación desde el método getView en los componentes.

Un problema importante con el uso ComposeView es que establece etiquetas en la vista raíz del diseño para almacenar variables globales que se comparten entre diferentes ComposeViews en la jerarquía. Dado que los identificadores de recursos del complemento no tienen espacios de nombres separados de los de la aplicación, esto podría causar conflictos cuando tanto la aplicación como el complemento configuran etiquetas en la misma vista. A continuación se proporciona un ComposeViewWithLifecycle personalizado que mueve estas variables globales a ComposeView . Una vez más, esto no debería 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)
//  }
}