Plug-ins d'interface utilisateur pour voitures

Utilisez les plug-ins de la bibliothèque Car UI pour créer des implémentations complètes des 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 plug-in

Un plug-in de la bibliothèque Car UI est un APK qui contient des classes qui implémentent un ensemble d'API de plug-in. Les API de plug-in peuvent être compilées dans un plug-in en tant que bibliothèque statique.

Consultez des exemples dans Soong et Gradle :

Soong

Prenons cet exemple 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

Consultez 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 plug-in doit avoir un fournisseur de contenu déclaré dans son fichier manifeste avec les attributs suivants :

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

android:authorities="com.android.car.ui.plugin" permet à la bibliothèque Car UI de découvrir le plug-in. Le fournisseur doit être exporté afin de 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 est utilisée à la place de l'implémentation du plug-in. La classe de fournisseur de contenu n'a pas besoin d'exister. Dans ce cas, veillez à ajouter tools:ignore="MissingClass" à la définition du fournisseur. Consultez l'exemple d'entrée de fichier 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.

Plug-ins en tant que 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 plug-ins implémentés en tant que bibliothèque partagée Android voient leurs classes ajoutées automatiquement 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 plug-in, son chargeur de classes peut accéder aux classes de la bibliothèque partagée du plug-in. Les plug-ins 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 est très similaire à celui des applications Android normales, à quelques différences clés près.

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

Dépendances des bibliothèques partagées

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

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

Installer un plug-in

Les plug-ins 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 plug-in existant sur le système, toutes les applications qui l'utilisent se ferment automatiquement. Une fois rouvertes par l'utilisateur, elles bénéficient des modifications mises à jour. Si l'application n'était pas en cours d'exécution, elle bénéficiera du plug-in mis à jour au prochain démarrage.

