Auto-UI-Plugins

Verwenden Sie car-ui-lib Plugins , um vollständige Implementierungen von Komponentenanpassungen in car-ui-lib zu erstellen, anstatt Runtime Resource Overlays (RROs) zu verwenden. Mit RROs können Sie nur die XML-Ressourcen von car-ui-lib Komponenten ändern, was den Umfang Ihrer Anpassungsmöglichkeiten einschränkt.

Ein Plugin erstellen

Ein car-ui-lib Plugin ist ein APK, das Klassen enthält, die eine Reihe von Plugin-APIs implementieren. Die Plugin-APIs befinden sich in packages/apps/Car/libs/car-ui-lib/oem-apis und können als statische Bibliothek in ein Plugin kompiliert werden.

Sehen Sie sich die Soong- und Gradle-Beispiele unten an:

Bald

Betrachten Sie dieses Soong-Beispiel:

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

Sehen Sie sich diese build.gradle -Datei an:

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

Das Plugin muss in seinem Manifest einen Inhaltsanbieter deklariert haben, der die folgenden Attribute aufweist:

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

android:authorities="com.android.car.ui.plugin" macht das Plugin für car-ui-lib erkennbar. Der Anbieter muss exportiert werden, damit er zur Laufzeit abgefragt werden kann. Wenn das Attribut enabled auf false gesetzt ist, wird außerdem die Standardimplementierung anstelle der Plugin-Implementierung verwendet. Die Inhaltsanbieterklasse muss nicht vorhanden sein. Fügen Sie in diesem Fall unbedingt tools:ignore="MissingClass" zur Anbieterdefinition hinzu. Sehen Sie sich den Beispielmanifesteintrag unten an:

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

Als Sicherheitsmaßnahme signieren Sie abschließend Ihre App .

Installieren eines Plugins

Sobald Sie das Plugin erstellt haben, kann es wie jede andere App installiert werden, indem Sie es beispielsweise zu PRODUCT_PACKAGES hinzufügen oder adb install verwenden. Handelt es sich jedoch um eine Neuinstallation eines Plugins, müssen die Apps neu gestartet werden, damit die Änderungen wirksam werden. Dies kann durch einen vollständigen adb reboot oder adb shell am force-stop package.name für eine bestimmte App erfolgen.

Wenn Sie ein vorhandenes car-ui-lib Plugin auf dem System aktualisieren, werden alle Apps, die dieses Plugin verwenden, automatisch geschlossen und verfügen nach dem erneuten Öffnen durch den Benutzer über die aktualisierten Änderungen. Dies sieht nach einem Absturz aus, wenn die Apps gerade im Vordergrund sind. Wenn die App nicht ausgeführt wurde, verfügt sie beim nächsten Start über das aktualisierte Plugin.

Bei der Installation eines Plugins mit Android Studio müssen einige zusätzliche Überlegungen berücksichtigt werden. Zum Zeitpunkt des Schreibens gibt es einen Fehler im Installationsprozess der Android Studio-App, der dazu führt, dass Aktualisierungen eines Plugins nicht wirksam werden. Dies kann durch Auswahl der Option „Immer mit Paketmanager installieren“ (deaktiviert Bereitstellungsoptimierungen auf Android 11 und höher) in der Build-Konfiguration des Plugins behoben werden.

Darüber hinaus meldet Android Studio bei der Installation des Plugins die Fehlermeldung, dass keine Hauptaktivität zum Starten gefunden werden kann. Dies ist zu erwarten, da das Plugin keine Aktivitäten hat (außer dem leeren Intent, der zum Auflösen eines Intents verwendet wird). Um den Fehler zu beheben, ändern Sie in der Build-Konfiguration die Option „Starten“ auf „ Nichts“ .

Plugin-Android-Studio-Konfiguration Abbildung 1. Konfiguration des Plugins Android Studio

Implementierung der Plugin-APIs

Der Haupteinstiegspunkt zum Plugin ist die Klasse com.android.car.ui.plugin.PluginVersionProviderImpl . Alle Plugins müssen eine Klasse mit genau diesem Namen und Paketnamen enthalten. Diese Klasse muss über einen Standardkonstruktor verfügen und die PluginVersionProviderOEMV1 Schnittstelle implementieren.

CarUi-Plugins müssen mit Apps funktionieren, die älter oder neuer als das Plugin sind. Um dies zu erleichtern, werden alle Plugin-APIs mit einem V# am Ende ihres Klassennamens versioniert. Wenn eine neue Version von car-ui-lib mit neuen Funktionen veröffentlicht wird, sind diese Teil der V2 Version der Komponente. car-ui-lib tut sein Bestes, damit neue Funktionen im Rahmen einer älteren Plugin-Komponente funktionieren. Beispielsweise durch Konvertieren eines neuen Schaltflächentyps in der Symbolleiste in MenuItems .

