Car UI 外掛程式

使用 Car UI 程式庫外掛程式,在 Car UI 程式庫中完整實作元件自訂項目,而不是使用執行階段資源疊加 (RRO)。RRO 可讓您只變更 Car UI 程式庫元件的 XML 資源,因此可自訂的範圍有限。

建立外掛程式

Car UI 程式庫外掛程式是 APK,內含實作一組外掛程式 API 的類別。外掛程式 API 可以編譯成外掛程式,做為靜態程式庫。

請參閱 Soong 和 Gradle 中的範例:

Soong

請參考以下 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

請參閱這個 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 共用程式庫會編譯成獨立 APK,並在執行階段由其他應用程式參照,這與直接編譯到應用程式中的 Android 靜態程式庫不同。

如果外掛程式是以 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 應用程式安裝程序存在錯誤,導致外掛程式更新無法生效。如要修正這個問題,請在外掛程式的建構設定中選取「Always install with package manager (disables deploy optimizations on Android 11 and later)」(一律使用套件管理員安裝,並停用 Android 11 以上版本的部署最佳化功能) 選項。

此外,安裝外掛程式時,Android Studio 會回報錯誤,指出找不到要啟動的主要活動。這是預期行為,因為外掛程式沒有任何活動 (用於解析意圖的空白意圖除外)。如要消除錯誤,請在建構設定中將「啟動」選項變更為「無」

外掛程式 Android Studio 設定 圖 1. 外掛程式 Android Studio 設定

Proxy 外掛程式

如要使用 Car UI 程式庫自訂應用程式,必須針對每個要修改的特定應用程式使用 RRO,即使應用程式的自訂項目相同也一樣。也就是說,每個應用程式都需要 RRO。查看哪些應用程式使用 Car UI 程式庫。

Car UI 程式庫 Proxy 外掛程式是外掛程式共用程式庫的範例,可將元件實作項目委派給 Car UI 程式庫的靜態版本。這個外掛程式可做為 RRO 的目標,並做為使用 Car UI 程式庫的應用程式的單一自訂點,不必實作功能外掛程式。如要進一步瞭解 RRO,請參閱「在執行階段變更應用程式資源的值」。

這個外掛程式僅為範例,可做為使用外掛程式進行自訂的起點。如要進行 RRO 以外的自訂作業,可以實作外掛程式元件的子集,並將其餘元件用於 Proxy 外掛程式,也可以從頭開始實作所有外掛程式元件。

雖然 Proxy 外掛程式為應用程式提供單一 RRO 自訂點,但選擇不使用外掛程式的應用程式仍需要直接以應用程式本身為目標的 RRO。

實作外掛程式 API

外掛程式的主要進入點是 com.android.car.ui.plugin.PluginVersionProviderImpl 類別。所有外掛程式都必須包含具有這個確切名稱和套件名稱的類別。這個類別必須有預設建構函式,並實作 PluginVersionProviderOEMV1 介面。

CarUi 外掛程式必須能與比外掛程式舊或新的應用程式搭配運作。為方便進行這項作業,所有外掛程式 API 的類別名稱結尾都會加上 V#,如果新版車輛 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 類別的外掛程式實作中,支援 maxVersions 2、5 以上版本。系統不支援第 1、3 和 4 版。詳情請參閱PluginVersionProviderImpl

PluginFactory 是建立所有其他 CarUi 元件的介面。此外,也定義了應使用的介面版本。如果外掛程式不打算實作任何這些元件,可以在建立函式中傳回 null (工具列除外,工具列有獨立的 customizesBaseLayout() 函式)。

pluginFactory 會限制可一起使用的 CarUi 元件版本。舉例來說,絕不會有 pluginFactory 可以同時建立 Toolbar 的 100 版和 RecyclerView 的 1 版,因為這樣很難保證各種版本的元件可以一起運作。如要使用工具列版本 100,開發人員必須提供 pluginFactory 版本的實作項目,建立工具列版本 100,然後限制可建立的其他元件版本選項。其他元件的版本可能不相等,例如 pluginFactoryOEMV100 可能會建立 ToolbarControllerOEMV100RecyclerViewOEMV70

