자동차 UI 플러그인

car-ui-lib 플러그인 을 사용하여 런타임 리소스 오버레이(RRO)를 사용하는 대신 car-ui-lib 에서 구성 요소 사용자 정의의 완전한 구현을 생성합니다. RRO를 사용하면 car-ui-lib 구성 요소의 XML 리소스만 변경할 수 있으므로 사용자 지정할 수 있는 범위가 제한됩니다.

플러그인 만들기

car-ui-lib 플러그인은 플러그인 API 세트를 구현하는 클래스가 포함된 APK입니다. 플러그인 API는 packages/apps/Car/libs/car-ui-lib/oem-apis 에 있으며 정적 라이브러리로 플러그인으로 컴파일할 수 있습니다.

아래의 Soong 및 in Gradle 예제를 참조하세요.

이 Song의 예를 고려하십시오.

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

그라들

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 앱 설치 프로세스의 버그가 있습니다. 플러그인의 빌드 구성에서 항상 패키지 관리자와 함께 설치(Android 11 이상에서 배포 최적화 비활성화) 옵션을 선택하면 이 문제를 해결할 수 있습니다.

또한 플러그인을 설치할 때 Android Studio는 시작할 주요 활동을 찾을 수 없다는 오류를 보고합니다. 이것은 플러그인에 활동이 없기 때문에 예상됩니다(인텐트를 해결하는 데 사용되는 빈 인텐트 제외). 오류를 제거하려면 빌드 구성에서 시작 옵션을 없음 으로 변경하십시오.

플러그인 Android 스튜디오 구성 그림 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 에는 다음과 같은 한 가지 메서드가 있습니다.

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 라고 합니다. 기본 레이아웃 은 도구 모음을 앱 콘텐츠 주변의 아무 곳에나 배치할 수 있도록 하는 개념으로, 앱의 상단/하단을 가로지르는 도구 모음, 측면을 따라 수직으로 또는 전체 앱을 둘러싸는 원형 도구 모음을 허용합니다. 이것은 도구 모음/기본 레이아웃이 둘러싸도록 View를 installBaseLayoutAround 에 전달하여 수행됩니다.

플러그인은 제공된 View를 가져와서 부모로부터 분리하고, 부모의 동일한 인덱스와 방금 분리된 뷰와 동일한 LayoutParams 로 플러그인의 자체 레이아웃을 확장해야 합니다. 그냥 부풀려. 앱에서 요청하는 경우 팽창된 레이아웃에는 도구 모음이 포함됩니다.

앱은 도구 모음 없이 기본 레이아웃을 요청할 수 있습니다. 그렇다면 installBaseLayoutAround 가 null을 반환해야 합니다. 대부분의 플러그인의 경우 이것이 일어나기만 하면 됩니다. 그러나 플러그인 작성자가 예를 들어 앱 가장자리 주변에 장식을 적용하려는 경우 기본 레이아웃으로 계속 수행할 수 있습니다. 이러한 장식은 앱을 직사각형 공간으로 밀어넣고 직사각형이 아닌 공간에 깔끔한 전환을 추가할 수 있으므로 직사각형이 아닌 화면이 있는 장치에 특히 유용합니다.

installBaseLayoutAroundConsumer<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 BaseLayout에서 false 를 반환해야 합니다.

회전식 컨트롤을 완전히 지원하려면 기본 레이아웃에 FocusParkingViewFocusArea 가 포함되어야 합니다. 회전을 지원하지 않는 장치에서는 이러한 보기를 생략할 수 있습니다. FocusParkingView/FocusAreas 는 정적 CarUi 라이브러리에서 구현되므로 setRotaryFactories 는 컨텍스트에서 보기를 생성하기 위한 팩토리를 제공하는 데 사용됩니다.

Focus 보기를 생성하는 데 사용되는 컨텍스트는 플러그인 컨텍스트가 아니라 소스 컨텍스트여야 합니다. FocusParkingView 는 사용자가 볼 수 있는 포커스가 없어야 할 때 포커스가 맞춰지기 때문에 합리적으로 가능한 한 트리의 첫 번째 뷰에 가장 가깝습니다. FocusArea 는 도구 모음이 회전하는 넛지 영역임을 나타내기 위해 기본 레이아웃에서 도구 모음을 래핑해야 합니다. FocusArea 가 제공되지 않으면 사용자는 회전식 컨트롤러를 사용하여 도구 모음의 버튼으로 이동할 수 없습니다.

툴바 컨트롤러

반환된 실제 ToolbarController 는 기본 레이아웃보다 구현하기가 훨씬 더 간단해야 합니다. 그 역할은 setter에 전달된 정보를 가져와 기본 레이아웃에 표시하는 것입니다. 대부분의 메소드에 대한 정보는 Javadoc을 참조하십시오. 더 복잡한 방법 중 일부는 아래에 설명되어 있습니다.

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

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

앱 스타일 보기

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

AppStyledViewinstallBaseLayoutAround 와 같이 View 계층 구조에 View를 즉시 삽입하지 않고 대신 getView 를 통해 정적 라이브러리로 보기를 반환한 다음 삽입을 수행합니다. AppStyledView 의 위치와 크기는 getDialogWindowLayoutParam 을 구현하여 제어할 수도 있습니다.

컨텍스트

플러그인 과 "소스" 컨텍스트가 모두 있으므로 플러그인은 컨텍스트를 사용할 때 주의해야 합니다. 플러그인 컨텍스트는 getPluginFactory 에 대한 인수로 제공되며 플러그인의 리소스가 포함된 유일한 컨텍스트입니다. 즉, 플러그인에서 레이아웃을 확장하는 데 사용할 수 있는 유일한 컨텍스트입니다.

그러나 플러그인 컨텍스트에 올바른 구성이 설정되어 있지 않을 수 있습니다. 올바른 구성을 얻기 위해 구성 요소를 생성하는 메서드에 소스 컨텍스트를 제공합니다. 소스 컨텍스트는 일반적으로 활동이지만 경우에 따라 서비스 또는 기타 Android 구성 요소일 수도 있습니다. 플러그인 컨텍스트의 리소스와 함께 소스 컨텍스트의 구성을 사용하려면 createConfigurationContext 를 사용하여 새 컨텍스트를 만들어야 합니다. 올바른 구성이 사용되지 않으면 Android 엄격 모드 위반이 발생하고 팽창된 보기의 크기가 올바르지 않을 수 있습니다.

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

모드 변경

일부 플러그인은 시각적으로 구별되는 스포츠 모드 또는 에코 모드 와 같이 구성 요소에 대해 여러 모드 를 지원할 수 있습니다. CarUi에는 이러한 기능에 대한 내장 지원이 없지만 플러그인이 완전히 내부적으로 구현하는 것을 막을 수 있는 방법은 없습니다. 플러그인은 브로드캐스트 수신과 같은 모드 전환 시점을 파악하려는 모든 조건을 모니터링할 수 있습니다. 플러그인은 모드를 변경하기 위해 구성 변경을 트리거할 수 없지만 각 구성 요소의 모양을 수동으로 업데이트하는 것이 사용자에게 더 매끄럽고 구성 변경으로 불가능한 전환도 허용하므로 구성 변경에 의존하는 것은 권장되지 않습니다.

제트팩 작성

플러그인은 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)
//  }
}