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

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

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

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

Реализация и сборка общих библиотек

Разработка с использованием общих библиотек Android во многом похожа на разработку обычных приложений Android, но с несколькими ключевыми отличиями.

  • Используйте тег library под тегом application с именем пакета плагина в манифесте приложения вашего плагина:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Настройте правило сборки Soong android_app ( Android.bp ) с флагом 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 сообщает об ошибке, что не может найти основное действие для запуска. Это ожидаемо, поскольку плагин не выполняет никаких действий (кроме пустого намерения, используемого для разрешения намерения). Чтобы устранить ошибку, измените параметр «Запуск» на «Ничего» в конфигурации сборки.

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

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

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

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

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

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

Поскольку ожидается, что 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());

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

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