Lors de l'installation d'un plug-in avec Android Studio, vous devez tenir compte de quelques éléments supplémentaires. Au moment de la rédaction de ce document, un bug dans le processus d'installation de l'application Android Studio empêche les mises à jour d'un plug-in de prendre effet. Pour résoudre ce problème, sélectionnez l'option Always install with package manager (disables deploy optimizations on Android 11 and later) (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 compilation du plug-in.

De plus, lors de l'installation du plug-in, Android Studio signale une erreur indiquant qu'il ne trouve pas d'activité principale à lancer. Ce comportement est normal, car le plug-in ne comporte aucune activité (à l'exception de l'intent vide utilisé pour résoudre un intent). Pour éliminer l'erreur, remplacez l'option Launch (Lancer) par Nothing (Rien) dans la configuration de compilation.

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

Plug-in 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 dans toutes les applications. Cela signifie qu'un RRO est requis par application. Découvrez les applications qui utilisent la bibliothèque Car UI.

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

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

Bien que le plug-in proxy fournisse un point unique de personnalisation RRO pour les applications, celles qui choisissent de ne pas l'utiliser nécessitent toujours un RRO qui cible directement l'application elle-même.

Implémenter les API de plug-in

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

Les plug-ins CarUi doivent fonctionner avec des applications plus anciennes ou plus récentes que le plug-in. Pour faciliter cela, toutes les API de plug-in 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, elles 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 champ d'application d'un ancien composant de plug-in. Par exemple, en convertissant un nouveau type de bouton dans la barre d'outils en MenuItems.

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

PluginVersionProviderOEMV1 comporte 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 plug-in, tout en étant inférieure ou égale à maxVersion. Si un plug-in ne dispose pas d'une implémentation d'un PluginFactory aussi ancien, il peut renvoyer null. Dans ce cas, l'implémentation statiquement liée des composants CarUi est utilisée.

Pour maintenir la rétrocompatibilité avec les applications compilées par rapport à des versions antérieures de la bibliothèque statique Car Ui, il est recommandé de prendre en charge les maxVersion de 2, 5 et versions ultérieures à partir de l'implémentation de la classe PluginVersionProvider de votre plug-in. Les versions 1, 3 et 4 ne sont pas prises en charge. Pour en savoir plus, consultez PluginVersionProviderImpl.

PluginFactory est l'interface qui crée tous les autres composants CarUi. Elle définit également la version de leurs interfaces à utiliser. Si le plug-in ne cherche pas à implémenter l'un de ces composants, il peut renvoyer null dans sa fonction de création (à l'exception de la barre d'outils, qui possède une fonction customizesBaseLayout() distincte).

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'un Toolbar et la version 1 d'un RecyclerView, car il n'y aurait que peu de garanties qu'une grande variété de versions de composants fonctionneraient ensemble. Pour utiliser la version 100 de la barre d'outils, 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 qui peuvent être créés. Les versions des autres composants peuvent ne pas être égales. Par exemple, un pluginFactoryOEMV100 peut créer un ToolbarControllerOEMV100 et un RecyclerViewOEMV70.

Barre d'outils

Mise en page 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 est appelée installBaseLayoutAround. La mise en page de base est un concept qui permet de positionner la barre d'outils n'importe où autour du contenu de l'application, afin de 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 encadrant l'ensemble de l'application. Pour ce faire, une vue est transmise à installBaseLayoutAround pour que la barre d'outils/mise en page de base s'enroule autour.

Le plug-in doit prendre la vue fournie, la dissocier de son parent, gonfler sa propre mise en page dans le même index du parent et avec les mêmes LayoutParams que la vue qui vient d'être dissociée, puis rattacher la vue quelque part à l'intérieur de la mise en page qui vient d'être gonflée. La mise en page gonflée contient la barre d'outils, si l'application le demande.

L'application peut demander une mise en page de base sans barre d'outils. Dans ce cas, installBaseLayoutAround doit renvoyer la valeur nulle. Pour la plupart des plug-ins, c'est tout ce qui doit se passer, mais si l'auteur du plug-in 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 propres dans l'espace non rectangulaire.

installBaseLayoutAround reçoit également un Consumer<InsetsOEMV1>. Ce consommateur peut être utilisé pour communiquer à l'application que le plug-in couvre partiellement le contenu de l'application (avec la barre d'outils ou autre). L'application saura alors qu'elle doit continuer à dessiner dans cet espace, mais qu'elle doit en exclure tous les composants critiques avec lesquels l'utilisateur peut interagir. 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 plug-in peut ignorer le consommateur.

Défilement du contenu sous la barre d&#39;outils Figure 2. Défilement du contenu sous la barre d'outils

Du point de vue de l'application, lorsque le plug-in envoie de nouveaux encarts, il les reçoit 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ère 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 plug-in reçoit un indice fullscreen, qui est utilisé pour indiquer si la vue à encapsuler occupe l'ensemble de l'application ou seulement une petite section. Cela permet d'éviter d'appliquer certaines décorations le long du bord qui n'ont de sens que si elles apparaissent le long du bord de l'écran entier. Un exemple d'application qui utilise des mises en page de base non plein écran est "Paramètres", dans laquelle chaque volet de la mise en page à deux volets possède sa propre barre d'outils.

Étant donné qu'il est prévu qu'installBaseLayoutAround renvoie la valeur nulle lorsque toolbarEnabled est false, pour que le plug-in indique qu'il ne souhaite pas personnaliser la mise en page de base, il doit renvoyer false à partir de customizesBaseLayout.

La mise en page 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 sont pas compatibles avec la rotation. Les FocusParkingView/FocusAreas sont implémentés dans la bibliothèque statique CarUi. Un setRotaryFactories est donc utilisé pour fournir des usines afin de créer les vues à partir de contextes.

Les contextes utilisés pour créer des vues de mise au point doivent être le contexte source, et non le contexte du plug-in. Le FocusParkingView doit être le plus proche possible de la première vue de l'arborescence, car c'est ce qui est mis au point lorsqu'aucune mise au point ne doit être visible pour l'utilisateur. Le FocusArea doit encapsuler la barre d'outils dans la mise en page de base pour indiquer qu'il s'agit d'une zone de rotation. Si le FocusArea n'est pas fourni, l'utilisateur ne peut pas accéder aux boutons de la barre d'outils avec le contrôleur rotatif.

Contrôleur de barre d'outils

Le ToolbarController renvoyé doit être beaucoup plus simple à implémenter que la mise en page de base. Son rôle consiste à prendre les informations transmises à ses setters et à les afficher dans la mise en page de base. Pour en savoir plus sur la plupart des méthodes, consultez la documentation Javadoc. Certaines des méthodes les plus complexes sont abordées ci-dessous.

getImeSearchInterface est utilisé pour afficher les résultats de 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 plug-in fournit simplement des méthodes permettant à la bibliothèque statique d'obtenir les rappels TextView et onPrivateIMECommand. Pour ce faire, le plug-in 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 les MenuItems à l'écran, mais il sera appelé étonnamment souvent. Étant donné que l'API de plug-in pour MenuItems est immuable, chaque fois qu'un MenuItem est modifié, un tout nouvel appel setMenuItems se produit. Cela peut se produire pour quelque chose d'aussi simple qu'un utilisateur a cliqué sur un MenuItem de commutateur, et ce clic a fait basculer le commutateur. Pour des raisons de performances et d'animation, il est donc recommandé de calculer la différence entre l'ancienne et la nouvelle liste MenuItems, et de ne mettre à jour que 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 dans différents appels à setMenuItems pour le même MenuItem.

Contextes

Le plug-in doit être prudent lors de l'utilisation des contextes, car il existe des contextes de plug-in et des contextes "source". Le contexte du plug-in est fourni en tant qu'argument à getPluginFactory, et il s'agit du seul contexte qui contient les ressources du plug-in. Cela signifie qu'il s'agit du seul contexte qui peut être utilisé pour gonfler les mises en page dans le plug-in.

Toutefois, il est possible que la configuration correcte ne soit pas définie sur le contexte du plug-in. Pour obtenir la configuration correcte, nous fournissons des contextes sources dans les 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 plug-in, vous devez créer un contexte à l'aide de createConfigurationContext. Si la configuration correcte n'est pas utilisée, une violation du mode strict Android se produit et les vues gonflées peuvent ne pas avoir les dimensions correctes.

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

Changements de mode

Certains plug-ins peuvent prendre en charge plusieurs modes pour leurs composants, tels qu'un mode sport ou un mode éco qui sont visuellement distincts. CarUi ne prend pas en charge cette fonctionnalité, mais rien n'empêche le plug-in de l'implémenter entièrement en interne. Le plug-in peut surveiller les conditions qu'il souhaite pour déterminer quand changer de mode, par exemple en écoutant les diffusions. Le plug-in ne peut pas déclencher de changement de configuration pour changer de mode, mais il n'est pas recommandé de s'appuyer sur les changements de configuration de toute façon, 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.

Jetpack Compose

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

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

L'un des principaux problèmes liés à l'utilisation de ComposeView est qu'il définit des tags sur la vue racine dans la mise en page afin de stocker des variables globales partagées entre différents ComposeViews dans la hiérarchie. Étant donné que les ID de ressources du plug-in ne sont pas séparés de ceux de l'application, cela peut entraîner des conflits lorsque l'application et le plug-in définissent des tags sur la même vue. Un personnalisé ComposeViewWithLifecycle qui déplace ces variables globales vers le ComposeView est fourni ci-dessous. Là encore, 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)
//  }
}