Plugin dell'interfaccia utente dell'auto

Utilizza i plug-in della libreria Car UI per creare implementazioni complete di personalizzazioni dei componenti nella libreria Car UI invece di utilizzare gli overlay delle risorse di runtime (RRO). Le RRO ti consentono di modificare solo le risorse XML dei componenti della libreria Car UI, il che limita la portata di ciò che puoi personalizzare.

Crea un plugin

Un plug-in della libreria dell'interfaccia utente dell'auto è un APK che contiene classi che implementano una serie di API plug-in . Le API del plugin possono essere compilate in un plugin come libreria statica.

Vedi esempi in Soong e Gradle:

Soong

Considera questo esempio di 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

Vedi questo file 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')

Il plugin deve avere un fornitore di contenuti dichiarato nel suo manifest che abbia i seguenti attributi:

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

android:authorities="com.android.car.ui.plugin" rende il plug-in rilevabile nella libreria dell'interfaccia utente dell'auto. Il provider deve essere esportato in modo che possa essere interrogato in fase di runtime. Inoltre, se l'attributo enabled è impostato su false , verrà utilizzata l'implementazione predefinita anziché l'implementazione del plug-in. La classe del fornitore di contenuti non deve necessariamente esistere. In tal caso, assicurati di aggiungere tools:ignore="MissingClass" alla definizione del provider. Consulta la voce manifest di esempio di seguito:

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

Infine, come misura di sicurezza, firma la tua app .

Plugin come libreria condivisa

A differenza delle librerie statiche Android che vengono compilate direttamente nelle app, le librerie condivise Android vengono compilate in un APK autonomo a cui fanno riferimento altre app in fase di runtime.

I plugin implementati come libreria condivisa Android hanno le loro classi aggiunte automaticamente al caricatore di classi condiviso tra le app. Quando un'app che utilizza la libreria Car UI specifica una dipendenza di runtime sulla libreria condivisa del plug-in, il suo caricatore di classi può accedere alle classi della libreria condivisa del plug-in. I plugin implementati come normali app Android (non come librerie condivise) possono avere un impatto negativo sui tempi di avvio a freddo delle app.

Implementare e costruire librerie condivise

Lo sviluppo con le librerie condivise Android è molto simile a quello delle normali app Android, con alcune differenze fondamentali.

  • Utilizza il tag library sotto il tag application con il nome del pacchetto plug-in nel manifest dell'app del tuo plug-in:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configura la regola di build Soong android_app ( Android.bp ) con il flag AAPT shared-lib , che viene utilizzato per creare una libreria condivisa:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dipendenze da librerie condivise

Per ogni app sul sistema che utilizza la libreria Car UI, includi il tag uses-library nel manifest dell'app sotto il tag application con il nome del pacchetto plug-in:

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

Installa un plug-in

I plugin DEVONO essere preinstallati sulla partizione di sistema includendo il modulo in PRODUCT_PACKAGES . Il pacchetto preinstallato può essere aggiornato in modo simile a qualsiasi altra app installata.

Se stai aggiornando un plug-in esistente nel sistema, tutte le app che utilizzano quel plug-in si chiuderanno automaticamente. Una volta riaperti dall'utente, hanno le modifiche aggiornate. Se l'app non era in esecuzione, al successivo avvio avrà il plug-in aggiornato.

Quando si installa un plug-in con Android Studio, ci sono alcune considerazioni aggiuntive da tenere in considerazione. Al momento in cui scriviamo, è presente un bug nel processo di installazione dell'app Android Studio che impedisce agli aggiornamenti di un plug-in di avere effetto. Questo problema può essere risolto selezionando l'opzione Installa sempre con il gestore pacchetti (disabilita le ottimizzazioni di distribuzione su Android 11 e versioni successive) nella configurazione della build del plug-in.

Inoltre, durante l'installazione del plug-in, Android Studio segnala un errore relativo all'impossibilità di trovare un'attività principale da avviare. Ciò è previsto, poiché il plugin non ha alcuna attività (tranne l'intento vuoto utilizzato per risolvere un intento). Per eliminare l'errore, modificare l'opzione Avvia su Niente nella configurazione della build.

Configurazione del plug-in Android Studio Figura 1. Configurazione del plug-in Android Studio

Plug-in proxy

La personalizzazione delle app utilizzando la libreria dell'interfaccia utente dell'auto richiede un RRO mirato a ciascuna app specifica da modificare, anche quando le personalizzazioni sono identiche tra le app. Ciò significa che è richiesto un RRO per app. Scopri quali app utilizzano la libreria dell'interfaccia utente dell'auto.

