Verwenden Sie car-ui-lib
Plug -ins, um vollständige Implementierungen von Komponentenanpassungen in car-ui-lib
zu erstellen, anstatt Runtime Resource Overlays (RROs) zu verwenden. RROs ermöglichen es Ihnen, nur die XML-Ressourcen von car-ui-lib
Komponenten zu ändern, was den Umfang dessen, was Sie anpassen können, einschränkt.
Plugin erstellen
Ein car-ui-lib
Plug-in ist ein APK, das Klassen enthält, die eine Reihe von Plug-in-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.
Siehe die Soong- und In-Gradle-Beispiele unten:
Bald
Betrachten Sie dieses Song-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",
Gradl
Siehe diese build.gradle
-Datei:
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 einen Inhaltsanbieter in seinem Manifest deklariert haben, der die folgenden Attribute hat:
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
auffindbar. Der Provider muss exportiert werden, damit er zur Laufzeit abgefragt werden kann. Auch wenn das Attribut enabled
auf false
gesetzt ist, wird die Standardimplementierung anstelle der Plugin-Implementierung verwendet. Die Inhaltsanbieterklasse muss nicht vorhanden sein. Stellen Sie in diesem Fall sicher, dass Sie tools:ignore="MissingClass"
zur Anbieterdefinition hinzufügen. Sehen Sie sich den Beispiel-Manifesteintrag 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 schließlich Ihre App .
Installieren eines Plugins
Nachdem Sie das Plug-in erstellt haben, kann es wie jede andere App installiert werden, indem Sie es beispielsweise zu PRODUCT_PACKAGES
oder adb install
verwenden. Wenn es sich jedoch um eine neue, frische Installation eines Plugins handelt, müssen die Apps neu gestartet werden, damit die Änderungen wirksam werden. Dies kann durch Ausführen eines vollständigen adb reboot
oder einer adb shell am force-stop package.name
für eine bestimmte App erfolgen.
Wenn Sie ein vorhandenes car-ui-lib
Plug-in auf dem System aktualisieren, werden alle Apps, die dieses Plug-in verwenden, automatisch geschlossen und verfügen nach dem erneuten Öffnen durch den Benutzer über die aktualisierten Änderungen. Das 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 Verfassens dieses Artikels gibt es einen Fehler im Installationsprozess der Android Studio-App, der dazu führt, dass Aktualisierungen eines Plug-ins nicht wirksam werden. Dies kann behoben werden, indem in der Build-Konfiguration des Plugins die Option Always install with package manager (deaktiviert Deployment-Optimierungen auf Android 11 und höher) ausgewählt wird.
Darüber hinaus meldet Android Studio bei der Installation des Plugins einen Fehler, dass es keine Hauptaktivität zum Starten finden kann. Dies ist zu erwarten, da das Plugin keine Aktivitäten hat (außer der leeren Absicht, die zum Auflösen einer Absicht verwendet wird). Um den Fehler zu beheben, ändern Sie in der Build-Konfiguration die Launch- Option auf Nothing .
Abbildung 1. Konfiguration des Plugins für Android Studio
Implementieren der Plugin-APIs
Der Haupteinstiegspunkt für das 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, sind 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, um neue Funktionen im Rahmen einer älteren Plugin-Komponente zum Laufen zu bringen. 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, erlauben wir Plugins, verschiedene Implementierungen von sich selbst zurückzugeben, basierend auf der Version der OEM-API, die von den Apps unterstützt wird.
PluginVersionProviderOEMV1
enthält eine Methode:
Object getPluginFactory(int maxVersion, Context context, String packageName);
Diese Methode gibt ein Objekt zurück, das die höchste Version von PluginFactoryOEMV#
implementiert, die vom Plugin unterstützt wird, während es immer noch kleiner oder gleich maxVersion
. Wenn ein Plugin keine so alte Implementierung einer PluginFactory
hat, kann es null
zurückgeben, in diesem Fall wird die statisch gelinkte Implementierung von CarUi-Komponenten verwendet.
Die PluginFactory
ist die Schnittstelle, die alle anderen CarUi-Komponenten erstellt. Es definiert auch, welche Version ihrer Schnittstellen verwendet werden soll. Wenn das Plug-in keine dieser Komponenten zu implementieren versucht, kann es in ihrer Erstellungsfunktion null
zurückgeben (mit Ausnahme der Symbolleiste, die eine separate customizesBaseLayout()
Funktion hat).
Die pluginFactory
begrenzt, welche Versionen von CarUi-Komponenten zusammen verwendet werden können. Beispielsweise wird es niemals eine pluginFactory
, die Version 100 einer Toolbar
und auch Version 1 einer RecyclerView
erstellen kann, da es wenig Garantie dafür gäbe, dass eine Vielzahl von Versionen von Komponenten zusammenarbeiten würden. Um die Symbolleistenversion 100 zu verwenden, wird von Entwicklern erwartet, dass sie eine Implementierung einer Version von pluginFactory
, die eine Symbolleistenversion 100 erstellt, die dann die Optionen auf die Versionen anderer Komponenten beschränkt, die erstellt werden können. Die Versionen anderer Komponenten sind möglicherweise nicht gleich, beispielsweise könnte eine pluginFactoryOEMV100
einen ToolbarControllerOEMV100
und einen RecyclerViewOEMV70
erstellen.
Symbolleiste
Basislayout
Die Symbolleiste und das "Basislayout" sind sehr eng miteinander verwandt, daher heißt die Funktion, die die Symbolleiste erstellt, installBaseLayoutAround
. Das Basislayout ist ein Konzept, das es ermöglicht, die Symbolleiste an beliebiger Stelle um den Inhalt der App herum zu positionieren, um eine Symbolleiste oben/unten in der App, vertikal an den Seiten oder sogar eine kreisförmige Symbolleiste zu ermöglichen, die die gesamte App umschließt. Dies wird erreicht, indem eine Ansicht an installBaseLayoutAround
wird, damit das Symbolleisten-/Basislayout umbrochen wird.
Das Plugin sollte die bereitgestellte Ansicht nehmen, sie von ihrem übergeordneten Element trennen, das eigene Layout des Plugins im selben Index des übergeordneten Elements und mit denselben LayoutParams
wie die gerade getrennte Ansicht aufblasen und die Ansicht dann irgendwo innerhalb des Layouts neu anfügen nur aufgeblasen. Das aufgeblasene Layout enthält die Symbolleiste, wenn dies von der App angefordert wird.
Die App kann ein Basislayout ohne Symbolleiste anfordern. Wenn dies der Fall ist, sollte installBaseLayoutAround
null zurückgeben. Für die meisten Plugins ist das alles, was passieren muss, aber wenn der Plugin-Autor zB eine Dekoration um den Rand der App herum 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 Bereich verschieben und saubere Übergänge in den nicht rechteckigen Bereich einfügen können.
installBaseLayoutAround
wird auch ein Consumer<InsetsOEMV1>
. Dieser Verbraucher kann verwendet werden, um der App mitzuteilen, dass das Plugin den Inhalt der App teilweise abdeckt (mit der Symbolleiste oder auf andere Weise). Die App wird dann wissen, dass sie in diesem Bereich weiter zeichnen muss, aber alle kritischen Komponenten, die mit dem Benutzer interagierbar sind, davon fernhalten. 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, würde das erste Element in einer Liste unter der Symbolleiste hängen bleiben und nicht anklickbar sein. Wenn dieser Effekt nicht benötigt wird, kann das Plugin den Consumer ignorieren.
Abbildung 2. Scrollen des Inhalts unter der Symbolleiste
Wenn das Plug-in neue Insets sendet, empfängt es aus Sicht der App diese über alle Aktivitäten/Fragmente, die InsetsChangedListener
implementieren. Hier ist ein Beispiel-Snippit einer Implementierung, die die Einschübe als Padding auf eine Recycler-Ansicht 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
Hinweis, mit dem angegeben wird, ob die zu umschließende Ansicht die gesamte App oder nur einen kleinen Ausschnitt einnimmt. Dies kann verwendet werden, um zu vermeiden, dass einige Dekorationen am Rand angebracht werden, die nur dann sinnvoll sind, wenn sie am Rand des gesamten Bildschirms erscheinen. Eine Beispiel-App, die Nicht-Vollbild-Basislayouts verwendet, ist Einstellungen, in der jeder Bereich des Layouts mit zwei Bereichen über eine eigene Symbolleiste verfügt.
Da erwartet wird, dass installBaseLayoutAround
null zurückgibt, wenn toolbarEnabled
false
ist, muss das Plug-in false
von customizesBaseLayout
zurückgeben, damit es angeben kann, dass es das Basislayout nicht anpassen möchte.
Das Basislayout muss eine FocusParkingView
und eine FocusArea
enthalten, um Drehregler vollständig zu unterstützen. Diese Ansichten können auf Geräten, die Rotation nicht unterstützen, weggelassen werden. Die FocusParkingView/FocusAreas
sind in der statischen CarUi-Bibliothek implementiert, sodass ein setRotaryFactories
verwendet wird, um Fabriken bereitzustellen, um die Ansichten aus Kontexten zu erstellen.
Die zum Erstellen von Fokusansichten verwendeten Kontexte müssen der Quellkontext sein, nicht der Kontext des Plugins. Die FocusParkingView
sollte der ersten Ansicht in der Struktur möglichst nahe kommen, da sie fokussiert wird, wenn für den Benutzer kein Fokus sichtbar sein sollte. Die FocusArea
muss die Symbolleiste im Basislayout umschließen, um anzugeben, dass es sich um eine Rotationsschubzone handelt. Wenn die FocusArea
nicht bereitgestellt wird, kann der Benutzer mit dem Drehregler zu keinen Schaltflächen in der Symbolleiste navigieren.
Toolbar-Controller
Der tatsächlich zurückgegebene ToolbarController
sollte viel einfacher zu implementieren sein als das Basislayout. Seine Aufgabe ist es, Informationen, die an seine Setter weitergegeben werden, aufzunehmen und im Basislayout anzuzeigen. Informationen zu den meisten Methoden finden Sie im Javadoc. Einige der komplexeren Verfahren werden unten diskutiert.
getImeSearchInterface
wird verwendet, um Suchergebnisse im IME-Fenster (Tastaturfenster) anzuzeigen. Dies kann nützlich sein, um Suchergebnisse neben der Tastatur anzuzeigen/animieren, beispielsweise wenn die Tastatur nur die Hälfte des Bildschirms einnimmt. Die meisten Funktionen sind in der statischen CarUi-Bibliothek implementiert, die Suchschnittstelle im Plugin stellt nur Methoden für die statische Bibliothek bereit, um die TextView
und onPrivateIMECommand
Callbacks abzurufen. Um dies zu unterstützen, sollte das Plug-in eine TextView
Unterklasse verwenden, die onPrivateIMECommand
überschreibt und den Aufruf als TextView
seiner Suchleiste an den bereitgestellten Listener weiterleitet.
setMenuItems
zeigt einfach MenuItems auf dem Bildschirm an, wird aber überraschend oft aufgerufen. Da die Plugin-API für MenuItems unveränderlich ist, wird immer dann, wenn ein MenuItem geändert wird, ein ganz neuer setMenuItems
-Aufruf ausgeführt. Dies könnte bei etwas so Trivialem passieren, wenn ein Benutzer auf einen Schalter MenuItem klickt und dieser Klick bewirkt, dass der Schalter umgeschaltet wird. Sowohl aus Performance- als auch aus 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 bieten ein key
, 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 diese Ansicht mit einem Rahmen zu versehen, der sie vom Rest der App abhebt, und dem Benutzer anzuzeigen, dass dies eine andere Art von Schnittstelle ist. Die von AppStyledView umschlossene Ansicht wird in setContent
angegeben. Die AppStyledView
kann je nach Anforderung der App auch eine Zurück- oder Schließen-Schaltfläche haben.
Die AppStyledView
fügt ihre Ansichten nicht sofort in die Ansichtshierarchie ein, wie dies bei installBaseLayoutAround
der Fall ist, sondern gibt ihre Ansicht stattdessen einfach über getView
an die statische Bibliothek zurück, die dann die Einfügung durchführt. Die Position und Größe der AppStyledView
kann auch durch die Implementierung von 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
und ist der einzige Kontext, der garantiert die Ressourcen des Plugins enthält. Dies bedeutet, dass dies 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, kann aber in einigen Fällen auch ein Dienst oder eine andere Android-Komponente sein. 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, tritt ein Verstoß gegen den strengen Android-Modus auf, und die vergrößerten Ansichten haben möglicherweise nicht die richtigen Abmessungen.
Context layoutInflationContext = pluginContext.createConfigurationContext(
sourceContext.getResources().getConfiguration());
Moduswechsel
Einige Plugins können mehrere Modi für ihre Komponenten unterstützen, wie z. B. einen Sportmodus oder einen Eco-Modus , die optisch unterschiedlich aussehen. Es gibt keine integrierte Unterstützung für solche Funktionen in CarUi, aber nichts hindert das Plugin daran, es vollständig intern zu implementieren. Das Plugin kann beliebige Bedingungen überwachen, um herauszufinden, wann der Modus gewechselt werden muss, z. B. das Abhören von Sendungen. Das Plugin kann keine Konfigurationsänderung in Änderungsmodi auslösen, aber es wird sowieso nicht empfohlen, sich auf Konfigurationsänderungen zu verlassen, da das manuelle Aktualisieren 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 komponieren
Plugins können mit Jetpack Compose implementiert werden, aber dies ist eine Alpha-Level-Funktion und sollte nicht als stabil betrachtet werden.
Plug-ins 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 von ComposeView
besteht darin, dass Tags in der 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 Plug-ins nicht getrennt von denen der App benannt werden, könnte dies zu Konflikten führen, wenn sowohl die App als auch das Plug-in Tags für dieselbe Ansicht festlegen. Ein benutzerdefiniertes ComposeViewWithLifecycle
, das diese globalen Variablen nach unten in die ComposeView
, wird unten bereitgestellt. 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)
// }
}