Car UI プラグイン

ランタイム リソース オーバーレイ(RRO)を使用する代わりに、Car UI ライブラリ プラグインを使用して Car UI ライブラリでコンポーネントのカスタマイズの完全な実装を作成します。RRO で変更できるのは、Car UI ライブラリ コンポーネントの XML リソースのみのため、カスタマイズできる範囲は限られます。

プラグインの作成

Car UI ライブラリ プラグインは、一連の Plugin API を実装するクラスが入った APK です。Plugin 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>
  • Soong の android_app ビルドルール(Android.bp)を AAPT フラグ shared-lib で設定し、共有ライブラリのビルドに使用する。
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] に変更してください。

Android Studio のプラグイン設定 図 1. Android Studio のプラグイン設定

プロキシ プラグイン

Car UI ライブラリを使用するアプリのカスタマイズには、変更対象のアプリそれぞれをターゲットとする RRO が必要です。これは、カスタマイズがすべてのアプリで同一である場合も同様です。つまり、アプリごとに RRO が必要となります。Car UI ライブラリを使用するアプリをご確認ください。

Car UI ライブラリ プロキシ プラグインは、コンポーネントの実装を静的バージョンの Car UI ライブラリに委任するプラグイン共有ライブラリのサンプルです。このプラグインは 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 には、次のメソッドが 1 つあります。

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

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

静的な Car UI ライブラリの古いバージョンに対してコンパイルされたアプリとの下位互換性を維持するため、PluginVersionProvider クラスのプラグインの実装内から 2、5、およびそれ以上の maxVersion をサポートすることが推奨されます。バージョン 1、3、4 はサポートされていません。詳しくは、PluginVersionProviderImpl をご覧ください。

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

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

ツールバー

ベース レイアウト

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

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

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

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

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 リストの差分を計算して、実際に変更されたビューのみを更新することをおすすめします。MenuItem が提供する key フィールドがこれに役立ちます。同じ MenuItem に対する setMenuItems の呼び出しでは同じ key を使う必要があるためです。

AppStyledView

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

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

Context

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

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

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

モードの変更

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

Jetpack Compose

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

プラグインでは、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)
//  }
}