汽車使用者介面插件

使用 Car UI 庫插件在 Car UI 庫中建立元件自訂的完整實現,而不是使用運行時資源覆蓋 (RRO)。 RRO 使您只能變更 Car UI 庫元件的 XML 資源,這限制了您可以自訂的範圍。

創建一個插件

Car UI 庫插件是一個 APK,其中包含實作一組插件 API的類別。插件 API可以作為靜態函式庫編譯到插件中。

請參閱 Soong 和 Gradle 中的範例:

考慮一下宋的例子:

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",
}

搖籃

請參閱此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')

該外掛程式必須在其清單中聲明一個內容提供者,該內容提供者俱有以下屬性:

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

android:authorities="com.android.car.ui.plugin"使 Car UI 庫可以發現該插件。必須導出提供程序,以便可以在運行時查詢它。另外,如果enabled屬性設為false ,則將使用預設實作而不是插件實作。內容提供者類別不必存在。在這種情況下,請務必將tools:ignore="MissingClass"加入提供者定義中。請參閱下面的範例清單條目:

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

最後,作為一項安全措施,對您的應用程式進行簽署

插件作為共享庫

與直接編譯到應用程式中的 Android 靜態函式庫不同,Android 共用函式庫被編譯成獨立的 APK,供其他應用程式在執行時引用。

作為 Android 共享庫實現的插件的類別會自動添加到應用程式之間的共享類別載入器中。當使用 Car UI 庫的應用程式指定對插件共享庫的運行時依賴項時,其類別載入器可以存取插件共享庫的類別。作為普通 Android 應用程式(而不是共享庫)實現的插件可能會對應用程式冷啟動時間產生負面影響。

實施和建構共享庫

使用 Android 共享庫進行開發與普通 Android 應用程式非常相似,但存在一些關鍵差異。

  • application標籤下的library標籤與插件應用程式清單中的插件包名稱一起使用:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • 使用 AAPT 標誌shared-lib配置您的 Soong android_app建置規則 ( Android.bp ),該規則用於建立共用程式庫:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

對共享庫的依賴

對於系統上使用 Car UI 庫的每個應用程序,請在應用程式清單中的application程式標籤下包含uses-library標籤和插件包名稱:

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

安裝插件

插件必須透過將模組包含在PRODUCT_PACKAGES中來預先安裝在系統分割區上。預先安裝的軟體包可以像任何其他安裝的應用程式一樣進行更新。

如果您要更新系統上的現有插件,任何使用該插件的應用程式都會自動關閉。一旦用戶重新打開,他們就會獲得更新的更改。如果應用程式未運行,下次啟動時它將具有更新的插件。

使用 Android Studio 安裝外掛程式時,需要考慮一些其他注意事項。截至撰寫本文時,Android Studio 應用程式安裝過程中存在一個錯誤,導致插件更新無法生效。可以透過在插件的建置配置中選擇選項來始終使用套件管理器安裝(在 Android 11 及更高版本上停用部署最佳化)來修復此問題。

此外,在安裝外掛程式時,Android Studio 會報告找不到要啟動的主要活動的錯誤。這是預期的,因為插件沒有任何活動(除了用於解析意圖的空意圖)。若要消除該錯誤,請在建置配置中將Launch選項變更為Nothing

Android Studio 外掛程式配置圖 1.插件 Android Studio 配置

代理商插件

使用 Car UI 庫自訂應用程式需要一個針對要修改的每個特定應用程式的 RRO,包括當應用程式之間的自訂內容相同時。這意味著每個應用程式都需要一個 RRO。查看哪些應用程式使用 Car UI 庫。

Car UI 庫代理外掛程式是一個範例外掛程式共用庫,它將其元件實作委託給 Car UI 庫的靜態版本。該插件可以以 RRO 為目標,可以用作使用 Car UI 庫的應用程式的單點自訂,而無需實現功能插件。有關 RRO 的更多信息,請參閱在運行時更改應用程式資源的值

代理插件只是一個範例,也是使用插件進行自訂的起點。對於 RRO 之外的定制,可以實現插件組件的子集並使用代理插件來完成其餘組件,或者完全從頭開始實現所有插件組件。

儘管代理插件為應用程式提供了單點 RRO 自訂,但選擇不使用該插件的應用程式仍需要直接針對應用程式本身的 RRO。

實作插件 API

該插件的主要入口點是com.android.car.ui.plugin.PluginVersionProviderImpl類別。所有插件都必須包含具有該確切名稱和套件名稱的類別。此類必須具有預設建構子並實作PluginVersionProviderOEMV1介面。

CarUi 插件必須與比該插件舊或新的應用程式配合使用。為了實現這一點,所有插件 API 都在其類別名稱末尾使用V#進行版本控制。如果發布具有新功能的新版本 Car UI 庫,它們將成為該元件V2版本的一部分。 Car UI 庫盡力使新功能在舊插件元件的範圍內運作。例如,透過將工具列中的新類型按鈕轉換為MenuItems

