자동차 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

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 스튜디오로 플러그인을 설치할 때 추가로 고려해야 할 사항이 있습니다. 이 문서를 작성하는 시점 기준으로 Android 스튜디오 앱 설치 프로세스에는 플러그인 업데이트가 적용되지 않는 버그가 있습니다. 플러그인 빌드 구성에서 Always install with package manager (disables deploy optimizations on Android 11 and later) 옵션을 선택하여 해결할 수 있습니다.

또한 플러그인 설치 시 실행할 기본 활동을 찾을 수 없다는 오류 메시지가 Android 스튜디오에 표시됩니다. 플러그인에는 활동이 없으므로(인텐트를 해결하는 데 사용되는 빈 인텐트 제외) 이는 정상적인 상황입니다. 오류를 없애려면 빌드 구성에서 Launch 옵션을 Nothing으로 변경하세요.

플러그인 Android 스튜디오 구성그림 1. 플러그인 Android 스튜디오 구성

플러그인 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 구성요소의 버전을 제한합니다. 예를 들어 Toolbar 버전 100과 RecyclerView 버전 1을 둘 다 생성할 수 있는 pluginFactory는 없을 것입니다. 광범위한 버전의 구성요소가 호환된다는 보장이 없기 때문입니다. 툴바 버전 100을 사용하려면 개발자가 툴바 버전 100을 만드는 pluginFactory 버전을 구현하여 제공해야 합니다. 그러면 이 구현으로 만들 수 있는 다른 구성요소 버전의 옵션이 제한됩니다. 이때 다른 구성요소의 버전은 동일하지 않을 수 있습니다. 예를 들어 pluginFactoryOEMV100ToolbarControllerOEMV100RecyclerViewOEMV70을 생성할 수 있습니다.

툴바

기본 레이아웃

툴바와 '기본 레이아웃'은 매우 밀접하게 관련되어 있기 때문에 툴바를 만드는 함수를 installBaseLayoutAround라고 합니다. 기본 레이아웃은 툴바를 앱 상단/하단에 걸쳐 배치하거나, 측면을 따라 세로로 배치하거나, 전체 앱을 감싸는 원형 툴바로 배치하는 등 앱 콘텐츠 주변 어디에나 툴바를 배치할 수 있도록 하는 개념입니다. 이렇게 하려면 툴바/기본 레이아웃이 래핑되는 뷰를 installBaseLayoutAround에 전달하면 됩니다.

플러그인은 제공된 뷰를 상위 요소에서 분리하고, 플러그인의 자체 레이아웃을 상위 요소와 동일한 색인에서 확장하고, 방금 분리한 뷰와 동일한 LayoutParams를 사용하여 방금 확장한 레이아웃 내부에 뷰를 다시 연결합니다. 앱에서 요청하는 경우 확장된 레이아웃은 툴바를 포함하게 됩니다.

앱은 툴바가 없는 기본 레이아웃을 요청할 수 있습니다. 그 경우 installBaseLayoutAround는 null을 반환합니다. 대부분의 플러그인에서는 이 정도만 하면 됩니다. 플러그인 작성자가 예를 들어 앱 가장자리를 장식하려는 경우에는 기본 레이아웃을 사용하면 됩니다. 앱을 직사각형 공간에 밀어넣은 다음 깔끔한 전환을 추가하여 사각형이 아닌 공간으로 이어지도록 할 수 있으므로 이러한 장식은 직사각형이 아닌 화면에 특히 유용합니다.

installBaseLayoutAround에는 Consumer<InsetsOEMV1>도 전달됩니다. 이 소비자를 사용하여 플러그인이 앱의 콘텐츠를 부분적으로 처리한다는 것을 앱에 알릴 수 있습니다(툴바 사용 등). 그러면 앱은 도형을 이 공간 안에 유지하되 사용자 상호작용이 가능한 중요한 구성요소는 공간 외부에 두어야 한다고 인식합니다. Google의 참조 디자인에서는 이 효과를 사용하여 툴바를 반투명하게 만들고 툴바 아래에 목록이 스크롤되도록 합니다. 이 기능이 구현되지 않으면 목록의 첫 번째 항목이 툴바 아래에 고착되고 클릭할 수 없게 됩니다. 이 효과가 필요 없으면 플러그인은 이 소비자를 무시해도 됩니다.

