Plugins d'interface utilisateur de voiture

Utilisez les plugins de la bibliothèque Car UI pour créer des implémentations complètes de personnalisations de composants dans la bibliothèque Car UI au lieu d'utiliser des superpositions de ressources d'exécution (RRO). Les RRO vous permettent de modifier uniquement les ressources XML des composants de la bibliothèque Car UI, ce qui limite l'étendue de ce que vous pouvez personnaliser.

Créer un plugin

Un plugin de bibliothèque Car UI est un APK qui contient des classes qui implémentent un ensemble d’ API de plugin . Les API du plugin peuvent être compilées dans un plugin en tant que bibliothèque statique.

Voir les exemples dans Soong et Gradle :

Bientôt

Considérez cet exemple 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",
}

Graduation

Voir ce fichier 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')

Le plugin doit avoir un fournisseur de contenu déclaré dans son manifeste qui possède les attributs suivants :

  android:authorities="com.android.car.ui.plugin"
  android:enabled="true"
  android:exported="true"

android:authorities="com.android.car.ui.plugin" rend le plugin détectable dans la bibliothèque Car UI. Le fournisseur doit être exporté pour pouvoir être interrogé au moment de l'exécution. De plus, si l'attribut enabled est défini sur false , l'implémentation par défaut sera utilisée à la place de l'implémentation du plugin. La classe du fournisseur de contenu ne doit pas nécessairement exister. Dans ce cas, veillez à ajouter tools:ignore="MissingClass" à la définition du fournisseur. Consultez l’exemple d’entrée de manifeste ci-dessous :

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

Enfin, par mesure de sécurité, signez votre application .

Plugins comme bibliothèque partagée

Contrairement aux bibliothèques statiques Android qui sont compilées directement dans les applications, les bibliothèques partagées Android sont compilées dans un APK autonome référencé par d'autres applications au moment de l'exécution.

Les plugins implémentés en tant que bibliothèque partagée Android voient leurs classes automatiquement ajoutées au chargeur de classes partagé entre les applications. Lorsqu'une application qui utilise la bibliothèque Car UI spécifie une dépendance d'exécution sur la bibliothèque partagée du plugin, son chargeur de classe peut accéder aux classes de la bibliothèque partagée du plugin. Les plugins implémentés en tant qu'applications Android normales (et non en tant que bibliothèque partagée) peuvent avoir un impact négatif sur les temps de démarrage à froid des applications.

Implémenter et créer des bibliothèques partagées

Le développement avec des bibliothèques partagées Android ressemble beaucoup à celui des applications Android normales, avec quelques différences clés.

  • Utilisez la balise library sous la balise application avec le nom du package du plugin dans le manifeste de l'application de votre plugin :
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configurez votre règle de construction Soong android_app ( Android.bp ) avec l'indicateur AAPT shared-lib , qui est utilisé pour créer une bibliothèque partagée :
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dépendances sur les bibliothèques partagées

Pour chaque application du système qui utilise la bibliothèque Car UI, incluez la uses-library dans le manifeste de l'application sous la balise d' application avec le nom du package du plugin :

<manifest>
  <application
      android:name=".MyApp"
      ...>
    <uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
    ...
  </application>
</manifest>

Installer un plugin

Les plugins DOIVENT être préinstallés sur la partition système en incluant le module dans PRODUCT_PACKAGES . Le package préinstallé peut être mis à jour de la même manière que n’importe quelle autre application installée.

Si vous mettez à jour un plugin existant sur le système, toutes les applications utilisant ce plugin se ferment automatiquement. Une fois rouverts par l'utilisateur, ils disposent des modifications mises à jour. Si l'application n'est pas en cours d'exécution, au prochain démarrage, le plugin sera mis à jour.

