Plug-in UI auto

Utilizza i plug-in della libreria dell'interfaccia utente dell'auto per creare implementazioni complete delle personalizzazioni dei componenti nella libreria dell'interfaccia utente dell'auto anziché utilizzare gli overlay delle risorse di runtime (RRO). Le RRO ti consentono di modificare solo le risorse XML dei componenti della libreria dell'interfaccia utente dell'auto, il che limita la possibilità di personalizzazione.

Creare un plug-in

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

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

Consulta 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 plug-in deve avere un fornitore di contenuti dichiarato nel file manifest con 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 dalla libreria dell'interfaccia utente dell'auto. Il provider deve essere esportato in modo da poter essere sottoposto a query in fase di esecuzione. 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 esistere. In questo caso, assicurati di aggiungere tools:ignore="MissingClass" alla definizione del provider. Vedi l'elemento manifest di esempio riportato 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 esecuzione.

Le classi dei plug-in implementati come librerie condivise di Android vengono aggiunte automaticamente al caricatore di classi condiviso tra le app. Quando un'app che utilizza la libreria dell'interfaccia utente dell'auto specifica una dipendenza di runtime sulla libreria condivisa del plug-in, il suo caricatore delle classi può accedere alle classi della libreria condivisa del plug-in. I plug-in implementati come normali app per Android (non come librerie condivise) possono influire negativamente sui tempi di avvio a freddo delle app.

Implementare e creare librerie condivise

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

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

Dipendenze dalle librerie condivise

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

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

Installare un plug-in

I plug-in DEVONO essere preinstallati nella 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 sul sistema, tutte le app che lo utilizzano si chiuderanno automaticamente. Una volta riaperto dall'utente, il file contiene le modifiche aggiornate. Se l'app non era in esecuzione, al successivo avvio avrà il plug-in aggiornato.

Quando installi un plug-in con Android Studio, ci sono alcune considerazioni aggiuntive da tenere presenti. Al momento della stesura di questo articolo, esiste un bug nella procedura di installazione dell'app Android Studio che causa l'annullamento degli aggiornamenti di un plug-in. Il problema può essere risolto selezionando l'opzione Installa sempre con il gestore pacchetti (disattiva le ottimizzazioni di deployment su Android 11 e versioni successive) nella configurazione di compilazione del plug-in.