툴바 아래의 콘텐츠 스크롤그림 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는 기본 레이아웃보다 훨씬 더 간단하게 구현됩니다. 이 컨트롤러는 setter에 전달된 정보를 가져와서 기본 레이아웃에 표시하는 역할을 합니다. 대부분의 메서드에 관한 정보는 Javadoc을 참고하세요. 아래에서는 보다 복잡한 몇 가지 메서드를 설명합니다.

getImeSearchInterface는 IME(키보드) 창에 검색결과를 표시하는 데 사용됩니다. 예를 들어 키보드가 화면의 절반만 차지하는 경우 검색결과를 키보드와 나란히 표시하고 애니메이션 처리하는 데 유용할 수 있습니다. 대부분의 기능은 정적 CarUi 라이브러리에 구현되며, 플러그인의 검색 인터페이스는 정적 라이브러리에서 TextViewonPrivateIMECommand 콜백을 가져오는 메서드만 제공합니다. 이를 지원하려면 플러그인이 onPrivateIMECommand를 재정의하고 호출을 제공된 리스너에 검색창의 TextView로 전달하는 TextView 서브클래스를 사용해야 합니다.

setMenuItems는 단순히 화면에 MenuItems를 표시하지만 놀라울 정도로 자주 호출됩니다. MenuItems용 플러그인 API는 변경할 수 없으므로 MenuItem이 변경될 때마다 완전히 새로운 setMenuItems 호출이 발생합니다. 이러한 호출은 사용자가 스위치 MenuItem을 클릭하고 그로 인해 스위치가 전환되는 것처럼 사소한 작업에서도 발생할 수 있습니다. 따라서 성능과 애니메이션 처리를 위해 이전 및 신규 MenuItems 목록의 차이를 계산해 보고 실제로 변경된 뷰만 업데이트하는 것이 좋습니다. MenuItems는 이 작업에 도움이 될 수 있는 key 필드를 제공합니다. 이 필드는 동일한 MenuItem에 대한 여러 setMenuItems 호출에서 키가 동일해야 하기 때문에 유용합니다.

AppStyledView

AppStyledView는 전혀 맞춤설정되지 않은 뷰의 컨테이너입니다. 뷰에 테두리를 제공하여 앱의 나머지 부분과 구분되도록 만들고 다른 종류의 인터페이스임을 사용자에게 알리기 위해 이 컨테이너를 사용할 수 있습니다. AppStyledView에 의해 래핑된 뷰는 setContent에서 제공됩니다. AppStyledView에는 앱의 요청에 따라 뒤로 또는 닫기 버튼도 있을 수 있습니다.

AppStyledViewinstallBaseLayoutAround처럼 뷰 계층 구조에 뷰를 바로 삽입하지 않습니다. 대신 getView를 통해 정적 라이브러리에 뷰를 반환하고 라이브러리에서 삽입하도록 합니다. AppStyledView의 위치와 크기는 getDialogWindowLayoutParam을 구현하여 제어할 수도 있습니다.

컨텍스트

플러그인 컨텍스트와 '소스' 컨텍스트가 둘 다 있으므로 플러그인은 컨텍스트를 사용할 때 주의해야 합니다. 플러그인 컨텍스트는 getPluginFactory에 인수로 제공되며 플러그인의 리소스가 확실히 보장되는 유일한 컨텍스트입니다. 즉, 플러그인에서 레이아웃을 확장하는 데 사용할 수 있는 유일한 컨텍스트입니다.

그러나 플러그인 컨텍스트에 올바른 구성이 설정되어 있지 않을 수도 있습니다. 올바른 구성을 가져오기 위해 Google은 구성요소를 만드는 메서드에 소스 컨텍스트를 제공합니다. 소스 컨텍스트는 일반적으로 활동이지만 경우에 따라 서비스이거나 다른 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)
//  }
}