Lors de l'installation d'un plugin avec Android Studio, vous devez prendre en compte quelques considérations supplémentaires. Au moment de la rédaction, il existe un bug dans le processus d'installation de l'application Android Studio qui empêche les mises à jour d'un plugin de prendre effet. Cela peut être résolu en sélectionnant l'option Toujours installer avec le gestionnaire de packages (désactive les optimisations de déploiement sur Android 11 et versions ultérieures) dans la configuration de build du plugin.

De plus, lors de l'installation du plugin, Android Studio signale une erreur indiquant qu'il ne trouve pas d'activité principale à lancer. Ceci est normal, car le plugin n'a aucune activité (à l'exception de l'intention vide utilisée pour résoudre une intention). Pour éliminer l'erreur, modifiez l'option de lancement sur Rien dans la configuration de build.

Configuration du plugin Android Studio Figure 1. Configuration du plug-in Android Studio

Plugin proxy

La personnalisation des applications à l'aide de la bibliothèque Car UI nécessite un RRO qui cible chaque application spécifique à modifier, y compris lorsque les personnalisations sont identiques entre les applications. Cela signifie qu'un RRO par application est requis. Découvrez quelles applications utilisent la bibliothèque Car UI.

Le plugin proxy de la bibliothèque Car UI est un exemple de bibliothèque partagée de plugin qui délègue ses implémentations de composants à la version statique de la bibliothèque Car UI. Ce plugin peut être ciblé avec un RRO, qui peut être utilisé comme point unique de personnalisation pour les applications qui utilisent la bibliothèque Car UI sans avoir besoin d'implémenter un plugin fonctionnel. Pour plus d'informations sur les RRO, consultez Modifier la valeur des ressources d'une application au moment de l'exécution .

Le plugin proxy n'est qu'un exemple et un point de départ pour effectuer une personnalisation à l'aide d'un plugin. Pour une personnalisation au-delà des RRO, on peut implémenter un sous-ensemble de composants de plugin et utiliser le plugin proxy pour le reste, ou implémenter entièrement tous les composants de plugin à partir de zéro.

Bien que le plugin proxy fournisse un point unique de personnalisation du RRO pour les applications, les applications qui refusent d'utiliser le plugin nécessiteront toujours un RRO qui cible directement l'application elle-même.

Implémenter les API du plugin

Le point d’entrée principal du plugin est la classe com.android.car.ui.plugin.PluginVersionProviderImpl . Tous les plugins doivent inclure une classe avec ce nom exact et ce nom de package. Cette classe doit avoir un constructeur par défaut et implémenter l'interface PluginVersionProviderOEMV1 .

Les plugins CarUi doivent fonctionner avec des applications plus anciennes ou plus récentes que le plugin. Pour faciliter cela, toutes les API de plugin sont versionnées avec un V# à la fin de leur nom de classe. Si une nouvelle version de la bibliothèque Car UI est publiée avec de nouvelles fonctionnalités, celles-ci font partie de la version V2 du composant. La bibliothèque Car UI fait de son mieux pour que les nouvelles fonctionnalités fonctionnent dans le cadre d'un ancien composant de plugin. Par exemple, en convertissant un nouveau type de bouton dans la barre d'outils en MenuItems .

Cependant, une application avec une ancienne version de la bibliothèque Car UI ne peut pas s'adapter à un nouveau plugin écrit avec des API plus récentes. Pour résoudre ce problème, nous autorisons les plugins à renvoyer différentes implémentations d'eux-mêmes en fonction de la version de l'API OEM prise en charge par les applications.

PluginVersionProviderOEMV1 contient une méthode :

Object getPluginFactory(int maxVersion, Context context, String packageName);

Cette méthode renvoie un objet qui implémente la version la plus élevée de PluginFactoryOEMV# prise en charge par le plugin, tout en étant inférieure ou égale à maxVersion . Si un plugin n'a pas d'implémentation d'un PluginFactory aussi ancienne, il peut renvoyer null , auquel cas l'implémentation liée statiquement des composants CarUi est utilisée.

