車のUIプラグイン

ランタイムリソースオーバーレイ(RRO)を使用する代わりに、 car-ui-libプラグインを使用して、 car-ui-libでコンポーネントのカスタマイズの完全な実装を作成します。 RROを使用すると、 car-ui-libコンポーネントのXMLリソースのみを変更できます。これにより、カスタマイズできる範囲が制限されます。

プラグインの作成

car-ui-libプラグインは、プラグインAPIのセットを実装するクラスを含むAPKです。プラグインAPIはpackages/apps/Car/libs/car-ui-lib/oem-apisにあり、静的ライブラリとしてプラグインにコンパイルできます。

以下のSoongとGradleの例を参照してください。

スン

このSoongの例を考えてみましょう。

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",

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 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 rebootを実行するか、特定のアプリに対してadb shell am force-stop package.name amforce-stoppackage.nameを実行することで実行できます。

システム上の既存のcar-ui-libプラグインを更新している場合、そのプラグインを使用しているアプリはすべて自動的に閉じ、ユーザーが再度開くと、更新された変更が適用されます。その時点でアプリがフォアグラウンドにある場合、これはクラッシュのように見えます。アプリが実行されていなかった場合は、次に起動したときにプラグインが更新されます。

Android Studioでプラグインをインストールする場合、考慮すべき追加の考慮事項がいくつかあります。執筆時点では、Android Studioアプリのインストールプロセスにバグがあり、プラグインの更新が有効になりません。これは、プラグインのビルド構成で[常にパッケージマネージャーを使用してインストールする(Android 11以降での展開の最適化を無効にする) ]オプションを選択することで修正できます。

さらに、プラグインをインストールすると、Android Studioは、起動するメインアクティビティが見つからないというエラーを報告します。プラグインにはアクティビティがないため、これは予想されます(インテントの解決に使用される空のインテントを除く)。エラーを解消するには、ビルド構成で[起動]オプションを[なし]に変更します。

プラグインAndroidStudioの構成図1.プラグインのAndroidStudio構成

プラグイン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には、次の1つのメソッドがあります。

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

このメソッドは、プラグインでサポートされている最高バージョンのPluginFactoryOEMV#を実装するオブジェクトを返しますが、それでもmaxVersion以下です。プラグインにその古いPluginFactoryの実装がない場合、 nullを返す可能性があります。その場合、静的に接続されたCarUiコンポーネントの実装が使用されます。

PluginFactoryは、他のすべてのCarUiコンポーネントを作成するインターフェイスです。また、使用するインターフェースのバージョンも定義します。プラグインがこれらのコンポーネントのいずれも実装しようとしない場合、プラグインはそれらの作成関数でnullを返す可能性があります(個別のcustomizesBaseLayout()関数を持つツールバーを除く)。

pluginFactoryは、CarUiコンポーネントのどのバージョンを一緒に使用できるかを制限します。たとえば、 Toolbarのバージョン100とRecyclerViewのバージョン1を作成できるpluginFactoryはありません。これは、さまざまなバージョンのコンポーネントが連携して動作するという保証がほとんどないためです。ツールバーバージョン100を使用するには、開発者は、ツールバーバージョン100を作成するバージョンのpluginFactoryの実装を提供する必要があります。これにより、作成できる他のコンポーネントのバージョンのオプションが制限されます。他のコンポーネントのバージョンは等しくない場合があります。たとえば、 pluginFactoryOEMV100ToolbarControllerOEMV100RecyclerViewOEMV70を作成できます。

ツールバー

基本レイアウト

ツールバーと「基本レイアウト」は非常に密接に関連しているため、ツールバーを作成する関数はinstallBaseLayoutAroundと呼ばれます。基本レイアウトは、ツールバーをアプリのコンテンツの周囲のどこにでも配置できるようにする概念であり、アプリの上部/下部、側面に沿って垂直にツールバー、またはアプリ全体を囲む円形のツールバーを使用できます。これは、ツールバー/ベースレイアウトをラップアラウンドするためにビューをinstallBaseLayoutAroundに渡すことによって実現されます。