Eine alte App mit einer alten Version von car-ui-lib kann sich jedoch nicht an ein neues Plugin anpassen, das für neuere APIs geschrieben wurde. Um dieses Problem zu lösen, ermöglichen wir Plugins, je nach der von den Apps unterstützten Version der OEM-API unterschiedliche Implementierungen ihrer selbst zurückzugeben.

PluginVersionProviderOEMV1 enthält eine Methode:

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

Diese Methode gibt ein Objekt zurück, das die höchste vom Plugin unterstützte Version von PluginFactoryOEMV# implementiert, aber immer noch kleiner oder gleich maxVersion ist. Wenn ein Plugin keine so alte Implementierung einer PluginFactory hat, kann es null zurückgeben. In diesem Fall wird die statisch gebundene Implementierung von CarUi-Komponenten verwendet.

Die PluginFactory ist die Schnittstelle, die alle anderen CarUi-Komponenten erstellt. Außerdem wird definiert, welche Version ihrer Schnittstellen verwendet werden soll. Wenn das Plugin keine dieser Komponenten implementieren möchte, gibt es in seiner Erstellungsfunktion möglicherweise null zurück (mit Ausnahme der Symbolleiste, die über eine separate Funktion customizesBaseLayout() verfügt).

Die pluginFactory begrenzt, welche Versionen von CarUi-Komponenten zusammen verwendet werden können. Beispielsweise wird es niemals eine pluginFactory geben, die Version 100 einer Toolbar und auch Version 1 einer RecyclerView erstellen kann, da es kaum eine Garantie dafür gäbe, dass eine Vielzahl von Versionen von Komponenten zusammenarbeiten würden. Um die Symbolleistenversion 100 verwenden zu können, wird von den Entwicklern erwartet, dass sie eine Implementierung einer pluginFactory Version bereitstellen, die eine Symbolleistenversion 100 erstellt, die dann die Optionen auf die Versionen anderer Komponenten einschränkt, die erstellt werden können. Die Versionen anderer Komponenten sind möglicherweise nicht gleich. Beispielsweise könnte ein pluginFactoryOEMV100 einen ToolbarControllerOEMV100 und einen RecyclerViewOEMV70 erstellen.

Symbolleiste

Grundlayout

Die Symbolleiste und das „Basislayout“ sind sehr eng miteinander verbunden, daher heißt die Funktion, die die Symbolleiste erstellt, installBaseLayoutAround . Das Grundlayout ist ein Konzept, das es ermöglicht, die Symbolleiste an einer beliebigen Stelle rund um den Inhalt der App zu positionieren, um eine Symbolleiste oben/unten in der App, vertikal entlang der Seiten oder sogar eine kreisförmige Symbolleiste, die die gesamte App umschließt, zu ermöglichen. Dies wird erreicht, indem eine Ansicht an installBaseLayoutAround übergeben wird, damit das Symbolleisten-/Basislayout umbrochen wird.

Das Plugin sollte die bereitgestellte Ansicht übernehmen, sie von der übergeordneten Ansicht trennen, das eigene Layout des Plugins im gleichen Index der übergeordneten Ansicht und mit denselben LayoutParams wie die gerade getrennte Ansicht aufblasen und die Ansicht dann irgendwo innerhalb des ursprünglichen Layouts wieder anhängen einfach aufgeblasen. Das vergrößerte Layout enthält die Symbolleiste, sofern dies von der App angefordert wird.

Die App kann ein Basislayout ohne Symbolleiste anfordern. Wenn dies der Fall ist, sollte installBaseLayoutAround null zurückgeben. Bei den meisten Plugins ist das alles, was passieren muss, aber wenn der Plugin-Autor beispielsweise eine Dekoration am Rand der App anbringen möchte, könnte dies immer noch mit einem Basislayout erfolgen. Diese Dekorationen sind besonders nützlich für Geräte mit nicht rechteckigen Bildschirmen, da sie die App in einen rechteckigen Raum verschieben und saubere Übergänge in den nicht rechteckigen Raum hinzufügen können.