但是,具有舊版本 Car UI 庫的應用程式無法適應針對較新 API 編寫的新插件。為了解決這個問題,我們允許插件根據應用程式支援的 OEM API 版本返回不同的實作。

PluginVersionProviderOEMV1有個方法:

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

此方法傳回一個對象,該物件實作插件支援的PluginFactoryOEMV#的最高版本,同時仍然小於或等於maxVersion 。如果插件沒有那麼舊的PluginFactory實現,它可能會傳回null ,在這種情況下,使用 CarUi 元件的靜態連結實作。

為了保持與針對舊版本靜態 Car Ui 函式庫編譯的應用程式的向後相容性,建議在PluginVersionProvider類別的插件實作中支援maxVersion 2、5 和更高版本。不支援版本 1、3 和 4。有關更多信息,請參閱PluginVersionProviderImpl

PluginFactory是創建所有其他 CarUi 元件的介面。它還定義了應使用其介面的哪個版本。如果外掛程式不尋求實作任何這些元件,它可能會在其建立函數中傳回null (工具列除外,它具有單獨的customizesBaseLayout()函數)。

pluginFactory限制了哪些版本的 CarUi 元件可以一起使用。例如,永遠不會有一個pluginFactory可以創建版本100的Toolbar和版本1的RecyclerView ,因為幾乎不能保證各種版本的元件能夠一起工作。要使用工具列版本 100,開發人員需要提供建立工具列版本 100 的pluginFactory版本的實現,然後限制可以建立的其他元件的版本選項。其他元件的版本可能不相等,例如pluginFactoryOEMV100可以建立ToolbarControllerOEMV100RecyclerViewOEMV70

工具列

基地佈局

工具列和「基本佈局」密切相關,因此建立工具列的函數稱為installBaseLayoutAround基本佈局是一個概念,允許工具列放置在應用程式內容周圍的任何位置,允許工具列橫跨應用程式的頂部/底部、垂直於兩側,甚至是包圍整個應用程式的圓形工具列。這是透過將視圖傳遞給installBaseLayoutAround以使工具列/基本佈局環繞來完成的。

插件應該取得提供的視圖,將其與其父級分離,在父級的相同索引中膨脹插件自己的佈局,並使用與剛剛分離的視圖相同的LayoutParams ,然後將視圖重新附加到原來的佈局內的某個位置。只是膨脹了。如果應用程式請求,膨脹的佈局將包含工具列。

該應用程式可以請求沒有工具列的基本佈局。如果是, installBaseLayoutAround應傳回 null。對於大多數插件來說,這就是需要發生的全部事情,但是如果插件作者想要在應用程式邊緣應用裝飾,那麼仍然可以使用基本佈局來完成。這些裝飾對於具有非矩形螢幕的裝置特別有用,因為它們可以將應用程式推入矩形空間,並向非矩形空間添加乾淨的過渡。

installBaseLayoutAround也傳遞了Consumer<InsetsOEMV1> 。該用戶可用於向應用程式傳達該插件部分覆蓋了應用程式的內容(使用工具列或其他方式)。然後,應用程式將知道繼續在此空間中繪製,但將任何關鍵的用戶可交互組件排除在外。我們的參考設計中使用了此效果,使工具列半透明,並在其下方滾動列表。如果未實現此功能,則清單中的第一項將卡在工具列下方且不可按一下。如果不需要此效果,插件可以忽略 Consumer。

內容在工具列下方滾動圖 2.工具列下方滾動的內容

從應用程式的角度來看,當插件發送新的插入時,它將從實作InsetsChangedListener的任何活動或片段接收它們。如果 Activity 或 Fragment 未實作InsetsChangedListener ,則 Car Ui 函式庫會預設處理 insets,方法是將 insets 作為填充套用到包含該 Fragment ActivityFragmentActivity 。預設情況下,該庫不會將插圖套用於片段。以下是將插圖應用為應用程式中RecyclerView上的填充的範例程式碼片段:

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

最後,插件被給予fullscreen提示,用於指示應該包裝的視圖是否佔據整個應用程式或僅佔據一小部分。這可以用來避免沿著邊緣應用一些裝飾,這些裝飾只有當它們出現在整個螢幕的邊緣時才有意義。使用非全螢幕基本佈局的範例應用程式是“設定”,其中雙窗格佈局的每個窗格都有自己的工具列。

由於當toolbarEnabledfalseinstallBaseLayoutAround預計會傳回 null ,因此為了讓外掛程式指示它不希望自訂基本佈局,它必須從customizesBaseLayout傳回false

