Car UI プラグイン

car-ui-lib のコンポーネントを完全にカスタマイズするには、ランタイム リソース オーバーレイ(RRO)の代わりに、car-ui-lib プラグインを使用してください。RRO を使用しても、car-ui-lib コンポーネントの XML リソースしか変更できないため、カスタマイズできる範囲は限られます。

プラグインの作成

car-ui-lib プラグインは、一連の Plugin API を実装するクラスが入った APK です。Plugin API は packages/apps/Car/libs/car-ui-lib/oem-apis にあり、静的ライブラリとしてプラグインにまとめることができます。

以下の Soong と Gradle の例をご覧ください。

Soong

次の 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 を最後まで実行します。

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

Android Studio でプラグインをインストールする場合は、他にも考慮すべきことがあります。執筆時点では、Android Studio にはアプリのインストール処理にバグがあり、プラグインのアップデートが反映されません。この問題は、プラグインのビルド構成で [Always install with package manager (disables deploy optimizations on Android 11 and later)] を選択することで修正できます。

また、プラグインをインストールすると、起動するメイン アクティビティが見つからないというエラーが Android Studio から報告されます。これは想定どおりの動作です。プラグインにはアクティビティがないためです(インテントの解決に使用される空のインテントを除く)。このエラーを避けるには、ビルド構成で [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 には、次のメソッドが 1 つあります。

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

このメソッドは、そのプラグインが対応していて maxVersion を越えない最高バージョンの PluginFactoryOEMV# を実装するオブジェクトを返します。プラグインにそのような PluginFactory の実装がない場合には、null を返すことができます。その場合は、静的リンクされた CarUi コンポーネントの実装が使用されます。

PluginFactory は、他のすべての CarUi コンポーネントを作成するインターフェースです。また、使用する必要のあるインターフェースのバージョンも定義します。このようなコンポーネントを実装しないプラグインは、作成関数で null を返すことができます(customizesBaseLayout() 関数が別にあるツールバーは例外です)。

pluginFactory は、同時に使用できる CarUi コンポーネントのバージョンを制限します。たとえば、バージョン 100 の Toolbar とバージョン 1 の RecyclerView を作成できる pluginFactory はまず存在しません。さまざまなバージョンのコンポーネントが同時に動作する保証はほとんどないためです。ツールバー バージョン 100 を使用する場合は、ツールバー バージョン 100 を作成してから、作成できる他のコンポーネントのバージョンを制限する pluginFactory の実装を提供することが想定されています。他のコンポーネントのバージョンは違っている場合もあります。たとえば、pluginFactoryOEMV100ToolbarControllerOEMV100RecyclerViewOEMV70 を作成することができます。

ツールバー

ベース レイアウト

ツールバーと「ベース レイアウト」は密接に関連しているため、ツールバーを作成する関数は installBaseLayoutAround という名前になっています。ベース レイアウトとは、ツールバーをアプリのコンテンツの周囲のどこにでも配置できるようにするというコンセプトです(上下左右だけでなく、丸く全体を囲うこともできます)。これを行うには、View を installBaseLayoutAround に渡してツールバー / ベース レイアウトが周囲に回り込むようにします。

プラグインは、提供された View を受け取って親からデタッチし、プラグインの独自のレイアウトを親と同じインデックスで、デタッチしたビューと同じ LayoutParams を使用してインフレートしてから、インフレートしたレイアウトの任意の場所に View を再度アタッチします。アプリからリクエストすると、インフレートされたレイアウトにツールバーが追加されます。

ツールバーのないベース レイアウトをリクエストすることもできます。その場合、installBaseLayoutAround は null を返します。ほとんどのプラグインではこれで十分ですが、プラグインの作成者がアプリの周囲を装飾したい場合などには、ベース レイアウトを使って行うこともできます。こうした装飾は、画面が非矩形のデバイスで、アプリを矩形領域に押し込み、非矩形領域へのクリーンな遷移を追加する場合に便利です。

installBaseLayoutAround には Consumer<InsetsOEMV1> も渡されます。このコンシューマを使用することで、プラグインがアプリのコンテンツを(ツールバーなどで)部分的に覆っていることをアプリに知らせることができます。アプリはこの領域の描画を続ける必要がありますが、ユーザーが操作できる重要なコンポーネントを配置しないようにする必要があります。この効果はリファレンス デザインで使用されていて、ツールバーが半透明になり、その下でリストがスクロールします。この機能が実装されていない場合、リストの最初の項目はツールバーの下に固定され、クリックできなくなります。この効果が不要の場合、プラグインは Consumer を無視できます。

ツールバーの下でスクロールするコンテンツ 図 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 ヒントが与えられます。このヒントは、囲む対象の View がアプリ全体を占めているか、小さな領域だけを占めているかを示すために使用されます。これを使って、画面全体の縁に沿って表示された場合にのみ意味のあるような、縁に沿った装飾が適用されることを回避できます。フルスクリーンでないベース レイアウトを使用するアプリの例として、設定アプリがあります。設定アプリには、デュアルペイン レイアウトの各ペインにそれぞれツールバーがあります。

toolbarEnabledfalse のときには installBaseLayoutAround が null を返すと想定されているため、プラグインからベース レイアウトのカスタマイズを望まないことを知らせるには、customizesBaseLayout から false を返す必要があります。

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

Focus ビューの作成に使用されるコンテキストは、プラグインのコンテキストではなく、ソース コンテキストである必要があります。FocusParkingView は、できるだけツリーの最初のビューに近いものにする必要があります。ユーザーから見えるフォーカスがないときにフォーカスが置かれるものだからです。FocusArea は、ベース レイアウトのツールバーの周囲に回り込んで、そこがロータリーのナッジゾーンであることを示します。FocusArea が指定されていない場合、ユーザーはロータリー コントローラを使ってツールバーのボタン間を移動できません。

ツールバー コントローラ

実際に返される ToolbarController の実装は、ベース レイアウトよりもはるかに簡単です。その役割は、セッターに渡す情報を受け取って、ベース レイアウトに表示することです。ほとんどのメソッドについては、Javadoc をご覧ください。より複雑なメソッドについては、以下で説明します。

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

setMenuItems は、画面に MenuItem を表示するだけですが、想像以上に頻繁に呼び出されます。MenuItem のプラグイン API は不変なので、MenuItem が変更されるたびに、まったく新規の setMenuItems 呼び出しが発生します。これは、ユーザーがスイッチの MenuItem をクリックし、そのクリックでスイッチが切り替わるといった程度のことでも発生します。そのため、パフォーマンスとアニメーションの両方の理由から、古い MenuItem リストと新しい MenuItem リストの差分を計算して、実際に変更された View のみを更新することをおすすめします。MenuItem が提供する key フィールドがこれに役立ちます。同じ MenuItem に対する setMenuItems の呼び出しでは同じ key を使う必要があるためです。

AppStyledView

AppStyledView は、まったくカスタマイズされていない View 用のコンテナです。これを使用して、View の周りに枠線を引いて、アプリの他の部分より目立たせ、これが別の種類のインターフェースであることをユーザーに示すことができます。AppStyledView で囲う View は setContent で指定します。AppStyledView には、アプリからのリクエストに基づいて [戻る] ボタンや閉じるボタンも表示できます。

AppStyledView は、installBaseLayoutAround のように直接その View を View 階層に挿入するのではなく、getView を通じてそのビューを静的ライブラリに返し、静的ライブラリで挿入が行われます。AppStyledView の位置とサイズも、getDialogWindowLayoutParam を実装することにより制御できます。

Context

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

ただし、プラグインのコンテキストに正しい Configuration が設定されていない場合があります。正しい構成を得るために、コンポーネントを作成するメソッドにソース コンテキストが用意されています。通常、ソース コンテキストは Activity ですが、Service などの Android コンポーネントの場合もあります。ソース コンテキストの構成をプラグイン コンテキストのリソースとともに使用するには、createConfigurationContext で新しいコンテキストを作成する必要があります。正しい Configuration が使用されていない場合、Android の厳格モードに違反することなり、インフレートされたビューのサイズが誤ったものになる可能性があります。

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

モードの変更

一部のプラグインでは、コンポーネントで見た目が異なる複数のモードスポーツモードエコモードなど)をサポートできます。CarUi には、この機能に対する組み込みのサポートはありませんが、プラグインがすべて内部的に実装することを妨げるものはありません。プラグインでは、ブロードキャストをリッスンするなど、モード切り替えのタイミングを判断するための条件を監視することができます。プラグインで、構成変更のためにモードの変更をトリガーすることはできませんが、いずれにしても構成変更に依存することは推奨されません。各コンポーネントの外観を手動で更新した方がユーザーからはスムーズに見え、また構成変更では不可能な遷移も可能となるためです。

Jetpack Compose

プラグインは Jetpack Compose でも実装できますが、これはアルファ版の機能であり、安定版とは考えないでください。

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

ComposeView を使用する際の大きな問題として、階層内の ComposeView 間で共有されるグローバル変数を格納するために、レイアウト内のルートビューにタグを設定するということがあります。プラグインのリソース ID の名前空間はアプリと別になっていないため、アプリとプラグインの両方で同じ View にタグを設定すると競合する可能性があります。こういったグローバル変数を 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)
//  }
}