Используйте плагины библиотеки 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
) с флагом AAPTshared-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 в конфигурации сборки.
Рисунок 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
, создающей версию 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());
Изменения режима
Некоторые плагины могут поддерживать несколько режимов для своих компонентов, например, спортивный режим или режим Eco , которые визуально отличаются друг от друга. Встроенной поддержки такой функции в CarUi нет, но ничто не мешает плагину реализовать её полностью внутри. Плагин может отслеживать любые условия, например, прослушивать трансляции, чтобы определить, когда переключать режимы. Плагин не может инициировать изменение конфигурации для смены режимов, но в любом случае не рекомендуется полагаться на изменения конфигурации, поскольку ручное обновление внешнего вида каждого компонента более плавно для пользователя и позволяет осуществлять переходы, которые невозможны при изменении конфигурации.
Jetpack Compose
Плагины можно реализовать с помощью 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)
// }
}