汽车用户界面插件

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