Il plug-in proxy della libreria Car UI è una libreria condivisa di plug-in di esempio che delega le implementazioni dei componenti alla versione statica della libreria Car UI. Questo plug-in può essere scelto come target con un RRO, che può essere utilizzato come singolo punto di personalizzazione per le app che utilizzano la libreria Car UI senza la necessità di implementare un plug-in funzionale. Per ulteriori informazioni sulle RRO, consulta Modificare il valore delle risorse di un'app in fase di esecuzione .

Il plugin proxy è solo un esempio e punto di partenza per effettuare la personalizzazione utilizzando un plugin. Per la personalizzazione oltre le RRO, è possibile implementare un sottoinsieme di componenti del plug-in e utilizzare il plug-in proxy per il resto oppure implementare tutti i componenti del plug-in interamente da zero.

Sebbene il plug-in proxy fornisca un unico punto di personalizzazione RRO per le app, le app che rinunciano a utilizzare il plug-in richiederanno comunque un RRO indirizzato direttamente all'app stessa.

Implementa le API del plugin

Il punto di accesso principale al plugin è la classe com.android.car.ui.plugin.PluginVersionProviderImpl . Tutti i plugin devono includere una classe con questo nome esatto e nome del pacchetto. Questa classe deve avere un costruttore predefinito e implementare l'interfaccia PluginVersionProviderOEMV1 .

I plug-in CarUi devono funzionare con app precedenti o più recenti del plug-in. Per facilitare ciò, tutte le API dei plugin hanno una versione con una V# alla fine del nome della classe. Se viene rilasciata una nuova versione della libreria Car UI con nuove funzionalità, queste fanno parte della versione V2 del componente. La libreria Car UI fa del suo meglio per far funzionare le nuove funzionalità nell'ambito di un vecchio componente plug-in. Ad esempio, convertendo un nuovo tipo di pulsante nella barra degli strumenti in MenuItems .

Tuttavia, un'app con una versione precedente della libreria Car UI non può adattarsi a un nuovo plug-in scritto con API più recenti. Per risolvere questo problema, consentiamo ai plugin di restituire diverse implementazioni di se stessi in base alla versione dell'API OEM supportata dalle app.

PluginVersionProviderOEMV1 contiene un metodo:

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

Questo metodo restituisce un oggetto che implementa la versione più alta di PluginFactoryOEMV# supportata dal plugin, pur essendo inferiore o uguale a maxVersion . Se un plugin non ha un'implementazione di un PluginFactory così vecchia, potrebbe restituire null , nel qual caso viene utilizzata l'implementazione collegata staticamente dei componenti CarUi.

Per mantenere la compatibilità con le app compilate con versioni precedenti della libreria statica Car Ui, si consiglia di supportare maxVersion 2, 5 e versioni successive dall'implementazione del plug-in della classe PluginVersionProvider . Le versioni 1, 3 e 4 non sono supportate. Per ulteriori informazioni, vedere PluginVersionProviderImpl .

PluginFactory è l'interfaccia che crea tutti gli altri componenti CarUi. Definisce inoltre quale versione delle loro interfacce deve essere utilizzata. Se il plugin non cerca di implementare nessuno di questi componenti, potrebbe restituire null nella loro funzione di creazione (ad eccezione della barra degli strumenti, che ha una funzione customizesBaseLayout() separata).

Il pluginFactory limita quali versioni dei componenti CarUi possono essere utilizzate insieme. Ad esempio, non ci sarà mai un pluginFactory in grado di creare la versione 100 di una Toolbar e anche la versione 1 di un RecyclerView , poiché ci sarebbero poche garanzie che un'ampia varietà di versioni di componenti funzionino insieme. Per utilizzare la versione 100 della barra degli strumenti, ci si aspetta che gli sviluppatori forniscano un'implementazione di una versione di pluginFactory che crei una versione 100 della barra degli strumenti, che quindi limiti le opzioni sulle versioni degli altri componenti che possono essere creati. Le versioni degli altri componenti potrebbero non essere uguali, ad esempio un pluginFactoryOEMV100 potrebbe creare un ToolbarControllerOEMV100 e un RecyclerViewOEMV70 .

Barra degli strumenti

Disposizione della base

La barra degli strumenti e il "layout di base" sono strettamente correlati, quindi la funzione che crea la barra degli strumenti si chiama installBaseLayoutAround . Il layout di base è un concetto che consente di posizionare la barra degli strumenti ovunque attorno al contenuto dell'app, per consentire una barra degli strumenti nella parte superiore/inferiore dell'app, verticalmente lungo i lati o anche una barra degli strumenti circolare che racchiude l'intera app. Ciò si ottiene passando una vista a installBaseLayoutAround per il layout della barra degli strumenti/base da avvolgere.

