Плагины пользовательского интерфейса автомобиля

Используйте плагины car-ui-lib для создания полных реализаций настроек компонентов в car-ui-lib вместо использования наложений ресурсов времени выполнения (RRO). RRO позволяют изменять только XML-ресурсы компонентов car-ui-lib , что ограничивает возможности настройки.

Создание плагина

Плагин car-ui-lib — это APK-файл, содержащий классы, реализующие набор API-интерфейсов плагинов . API-интерфейсы плагинов расположены в packages/apps/Car/libs/car-ui-lib/oem-apis и могут быть скомпилированы в плагин в виде статической библиотеки.

См. примеры Soong и Gradle ниже:

Сун

Рассмотрим этот пример Сун:

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 сообщает об ошибке, что не может найти основное действие для запуска. Это ожидаемо, так как у плагина нет никаких действий (кроме пустого намерения, используемого для разрешения намерения). Чтобы устранить ошибку, измените параметр 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 есть один метод:

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

Этот метод возвращает объект, который реализует наивысшую версию PluginFactoryOEMV# , поддерживаемую подключаемым модулем, но при этом меньше или равен maxVersion . Если в плагине нет такой старой реализации PluginFactory , он может вернуть null , и в этом случае используется статически связанная реализация компонентов CarUi.

PluginFactory — это интерфейс, который создает все остальные компоненты CarUi. Он также определяет, какую версию их интерфейсов следует использовать. Если плагин не стремится реализовать какой-либо из этих компонентов, он может вернуть null в своей функции создания (за исключением панели инструментов, которая имеет отдельную функцию customizesBaseLayout() ).

pluginFactory ограничивает, какие версии компонентов CarUi можно использовать вместе. Например, никогда не будет pluginFactory , который может создать версию 100 Toolbar , а также версию 1 RecyclerView , так как мало гарантий того, что самые разные версии компонентов будут работать вместе. Чтобы использовать панель инструментов версии 100, разработчики должны предоставить реализацию версии pluginFactory , которая создает панель инструментов версии 100, которая затем ограничивает параметры версий других компонентов, которые могут быть созданы. Версии других компонентов могут не совпадать, например pluginFactoryOEMV100 может создать ToolbarControllerOEMV100 и RecyclerViewOEMV70 .

Панель инструментов

Базовый макет

Панель инструментов и «базовый макет» очень тесно связаны между собой, поэтому функция, создающая панель инструментов, называется installBaseLayoutAround . Базовый макет — это концепция, которая позволяет располагать панель инструментов в любом месте вокруг содержимого приложения, чтобы панель инструментов располагалась сверху/снизу приложения, вертикально по бокам или даже в виде круглой панели инструментов, охватывающей все приложение. Это достигается путем передачи View в installBaseLayoutAround для переноса панели инструментов/базового макета.

Плагин должен взять предоставленное представление, отсоединить его от своего родителя, раздуть собственный макет плагина в том же индексе родителя и с теми же LayoutParams , что и представление, которое было только что отсоединено, а затем повторно прикрепить представление где-нибудь внутри макета, который был просто надутый. Раздутый макет будет содержать панель инструментов, если это требуется приложением.

Приложение может запросить базовый макет без панели инструментов. Если это так, 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 подсказку, которая используется для указания того, занимает ли представление, которое должно быть обернуто, все приложение или только небольшой раздел. Это можно использовать, чтобы избежать применения некоторых украшений вдоль края, которые имеют смысл только в том случае, если они появляются вдоль края всего экрана. Пример приложения, в котором используются неполноэкранные базовые макеты, — «Настройки», в котором каждая панель двухпанельного макета имеет собственную панель инструментов.

Поскольку ожидается, что installBaseLayoutAround вернет null, когда toolbarEnabled имеет значение false , чтобы подключаемый модуль указал, что он не хочет настраивать базовый макет, он должен вернуть false из customizesBaseLayout .

Базовый макет должен содержать FocusParkingView и FocusArea для полной поддержки поворотных элементов управления. Эти виды можно не отображать на устройствах, не поддерживающих поворот. FocusParkingView/FocusAreas реализованы в статической библиотеке CarUi, поэтому setRotaryFactories используется для предоставления фабрик для создания представлений из контекстов.

Контексты, используемые для создания представлений Focus, должны быть исходным контекстом, а не контекстом плагина. FocusParkingView должен быть как можно ближе к первому представлению в дереве, поскольку это то, что находится в фокусе, когда фокус не должен быть виден пользователю. FocusArea должен обернуть панель инструментов в базовом макете, чтобы указать, что это зона поворотного перемещения. Если FocusArea не указан, пользователь не сможет перейти к каким-либо кнопкам на панели инструментов с помощью поворотного контроллера.

Контроллер панели инструментов

