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

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

Создать плагин

Плагин библиотеки Car UI — это APK, содержащий классы, реализующие набор API плагинов . API плагинов можно скомпилировать в плагин как статическую библиотеку.

Смотрите примеры в Soong и Gradle:

Сунг

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

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

Градл

Посмотрите этот файл 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, но имеет несколько ключевых отличий.

  • Используйте тег library под тегом application с именем пакета плагина в манифесте приложения вашего плагина:
    <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, включите тег uses-library в манифест приложения под тегом application с именем пакета плагина:

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

Кроме того, при установке плагина Android Studio сообщает об ошибке, что не может найти основную активность для запуска. Это ожидаемо, так как плагин не имеет никаких активностей (кроме пустого намерения, используемого для разрешения намерения). Чтобы устранить ошибку, измените параметр Launch на Nothing в конфигурации сборки.

Конфигурация плагина Android Studio Рисунок 1. Конфигурация плагина Android Studio

Прокси-плагин

Настройка приложений с использованием библиотеки Car UI требует RRO, нацеленного на каждое конкретное приложение, которое должно быть изменено, включая случаи, когда настройки идентичны во всех приложениях. Это означает, что требуется RRO для каждого приложения. Посмотрите, какие приложения используют библиотеку Car UI.

Плагин прокси-сервера библиотеки Car UI — это пример библиотеки общего доступа, которая делегирует реализации своих компонентов статической версии библиотеки Car UI. Этот плагин может быть нацелен с помощью RRO, который может использоваться как единая точка настройки для приложений, использующих библиотеку Car UI без необходимости реализации функционального плагина. Для получения дополнительной информации о RRO см. Изменение значения ресурсов приложения во время выполнения .

Плагин proxy — это всего лишь пример и отправная точка для настройки с помощью плагина. Для настройки за пределами RRO можно реализовать подмножество компонентов плагина и использовать плагин proxy для остальных или реализовать все компоненты плагина полностью с нуля.

Хотя плагин прокси-сервера обеспечивает единую точку настройки 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 имеет один метод:

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

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

Для поддержания обратной совместимости с приложениями, скомпилированными с использованием более старых версий статической библиотеки Car Ui, рекомендуется поддерживать maxVersion 2, 5 и выше в реализации класса PluginVersionProvider вашего плагина. Версии 1, 3 и 4 не поддерживаются. Для получения дополнительной информации см. PluginVersionProviderImpl .

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

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

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

Базовая компоновка

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

Поскольку ожидается, что 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 и обновлять только те представления, которые фактически изменились. MenuItems предоставляют key поле, которое может помочь в этом, поскольку ключ должен быть одинаковым для разных вызовов setMenuItems для одного и того же MenuItem.

AppStyledView

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

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

Контексты

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

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

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

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

Некоторые плагины могут поддерживать несколько режимов для своих компонентов, например, спортивный режим или режим Eco , которые выглядят визуально по-разному. В 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)
//  }
}
,

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

Создать плагин

Плагин библиотеки Car UI — это APK, содержащий классы, реализующие набор API плагинов . API плагинов можно скомпилировать в плагин как статическую библиотеку.

Смотрите примеры в Soong и Gradle:

Сунг

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

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

Градл

Посмотрите этот файл 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, но имеет несколько ключевых отличий.

  • Используйте тег library под тегом application с именем пакета плагина в манифесте приложения вашего плагина:
    <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, включите тег uses-library в манифест приложения под тегом application с именем пакета плагина:

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

Кроме того, при установке плагина Android Studio сообщает об ошибке, что не может найти основную активность для запуска. Это ожидаемо, так как плагин не имеет никаких активностей (кроме пустого намерения, используемого для разрешения намерения). Чтобы устранить ошибку, измените параметр Launch на Nothing в конфигурации сборки.

Конфигурация плагина Android Studio Рисунок 1. Конфигурация плагина Android Studio

Прокси-плагин

Настройка приложений с использованием библиотеки Car UI требует RRO, нацеленного на каждое конкретное приложение, которое должно быть изменено, включая случаи, когда настройки идентичны во всех приложениях. Это означает, что требуется RRO для каждого приложения. Посмотрите, какие приложения используют библиотеку Car UI.

Плагин прокси-сервера библиотеки Car UI — это пример библиотеки общего доступа, которая делегирует реализации своих компонентов статической версии библиотеки Car UI. Этот плагин может быть нацелен с помощью RRO, который может использоваться как единая точка настройки для приложений, использующих библиотеку Car UI без необходимости реализации функционального плагина. Для получения дополнительной информации о RRO см. Изменение значения ресурсов приложения во время выполнения .

Плагин proxy — это всего лишь пример и отправная точка для настройки с помощью плагина. Для настройки за пределами RRO можно реализовать подмножество компонентов плагина и использовать плагин proxy для остальных или реализовать все компоненты плагина полностью с нуля.

Хотя плагин прокси-сервера обеспечивает единую точку настройки 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 имеет один метод:

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

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

Для поддержания обратной совместимости с приложениями, скомпилированными с использованием более старых версий статической библиотеки Car Ui, рекомендуется поддерживать maxVersion 2, 5 и выше в реализации класса PluginVersionProvider вашего плагина. Версии 1, 3 и 4 не поддерживаются. Для получения дополнительной информации см. PluginVersionProviderImpl .

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

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

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

Базовая компоновка

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

Поскольку ожидается, что 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 и обновлять только те представления, которые фактически изменились. MenuItems предоставляют key поле, которое может помочь в этом, поскольку ключ должен быть одинаковым для разных вызовов setMenuItems для одного и того же MenuItem.

AppStyledView

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

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

Контексты

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

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

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

Режим изменений

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

JetPack Compose

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

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

Одна из основных проблем с использованием 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)
//  }
}