Il plugin dovrebbe prendere la vista fornita, staccarla dal suo genitore, gonfiare il layout del plugin nello stesso indice del genitore e con gli stessi LayoutParams della vista appena staccata, e poi ricollegare la vista da qualche parte all'interno del layout che era semplicemente gonfiato. Il layout ingrandito conterrà la barra degli strumenti, se richiesto dall'app.

L'app può richiedere un layout di base senza barra degli strumenti. In tal caso, installBaseLayoutAround dovrebbe restituire null. Per la maggior parte dei plugin, questo è tutto ciò che deve accadere, ma se l'autore del plugin desidera applicare, ad esempio, una decorazione attorno al bordo dell'app, ciò potrebbe comunque essere fatto con un layout di base. Queste decorazioni sono particolarmente utili per i dispositivi con schermi non rettangolari, poiché possono spingere l'app in uno spazio rettangolare e aggiungere transizioni nette nello spazio non rettangolare.

installBaseLayoutAround viene passato anche un Consumer<InsetsOEMV1> . Questo consumer può essere utilizzato per comunicare all'app che il plug-in copre parzialmente il contenuto dell'app (con la barra degli strumenti o in altro modo). L'app saprà quindi di continuare a disegnare in questo spazio, ma di tenerne fuori tutti i componenti critici interagibili con l'utente. Questo effetto viene utilizzato nel nostro progetto di riferimento per rendere la barra degli strumenti semitrasparente e far scorrere gli elenchi sotto di essa. Se questa funzionalità non fosse implementata, il primo elemento di un elenco rimarrebbe bloccato sotto la barra degli strumenti e non sarebbe possibile fare clic. Se questo effetto non è necessario, il plugin può ignorare il Consumer.

Scorrimento del contenuto sotto la barra degli strumenti Figura 2. Scorrimento del contenuto sotto la barra degli strumenti

Dal punto di vista dell'app, quando il plug-in invia nuovi inserti, li riceverà da qualsiasi attività o frammento che implementa InsetsChangedListener . Se un'attività o un frammento non implementa InsetsChangedListener , la libreria Car Ui gestirà gli inserti per impostazione predefinita applicandoli come riempimento Activity o FragmentActivity contenente il frammento. Per impostazione predefinita, la libreria non applica gli inserti ai frammenti. Ecco uno snippet di esempio di un'implementazione che applica gli inserti come riempimento su un RecyclerView nell'app:

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

Infine, al plugin viene fornito un suggerimento fullscreen , che viene utilizzato per indicare se la vista da racchiudere occupa l'intera app o solo una piccola sezione. Questo può essere utilizzato per evitare di applicare alcune decorazioni lungo il bordo che hanno senso solo se appaiono lungo il bordo dell'intero schermo. Un'app di esempio che utilizza layout di base non a schermo intero è Impostazioni, in cui ogni riquadro del layout a doppio riquadro ha la propria barra degli strumenti.

Poiché è previsto che installBaseLayoutAround restituisca null quando toolbarEnabled è false , affinché il plugin indichi che non desidera personalizzare il layout di base, deve restituire false da customizesBaseLayout .

Il layout di base deve contenere un FocusParkingView e un FocusArea per supportare completamente i controlli rotanti. Queste visualizzazioni possono essere omesse sui dispositivi che non supportano la rotazione. FocusParkingView/FocusAreas sono implementati nella libreria statica CarUi, quindi un setRotaryFactories viene utilizzato per fornire fabbriche per creare le visualizzazioni dai contesti.

I contesti utilizzati per creare visualizzazioni Focus devono essere il contesto di origine, non il contesto del plugin. FocusParkingView dovrebbe essere il più vicino possibile alla prima visualizzazione nell'albero, poiché è ciò che viene messo a fuoco quando non dovrebbe esserci alcun focus visibile all'utente. La FocusArea deve racchiudere la barra degli strumenti nel layout di base per indicare che si tratta di una zona di spinta rotante. Se FocusArea non viene fornita, l'utente non è in grado di spostarsi su alcun pulsante nella barra degli strumenti con la manopola.

Controllore della barra degli strumenti

L'effettivo ToolbarController restituito dovrebbe essere molto più semplice da implementare rispetto al layout di base. Il suo compito è prendere le informazioni passate ai suoi setter e visualizzarle nel layout di base. Consultare Javadoc per informazioni sulla maggior parte dei metodi. Alcuni dei metodi più complessi sono discussi di seguito.

