請使用 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 靜態資料庫不同,Android 共用資料庫會編譯為獨立 APK,供其他應用程式在執行階段參照。
以 Android 共用程式庫形式實作的外掛程式,其類別會自動新增至應用程式之間的共用類別載入器。使用 Car UI 程式庫的應用程式在外掛程式共用程式庫上指定執行階段依附元件時,其類別載入器就能存取外掛程式共用程式庫的類別。以一般 Android 應用程式 (而非共用程式庫) 形式實作的外掛程式,可能會對應用程式的冷啟動時間造成負面影響。
實作及建構共用程式庫
使用 Android 共用程式庫進行開發的過程與一般 Android 應用程式相似,但有幾個主要差異。
- 在外掛程式的應用程式資訊清單中,使用
application
標記下方的library
標記,並加上外掛程式套件名稱:
<application>
<library android:name="com.chassis.car.ui.plugin" />
...
</application>
- 使用 AAPT 標記
shared-lib
設定 Soongandroid_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 Studio 會回報錯誤,指出找不到要啟動的活動。這是預期的結果,因為外掛程式沒有任何活動 (除了用於解析意圖的空白意圖)。如要排除錯誤,請在建構設定中將「Launch」選項變更為「Nothing」。
圖 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#
以標示版本。如果 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
類別時,支援 2、5 以上版本的 maxVersion
。不支援 1、3 和 4 版。詳情請參閱 PluginVersionProviderImpl
。
PluginFactory
是建立所有其他 CarUi 元件的介面。並定義應使用哪個介面版本。如果外掛程式未嘗試實作任何這些元件,可能會在建立函式中傳回 null
(工具列除外,因為它有個獨立的 customizesBaseLayout()
函式)。
pluginFactory
會限制可同時使用的 CarUi 元件版本。舉例來說,永遠不會有 pluginFactory
可以建立 Toolbar
的 100 版,以及 RecyclerView
的 1 版,因為很難保證各種元件版本都能互相搭配運作。如要使用工具列 100 版,開發人員應提供 pluginFactory
版本的實作項目,以便建立工具列 100 版,進而限制可建立的其他元件版本選項。其他元件的版本可能不相等,例如 pluginFactoryOEMV100
可以建立 ToolbarControllerOEMV100
和 RecyclerViewOEMV70
。
工具列
基礎版面
工具列和「基本版面配置」之間的關聯性非常緊密,因此建立工具列的函式會稱為 installBaseLayoutAround
。基本版面配置是一種概念,可讓工具列置於應用程式內容周圍的任何位置,讓工具列橫跨應用程式的頂端/底部、沿著側邊垂直顯示,甚至是包圍整個應用程式的圓形工具列。這可透過將檢視畫面傳遞至 installBaseLayoutAround
,讓工具列/基本版面配置包圍檢視畫面來達成。
外掛程式應採用提供的檢視畫面,將其從父項中分離,並在父項的相同索引中以與剛分離的檢視畫面相同的 LayoutParams
方式,將外掛程式自己的版面配置展開,然後在剛展開的版面配置中重新附加檢視畫面。如果應用程式要求,則會在已加載的版面配置中加入工具列。
應用程式可以要求不含工具列的基本版面配置。如果是,installBaseLayoutAround
應傳回空值。對於多數外掛程式,這就是需要完成的所有工作,但如果外掛程式作者想套用裝飾 (例如在應用程式邊緣),仍可使用基本版面配置完成。這些裝飾對於非矩形螢幕的裝置特別實用,因為它們可以將應用程式推送至矩形空間,並在非矩形空間中加入清晰的轉場效果。
installBaseLayoutAround
也會傳遞 Consumer<InsetsOEMV1>
。這個使用者可以用來向應用程式傳達外掛程式是否會部分遮蓋應用程式的內容 (使用工具列或其他方式)。應用程式就會知道要繼續在這個空間繪圖,但請將任何可供使用者互動的關鍵元件排除在外。這項效果會用於參考設計,讓工具列呈半透明狀,並在下方捲動清單。如果未實作這項功能,清單中的首個項目就會卡在工具列下方,無法點選。如果不需要這項效果,外掛程式可以忽略 Consumer。
圖 2. 內容會捲動至工具列下方
從應用程式的角度來看,當外掛程式傳送新的邊框時,會從實作 InsetsChangedListener
的任何活動或片段接收邊框。如果活動或片段未實作 InsetsChangedListener
,Car UI 程式庫會預設套用插邊,將插邊做為邊框間距套用至包含片段的 Activity
或 FragmentActivity
。根據預設,程式庫不會將內嵌套套用至區塊。以下是實作範例的程式碼片段,可將邊框套用為應用程式中 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
提示,用於指出應包裝的檢視畫面是占用整個應用程式,還是只占用一小部分。這可用於避免沿著邊緣套用某些裝飾,因為這些裝飾只會出現在整個螢幕的邊緣。設定是使用非全螢幕版面配置的基本版面配置範例應用程式,其中雙窗格版面配置的每個窗格都有專屬的工具列。
toolbarEnabled
為 false
時,installBaseLayoutAround
應會傳回空值,因此如果外掛程式要表示不想自訂基礎版面配置,就必須從 customizesBaseLayout
傳回 false
。
基礎版面配置必須包含 FocusParkingView
和 FocusArea
,才能完全支援旋轉控制項。在未支援旋轉功能的裝置上,可以省略這些檢視畫面。FocusParkingView/FocusAreas
是在靜態 CarUi 程式庫中實作,因此會使用 setRotaryFactories
提供工廠,以便從內容建立檢視畫面。
用於建立焦點檢視畫面的情境必須是來源情境,而非外掛程式的情境。FocusParkingView
應盡可能與樹狀結構中的第一個檢視畫面最接近,因為在使用者看不到焦點時,FocusParkingView
就是焦點。FocusArea
必須在基礎版面配置中包裝工具列,以表示這是旋轉推擠區。如果未提供 FocusArea
,使用者就無法透過旋轉控制器前往工具列中的任何按鈕。
工具列控制器
實際傳回的 ToolbarController
應比基礎版面配置更容易實作。其工作是擷取傳遞至 setter 的資訊,並在基本版面配置中顯示。如要瞭解大部分方法的資訊,請參閱 Javadoc。以下將討論一些較複雜的方法。
getImeSearchInterface
用於在 IME (鍵盤) 視窗中顯示搜尋結果。這項功能可用於在鍵盤旁邊顯示/動畫化搜尋結果,例如當鍵盤只佔用螢幕一半的空間時。大部分的功能都是在靜態 CarUi 程式庫中實作,外掛程式中的搜尋介面只提供方法,讓靜態程式庫取得 TextView
和 onPrivateIMECommand
回呼。為支援這項功能,外掛程式應使用會覆寫 onPrivateIMECommand
的 TextView
子類別,並將呼叫傳遞至提供的事件監聽器,做為搜尋列的 TextView
。
setMenuItems
只是在螢幕上顯示 MenuItem,但會意外地頻繁呼叫。由於 MenuItem 的外掛程式 API 是不可變動的,因此只要 MenuItem 發生變更,就會發生全新的 setMenuItems
呼叫。這可能發生在使用者按下切換 MenuItem 的情況下,而該點擊導致切換鈕切換。因此,基於效能和動畫方面的考量,建議您計算舊版和新版 MenuItems 清單之間的差異,並只更新實際變更的檢視畫面。MenuItems 提供可協助處理此問題的 key
欄位,因為在針對相同 MenuItem 對 setMenuItems
的不同呼叫中,鍵應保持一致。
AppStyledView
AppStyledView
是未經自訂的檢視畫面容器。可用於在該檢視畫面周圍提供邊框,讓該檢視畫面與應用程式的其他部分區隔開來,並向使用者指出這是不同類型的介面。setContent
會提供由 AppStyledView 包裝的檢視畫面。AppStyledView
也可以根據應用程式的要求,顯示返回或關閉按鈕。
AppStyledView
不會像 installBaseLayoutAround
那樣立即將其檢視畫面插入檢視畫面階層,而是透過 getView
將其檢視畫面傳回至靜態程式庫,然後由後者執行插入作業。您也可以透過實作 getDialogWindowLayoutParam
來控制 AppStyledView
的位置和大小。
背景
外掛程式在使用情境時必須小心謹慎,因為同時存在外掛程式和「來源」情境。外掛程式內容會做為引數提供給 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 並未分開命名空間,因此當應用程式和外掛程式在同一個檢視畫面上設定標記時,可能會發生衝突。下方提供可將這些全域變數移至 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)
// }
}