基本佈局必須包含FocusParkingViewFocusArea以完全支援旋轉控制。在不支援旋轉的裝置上可以省略這些視圖。 FocusParkingView/FocusAreas是在靜態 CarUi 庫中實現的,因此setRotaryFactories用於提供工廠來從上下文建立視圖。

用於建立焦點視圖的上下文必須是來源上下文,而不是插件的上下文。 FocusParkingView應盡可能最接近樹中的第一個視圖,因為當使用者不應該看到焦點時,它就是焦點所在。 FocusArea必須將工具列包裹在基本佈局中,以指示它是旋轉微移區域。如果未提供FocusArea ,使用者將無法使用旋轉控制器導覽至工具列中的任何按鈕。

工具列控制器

傳回的實際ToolbarController應該比基本佈局更容易實現。它的工作是獲取傳遞給其 setter 的資訊並將其顯示在基本佈局中。有關大多數方法的信息,請參閱 Javadoc。下面討論一些更複雜的方法。

getImeSearchInterface用於在 IME(鍵盤)視窗中顯示搜尋結果。這對於在鍵盤旁邊顯示/動畫搜尋結果非常有用,例如,如果鍵盤只佔據螢幕的一半。大部分功能是在靜態 CarUi 庫中實現的,插件中的搜尋介面僅提供靜態庫獲取TextViewonPrivateIMECommand回呼的方法。為了支援這一點,插件應該使用TextView子類別來重寫onPrivateIMECommand並將呼叫傳遞給提供的偵聽器作為其搜尋欄的TextView

setMenuItems只是在螢幕上顯示 MenuItems,但它的呼叫頻率卻令人驚訝。由於 MenuItems 的插件 API 是不可變的,因此每當 MenuItem 更改時,都會發生全新的setMenuItems呼叫。這種情況可能發生在像用戶單擊開關選單項目這樣微不足道的事情上,並且該單擊導致開關切換。出於效能和動畫方面的原因,建議計算新舊 MenuItems 清單之間的差異,並僅更新實際變更的視圖。 MenuItems 提供了一個可以幫助解決此問題的key字段,因為對於相同 MenuItem 對setMenuItems不同調用,該鍵應該是相同的。

應用程式樣式視圖

AppStyledView是根本未自訂的視圖的容器。它可用於在該視圖周圍提供邊框,使其從應用程式的其餘部分中脫穎而出,並向使用者表明這是不同類型的介面。由 AppStyledView 包裝的視圖在setContent中給出。 AppStyledView還可以根據應用程式的要求有一個後退或關閉按鈕。

AppStyledView不會像installBaseLayoutAround那樣立即將其視圖插入視圖層次結構,而是僅透過getView將其視圖返回到靜態庫,然後由靜態庫執行插入操作。 AppStyledView的位置和大小也可以透過實作getDialogWindowLayoutParam來控制。

情境

插件在使用上下文時必須小心,因為同時存在插件和“來源”上下文。插件上下文是作為getPluginFactory參數給出,並且是唯一包含插件資源的上下文。這意味著它是唯一可用於擴充插件中的佈局的上下文。

但是,插件上下文可能沒有設定正確的配置。為了獲得正確的配置,我們在建立元件的方法中提供來源上下文。來源上下文通常是一個 Activity,但在某些情況下也可能是一個 Service 或其他 Android 元件。若要將來源上下文中的配置與外掛程式上下文中的資源一起使用,必須使用createConfigurationContext建立新上下文。如果未使用正確的配置,則會出現 Android 嚴格模式違規,且膨脹視圖可能沒有正確的尺寸。

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

模式變化

一些插件可以支援其組件的多種模式,例如看起來視覺上不同的運動模式生態模式。 CarUi 中沒有對此類功能的內建支持,但沒有什麼可以阻止插件完全在內部實現它。該插件可以監視它想要確定何時切換模式的任何條件,例如監聽廣播。該插件無法觸發配置更改來更改模式,但無論如何都不建議依賴配置更改,因為手動更新每個組件的外觀對用戶來說更平滑,並且還允許進行配置更改不可能實現的轉換。

Jetpack 組合

外掛可以使用 Jetpack Compose 來實現,但這是一個 alpha 等級的功能,不應被視為穩定。

插件可以使用ComposeView建立一個支援 Compose 的表面進行渲染。此ComposeView將是元件中的getView方法從應用程式傳回的內容。

使用ComposeView的一個主要問題是它在佈局中的根視圖上設定標籤,以便儲存在層次結構中的不同 ComposeView 之間共享的全域變數。由於插件的資源 ID 沒有與應用程式的資源 ID 分開命名,因此當應用程式和外掛程式在同一視圖上設定標籤時,這可能會導致衝突。下面提供了一個自訂ComposeViewWithLifecycle ,可將這些全域變數向下移動到ComposeView 。同樣,這不應被視為穩定。

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