Используйте плагины библиотеки Car UI для создания полных реализаций настройки компонентов в библиотеке Car UI вместо использования наложений ресурсов во время выполнения (RRO). RRO позволяют изменять только XML-ресурсы компонентов библиотеки Car UI, что ограничивает возможности настройки.
Создать плагин
Плагин библиотеки Car UI — это APK-файл, содержащий классы, реализующие набор API плагина . API плагина могут быть скомпилированы в плагин в виде статической библиотеки.
См. примеры в работах Сунга и Грейдла:
Сунг
Рассмотрим пример с Сунгом:
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 (Registered Resources Object), что позволяет использовать его в качестве единой точки настройки для приложений, использующих библиотеку Car UI, без необходимости реализации функционального плагина. Дополнительную информацию о RRO см. в разделе «Изменение значений ресурсов приложения во время выполнения» .
Плагин прокси — это лишь пример и отправная точка для настройки с помощью плагина. Для настройки, выходящей за рамки RRO, можно реализовать подмножество компонентов плагина и использовать плагин прокси для остальных, или же реализовать все компоненты плагина с нуля.
Хотя плагин прокси предоставляет единую точку настройки RRO для приложений, приложениям, которые откажутся от использования плагина, все равно потребуется RRO, непосредственно нацеленный на само приложение.
Реализуйте API плагинов.
Основной точкой входа в плагин является класс com.android.car.ui.plugin.PluginVersionProviderImpl . Все плагины должны включать класс с точно таким же именем и именем пакета. Этот класс должен иметь конструктор по умолчанию и реализовывать интерфейс PluginVersionProviderOEMV1 .
Плагины CarUI должны работать с приложениями, которые старше или новее самого плагина. Для этого все API плагинов имеют версионирование с добавлением V# в конце имени класса. Если выпускается новая версия библиотеки Car UI с новыми функциями, они входят в версию V2 компонента. Библиотека Car UI делает все возможное, чтобы новые функции работали в рамках более старого компонента плагина. Например, путем преобразования нового типа кнопок на панели инструментов в MenuItems .
Однако приложение, использующее более старую версию библиотеки Car UI, не сможет адаптироваться к новому плагину, написанному с использованием более новых API. Для решения этой проблемы мы позволяем плагинам возвращать различные реализации самих себя в зависимости от версии 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 .
Для создания фокусных представлений необходимо использовать контекст источника, а не контекст плагина. FocusParkingView должен располагаться как можно ближе к первому представлению в дереве, поскольку именно оно находится в фокусе, когда пользователю не следует видеть фокус. FocusArea должна охватывать панель инструментов базовым макетом, указывая на то, что это зона для поворота. Если FocusArea не указана, пользователь не сможет перейти к кнопкам на панели инструментов с помощью поворотного контроллера.
Панель инструментов контроллера
Фактический возвращаемый ToolbarController должен быть гораздо проще в реализации, чем базовый макет. Его задача — принимать информацию, передаваемую в его сеттеры, и отображать её в базовом макете. См. документацию Javadoc для получения информации о большинстве методов. Некоторые из более сложных методов обсуждаются ниже.
getImeSearchInterface используется для отображения результатов поиска в окне ввода текста (клавиатуры). Это может быть полезно для отображения/анимации результатов поиска рядом с клавиатурой, например, если клавиатура занимает только половину экрана. Большая часть функциональности реализована в статической библиотеке CarUi, а интерфейс поиска в плагине просто предоставляет методы для получения коллбэков TextView и onPrivateIMECommand из статической библиотеки. Для поддержки этого плагин должен использовать подкласс TextView , который переопределяет onPrivateIMECommand и передает вызов предоставленному слушателю в качестве TextView своей строки поиска.
setMenuItems просто отображает элементы меню на экране, но вызывается она на удивление часто. Поскольку API плагина для элементов меню неизменяем, при каждом изменении элемента меню будет происходить новый вызов setMenuItems . Это может произойти даже из-за такой мелочи, как щелчок пользователя по переключателю в элементе меню, и этот щелчок привел к переключению. Поэтому, как с точки зрения производительности, так и с точки зрения анимации, рекомендуется вычислять разницу между старым и новым списком элементов меню и обновлять только те представления, которые фактически изменились. Элементы меню предоставляют key поле, которое может помочь в этом, поскольку ключ должен быть одинаковым при разных вызовах setMenuItems для одного и того же элемента меню.
Контексты
Плагин должен быть осторожен при использовании контекстов, поскольку существуют как контексты плагина , так и «исходные» контексты. Контекст плагина передается в качестве аргумента функции getPluginFactory и является единственным контекстом, содержащим ресурсы плагина. Это означает, что только этот контекст можно использовать для создания макетов в плагине.
Однако контекст плагина может содержать некорректно настроенную конфигурацию. Для получения правильной конфигурации мы предоставляем исходные контексты в методах, создающих компоненты. Исходный контекст обычно представляет собой активность, но в некоторых случаях это может быть также сервис или другой компонент Android. Чтобы использовать конфигурацию из исходного контекста с ресурсами из контекста плагина, необходимо создать новый контекст с помощью createConfigurationContext . Если правильная конфигурация не используется, произойдет нарушение строгого режима Android, и создаваемые представления могут иметь неверные размеры.
Context layoutInflationContext = pluginContext.createConfigurationContext(
sourceContext.getResources().getConfiguration());
Изменения режима
Некоторые плагины могут поддерживать несколько режимов для своих компонентов, например, спортивный режим или экономичный режим , которые визуально отличаются друг от друга. Встроенной поддержки такой функциональности в CarUi нет, но ничто не мешает плагину реализовать её полностью внутри себя. Плагин может отслеживать любые условия, чтобы определить, когда переключать режимы, например, прослушивая трансляции. Плагин не может инициировать изменение конфигурации для смены режимов, но в любом случае не рекомендуется полагаться на изменения конфигурации, поскольку ручное обновление внешнего вида каждого компонента удобнее для пользователя и позволяет осуществлять переходы, которые невозможны при изменении конфигурации.
Композитор Jetpack
Плагины можно реализовать с помощью 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)
// }
}