汽車用戶界面插件

使用car-ui-lib插件car-ui-lib -ui-lib 中創建組件自定義的完整實現,而不是使用運行時資源覆蓋 (RRO)。 RRO 使您只能更改car-ui-lib組件的 XML 資源,這限制了您可以自定義的範圍。

創建插件

car-ui-lib插件是一個 APK,其中包含實現一組插件 API的類。插件 API 位於packages/apps/Car/libs/car-ui-lib/oem-apis中,可以作為靜態庫編譯成插件。

請參閱下面的 Soong 和 Gradle 示例:

考慮一下這個宋的例子:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    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",
    ],

    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 skip the 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-lib發現。必須導出提供程序,以便在運行時對其進行查詢。此外,如果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>

最後,作為一項安全措施,簽署您的應用程序

安裝插件

構建插件後,可以像安裝任何其他應用程序一樣安裝它,例如將其添加到PRODUCT_PACKAGES或使用adb install 。但是,如果這是新安裝的插件,則必須重新啟動應用程序才能使更改生效。這可以通過對特定應用執行完整的adb rebootadb shell am force-stop package.name來完成。

如果您要更新系統上現有的car-ui-lib插件,則使用該插件的任何應用程序都會自動關閉,並且一旦被用戶重新打開,就會獲得更新的更改。如果應用程序當時在前台,這看起來像一個崩潰。如果應用程序未運行,則下次啟動時它會更新插件。

使用 Android Studio 安裝插件時,需要考慮一些額外的注意事項。在撰寫本文時,Android Studio 應用程序安裝過程中存在導致插件更新無法生效的錯誤。這可以通過在插件的構建配置中選擇始終使用包管理器安裝(禁用 Android 11 及更高版本上的部署優化)選項來解決。

另外,在安裝插件的時候,Android Studio 報錯找不到主要的activity來啟動。這是意料之中的,因為插件沒有任何活動(用於解析意圖的空意圖除外)。要消除該錯誤,請在構建配置中將Launch選項更改為Nothing

插件 Android Studio 配置圖 1.插件 Android Studio 配置

實現插件 API

插件的主要入口點是com.android.car.ui.plugin.PluginVersionProviderImpl類。所有插件都必須包含具有此確切名稱和包名稱的類。此類必須具有默認構造函數並實現PluginVersionProviderOEMV1接口。

CarUi 插件必須與比插件舊或新的應用程序一起使用。為了促進這一點,所有插件 API 都在其類名的末尾使用V#進行版本控制。如果發布了帶有新功能的新版本car-ui-lib ,它們是組件V2版本的一部分。 car-ui-lib盡最大努力使新功能在舊插件組件的範圍內工作。例如,通過將工具欄中的一種新型按鈕轉換為MenuItems

但是,帶有舊版本car-ui-lib的舊應用程序無法適應針對較新 API 編寫的新插件。為了解決這個問題,我們允許插件根據應用程序支持的 OEM API 版本返回不同的實現。

PluginVersionProviderOEMV1有一個方法:

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

此方法返回一個實現插件支持的最高版本PluginFactoryOEMV#的對象,同時仍小於或等於maxVersion 。如果插件沒有那麼舊的PluginFactory實現,它可能會返回null ,在這種情況下,使用 CarUi 組件的靜態鏈接實現。

PluginFactory是創建所有其他 CarUi 組件的接口。它還定義了應使用其接口的哪個版本。如果插件不尋求實現這些組件中的任何一個,它可能會在其創建函數中返回null (工具欄除外,它具有單獨的customizesBaseLayout()函數)。

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

工具欄

基地佈局

工具欄和“基本佈局”密切相關,因此創建工具欄的函數稱為installBaseLayoutAround基本佈局是一個概念,它允許工具欄放置在應用程序內容周圍的任何位置,以允許工具欄橫跨應用程序的頂部/底部,垂直於側面,甚至是一個包含整個應用程序的圓形工具欄。這是通過將 View 傳遞給installBaseLayoutAround以使工具欄/基本佈局環繞來實現的。

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