プラグインは、提供されたビューを取得し、その親からデタッチし、親の同じインデックスで、デタッチされたばかりのビューと同じLayoutParamsを使用してプラグイン自体のレイアウトを膨らませてから、ビューをレイアウト内のどこかに再アタッチする必要があります。ちょうど膨らんだ。アプリから要求された場合、膨張したレイアウトにはツールバーが含まれます。

アプリはツールバーなしで基本レイアウトをリクエストできます。含まれている場合、 installBaseLayoutAroundはnullを返す必要があります。ほとんどのプラグインでは、これで十分ですが、プラグインの作成者がアプリの端に装飾を適用したい場合は、基本レイアウトでそれを行うことができます。これらの装飾は、アプリを長方形のスペースに押し込み、非長方形のスペースにクリーンなトランジションを追加できるため、非長方形の画面を備えたデバイスに特に役立ちます。

installBaseLayoutAroundには、 Consumer<InsetsOEMV1>も渡されます。このコンシューマーを使用して、プラグインがアプリのコンテンツを部分的にカバーしていることをアプリに伝えることができます(ツールバーなどを使用)。その後、アプリはこのスペースに描画し続けることを認識しますが、ユーザーが操作できる重要なコンポーネントはそのスペースから除外します。この効果は、ツールバーを半透明にし、その下にリストをスクロールさせるために、リファレンスデザインで使用されます。この機能が実装されていない場合、リストの最初の項目がツールバーの下に表示され、クリックできなくなります。この効果が必要ない場合、プラグインはコンシューマーを無視できます。

ツールバーの下でスクロールするコンテンツ図2.ツールバーの下でスクロールするコンテンツ

アプリの観点からは、プラグインが新しいインセットを送信すると、 InsetsChangedListenerを実装するアクティビティ/フラグメントを介してプラグインがそれらを受信します。これは、アプリの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のヒントが与えられます。これは、ラップする必要のあるビューがアプリ全体を占めるのか、それとも小さなセクションを占めるのかを示すために使用されます。これは、画面全体の端に沿って表示される場合にのみ意味のある装飾を端に沿って適用することを回避するために使用できます。フルスクリーン以外の基本レイアウトを使用するサンプルアプリは設定です。このアプリでは、デュアルペインレイアウトの各ペインに独自のツールバーがあります。

toolbarEnabledfalseの場合、 installBaseLayoutAroundがnullを返すことが予想されるため、プラグインが基本レイアウトをカスタマイズしないことを示すには、 customizesBaseLayoutからfalseを返す必要があります。

ロータリーコントロールを完全にサポートするには、基本レイアウトにFocusParkingViewFocusAreaが含まれている必要があります。これらのビューは、ロータリーをサポートしていないデバイスでは省略できます。 FocusParkingView/FocusAreasは静的CarUiライブラリに実装されているため、 setRotaryFactoriesを使用して、コンテキストからビューを作成するためのファクトリを提供します。

フォーカスビューの作成に使用されるコンテキストは、プラグインのコンテキストではなく、ソースコンテキストである必要があります。 FocusParkingViewは、ユーザーにフォーカスが表示されていないときにフォーカスされるものであるため、ツリーの最初のビューにできるだけ近いものにする必要があります。 FocusAreaは、ツールバーを基本レイアウトでラップして、回転ナッジゾーンであることを示す必要があります。 FocusAreaが提供されていない場合、ユーザーはロータリーコントローラーを使用してツールバーのボタンに移動できません。

ツールバーコントローラー

返される実際のToolbarControllerは、基本レイアウトよりも実装がはるかに簡単である必要があります。その仕事は、セッターに渡された情報を取得し、それを基本レイアウトに表示することです。ほとんどのメソッドについては、Javadocを参照してください。より複雑な方法のいくつかを以下で説明します。