Реальный возвращенный ToolbarController должен быть намного проще в реализации, чем базовый макет. Его работа состоит в том, чтобы принимать информацию, переданную его сеттерам, и отображать ее в базовом макете. См. Javadoc для получения информации о большинстве методов. Некоторые из более сложных методов обсуждаются ниже.

getImeSearchInterface используется для отображения результатов поиска в окне IME (клавиатура). Это может быть полезно для отображения/анимации результатов поиска рядом с клавиатурой, например, если клавиатура занимает только половину экрана. Большая часть функциональности реализована в статической библиотеке CarUi, интерфейс поиска в плагине просто предоставляет методы статической библиотеке для получения обратных вызовов TextView и onPrivateIMECommand . Для поддержки этого подключаемый модуль должен использовать подкласс TextView , который переопределяет onPrivateIMECommand и передает вызов предоставленному слушателю в качестве TextView панели поиска.

setMenuItems просто отображает MenuItems на экране, но вызывается на удивление часто. Поскольку API-интерфейс плагина для MenuItems неизменяем, всякий раз, когда MenuItem изменяется, происходит совершенно новый вызов setMenuItems . Это могло произойти из-за чего-то тривиального, например, если пользователь щелкнул переключатель MenuItem, и этот щелчок вызвал переключение переключателя. Поэтому по соображениям производительности и анимации рекомендуется вычислять разницу между старым и новым списками MenuItems и обновлять только те виды, которые действительно изменились. Элементы меню предоставляют key поле, которое может помочь в этом, так как ключ должен быть одинаковым для разных вызовов setMenuItems для одного и того же элемента меню.

AppStyledView

AppStyledView — это контейнер для представления, которое вообще не настраивается. Его можно использовать для предоставления границы вокруг этого представления, которая выделяет его из остальной части приложения и указывает пользователю, что это интерфейс другого типа. Представление, обернутое AppStyledView, задается в setContent . AppStyledView также может иметь кнопку «Назад» или «Закрыть» по запросу приложения.

AppStyledView не сразу вставляет свои представления в иерархию представлений, как это делает installBaseLayoutAround , вместо этого он просто возвращает свое представление в статическую библиотеку через getView , которая затем выполняет вставку. Позицией и размером AppStyledView также можно управлять с помощью реализации getDialogWindowLayoutParam .

Контексты

Плагин должен быть осторожен при использовании контекстов, так как есть как плагин , так и «исходный» контекст. Контекст плагина передается в качестве аргумента для getPluginFactory и является единственным контекстом, который гарантированно содержит ресурсы плагина. Это означает, что это единственный контекст, который можно использовать для расширения макетов в плагине.

Однако контекст плагина может иметь неправильную конфигурацию. Чтобы получить правильную конфигурацию, мы предоставляем исходные контексты в методах, создающих компоненты. Исходный контекст обычно представляет собой действие, но в некоторых случаях может быть также службой или другим компонентом Android. Чтобы использовать конфигурацию из исходного контекста с ресурсами из контекста плагина, необходимо создать новый контекст с createConfigurationContext . Если не используется правильная конфигурация, будет нарушен строгий режим Android, и увеличенные представления могут иметь неправильные размеры.

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

Изменения режима

Некоторые плагины могут поддерживать несколько режимов для своих компонентов, например, спортивный режим или эко-режим , которые визуально отличаются друг от друга. В CarUi нет встроенной поддержки такой функциональности, но ничто не мешает плагину полностью реализовать ее внутри. Плагин может отслеживать любые условия, которые ему нужны, чтобы выяснить, когда переключать режимы, например прослушивание трансляций. Плагин не может инициировать изменение конфигурации для изменения режимов, но в любом случае не рекомендуется полагаться на изменения конфигурации, поскольку ручное обновление внешнего вида каждого компонента более плавно для пользователя, а также допускает переходы, которые невозможны при изменении конфигурации.

Реактивный ранец

Плагины могут быть реализованы с помощью Jetpack Compose, но это функция альфа-уровня, и ее нельзя считать стабильной.

Плагины могут использовать ComposeView для создания поверхности с поддержкой Compose для рендеринга. Этот ComposeView будет тем, что возвращается в приложение из метода getView в компонентах.

Одна из основных проблем с использованием ComposeView заключается в том, что он устанавливает теги в корневом представлении в макете, чтобы хранить глобальные переменные, которые являются общими для разных ComposeView в иерархии. Поскольку идентификаторы ресурсов подключаемого модуля не имеют пространства имен отдельно от пространства имен приложения, это может вызвать конфликты, когда и приложение, и подключаемый модуль устанавливают теги в одном и том же представлении. Пользовательский ComposeViewWithLifecycle , который перемещает эти глобальные переменные в ComposeView , представлен ниже. Опять же, это не должно считаться стабильным.

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)
//  }
}