工具列

基礎版面

工具列和「基本版面配置」密切相關,因此建立工具列的函式稱為 installBaseLayoutAround基本版面配置的概念是讓工具列能放置在應用程式內容周圍的任何位置,因此工具列可以位於應用程式頂端/底部、沿著側邊垂直放置,甚至可以環繞整個應用程式。如要達成這個目標,請將檢視區塊傳遞至 installBaseLayoutAround,讓工具列/基本版面配置包覆該檢視區塊。

外掛程式應採用提供的檢視區塊、從父項中分離、在父項的相同索引中膨脹外掛程式自己的版面配置,並使用與剛分離的檢視區塊相同的 LayoutParams,然後在剛膨脹的版面配置內重新附加檢視區塊。如果應用程式要求,擴充的版面配置會包含工具列。

應用程式可以要求不含工具列的基本版面配置。如果有的話,installBaseLayoutAround 應傳回空值。對大多數外掛程式而言,這樣就夠了,但如果外掛程式作者想在應用程式邊緣套用裝飾等效果,還是可以使用基本版面配置。對於非矩形螢幕的裝置,這些裝飾特別實用,因為它們可將應用程式推入矩形空間,並在非矩形空間中加入乾淨的轉場效果。

installBaseLayoutAround 也會傳遞 Consumer<InsetsOEMV1>。這個取用者可用於向應用程式傳達外掛程式部分涵蓋應用程式內容 (透過工具列或其他方式)。應用程式就會知道要繼續在這個空間中繪製內容,但請將任何可供使用者互動的重要元件排除在外。參考設計會使用這項效果,讓工具列呈現半透明狀態,並讓清單在工具列下方捲動。如果未實作這項功能,清單中的第一個項目會卡在工具列下方,無法點選。如果不需要這項效果,外掛程式可以忽略 Consumer。

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

從應用程式的角度來看,外掛程式傳送新的插邊時,實作 InsetsChangedListener 的任何活動或片段都會收到這些插邊。如果活動或片段未實作 InsetsChangedListener,Car Ui 程式庫預設會處理插邊,方法是將插邊套用為包含片段的 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 提示,用於指出應包裝的檢視區塊是否佔用整個應用程式,或只佔用一小部分。這項屬性可用於避免沿著邊緣套用某些裝飾,因為這些裝飾只有出現在整個螢幕的邊緣時才有意義。使用非全螢幕基本版面配置的範例應用程式是「設定」,其中雙窗格版面配置的每個窗格都有自己的工具列。

由於 installBaseLayoutAround 預期會在 toolbarEnabledfalse 時傳回空值,因此外掛程式必須從 customizesBaseLayout 傳回 false,才能表示不想自訂基本版面配置。

基本版面配置必須包含 FocusParkingViewFocusArea,才能完整支援旋鈕控制項。如果裝置不支援旋鈕,可以省略這些檢視畫面。FocusParkingView/FocusAreas 會在靜態 CarUi 程式庫中實作,因此 setRotaryFactories 會用於提供工廠,以便從內容建立檢視區塊。

用於建立 Focus 檢視區塊的情境必須是來源情境,而非外掛程式的情境。FocusParkingView 應盡可能靠近樹狀結構中的第一個檢視區塊,因為當使用者看不到焦點時,系統會將焦點放在這個檢視區塊。FocusArea 必須將工具列包裝在基本版面配置中,表示這是旋鈕輕觸區。如果未提供 FocusArea,使用者就無法使用旋轉控制器瀏覽工具列中的任何按鈕。

工具列控制器

實際傳回的 ToolbarController 應比基本版面配置更容易實作。這項工作的目標是擷取傳遞至其設定工具的資訊,並顯示在基本版面配置中。如要瞭解大多數方法,請參閱 Javadoc。以下將說明一些較複雜的方法。

