Car UI 外掛程式

使用 Car UI 程式庫外掛程式建立完整的元件實作 Car UI 程式庫的自訂項目,而非使用執行階段資源疊加層 (RRO)。RRO 可讓您僅變更 Car UI 程式庫的 XML 資源 元件,用於限制可自訂的範圍。

建立外掛程式

Car UI 程式庫外掛程式是 APK,內含實作一組 外掛程式 API外掛程式 API 可以編譯為 擴充為靜態資料庫

查看 Soong 和 Gradle 中的範例:

Soong (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 預設值 而不是外掛程式實作。內容 provider 類別不存在。此時,請務必將 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 設定 Soong android_app 建構規則 (Android.bp) 標記 shared-lib,用於建構共用資料庫:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

共用程式庫的依附元件

針對使用 Car UI 程式庫的每個應用程式,加入 uses-library 標記位於 含有外掛程式套件名稱的 application 標記:

<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」 此外還會從 0 自動調整資源配置 您完全不必調整資源調度設定

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

Proxy 外掛程式

自訂 使用 Car UI 程式庫的應用程式 您需要使用 RRO,指定每個要修改的特定應用程式。 包括不同應用程式自訂項目都相同的情況也就是說,每個每秒要求數的 必須提供應用程式。查看哪些應用程式使用 Car UI 程式庫。

Car UI 程式庫 Proxy 外掛程式就是一種範例 外掛程式的共用程式庫,能將其元件實作委派給靜態 Car UI 程式庫的版本。您可以使用 RRO 指定這個外掛程式, 做為使用 Car UI 程式庫的應用程式的單一自訂點 而不必實作功能正常的外掛程式如要進一步瞭解 RRO,請參閱「變更應用程式資源的價值: 執行階段

Proxy 外掛程式只是一個範例,說明如何開始使用 外掛程式。針對 RRO 以外的自訂項目,您可以實作一部分外掛程式 其他元件則使用 Proxy 外掛程式 或實作所有外掛程式 完全從頭開始

雖然 Proxy 外掛程式為應用程式提供單點 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 程式庫 maxVersion 設為 2、5 以上 PluginVersionProvider 類別。第 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>。這個 使用者可用來與應用程式通訊,這是部分外掛程式 覆蓋應用程式的內容 (透過工具列或其他方式)。應用程式將會 這個領域應該繼續繪圖 但任何重要的使用者互動元素 其他元件這個效果已用於我們的參考設計中, 工具列是半透明狀態,且清單底下則捲動。如果這項功能 未實作時,清單中的第一個項目會停留在工具列下方 且不可點擊如果不需要此效果,外掛程式可以忽略 消費者。

內容會在工具列底下捲動 圖 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,供外掛程式表示 如果想要自訂基本版面配置,則必須從下列元素傳回 falsecustomizesBaseLayout

基本版面配置必須包含 FocusParkingViewFocusArea,才能完整顯示 支援旋轉控制項。如果裝置 不支援旋轉功能。FocusParkingView/FocusAreas 會在 靜態的 CarUi 程式庫,所以 setRotaryFactories 是用來提供工廠 從背景資訊建立檢視畫面

用於建立「焦點」檢視畫面的結構定義必須是來源內容,不能是 外掛程式的內容FocusParkingView 應最接近第一個檢視畫面 因為這有助於 使用者就不會看到焦點FocusArea 必須將工具列納入 基本版面配置,指出這是旋轉小圖示。如果 FocusArea 不是 時,使用者無法透過含有 旋轉控制器

工具列控制器

傳回的實際 ToolbarController 應該更容易理解, 實作了基本版面配置作用是將傳遞至 BigQuery 設定器,並以基本版面配置顯示。如需相關資訊,請參閱 Javadoc 多數方法。以下將說明一些較為複雜的方法。

getImeSearchInterface 是用來在輸入法編輯器 (鍵盤) 中顯示搜尋結果 視窗。這在顯示/動畫搜尋結果時,這個功能就非常實用 舉例來說,如果鍵盤只佔一半的螢幕。大部分的 功能是在靜態的 CarUi 程式庫中實作 外掛程式中的介面,只提供靜態資料庫取得 TextViewonPrivateIMECommand 回呼。為了支援這項功能 應使用覆寫 onPrivateIMECommand 並傳遞的 TextView 子類別 對提供的事件監聽器的呼叫,做為搜尋列的 TextView

setMenuItems 只會在螢幕上顯示 MenuItems,但會呼叫 讓人感到意外。由於 MenuItems 的外掛程式 API 無法變更,因此每當 MenuItem 有所變更,新的 setMenuItems 呼叫將會進行。這麼做 只發生在使用者點按切換 MenuItem 模式的情形時 切換鈕以便切換無論是效能還是動畫 因此建議您計算新舊版本的 MenuItems 清單,然後只更新實際變更的檢視畫面。MenuItems 提供有助於解決這個問題的 key 欄位,因為金鑰應相同 對同一個 MenuItem 呼叫 setMenuItems 的不同呼叫

AppStyledView

AppStyledView 是完全不自訂檢視區塊的容器。這項服務 可用於為檢視畫面加上邊框,讓該檢視畫面更顯眼 應用程式的其餘部分,並向使用者指出這是有別於 存取 APIAppStyledView 納入的檢視畫面位於 setContentAppStyledView 也可以具有返回或關閉按鈕, 應用程式要求。

AppStyledView 不會立即將其檢視畫面插入檢視區塊階層 不像 installBaseLayoutAround 的做法,反而是將檢視畫面傳回給 getView 建立靜態程式庫位置和 您也可透過實作方式控制 AppStyledView 的大小 getDialogWindowLayoutParam

背景資訊

使用 Contexts 時,請務必謹慎小心,因為系統同時提供外掛程式和 「來源」定義。外掛程式結構定義會做為引數 getPluginFactory,而且是唯一有 外掛程式的資源也就是說,你只能在這個情況下將內容用於 在外掛程式中加載版面配置。

然而,外掛程式內容可能並未設置正確的設定。目的地: 取得正確的設定,並在建立程式碼的方法中提供來源結構定義 元件。來源背景通常是活動,但在某些情況下 以及 Service 或其他 Android 元件如要使用 來源結構定義與外掛程式結構定義中的資源,就必須有新的結構定義 使用 createConfigurationContext 建立。如果 否則就會違反 Android 嚴格模式規範,而加載的檢視畫面 尺寸不正確

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

模式變更

有些外掛程式的元件可支援多種模式,例如 運動模式節能模式的外觀。由於沒有 CarUi 為這類功能內建支援,但沒有停止 就不會在內部完全實作外掛程式外掛程式可以監控 任何想要瞭解切換模式的時機,例如 監聽廣播訊息這個外掛程式無法觸發設定變更 來變更模式,但我們不建議您仰賴設定變更 反而會更加順暢地手動更新每個元件的外觀 同時享有無法透過 設定變更。

Jetpack Compose

您可以使用 Jetpack Compose 實作外掛程式,但這是 Alpha 等級 因此不應視為穩定版本。

外掛程式可以使用 ComposeView敬上 以建立啟用 Compose 的介面進行算繪。這個ComposeView會是 又要從元件中的 getView 方法傳回給應用程式的內容

使用 ComposeView 的一個主要問題是,這會在根層級檢視畫面設定標記 嵌入版面配置中共用的全域變數 階層中的不同 ComposeView由於外掛程式的資源 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)
//  }
}