該應用程序可以請求沒有工具欄的基本佈局。如果是這樣, installBaseLayoutAround應該返回 null。對於大多數插件來說,這就是所有需要發生的事情,但是如果插件作者想在應用程序的邊緣應用例如裝飾,那仍然可以通過基本佈局來完成。這些裝飾對於具有非矩形屏幕的設備特別有用,因為它們可以將應用程序推入矩形空間並在非矩形空間中添加干淨的過渡。

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

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

從應用程序的角度來看,當插件發送新插入時,它將通過任何實現InsetsChangedListener的活動/片段接收它們。下面是一個實現的示例片段,該示例將 insets 應用為應用程序中的 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提示,用於指示應該包裝的 View 是佔據整個應用程序還是只是一小部分。這可以用來避免沿邊緣應用一些裝飾,這些裝飾只有在它們出現在整個屏幕的邊緣時才有意義。使用非全屏基本佈局的示例應用程序是設置,其中雙窗格佈局的每個窗格都有自己的工具欄。

由於當toolbarEnabledfalseinstallBaseLayoutAround預計返回 null ,因此插件表明它不希望自定義基本佈局,它必須從customizesBaseLayout返回false

基本佈局必須包含FocusParkingViewFocusArea才能完全支持旋轉控件。在不支持旋轉的設備上可以省略這些視圖。 FocusParkingView/FocusAreas在靜態 CarUi 庫中實現,因此使用setRotaryFactories提供工廠以從上下文創建視圖。

用於創建焦點視圖的上下文必須是源上下文,而不是插件的上下文。 FocusParkingView應該盡可能合理地最接近樹中的第一個視圖,因為當用戶不應該看到焦點時,它才是焦點所在。 FocusArea必須將工具欄包裹在基本佈局中,以表明它是一個旋轉微調區域。如果未提供FocusArea ,則用戶無法使用旋轉控制器導航到工具欄中的任何按鈕。

工具欄控制器

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

getImeSearchInterface用於在 IME(鍵盤)窗口中顯示搜索結果。這對於在鍵盤旁邊顯示/動畫搜索結果很有用,例如,如果鍵盤只佔屏幕的一半。大部分功能是在靜態 CarUi 庫中實現的,插件中的搜索接口只是為靜態庫提供了獲取TextViewonPrivateIMECommand回調的方法。為了支持這一點,插件應該使用TextView子類來覆蓋onPrivateIMECommand並將調用傳遞給提供的偵聽器作為其搜索欄的TextView

setMenuItems只是在屏幕上顯示 MenuItems,但它會經常被調用。由於 MenuItems 的插件 API 是不可變的,因此每當更改 MenuItem 時,都會發生一個全新的setMenuItems調用。這可能發生在用戶單擊開關 MenuItem 等微不足道的事情上,並且該單擊導致開關切換。出於性能和動畫的原因,因此鼓勵計算新舊 MenuItems 列表之間的差異,並且只更新實際更改的視圖。 MenuItems 提供了一個可以幫助解決此問題的key字段,因為對於同一 MenuItem 對setMenuItems的不同調用,該鍵應該相同。

AppStyledView

AppStyledView是一個完全沒有自定義的 View 的容器。它可用於在該視圖周圍提供一個邊框,使其從應用程序的其餘部分中脫穎而出,並向用戶表明這是一種不同類型的界面。由 AppStyledView 包裝的 View 在setContent中給出。 AppStyledView還可以有應用程序請求的後退或關閉按鈕。

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

上下文

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

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

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

模式變化

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

噴氣背包組成

插件可以使用 Jetpack Compose 實現,但這是一個 alpha 級功能,不應被視為穩定。

插件可以使用ComposeView創建啟用 Compose 的表面以進行渲染。這個ComposeView將是從組件中的getView方法返回給應用程序的內容。

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

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