Pour maintenir la compatibilité ascendante avec les applications compilées avec des versions plus anciennes de la bibliothèque statique Car Ui, il est recommandé de prendre en charge les maxVersion s de 2, 5 et supérieures à partir de l'implémentation de votre plugin de la classe PluginVersionProvider . Les versions 1, 3 et 4 ne sont pas prises en charge. Pour plus d’informations, consultez PluginVersionProviderImpl .

PluginFactory est l'interface qui crée tous les autres composants CarUi. Il définit également quelle version de leurs interfaces doit être utilisée. Si le plugin ne cherche à implémenter aucun de ces composants, il peut renvoyer null dans sa fonction de création (à l'exception de la barre d'outils, qui a une fonction customizesBaseLayout() distincte).

Le pluginFactory limite les versions des composants CarUi qui peuvent être utilisées ensemble. Par exemple, il n'y aura jamais de pluginFactory capable de créer la version 100 d'une Toolbar ainsi que la version 1 d'une RecyclerView , car il y aurait peu de garantie qu'une grande variété de versions de composants fonctionneraient ensemble. Pour utiliser la barre d'outils version 100, les développeurs doivent fournir une implémentation d'une version de pluginFactory qui crée une barre d'outils version 100, ce qui limite ensuite les options sur les versions des autres composants pouvant être créées. Les versions des autres composants peuvent ne pas être égales, par exemple un pluginFactoryOEMV100 pourrait créer un ToolbarControllerOEMV100 et un RecyclerViewOEMV70 .

Barre d'outils

Disposition de base

La barre d'outils et la "mise en page de base" sont très étroitement liées, c'est pourquoi la fonction qui crée la barre d'outils s'appelle installBaseLayoutAround . La disposition de base est un concept qui permet à la barre d'outils d'être positionnée n'importe où autour du contenu de l'application, pour permettre une barre d'outils en haut/en bas de l'application, verticalement sur les côtés, ou même une barre d'outils circulaire entourant l'ensemble de l'application. Ceci est accompli en transmettant une vue à installBaseLayoutAround pour que la barre d'outils/disposition de base soit enroulée.

Le plugin doit prendre la vue fournie, la détacher de son parent, gonfler la propre mise en page du plugin dans le même index du parent et avec les mêmes LayoutParams que la vue qui vient d'être détachée, puis rattacher la vue quelque part à l'intérieur de la mise en page qui a été juste gonflé. La mise en page gonflée contiendra la barre d'outils, si l'application le demande.

L'application peut demander une mise en page de base sans barre d'outils. Si tel est le cas, installBaseLayoutAround devrait renvoyer null. Pour la plupart des plugins, c'est tout ce qui doit être fait, mais si l'auteur du plugin souhaite appliquer par exemple une décoration autour du bord de l'application, cela peut toujours être fait avec une mise en page de base. Ces décorations sont particulièrement utiles pour les appareils dotés d'écrans non rectangulaires, car elles peuvent pousser l'application dans un espace rectangulaire et ajouter des transitions nettes dans l'espace non rectangulaire.

