ランタイムリソースオーバーレイ(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は、起動するメインアクティビティが見つからないというエラーを報告します。プラグインにはアクティビティがないため、これは予想されます(インテントの解決に使用される空のインテントを除く)。エラーを解消するには、ビルド構成で[起動]オプションを[なし]に変更します。
図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
の実装を提供する必要があります。これにより、作成できる他のコンポーネントのバージョンのオプションが制限されます。他のコンポーネントのバージョンは等しくない場合があります。たとえば、 pluginFactoryOEMV100
はToolbarControllerOEMV100
とRecyclerViewOEMV70
を作成できます。
ツールバー
基本レイアウト
ツールバーと「基本レイアウト」は非常に密接に関連しているため、ツールバーを作成する関数は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
のヒントが与えられます。これは、ラップする必要のあるビューがアプリ全体を占めるのか、それとも小さなセクションを占めるのかを示すために使用されます。これは、画面全体の端に沿って表示される場合にのみ意味のある装飾を端に沿って適用することを回避するために使用できます。フルスクリーン以外の基本レイアウトを使用するサンプルアプリは設定です。このアプリでは、デュアルペインレイアウトの各ペインに独自のツールバーがあります。
toolbarEnabled
がfalse
の場合、 installBaseLayoutAround
がnullを返すことが予想されるため、プラグインが基本レイアウトをカスタマイズしないことを示すには、 customizesBaseLayout
からfalse
を返す必要があります。
ロータリーコントロールを完全にサポートするには、基本レイアウトにFocusParkingView
とFocusArea
が含まれている必要があります。これらのビューは、ロータリーをサポートしていないデバイスでは省略できます。 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)
// }
}