installBaseLayoutAround wird außerdem ein Consumer<InsetsOEMV1> übergeben. Dieser Verbraucher kann verwendet werden, um der App mitzuteilen, dass das Plugin den Inhalt der App teilweise verdeckt (über die Symbolleiste oder auf andere Weise). Die App weiß dann, dass sie weiterhin in diesem Bereich zeichnen muss, aber alle wichtigen, vom Benutzer interagierbaren Komponenten davon fernhalten muss. Dieser Effekt wird in unserem Referenzdesign verwendet, um die Symbolleiste halbtransparent zu machen und Listen darunter scrollen zu lassen. Wenn diese Funktion nicht implementiert wäre, bliebe das erste Element in einer Liste unter der Symbolleiste hängen und wäre nicht anklickbar. Wenn dieser Effekt nicht benötigt wird, kann das Plugin den Consumer ignorieren.

Scrollen des Inhalts unterhalb der Symbolleiste Abbildung 2. Scrollen des Inhalts unter der Symbolleiste

Aus Sicht der App empfängt das Plugin beim Senden neuer Insets diese über alle Aktivitäten/Fragmente, die InsetsChangedListener implementieren. Hier ist ein Beispielausschnitt einer Implementierung, die die Einfügungen als Auffüllung auf eine Recycleransicht in der App anwendet:

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

Abschließend erhält das Plugin einen fullscreen , der angibt, ob die Ansicht, die umschlossen werden soll, die gesamte App oder nur einen kleinen Abschnitt einnimmt. Dies kann verwendet werden, um das Anbringen einiger Dekorationen am Rand zu vermeiden, die nur dann Sinn machen, wenn sie am Rand des gesamten Bildschirms erscheinen. Eine Beispiel-App, die Nicht-Vollbild-Basislayouts verwendet, ist „Einstellungen“, in der jeder Bereich des Dual-Panee-Layouts über eine eigene Symbolleiste verfügt.

Da erwartet wird, dass installBaseLayoutAround null zurückgibt, wenn toolbarEnabled false ist, muss das Plug-in von customizesBaseLayout false zurückgeben, damit es anzeigt, dass es das Basislayout nicht anpassen möchte.

Das Basislayout muss eine FocusParkingView und eine FocusArea enthalten, um Drehsteuerungen vollständig zu unterstützen. Diese Ansichten können auf Geräten weggelassen werden, die keine Rotation unterstützen. Die FocusParkingView/FocusAreas werden in der statischen CarUi-Bibliothek implementiert, daher wird ein setRotaryFactories verwendet, um Fabriken bereitzustellen, um die Ansichten aus Kontexten zu erstellen.

Die zum Erstellen von Focus-Ansichten verwendeten Kontexte müssen der Quellkontext und nicht der Kontext des Plugins sein. Die FocusParkingView sollte möglichst nahe an der ersten Ansicht in der Baumstruktur liegen, da sie fokussiert ist, wenn für den Benutzer kein Fokus sichtbar sein sollte. Der FocusArea muss die Symbolleiste in das Basislayout einschließen, um anzuzeigen, dass es sich um eine rotierende Nudge-Zone handelt. Wenn die FocusArea nicht bereitgestellt wird, kann der Benutzer mit dem Drehregler nicht zu den Schaltflächen in der Symbolleiste navigieren.

Symbolleisten-Controller

Der tatsächlich zurückgegebene ToolbarController sollte viel einfacher zu implementieren sein als das Basislayout. Seine Aufgabe besteht darin, die an seine Setter übergebenen Informationen zu übernehmen und im Basislayout anzuzeigen. Informationen zu den meisten Methoden finden Sie im Javadoc. Einige der komplexeren Methoden werden im Folgenden erläutert.

getImeSearchInterface wird zum Anzeigen von Suchergebnissen im IME-Fenster (Tastatur) verwendet. Dies kann nützlich sein, um Suchergebnisse neben der Tastatur anzuzeigen/animieren, beispielsweise wenn die Tastatur nur die Hälfte des Bildschirms einnimmt. Der größte Teil der Funktionalität ist in der statischen CarUi-Bibliothek implementiert. Die Suchschnittstelle im Plugin stellt lediglich Methoden für die statische Bibliothek bereit, um die Rückrufe TextView und onPrivateIMECommand abzurufen. Um dies zu unterstützen, sollte das Plugin eine TextView Unterklasse verwenden, die onPrivateIMECommand überschreibt und den Aufruf an den bereitgestellten Listener als TextView seiner Suchleiste übergibt.

setMenuItems zeigt einfach MenuItems auf dem Bildschirm an, wird aber überraschend oft aufgerufen. Da die Plugin-API für MenuItems unveränderlich ist, erfolgt bei jeder Änderung eines MenuItems ein völlig neuer setMenuItems Aufruf. Dies kann bei etwas so Trivialem passieren, dass ein Benutzer auf einen Schalter „MenuItem“ klickt und dieser Klick dazu führt, dass der Schalter umgeschaltet wird. Aus Leistungs- und Animationsgründen wird daher empfohlen, die Differenz zwischen der alten und der neuen MenuItems-Liste zu berechnen und nur die Ansichten zu aktualisieren, die sich tatsächlich geändert haben. Die MenuItems stellen ein key bereit, das dabei helfen kann, da der Schlüssel bei verschiedenen Aufrufen von setMenuItems für dasselbe MenuItem gleich sein sollte.