getImeSearchInterface 用於在 IME (鍵盤) 視窗中顯示搜尋結果。舉例來說,如果鍵盤只占據一半的螢幕,這項功能就非常實用,可讓搜尋結果與鍵盤並排顯示/動畫。大部分功能都是在靜態 CarUi 程式庫中實作,外掛程式中的搜尋介面只提供方法,讓靜態程式庫取得 TextViewonPrivateIMECommand 回呼。為支援這項功能,外掛程式應使用覆寫 onPrivateIMECommandTextView 子類別,並將呼叫傳遞至提供的接聽程式做為搜尋列的 TextView

setMenuItems 只會在畫面上顯示 MenuItems,但呼叫頻率卻出乎意料地高。由於 MenuItems 的外掛程式 API 是不可變動的,因此每當 MenuItem 變更時,系統都會發出全新的 setMenuItems 呼叫。舉例來說,使用者點選切換 MenuItem,導致切換按鈕切換狀態,就可能發生這種情況。因此,無論是為了提升效能或動畫效果,都建議您計算新舊 MenuItems 清單之間的差異,並只更新實際變更的檢視區塊。MenuItems 提供 key 欄位,可協助完成這項作業,因為相同 MenuItem 的不同 setMenuItems 呼叫應使用相同的鍵。

AppStyledView

AppStyledView 是完全未自訂檢視區塊的容器。可用於在該檢視區塊周圍加上邊框,使其從應用程式的其餘部分脫穎而出,並向使用者指出這是不同類型的介面。AppStyledView 包裝的檢視畫面會以 setContent 形式提供。應用程式也可以要求 AppStyledView 顯示返回或關閉按鈕。

AppStyledView 不會像 installBaseLayoutAround 一樣立即將檢視區塊插入檢視區塊階層,而是透過 getView 將檢視區塊傳回靜態程式庫,然後由該程式庫執行插入作業。實作 getDialogWindowLayoutParam 即可控制 AppStyledView 的位置和大小。

背景資訊

外掛程式使用 Context 時必須謹慎,因為有外掛程式和「來源」Context。外掛程式內容會做為引數提供給 getPluginFactory,且是唯一包含外掛程式資源的內容。也就是說,這是唯一可用於在外掛程式中擴充版面配置的環境。

不過,外掛程式環境可能未設定正確的設定。為取得正確的設定,我們會在建立元件的方法中提供來源內容。來源內容通常是活動,但有時也可能是服務或其他 Android 元件。如要將來源內容的設定與外掛程式內容的資源搭配使用,必須使用 createConfigurationContext 建立新內容。如果未採用正確的設定,就會發生 Android 嚴格模式違規情形,且擴充檢視區塊的尺寸可能不正確。

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

模式變更

部分外掛程式可支援元件的多種模式,例如運動模式省電模式,這些模式在視覺上有所不同。CarUi 並未內建支援這類功能,但外掛程式完全可以在內部實作這類功能。外掛程式可以監控任何條件,判斷何時切換模式,例如監聽廣播。外掛程式無法觸發設定變更來變更模式,但無論如何,不建議依賴設定變更,因為手動更新每個元件的外觀對使用者來說更順暢,而且還能實現設定變更無法達成的轉場效果。

Jetpack Compose

外掛程式可使用 Jetpack Compose 實作,但這項功能處於 Alpha 版,不應視為穩定版。

外掛程式可以使用 ComposeView 建立可啟用 Compose 的介面,以便算繪至該介面。這個 ComposeView 是元件中 getView 方法傳回給應用程式的內容。

使用 ComposeView 的主要問題之一,是它會在版面配置的根層級檢視區塊上設定標記,以便儲存階層中不同 ComposeView 共用的全域變數。由於外掛程式的資源 ID 並未與應用程式的資源 ID 分開命名空間,因此當應用程式和外掛程式在同一個檢視區塊中設定標記時,可能會發生衝突。下方提供自訂函式,可將這些全域變數向下移至資料層。ComposeViewWithLifecycleComposeView再次提醒,這並非穩定版。

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