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