Complementos de interfaz de usuario de coche

Utilice complementos de car-ui-lib para crear implementaciones completas de personalizaciones de componentes en car-ui-lib en lugar de utilizar superposiciones de recursos de tiempo de ejecución (RRO). Las RRO le permiten cambiar solo los recursos XML de los componentes car-ui-lib , lo que limita el alcance de lo que puede personalizar.

Crear un complemento

Un complemento car-ui-lib es un APK que contiene clases que implementan un conjunto de API de complemento . Las API del complemento se encuentran en packages/apps/Car/libs/car-ui-lib/oem-apis y se pueden compilar en un complemento como una biblioteca estática.

Vea los ejemplos de Soong y en Gradle a continuación:

pronto

Considere este ejemplo de Soong:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    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",
    ],

    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 skip the 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 para car-ui-lib . El proveedor debe exportarse para que pueda consultarse en 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. La clase de proveedor de contenido no tiene que existir. En cuyo caso, asegúrese de agregar tools:ignore="MissingClass" a la definición del proveedor. Consulte 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 .

Instalar un complemento

Una vez que haya creado el complemento, puede instalarlo como cualquier otra aplicación, como agregarlo a PRODUCT_PACKAGES o usar adb install . Sin embargo, si se trata de una nueva instalación de un complemento, las aplicaciones deben reiniciarse para que los cambios surtan efecto. Esto se puede hacer realizando un adb reboot , o adb shell am force-stop package.name para una aplicación específica.

Si está actualizando un complemento car-ui-lib existente en el sistema, todas las aplicaciones que usan ese complemento se cierran automáticamente y, una vez que el usuario las vuelve a abrir, tienen los cambios actualizados. Esto parece un bloqueo si las aplicaciones están en primer plano en ese momento. 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. En el 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 de que no puede encontrar una actividad principal para iniciar. Esto es de esperar, 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.

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

Implementando las API del complemento

El principal punto de entrada al complemento es la clase com.android.car.ui.plugin.PluginVersionProviderImpl . Todos los complementos deben incluir una clase con este nombre exacto y el nombre del paquete. Esta clase debe tener un constructor predeterminado e implementar la interfaz PluginVersionProviderOEMV1 .

Los complementos de CarUi deben funcionar con aplicaciones más antiguas o más nuevas que el complemento. Para facilitar esto, todas las API de los complementos se versionan con un V# al final de su nombre de clase. Si se lanza una nueva versión de car-ui-lib con nuevas funciones, forman parte de la versión V2 del componente. car-ui-lib hace todo lo posible para que las nuevas funciones funcionen dentro del alcance de un componente de complemento anterior. Por ejemplo, al convertir un nuevo tipo de botón en la barra de herramientas en MenuItems .

Sin embargo, una aplicación antigua con una versión antigua de car-ui-lib no se puede adaptar a un nuevo complemento escrito con API más nuevas. Para resolver este problema, permitimos que los complementos devuelvan diferentes implementaciones de sí mismos en función de 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 un null , en cuyo caso se utiliza la implementación estáticamente enlazada de los componentes CarUi.

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 individualizada customizesBaseLayout() ).

La pluginFactory de complementos limita qué versiones de los componentes de CarUi se pueden usar juntas. Por ejemplo, nunca habrá una pluginFactory de complementos que pueda crear la versión 100 de una Toolbar de herramientas 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 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 crea una versión 100 de la barra de herramientas, que luego limita las opciones en 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, 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 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 encierra 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 principal, inflar el propio diseño del complemento en el mismo índice del principal 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 fue recién 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 una barra de herramientas. Si es así, installBaseLayoutAround debería devolver un valor nulo. Para la mayoría de los complementos, eso es todo lo que debe suceder, pero si el autor del complemento quisiera aplicar, por ejemplo, una decoración alrededor del borde de la aplicación, aún podría hacerse 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 recibe 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 entonces sabrá que debe seguir dibujando en este espacio, pero mantendrá fuera de él cualquier componente crítico con el que el usuario pueda interactuar. Este efecto se usa en nuestro diseño de referencia, para hacer que la barra de herramientas sea semitransparente y hacer que las listas se desplacen debajo de ella. Si no se implementara esta función, el primer elemento de una lista estarí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 al Consumidor.

Desplazamiento de contenido 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 nuevas inserciones, las recibirá a través de cualquier actividad o fragmento que implemente InsetsChangedListener . Aquí hay un fragmento de ejemplo de una implementación que aplica las inserciones como relleno en una vista de reciclador 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 de fullscreen , que se usa para indicar si la vista que debe ajustarse ocupa toda la aplicación 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 aplicación de muestra que usa diseños básicos que no son de pantalla completa es Configuración, en la que cada panel del diseño de panel doble tiene su propia barra de herramientas.

Dado que se espera que installBaseLayoutAround devuelva nulo cuando toolbarEnabled es 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 admitan la rotación. FocusParkingView/FocusAreas se implementan en la biblioteca estática CarUi, por lo que se usa 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 en el árbol, ya que es lo que se enfoca cuando el usuario no debe ver ningún foco. FocusArea debe envolver la barra de herramientas en el diseño base para indicar que es una zona de empuje 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 que se pasa 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 discuten a continuación.

getImeSearchInterface se usa para mostrar resultados de búsqueda en la ventana IME (teclado). Esto puede ser útil para mostrar/animar los resultados de 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 CarUi, la interfaz de búsqueda en el complemento solo proporciona métodos para que la biblioteca estática obtenga las devoluciones de llamada de TextView y onPrivateIMECommand . Para respaldar esto, el complemento debe usar una subclase TextView que anula onPrivateIMECommand y pasa 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 nueva llamada setMenuItems . Esto podría suceder por algo tan trivial como que un usuario hizo clic en un elemento de menú del interruptor, y ese clic hizo que el interruptor cambiara. 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 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 hace destacar del resto de la aplicación e indicarle al usuario que se trata de un tipo diferente de interfaz. La vista que está envuelta por AppStyledView se proporciona en setContent . AppStyledView también puede tener un botón Atrás o Cerrar 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 hay contextos tanto de complemento como de "fuente". El contexto del complemento se proporciona como un argumento para getPluginFactory y es el único contexto que se garantiza 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 la configuración correcta. 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 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 usa la configuración correcta, habrá una violació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 incorporado para dicha funcionalidad en CarUi, pero no hay nada que impida que el complemento lo 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 desencadenar un cambio de configuración para cambiar de modo, pero de todos modos no se recomienda confiar en los cambios de configuración, 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.

Jetpack componer

Los complementos se pueden implementar con 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 para renderizar. Este ComposeView sería lo que se devuelve a la aplicación desde el método getView en 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 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 establecen 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 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)
//  }
}