Plug-in dell'interfaccia utente dell'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 overlay di risorse di runtime (RRO). Gli RRO ti consentono di modificare solo le risorse XML dei componenti della libreria dell'interfaccia utente dell'auto, il che limita l'entità della personalizzazione.

Crea un plug-in

Un plug-in della libreria dell'interfaccia utente dell'auto è un APK che contiene classi che implementano un insieme di API plug-in. Le API dei plug-in possono essere compilate in un plug-in 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

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 content provider dichiarato nel 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 fornitore 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é quella del plug-in. La classe del provider di contenuti non deve esistere. In questo caso, assicurati di aggiungere tools:ignore="MissingClass" alla definizione del fornitore. Vedi la voce del 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.

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

Implementare e creare 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 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 build di 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 dalle librerie condivise

Per ogni app sul sistema che utilizza la libreria dell'interfaccia utente dell'auto, includi il tag uses-library nel 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 aggiorni un plug-in esistente sul sistema, tutte le app che lo utilizzano vengono chiuse automaticamente. Una volta riaperto dall'utente, il file conterrà le modifiche aggiornate. Se l'app non era in esecuzione, la volta successiva che viene avviata avrà il plug-in aggiornato.

Quando installi un plug-in con Android Studio, ci sono alcune considerazioni aggiuntive da tenere in considerazione. Al momento della stesura, esiste un bug nel processo di installazione dell'app Android Studio che impedisce l'applicazione degli aggiornamenti a un plug-in. Questo problema può essere risolto selezionando l'opzione Installa sempre con il gestore pacchetti (disattiva le ottimizzazioni della distribuzione su Android 11 e versioni successive) nella configurazione di build del plug-in.