getImeSearchInterfaceは、IME(キーボード)ウィンドウに検索結果を表示するために使用されます。これは、キーボードが画面の半分しか占めていない場合など、キーボードと一緒に検索結果を表示/アニメーション化する場合に便利です。ほとんどの機能は静的CarUiライブラリに実装されており、プラグインの検索インターフェイスは、静的ライブラリがTextViewおよびonPrivateIMECommandコールバックを取得するためのメソッドを提供するだけです。これをサポートするには、プラグインはonPrivateIMECommandをオーバーライドし、検索バーのTextViewとして提供されたリスナーに呼び出しを渡すTextViewサブクラスを使用する必要があります。

setMenuItemsは単にMenuItemsを画面に表示しますが、驚くほど頻繁に呼び出されます。 MenuItemsのプラグインAPIは不変であるため、MenuItemが変更されるたびに、まったく新しいsetMenuItems呼び出しが発生します。これは、ユーザーがスイッチMenuItemをクリックし、そのクリックによってスイッチが切り替わるような些細なことで発生する可能性があります。したがって、パフォーマンスとアニメーションの両方の理由から、新旧のMenuItemsリストの違いを計算し、実際に変更されたビューのみを更新することをお勧めします。 MenuItemsは、これに役立つkeyフィールドを提供します。これは、同じMenuItemのsetMenuItemsへの異なる呼び出し間でキーが同じである必要があるためです。

AppStyledView

AppStyledViewは、まったくカスタマイズされていないビューのコンテナーです。これを使用して、ビューの周囲に境界線を設定し、アプリの他の部分から目立たせ、これが別の種類のインターフェースであることをユーザーに示すことができます。 AppStyledViewによってラップされるビューは、 setContentで指定されます。 AppStyledViewには、アプリの要求に応じて、戻るボタンまたは閉じるボタンを設定することもできます。

AppStyledViewは、 installBaseLayoutAroundのようにビューをビュー階層にすぐに挿入するのではなく、代わりにgetViewを介して静的ライブラリにビューを返します。静的ライブラリは挿入を行います。 AppStyledViewの位置とサイズは、 AppStyledViewを実装することによっても制御できgetDialogWindowLayoutParam

コンテキスト

プラグインと「ソース」コンテキストの両方があるため、コンテキストを使用するときはプラグインに注意する必要があります。プラグインコンテキストはgetPluginFactoryの引数として指定され、プラグインのリソースが含まれることが保証されている唯一のコンテキストです。これは、プラグインのレイアウトを拡張するために使用できる唯一のコンテキストであることを意味します。

ただし、プラグインコンテキストに正しい構成が設定されていない可能性があります。正しい構成を取得するために、コンポーネントを作成するメソッドでソースコンテキストを提供します。ソースコンテキストは通常​​アクティビティですが、サービスやその他のAndroidコンポーネントの場合もあります。ソースコンテキストの構成をプラグインコンテキストのリソースとともに使用するには、 createConfigurationContextを使用して新しいコンテキストを作成する必要があります。正しい構成が使用されていない場合、Androidの厳密モード違反が発生し、膨らんだビューのサイズが正しくない可能性があります。

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

モードの変更

一部のプラグインは、視覚的に区別できるスポーツモードエコモードなど、コンポーネントの複数のモードをサポートできます。 CarUiにはそのような機能の組み込みサポートはありませんが、プラグインが完全に内部的に実装することを妨げるものは何もありません。プラグインは、ブロードキャストのリッスンなど、モードを切り替えるタイミングを把握したい条件を監視できます。プラグインは構成変更をトリガーしてモードを変更することはできませんが、各コンポーネントの外観を手動で更新する方がユーザーにとってスムーズであり、構成変更では不可能な遷移も可能になるため、構成変更に依存することはお勧めしません。

JetpackCompose

プラグインはJetpackComposeを使用して実装できますが、これはアルファレベルの機能であり、安定していると見なすべきではありません。

プラグインは、 ComposeViewを使用して、レンダリングするCompose対応のサーフェスを作成できます。このComposeViewは、コンポーネントのgetViewメソッドからアプリに返されるものです。

ComposeViewの使用に関する大きな問題の1つは、階層内の異なる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)
//  }
}