installBaseLayoutAround reçoit également un Consumer<InsetsOEMV1> . Ce consommateur peut être utilisé pour communiquer à l'application que le plugin couvre partiellement le contenu de l'application (avec la barre d'outils ou autrement). L’application saura alors continuer à dessiner dans cet espace, mais en exclura tous les composants critiques pouvant interagir avec l’utilisateur. Cet effet est utilisé dans notre conception de référence, pour rendre la barre d'outils semi-transparente et faire défiler les listes en dessous. Si cette fonctionnalité n'était pas implémentée, le premier élément d'une liste serait bloqué sous la barre d'outils et ne serait pas cliquable. Si cet effet n'est pas nécessaire, le plugin peut ignorer le consommateur.

Contenu défilant sous la barre d'outils Figure 2. Contenu défilant sous la barre d'outils

Du point de vue de l'application, lorsque le plugin envoie de nouveaux encarts, il les recevra de toutes les activités ou fragments qui implémentent InsetsChangedListener . Si une activité ou un fragment n'implémente pas InsetsChangedListener , la bibliothèque Car Ui gérera les encarts par défaut en appliquant les encarts comme remplissage à l' Activity ou FragmentActivity contenant le fragment. La bibliothèque n'applique pas les encarts par défaut aux fragments. Voici un exemple d'extrait d'une implémentation qui applique les encarts comme remplissage sur un RecyclerView dans l'application :

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

Enfin, le plugin reçoit un indice fullscreen , qui est utilisé pour indiquer si la vue qui doit être encapsulée occupe l'intégralité de l'application ou seulement une petite section. Cela peut être utilisé pour éviter d'appliquer certaines décorations le long du bord qui n'ont de sens que si elles apparaissent sur le bord de tout l'écran. Un exemple d'application qui utilise des présentations de base non plein écran est Paramètres, dans lequel chaque volet de la présentation à double volet possède sa propre barre d'outils.

Puisqu'il est prévu que installBaseLayoutAround renvoie null lorsque toolbarEnabled est false , pour que le plugin indique qu'il ne souhaite pas personnaliser la mise en page de base, il doit renvoyer false à partir de customizesBaseLayout .

La disposition de base doit contenir un FocusParkingView et un FocusArea pour prendre entièrement en charge les commandes rotatives. Ces vues peuvent être omises sur les appareils qui ne prennent pas en charge la rotation. Les FocusParkingView/FocusAreas sont implémentés dans la bibliothèque statique CarUi, donc un setRotaryFactories est utilisé pour fournir des usines permettant de créer les vues à partir de contextes.

Les contextes utilisés pour créer des vues Focus doivent être le contexte source et non le contexte du plugin. Le FocusParkingView doit être le plus proche de la première vue de l'arborescence autant que possible, car c'est ce qui est mis au point alors qu'aucun focus ne devrait être visible pour l'utilisateur. La FocusArea doit envelopper la barre d'outils dans la disposition de base pour indiquer qu'il s'agit d'une zone de déplacement rotatif. Si la FocusArea n’est pas fournie, l’utilisateur ne peut accéder aux boutons de la barre d’outils avec le contrôleur rotatif.

Contrôleur de barre d'outils

Le ToolbarController réel renvoyé devrait être beaucoup plus simple à implémenter que la présentation de base. Son travail consiste à prendre les informations transmises à ses setters et à les afficher dans la mise en page de base. Consultez la Javadoc pour plus d'informations sur la plupart des méthodes. Certaines des méthodes les plus complexes sont décrites ci-dessous.

getImeSearchInterface est utilisé pour afficher les résultats de la recherche dans la fenêtre IME (clavier). Cela peut être utile pour afficher/animer les résultats de recherche à côté du clavier, par exemple si le clavier n'occupe que la moitié de l'écran. La plupart des fonctionnalités sont implémentées dans la bibliothèque statique CarUi, l'interface de recherche du plugin fournit simplement des méthodes permettant à la bibliothèque statique d'obtenir les rappels TextView et onPrivateIMECommand . Pour prendre en charge cela, le plugin doit utiliser une sous-classe TextView qui remplace onPrivateIMECommand et transmet l'appel à l'écouteur fourni en tant que TextView de sa barre de recherche.

setMenuItems affiche simplement MenuItems à l'écran, mais il sera appelé étonnamment souvent. Étant donné que l'API du plugin pour MenuItems est immuable, chaque fois qu'un MenuItem est modifié, un tout nouvel appel setMenuItems se produira. Cela pourrait se produire pour quelque chose d'aussi trivial qu'un utilisateur cliquait sur un commutateur MenuItem, et ce clic provoquait le basculement du commutateur. Pour des raisons de performances et d'animation, il est donc encouragé à calculer la différence entre l'ancienne et la nouvelle liste MenuItems, et à mettre à jour uniquement les vues qui ont réellement changé. Les MenuItems fournissent un champ key qui peut vous aider, car la clé doit être la même pour différents appels à setMenuItems pour le même MenuItem.

AppStyledView

AppStyledView est un conteneur pour une vue qui n'est pas du tout personnalisée. Il peut être utilisé pour fournir une bordure autour de cette vue qui la distingue du reste de l'application et indiquer à l'utilisateur qu'il s'agit d'un type d'interface différent. La vue encapsulée par AppStyledView est donnée dans setContent . L' AppStyledView peut également avoir un bouton de retour ou de fermeture comme demandé par l'application.

AppStyledView n'insère pas immédiatement ses vues dans la hiérarchie des vues comme le fait installBaseLayoutAround , il renvoie simplement sa vue à la bibliothèque statique via getView , qui effectue ensuite l'insertion. La position et la taille de AppStyledView peuvent également être contrôlées en implémentant getDialogWindowLayoutParam .

Contextes

Le plugin doit être prudent lors de l'utilisation des contextes, car il existe à la fois des contextes de plugin et des contextes "source". Le contexte du plugin est donné comme argument à getPluginFactory et est le seul contexte qui contient les ressources du plugin. Cela signifie que c'est le seul contexte qui peut être utilisé pour gonfler les mises en page dans le plugin.

Cependant, le contexte du plugin peut ne pas avoir la configuration correcte définie. Pour obtenir la configuration correcte, nous fournissons des contextes sources dans des méthodes qui créent des composants. Le contexte source est généralement une activité, mais dans certains cas, il peut également s'agir d'un service ou d'un autre composant Android. Pour utiliser la configuration du contexte source avec les ressources du contexte du plugin, un nouveau contexte doit être créé à l'aide de createConfigurationContext . Si la configuration correcte n’est pas utilisée, il y aura une violation du mode strict Android et les vues gonflées risquent de ne pas avoir les dimensions correctes.

Context layoutInflationContext = pluginContext.createConfigurationContext(
        sourceContext.getResources().getConfiguration());

Changements de mode

Certains plugins peuvent prendre en charge plusieurs modes pour leurs composants, comme un mode sport ou un mode éco qui semblent visuellement distincts. Il n’existe pas de prise en charge intégrée pour une telle fonctionnalité dans CarUi, mais rien n’empêche le plugin de l’implémenter entièrement en interne. Le plugin peut surveiller toutes les conditions qu'il souhaite pour déterminer quand changer de mode, comme l'écoute des émissions. Le plugin ne peut pas déclencher de changement de configuration pour changer de mode, mais il n'est de toute façon pas recommandé de s'appuyer sur des changements de configuration, car la mise à jour manuelle de l'apparence de chaque composant est plus fluide pour l'utilisateur et permet également des transitions qui ne sont pas possibles avec les changements de configuration.

Composer Jetpack

Les plugins peuvent être implémentés à l'aide de Jetpack Compose, mais il s'agit d'une fonctionnalité de niveau alpha et ne doit pas être considérée comme stable.

Les plugins peuvent utiliser ComposeView pour créer une surface compatible Compose dans laquelle effectuer le rendu. Ce ComposeView serait ce qui est renvoyé à l'application par la méthode getView dans les composants.

L'un des problèmes majeurs liés à l'utilisation ComposeView est qu'elle définit des balises sur la vue racine de la mise en page afin de stocker les variables globales partagées entre différents ComposeViews dans la hiérarchie. Étant donné que les identifiants de ressources du plugin ne sont pas séparés par un espace de noms distinct de celui de l'application, cela pourrait provoquer des conflits lorsque l'application et le plugin définissent des balises sur la même vue. Un ComposeViewWithLifecycle personnalisé qui déplace ces variables globales vers ComposeView est fourni ci-dessous. Encore une fois, cela ne doit pas être considéré comme stable.

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)
//  }
}