Inoltre, durante l'installazione del plug-in, Android Studio segnala un errore che indica che non riesce a trovare un'attività principale da avviare. Questo è normale, in quanto il plug-in non ha attività (tranne l'intent vuoto utilizzato per risolvere un intent). Per eliminare l'errore, imposta l'opzione Avvio su Niente nella configurazione della compilazione.

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

Plug-in proxy

La personalizzazione delle app che utilizzano la raccolta di UI per auto richiede un RRO che abbia come target ogni app specifica da modificare, anche quando le personalizzazioni sono identiche tra le app. Ciò significa che è necessaria una RRO per ogni app. Scopri quali app utilizzano la libreria dell'interfaccia utente dell'auto.

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

Il plug-in proxy è solo un esempio e un punto di partenza per eseguire la personalizzazione utilizzando un plug-in. Per la personalizzazione oltre gli 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 da zero.

Sebbene il plug-in proxy fornisca un unico punto di personalizzazione RRO per le app, le app che disattivano l'utilizzo del plug-in richiederanno comunque un RRO che abbia come target diretto l'app stessa.

Implementa le API dei plug-in

L'entry point principale del plug-in è la classe com.android.car.ui.plugin.PluginVersionProviderImpl. Tutti i plug-in devono includere un'apposita 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 successive al plug-in. Per facilitare questa operazione, tutte le API dei plug-in sono versionate con un V# alla fine del nome della classe. Se viene rilasciata una nuova versione della libreria UI dell'auto con nuove funzionalità, queste fanno parte della versione V2 del componente. La libreria dell'interfaccia utente dell'auto fa del suo meglio per far funzionare le nuove funzionalità nell'ambito di un componente del plug-in precedente. Ad esempio, convertendo un nuovo tipo di pulsante nella barra degli strumenti in MenuItems.

Tuttavia, un'app con una versione precedente della libreria dell'interfaccia utente dell'auto non può adattarsi a un nuovo plug-in scritto per API più recenti. Per risolvere questo problema, consentiamo ai plug-in di restituire implementazioni diverse 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ù recente di PluginFactoryOEMV# supportata dal plug-in, pur essendo inferiore o uguale a maxVersion. Se un plug-in 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 versioni precedenti della libreria Car Ui statica, ti consigliamo di supportare maxVersion di 2, 5 e versioni successive dall'implementazione della classe PluginVersionProvider del tuo plug-in. Le versioni 1, 3 e 4 non sono supportate. Per maggiori informazioni, consulta PluginVersionProviderImpl.

PluginFactory è l'interfaccia che crea tutti gli altri componenti di CarUi. Definisce inoltre la versione delle interfacce da utilizzare. Se il plug-in non cerca di implementare nessuno di questi componenti, potrebbe restituire null nella funzione di creazione (ad eccezione della barra degli strumenti, che ha una funzione customizesBaseLayout() separata).

pluginFactory limita le versioni dei componenti CarUi che possono essere utilizzate insieme. Ad esempio, non esisterà mai un pluginFactory in grado di creare la versione 100 di un Toolbar e anche la versione 1 di un RecyclerView, in quanto non vi sarebbe alcuna garanzia che un'ampia gamma di versioni dei componenti possa funzionare insieme. Per utilizzare la versione 100 della barra degli strumenti, gli sviluppatori devono fornire un'implementazione di una versione di pluginFactory che crei una versione 100 della barra degli strumenti, il che limita le opzioni sulle versioni di altri componenti che possono essere create. Le versioni di altri componenti potrebbero non essere uguali, ad esempio un pluginFactoryOEMV100 potrebbe creare un ToolbarControllerOEMV100 e un RecyclerViewOEMV70.

Barra degli strumenti

Layout di base

La barra degli strumenti e il "layout di base" sono molto correlati, pertanto 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 in qualsiasi punto dei contenuti dell'app, in modo da avere 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. Questo avviene passando una visualizzazione a installBaseLayoutAround per il layout della barra degli strumenti/di base da adattare.

Il plug-in deve prendere la visualizzazione fornita, scollegarla dall'elemento principale, gonfiare il layout del plug-in nello stesso indice dell'elemento principale e con lo stesso LayoutParams della visualizzazione appena scollegata, quindi ricollegare la visualizzazione da qualche parte all'interno del layout appena gonfiato. Il layout espanso conterrà la barra degli strumenti, se richiesta dall'app.

L'app può richiedere un layout di base senza una barra degli strumenti. In questo caso, installBaseLayoutAround dovrebbe restituire null. Per la maggior parte dei plug-in, è tutto ciò che deve accadere, ma se l'autore del plug-in vuole applicare, ad esempio, una decorazione ai bordi dell'app, può comunque farlo con un layout di base. Queste decorazioni sono particolarmente utili per i dispositivi con schermi non rettangolari, in quanto possono spingere l'app in uno spazio rettangolare e aggiungere transizioni nitide allo spazio non rettangolare.

A installBaseLayoutAround viene passato anche un Consumer<InsetsOEMV1>. Questo consumatore può essere utilizzato per comunicare all'app che il plug-in copre parzialmente i contenuti dell'app (con la barra degli strumenti o in altro modo). L'app saprà quindi di continuare a disegnare in questo spazio, ma di escludere eventuali componenti critici interattivi con l'utente. Questo effetto viene utilizzato nel nostro design di riferimento per rendere la barra degli strumenti semitrasparente e far scorrere gli elenchi sotto. Se questa funzionalità non fosse stata implementata, il primo elemento di un elenco rimarrebbe bloccato sotto la barra degli strumenti e non sarebbe selezionabile. Se questo effetto non è necessario, il plug-in può ignorare il consumatore.

Scorri i contenuti sotto la barra degli strumenti Figura 2. Scorri i contenuti sotto la barra degli strumenti

Dal punto di vista dell'app, quando il plug-in invia nuovi intestazioni, li riceve 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 spaziatura interna a Activity o FragmentActivity contenente il frammento. La libreria non applica gli inserti ai frammenti per impostazione predefinita. Ecco uno snippet di esempio di un'implementazione che applica gli inserti come spaziatura interna a 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 plug-in viene fornito un suggerimento fullscreen, che viene utilizzato per indicare se la visualizzazione da a capo occupa l'intera app o solo una piccola sezione. Questa opzione può essere utilizzata 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 due riquadri ha una propria barra degli strumenti.

Poiché è previsto che installBaseLayoutAround restituisca nullo quando toolbarEnabled è false, affinché il plug-in indichi che non vuole 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 rotatori. Queste visualizzazioni possono essere omesse sui dispositivi che non supportano il controllo rotativo. I FocusParkingView/FocusAreas sono implementati nella libreria statica CarUi, pertanto viene utilizzato un setRotaryFactories per fornire fabbriche per creare le visualizzazioni dai contesti.

I contesti utilizzati per creare le visualizzazioni in primo piano devono essere il contesto di origine, non il contesto del plug-in. FocusParkingView deve essere il più vicino possibile alla prima visualizzazione nell'albero, in quanto è l'elemento su cui si concentra l'attenzione quando non deve essere visualizzato alcun elemento attivo per l'utente. FocusArea deve avvolgere la barra degli strumenti nel layout di base per indicare che si tratta di una zona di spostamento rotatorio. Se il pulsante FocusArea non è fornito, l'utente non è in grado di accedere ai pulsanti della barra degli strumenti con il cursore.

Controller della barra degli strumenti

L'elemento ToolbarController restituito effettivo dovrebbe essere molto più semplice da implementare rispetto al layout di base. Il suo compito è acquisire le informazioni passate ai suoi setter e visualizzarle nel layout di base. Consulta la documentazione Javadoc per informazioni sulla maggior parte dei metodi. Di seguito sono descritti alcuni dei metodi più complessi.

getImeSearchInterface viene utilizzato per mostrare i risultati di ricerca nella finestra dell'IME (tastiera). Questa opzione può essere utile per visualizzare/animare i risultati di ricerca insieme 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 plug-in fornisce solo metodi per la libreria statica per ricevere i callback TextView e onPrivateIMECommand. Per supportarlo, il plug-in deve utilizzare una sottoclasse TextView che sostituisce onPrivateIMECommand e passa la chiamata all'ascoltatore fornito come TextView della barra di ricerca.

setMenuItems mostra semplicemente i MenuItem sullo schermo, ma viene chiamato sorprendentemente spesso. Poiché l'API del plug-in per i MenuItem è immutabile, ogni volta che un MenuItem viene modificato, viene eseguita una nuova chiamata setMenuItems. Ciò potrebbe accadere per un motivo banale come il fatto che un utente abbia fatto clic su un MenuItem di attivazione/disattivazione e questo clic abbia attivato/disattivato l'opzione. Per motivi di rendimento e animazione, è quindi consigliabile calcolare la differenza tra il vecchio e il nuovo elenco di MenuItems e aggiornare solo le visualizzazioni effettivamente modificate. Gli elementi MenuItem forniscono un campo key che può essere utile in questo caso, poiché la chiave deve essere la stessa per le varie chiamate a setMenuItems per lo stesso MenuItem.

AppStyledView

AppStyledView è un contenitore per una visualizzazione non personalizzata. Puoi usarlo per aggiungere un bordo alla visualizzazione in modo da distinguerla dal resto dell'app e indicare all'utente che si tratta di un tipo diverso di interfaccia. La visualizzazione racchiusa da AppStyledView è riportata 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 esegue l'inserimento. La posizione e le dimensioni del AppStyledView possono essere controllate anche implementando getDialogWindowLayoutParam.

Contesti

Il plug-in deve fare attenzione quando utilizza i contesti, in quanto esistono sia contesti plug-in sia "source". Il contesto del plug-in viene fornito come argomento a getPluginFactory ed è l'unico contesto che contiene le risorse del plug-in. Ciò significa che è l'unico contesto che può essere utilizzato per eseguire il caricamento dei layout nel plug-in.

Tuttavia, il contesto del plug-in potrebbe non avere la configurazione corretta. Per ottenere la configurazione corretta, forniamo i contesti di origine nei metodi che creano i componenti. Il contesto di origine è in genere un'attività, ma in alcuni casi può essere anche un servizio o un altro componente Android. Per utilizzare la configurazione del contesto di origine con le risorse del contesto del plug-in, è 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 gonfiate potrebbero non avere le dimensioni corrette.

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

Modifiche alla modalità

Alcuni plug-in possono supportare più modalità per i relativi componenti, ad esempio una modalità sportiva o una modalità eco che sono visivamente distinte. Non è previsto il supporto integrato di queste funzionalità in CarUi, ma nulla impedisce al plug-in di implementarle completamente internamente. Il plug-in può monitorare qualsiasi condizione per capire quando cambiare modalità, ad esempio l'ascolto delle trasmissioni. Il plug-in non può attivare una modifica della configurazione per cambiare modalità, ma non è comunque consigliabile fare affidamento sulle modifiche della configurazione, in quanto l'aggiornamento manuale dell'aspetto di ogni componente è più fluido per l'utente e consente anche transizioni non possibili con le modifiche della configurazione.

Jetpack Compose

I plug-in possono essere implementati utilizzando Jetpack Compose, ma si tratta di una funzionalità di livello alpha e non deve essere considerata stabile.

I plug-in possono utilizzare ComposeView per creare una superficie compatibile con Compose in cui eseguire il rendering. Questo ComposeView sarebbe quanto restituito dall'app dal metodo getView nei componenti.

Uno dei principali problemi dell'utilizzo di ComposeView è che imposta i tag sulla vista principale nel layout per memorizzare le variabili globali condivise tra diverse ComposeView nella gerarchia. Poiché gli ID risorsa del plug-in non hanno un proprio spazio dei nomi separato da quello dell'app, ciò potrebbe causare conflitti quando sia l'app sia il plug-in impostano i tag nella stessa visualizzazione. Di seguito è riportato un ComposeViewWithLifecycle personalizzato che sposta queste variabili globali in ComposeView. Anche in questo caso, non deve essere considerata 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)
//  }
}