AppStyledView

Die AppStyledView ist ein Container für eine Ansicht, die überhaupt nicht angepasst ist. Es kann verwendet werden, um einen Rahmen um diese Ansicht herum bereitzustellen, der sie vom Rest der App abhebt und dem Benutzer anzeigt, dass es sich um eine andere Art von Schnittstelle handelt. Die von AppStyledView umschlossene Ansicht ist in setContent angegeben. Die AppStyledView kann je nach Anforderung der App auch über eine Zurück- oder Schließen-Schaltfläche verfügen.

Die AppStyledView fügt ihre Ansichten nicht sofort in die Ansichtshierarchie ein, wie dies bei installBaseLayoutAround der Fall ist, sondern gibt ihre Ansicht einfach über getView an die statische Bibliothek zurück, die dann die Einfügung vornimmt. Die Position und Größe von AppStyledView kann auch durch die Implementierung getDialogWindowLayoutParam gesteuert werden.

Kontexte

Das Plugin muss bei der Verwendung von Kontexten vorsichtig sein, da es sowohl Plugin- als auch „Quell“-Kontexte gibt. Der Plugin-Kontext wird als Argument an getPluginFactory übergeben und ist der einzige Kontext, der garantiert die Ressourcen des Plugins enthält. Dies bedeutet, dass es der einzige Kontext ist, der zum Aufblasen von Layouts im Plugin verwendet werden kann.

Für den Plugin-Kontext ist jedoch möglicherweise nicht die richtige Konfiguration festgelegt. Um die richtige Konfiguration zu erhalten, stellen wir Quellkontexte in Methoden bereit, die Komponenten erstellen. Der Quellkontext ist normalerweise eine Aktivität, in einigen Fällen kann es sich jedoch auch um einen Dienst oder eine andere Android-Komponente handeln. Um die Konfiguration aus dem Quellkontext mit den Ressourcen aus dem Plugin-Kontext zu verwenden, muss mit createConfigurationContext ein neuer Kontext erstellt werden. Wenn nicht die richtige Konfiguration verwendet wird, liegt eine Verletzung des strengen Android-Modus vor und die vergrößerten Ansichten haben möglicherweise nicht die richtigen Abmessungen.

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

Modusänderungen

Einige Plugins können mehrere Modi für ihre Komponenten unterstützen, z. B. einen Sportmodus oder einen Eco-Modus , die optisch unterschiedlich aussehen. In CarUi gibt es keine integrierte Unterstützung für solche Funktionen, aber nichts hindert das Plugin daran, sie vollständig intern zu implementieren. Das Plugin kann alle gewünschten Bedingungen überwachen, um herauszufinden, wann der Modus gewechselt werden muss, z. B. das Abhören von Sendungen. Das Plugin kann keine Konfigurationsänderung auslösen, um Modi zu ändern. Es wird jedoch nicht empfohlen, sich auf Konfigurationsänderungen zu verlassen, da die manuelle Aktualisierung des Erscheinungsbilds jeder Komponente für den Benutzer reibungsloser ist und auch Übergänge ermöglicht, die mit Konfigurationsänderungen nicht möglich sind.

Jetpack Compose

Plugins können mit Jetpack Compose implementiert werden, dies ist jedoch eine Alpha-Level-Funktion und sollte nicht als stabil angesehen werden.

Plugins können ComposeView verwenden, um eine Compose-fähige Oberfläche zum Rendern zu erstellen. Diese ComposeView wäre das, was von der getView Methode in Komponenten an die App zurückgegeben wird.

Ein Hauptproblem bei der Verwendung ComposeView besteht darin, dass Tags für die Stammansicht im Layout festgelegt werden, um globale Variablen zu speichern, die von verschiedenen ComposeViews in der Hierarchie gemeinsam genutzt werden. Da die Ressourcen-IDs des Plugins nicht getrennt von denen der App benannt sind, kann dies zu Konflikten führen, wenn sowohl die App als auch das Plugin Tags für dieselbe Ansicht festlegen. Unten wird ein benutzerdefinierter ComposeViewWithLifecycle bereitgestellt, der diese globalen Variablen nach unten in den ComposeView verschiebt. Auch dies sollte nicht als stabil angesehen werden.

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