getImeSearchInterface viene utilizzato per mostrare i risultati della ricerca nella finestra IME (tastiera). Ciò può essere utile per visualizzare/animare i risultati della ricerca accanto alla tastiera, ad esempio se la tastiera occupa solo metà dello schermo. La maggior parte delle funzionalità è implementata nella libreria statica CarUi, l'interfaccia di ricerca nel plugin fornisce solo metodi per la libreria statica per ottenere i callback TextView e onPrivateIMECommand . Per supportare ciò, il plugin dovrebbe utilizzare una sottoclasse TextView che sovrascrive onPrivateIMECommand e passa la chiamata al listener fornito come TextView della barra di ricerca.

setMenuItems visualizza semplicemente MenuItems sullo schermo, ma verrà chiamato sorprendentemente spesso. Poiché l'API del plug-in per MenuItems è immutabile, ogni volta che un MenuItem viene modificato, verrà eseguita una chiamata setMenuItems completamente nuova. Ciò potrebbe accadere per qualcosa di banale come un utente che ha fatto clic su un interruttore MenuItem e quel clic ha causato l'attivazione/disattivazione dell'interruttore. Per ragioni sia di prestazioni che di animazione, è quindi consigliabile calcolare la differenza tra il vecchio e il nuovo elenco MenuItems e aggiornare solo le visualizzazioni effettivamente modificate. MenuItems fornisce un campo key che può aiutare in questo, poiché la chiave dovrebbe essere la stessa tra chiamate diverse a setMenuItems per lo stesso MenuItem.

AppStyledView

AppStyledView è un contenitore per una visualizzazione non personalizzata. Può essere utilizzato per fornire un bordo attorno alla visualizzazione che la distingua dal resto dell'app e indichi all'utente che si tratta di un tipo diverso di interfaccia. La vista racchiusa da AppStyledView è fornita in setContent . AppStyledView può anche avere un pulsante Indietro o Chiudi come richiesto dall'app.

AppStyledView non inserisce immediatamente le sue visualizzazioni nella gerarchia delle visualizzazioni come fa installBaseLayoutAround , ma restituisce semplicemente la sua visualizzazione alla libreria statica tramite getView , che quindi esegue l'inserimento. La posizione e la dimensione di AppStyledView possono essere controllate anche implementando getDialogWindowLayoutParam .

Contesti

Il plugin deve fare attenzione quando si usano i contesti, poiché ci sono sia contesti plugin che "sorgente". Il contesto del plugin viene fornito come argomento a getPluginFactory ed è l'unico contesto che contiene le risorse del plugin. Ciò significa che è l'unico contesto che può essere utilizzato per gonfiare i layout nel plugin.

Tuttavia, il contesto del plugin potrebbe non avere la configurazione corretta impostata su di esso. Per ottenere la configurazione corretta, forniamo contesti di origine in metodi che creano componenti. Il contesto di origine è solitamente un'attività, ma in alcuni casi può anche essere un servizio o un altro componente Android. Per utilizzare la configurazione dal contesto di origine con le risorse dal contesto del plugin, è necessario creare un nuovo contesto utilizzando createConfigurationContext . Se non viene utilizzata la configurazione corretta, si verificherà una violazione della modalità rigorosa di Android e le visualizzazioni ingrandite potrebbero non avere le dimensioni corrette.

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

Cambiamenti di modalità

Alcuni plugin possono supportare più modalità per i loro componenti, come la modalità sportiva o la modalità ecologica che appaiono visivamente distinte. Non esiste un supporto integrato per tale funzionalità in CarUi, ma nulla impedisce al plugin di implementarla interamente internamente. Il plugin può monitorare qualsiasi condizione desideri per capire quando cambiare modalità, come l'ascolto delle trasmissioni. Il plugin non può attivare una modifica della configurazione per cambiare modalità, ma non è comunque consigliabile fare affidamento sulle modifiche della configurazione, poiché l'aggiornamento manuale dell'aspetto di ciascun componente è più agevole per l'utente e consente anche transizioni che non sono possibili con le modifiche della configurazione.

Jetpack Componi

I plugin possono essere implementati utilizzando Jetpack Compose, ma questa è una funzionalità di livello alfa e non dovrebbe essere considerata stabile.

I plugin possono utilizzare ComposeView per creare una superficie abilitata per Compose in cui eseguire il rendering. Questo ComposeView sarebbe ciò che viene restituito all'app dal metodo getView nei componenti.

Uno dei problemi principali con l'utilizzo ComposeView è che imposta i tag sulla vista radice nel layout per archiviare variabili globali condivise tra diversi ComposeView nella gerarchia. Poiché gli ID delle risorse del plug-in non hanno spazi dei nomi separati da quelli dell'app, ciò potrebbe causare conflitti quando sia l'app che il plug-in impostano i tag sulla stessa vista. Di seguito viene fornito un ComposeViewWithLifecycle personalizzato che sposta queste variabili globali in ComposeView . Ancora una volta, questo non dovrebbe essere considerato stabile.

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