使用 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 中,其他应用在运行时会引用该 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 应用安装过程中存在一个错误,该错误会导致对插件的更新无法生效。可以通过在插件的构建配置中选择始终使用软件包管理器安装(在 Android 11 及更高版本上停用部署优化)选项来修复此问题。
此外,在安装插件时,Android Studio 会报告一个错误,指出找不到要启动的主 Activity。这是预期的,因为插件没有任何 Activity(用于解析 Intent 的空 Intent 除外)。要消除该错误,请在构建配置中将启动选项更改为无。
图 1. 插件 Android Studio 配置
代理插件
自定义使用 Car UI 库的应用需要一个 RRO,该 RRO 以要修改的每个特定应用为目标,即使跨应用的自定义项是相同的也是如此。这意味着每个应用都需要一个 RRO。请参阅哪些应用使用 Car UI 库。
Car UI 库代理插件是一个示例插件共享库,它将其组件实现委托给 Car UI 库的静态版本。此插件可以用 RRO 为目标,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
类的实现中支持 2、5 和更高的 maxVersion
。不支持版本 1、3 和 4。有关更多信息,请参阅 PluginVersionProviderImpl
。
PluginFactory
是创建所有其他 CarUi 组件的接口。它还定义了应使用其接口的哪个版本。如果插件不寻求实现这些组件中的任何一个,则可以在它们的创建函数中返回 null
(工具栏除外,工具栏具有单独的 customizesBaseLayout()
函数)。
pluginFactory
限制了可以一起使用哪些版本的 CarUi 组件。例如,永远不会有 pluginFactory
可以创建版本 100 的 Toolbar
以及版本 1 的 RecyclerView
,因为无法保证各种版本的组件可以协同工作。要使用工具栏版本 100,开发者需要提供一个 pluginFactory
版本的实现,该版本创建工具栏版本 100,然后限制可以创建的其他组件版本的选项。其他组件的版本可能不相等,例如 pluginFactoryOEMV100
可以创建 ToolbarControllerOEMV100
和 RecyclerViewOEMV70
。
工具栏
基本布局
工具栏和“基本布局”非常密切相关,因此创建工具栏的函数称为 installBaseLayoutAround
。基本布局是一个概念,允许工具栏位于应用内容的任何位置,以允许工具栏跨越应用的顶部/底部、沿侧面垂直或甚至圆形工具栏包围整个应用。这是通过将视图传递给 installBaseLayoutAround
以供工具栏/基本布局环绕来实现的。
插件应获取提供的视图,将其从其父项中分离,在父项的同一索引中并使用与刚分离的视图相同的 LayoutParams
膨胀插件自己的布局,然后将视图重新附加到刚膨胀的布局中的某个位置。膨胀的布局将包含工具栏(如果应用请求)。
应用可以请求没有工具栏的基本布局。如果应用这样做,installBaseLayoutAround
应返回 null。对于大多数插件,这就是所有需要发生的事情,但如果插件作者想要应用(例如)应用边缘周围的装饰,则仍然可以使用基本布局来完成。这些装饰对于具有非矩形屏幕的设备特别有用,因为它们可以将应用推入矩形空间,并为非矩形空间添加清晰的过渡效果。
installBaseLayoutAround
还会传递一个 Consumer<InsetsOEMV1>
。此使用者可用于向应用传达插件正在部分覆盖应用内容(使用工具栏或其他方式)。然后,应用将知道在此空间中继续绘制,但将任何关键的可用户互动的组件保留在外面。此效果在我们的参考设计中使用,使工具栏半透明,并使列表在其下方滚动。如果未实现此功能,则列表中的第一项将卡在工具栏下方且不可点击。如果不需要此效果,则插件可以忽略使用者。
图 2. 工具栏下方滚动的内容
从应用的视角来看,当插件发送新的插页时,它将从实现 InsetsChangedListener
的任何 Activity 或 Fragment 接收它们。如果 Activity 或 Fragment 未实现 InsetsChangedListener
,则 Car Ui 库将默认情况下通过将插页作为填充应用于包含 Fragment 的 Activity
或 FragmentActivity
来处理插页。该库默认情况下不会将插页应用于 Fragment。以下是将插页作为填充应用于应用中 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
会返回 null,因此,为了使插件指示它不希望自定义基本布局,它必须从 customizesBaseLayout
返回 false
。
基本布局必须包含 FocusParkingView
和 FocusArea
以完全支持旋转控件。在不支持旋转的设备上可以省略这些视图。FocusParkingView/FocusAreas
在静态 CarUi 库中实现,因此 setRotaryFactories
用于提供从上下文中创建视图的工厂。
用于创建 Focus 视图的上下文必须是源上下文,而不是插件的上下文。 FocusParkingView
应尽可能靠近树中的第一个视图,因为它是在用户不应看到任何焦点时聚焦的内容。FocusArea
必须包装基本布局中的工具栏,以指示它是旋转轻推区域。如果未提供 FocusArea
,则用户无法使用旋转控制器导航到工具栏中的任何按钮。
工具栏控制器
实际返回的 ToolbarController
的实现应该比基本布局简单得多。它的工作是获取传递给其设置器的信息并在基本布局中显示它。有关大多数方法的信息,请参阅 Javadoc。下面讨论一些更复杂的方法。
getImeSearchInterface
用于在 IME(键盘)窗口中显示搜索结果。这对于在键盘旁边显示/动画化搜索结果非常有用,例如,如果键盘仅占据屏幕的一半。大多数功能在静态 CarUi 库中实现,插件中的搜索界面仅提供用于使静态库获取 TextView
和 onPrivateIMECommand
回调的方法。为了支持这一点,插件应使用一个 TextView
子类,该子类会覆盖 onPrivateIMECommand
并将调用作为其搜索栏的 TextView
传递给提供的侦听器。
setMenuItems
只是在屏幕上显示 MenuItem,但它会被非常频繁地调用。由于 MenuItem 的插件 API 是不可变的,因此每当 MenuItem 发生更改时,都会发生全新的 setMenuItems
调用。这可能会发生在像用户单击切换 MenuItem 这样微不足道的事情上,并且该单击导致切换切换。出于性能和动画原因,因此鼓励计算旧 MenuItem 列表和新 MenuItem 列表之间的差异,并且仅更新实际更改的视图。MenuItem 提供一个 key
字段,该字段可以对此有所帮助,因为对于同一 MenuItem 的不同 setMenuItems
调用,该键应该是相同的。
AppStyledView
AppStyledView
是未自定义的视图的容器。它可用于在该视图周围提供边框,使其从应用的其余部分脱颖而出,并向用户指示这是一种不同的界面。AppStyledView
包装的视图在 setContent
中给出。AppStyledView
还可以具有应用请求的后退或关闭按钮。
AppStyledView
不会像 installBaseLayoutAround
那样立即将其视图插入到视图层次结构中,而是通过 getView
将其视图返回到静态库,然后执行插入。AppStyledView
的位置和大小也可以通过实现 getDialogWindowLayoutParam
来控制。
上下文
插件在使用上下文时必须小心,因为存在插件上下文和“源”上下文。插件上下文作为参数传递给 getPluginFactory
,并且是唯一在其资源中的上下文。这意味着它是唯一可用于膨胀插件中的布局的上下文。
但是,插件上下文可能未设置正确的配置。为了获得正确的配置,我们在创建组件的方法中提供源上下文。源上下文通常是一个 Activity,但在某些情况下也可能是 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)
// }
}