Inoltre, durante l'installazione del plug-in, Android Studio segnala un errore che non riesce a trovare un'attività principale da avviare. Questo è previsto, in quanto il plug-in non ha attività (tranne l'intent vuoto utilizzato per risolvere un intent). Per eliminare l'errore, imposta 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 che utilizzano la libreria Car UI richiede un RRO che abbia come target ogni app specifica da modificare, anche quando le personalizzazioni sono identiche tra le app. Ciò significa che è necessario un RRO per 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 di 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 con un RRO, che può essere utilizzato come unico punto di personalizzazione per le app che utilizzano la libreria dell'interfaccia utente dell'auto senza la necessità di implementare un plug-in funzionale. Per ulteriori informazioni sulle RRO, vedi Modificare il valore delle risorse di un'app in fase di runtime.

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

Anche se il plug-in proxy fornisce un unico punto di personalizzazione RRO per le app, le app che disattivano l'utilizzo del plug-in richiedono comunque un RRO che miri direttamente all'app stessa.

Implementa le API del plug-in

Il punto di ingresso principale del plug-in è la classe com.android.car.ui.plugin.PluginVersionProviderImpl. Tutti i plug-in devono includere una classe con questo nome e nome del pacchetto esatti. 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 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 Car UI 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 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 in base ad API più recenti. Per risolvere questo problema, consentiamo ai plug-in di restituire implementazioni diverse 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ù 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ì vecchio, potrebbe restituire null, nel qual caso vengono utilizzate le implementazioni collegate staticamente dei componenti CarUi.

Per mantenere la compatibilità con le versioni precedenti delle app compilate in base a versioni precedenti della libreria Car Ui statica, ti consigliamo di supportare maxVersion di 2, 5 e versioni successive dall'implementazione del plug-in della classe PluginVersionProvider. Le versioni 1, 3 e 4 non sono supportate. Per maggiori informazioni, vedi PluginVersionProviderImpl.

PluginFactory è l'interfaccia che crea tutti gli altri componenti CarUi. Definisce anche la versione delle interfacce da utilizzare. Se il plug-in non tenta di implementare nessuno di questi componenti, può 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 ci sarebbe alcuna garanzia che una vasta gamma di versioni dei componenti funzionino 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, che limiti poi le opzioni delle versioni di altri componenti che possono essere creati. 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 strettamente correlati, per questo motivo 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 intorno ai contenuti dell'app, per consentire una barra degli strumenti nella parte superiore/inferiore dell'app, verticalmente lungo i lati o persino una barra degli strumenti circolare che racchiude l'intera app. Ciò si ottiene passando una visualizzazione a installBaseLayoutAround per la barra degli strumenti/il layout di base da racchiudere.

Il plug-in deve prendere la visualizzazione fornita, staccarla dal relativo elemento principale, gonfiare il proprio layout nello stesso indice dell'elemento principale e con lo stesso LayoutParams della visualizzazione appena staccata, quindi ricollegare la visualizzazione in un punto qualsiasi 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 caso contrario, installBaseLayoutAround deve restituire null. Per la maggior parte dei plug-in, questo è tutto ciò che deve essere fatto, ma se l'autore del plug-in vuole applicare, ad esempio, una decorazione sul bordo 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 spostare l'app in uno spazio rettangolare e aggiungere transizioni pulite 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 i contenuti dell'app (con la barra degli strumenti o in altro modo). L'app saprà di continuare a disegnare in questo spazio, ma di tenere fuori i componenti critici con cui l'utente può interagire. Questo effetto viene utilizzato nel nostro design di riferimento per rendere la barra degli strumenti semitrasparente e per far scorrere gli elenchi sotto di essa. Se questa funzionalità non fosse stata implementata, il primo elemento di un elenco rimarrebbe bloccato sotto la barra degli strumenti e non sarebbe cliccabile. Se questo effetto non è necessario, il plug-in può ignorare Consumer.

Scorrimento dei contenuti sotto la barra degli strumenti Figura 2. Scorrimento dei contenuti sotto la barra degli strumenti

Dal punto di vista dell'app, quando il plug-in invia nuovi inserti, li riceve da qualsiasi attività o frammento che implementa InsetsChangedListener. Se un'attività o un fragment non implementa InsetsChangedListener, la libreria Car Ui gestirà gli inset per impostazione predefinita applicandoli come spaziatura interna al Activity o al FragmentActivity contenente il fragment. Per impostazione predefinita, la libreria non applica gli inset ai fragment. Ecco un esempio di snippet 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 racchiudere 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 la propria barra degli strumenti.

Poiché è previsto che installBaseLayoutAround restituisca un valore null 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 rotativi. Queste visualizzazioni possono essere omesse sui dispositivi che non supportano la rotazione. FocusParkingView/FocusAreas sono implementati nella libreria statica CarUi, quindi viene utilizzato un setRotaryFactories per fornire factory per creare le visualizzazioni dai contesti.

I contesti utilizzati per creare le visualizzazioni Focus devono essere il contesto di origine, non il contesto del plug-in. L'elemento FocusParkingView deve essere il più vicino possibile alla prima visualizzazione nell'albero, in quanto è l'elemento su cui viene messo a fuoco quando non deve essere visibile alcun focus all'utente. FocusArea deve racchiudere la barra degli strumenti nel layout di base per indicare che si tratta di una zona di notifica rotativa. Se FocusArea non è fornito, l'utente non può navigare tra i pulsanti della barra degli strumenti con il controller rotativo.

Controller della barra degli strumenti

L'ToolbarController effettivo restituito dovrebbe essere molto più semplice da implementare rispetto al layout di base. Il suo compito è prendere le informazioni passate ai relativi 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 IME (tastiera). Ciò può essere utile per visualizzare/animare i risultati di 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 plug-in fornisce solo metodi per la libreria statica per ottenere i callback TextView e onPrivateIMECommand. Per supportare questa funzionalità, il plug-in deve utilizzare una sottoclasse TextView che esegue l'override di 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 plug-in per MenuItems è immutabile, ogni volta che un MenuItem viene modificato, viene eseguita una nuova chiamata setMenuItems. Ciò potrebbe accadere per un'azione banale come il clic di un utente su un elemento di menu di un interruttore, che ha causato l'attivazione/disattivazione dell'interruttore. Per motivi di prestazioni e animazione, pertanto, è consigliabile calcolare la differenza tra l'elenco di MenuItem precedente e quello nuovo e aggiornare solo le visualizzazioni effettivamente modificate. MenuItems fornisce un campo key che può essere utile a questo scopo, in quanto la chiave deve essere la stessa in diverse chiamate a setMenuItems per lo stesso MenuItem.

AppStyledView

AppStyledView è un contenitore per una visualizzazione non personalizzata. Può essere utilizzato per fornire un bordo intorno a questa visualizzazione che la distingue dal resto dell'app e indicare all'utente che si tratta di un tipo diverso di interfaccia. La visualizzazione racchiusa da AppStyledView è fornita in setContent. La 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 la sua visualizzazione alla libreria statica tramite getView, che poi esegue l'inserimento. La posizione e le dimensioni di AppStyledView possono essere controllate anche implementando getDialogWindowLayoutParam.

Contesti

Il plug-in deve prestare attenzione quando utilizza i contesti, in quanto esistono contesti plug-in e "origine". 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 aumentare i layout nel plug-in.

Tuttavia, il contesto del plug-in potrebbe non avere la configurazione corretta. Per ottenere la configurazione corretta, forniamo contesti di origine nei metodi che creano 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 aumentate 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 loro componenti, ad esempio una modalità sport e una modalità eco che hanno un aspetto visivamente distinto. Non esiste un supporto integrato per questa funzionalità in CarUi, ma nulla impedisce al plug-in di implementarla internamente. Il plug-in può monitorare qualsiasi condizione per capire quando cambiare modalità, ad esempio l'ascolto di trasmissioni. Il plug-in non può attivare una modifica della configurazione per cambiare modalità, ma non è consigliabile fare affidamento sulle modifiche della configurazione in ogni caso, poiché 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 questa è 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 ciò che viene restituito all'app dal metodo getView nei componenti.

Un problema importante dell'utilizzo di ComposeView è che imposta i tag nella visualizzazione principale nel layout per archiviare le variabili globali condivise tra diverse ComposeView nella gerarchia. Poiché gli ID risorsa del plug-in non sono spazi dei nomi separati da quelli dell'app, ciò potrebbe causare conflitti quando sia l'app sia il plug-in impostano tag sulla stessa vista. Di seguito è riportato un ComposeViewWithLifecycle personalizzato che sposta queste variabili globali in ComposeView. Anche in questo caso, non deve 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)
//  }
}