18 марта Google переименовала операционную систему для носимой электроники Android Wear и начала распространять её под именем Wear OS, чтобы привлечь новую аудиторию. Компания опубликовала новые дизайн-гайдлайны и обновила документацию. Когда я начал разработку приложения для часов, не нашел ни одной русскоязычной публикации на эту тему. Поэтому хочу поделиться своим опытом и рассказать подробнее про Wear OS, из чего она состоит и как с ней работать. Всех небезразличных к мобильным технологиям прошу под кат.
Начиная с версии Android Wear 2.0, система научилась работать с «Standalone Apps» – полностью независимыми wearable-приложениями. Пользователь может установить их с нативного Google Play прямо на часы. Wear OS – это практически независимая система, которая всё ещё продолжает работать в рамках инфраструктуры Google Services, дополняя её, но не привязываясь к ней.
Android, но не очень
Как бы Google ни позиционировала Wear OS, платформа основана на Android со всеми его особенностями, прелестями и недостатками. Поэтому, если вы уже знакомы с Android-разработкой, то сложностей с Wear OS возникнуть не должно. Wear OS почти не отличается от своего «старшего брата», за исключением отсутствия некоторых пакетов:
- android.webkit
- android.print
- android.app.backup
- android.appwidget
- android.hardware.usb
Да, браузер на часах мы в ближайшее время не сможем увидеть из-за отсутствия Webkit. Но серфить на часах будет всё равно неудобно. У нас по-прежнему есть великий и ужасный Android Framework с Support Library и Google Services. Структурных и архитектурных отличий тоже будет мало.
Структура приложения
Предположим, мы решили сделать wearable-приложение. Открыли Android Studio, нажали «New project» и поставили галочку напротив «Wear». Мы сразу обнаружим, что в пакете нашего приложения появилось два модуля: wear и mobile.
Собираться эти два модуля будут в два разных .apk файла. Но они должны иметь одно название пакета, и при публикации должны быть подписаны одним релизным сертификатом. Это нужно только для того, чтобы приложения могли друг с другом взаимодействовать через Google Services. Мы к этому вернемся чуть позже. В принципе, ничто не мешает нам собрать приложение только на Wear OS, откинув мобильную платформу в сторону.
Clean architecture?
А почему бы и нет? Это такое же Android-приложение, поэтому архитектурные подходы для него могут быть схожие с Android.
Я использовал такой же стек технологий, который мы используем в Android-приложениях:
- Kotlin
- Clean architecture
- RxPM (как презентационный паттерн)
- Koin (для реализации DI)
- RxJava (просто дело вкуса)
У нас два модуля в проекте, и модели данных, скорее всего, будут одинаковые для обеих платформ. Поэтому часть логики и моделей можно вынести в ещё один модуль «common». Затем подключить его к mobile и wearable пакетам, чтобы не дублировать код.
UI
Одна из главных особенностей Android-разработки – обилие девайсов разного размера и с разным разрешением экрана. В Wear OS, ещё и разная форма экрана: круглый, квадратный и круглый с обрезанным краем.
Если мы попробуем сверстать какой-либо лейаут и отобразить его на разных экранах, скорее всего, увидим примерно такой вот кошмар:
Во второй версии системы Google любезно решила часть UI-проблем, включив в Support wearable library новые адаптивные view-компоненты. Пробежимся по самым любопытным из них.
BoxInsetLayout
BoxInsetLayout – это FrameLayout, который умеет адаптировать дочерние элементы под круглый дисплей. Он помещает их в прямоугольную область, вписанную в окружность экрана. Для квадратных дисплеев подобные преобразования, само собой, игнорируются.
Таким образом, одна и та же верстка будет примерно одинаково выглядеть для всех форм экранов часов.
Выглядит лучше, не правда ли?
WearableRecyclerView
Списки – удобный паттерн, который активно используется в мобильном (и не только) UX. Wear-интерфейсы исключением не стали. Но из-за закругления углов дисплея верхние View у списка могут обрезаться. WearableRecyclerView помогает исправить такие недоразумения.
Например, есть параметр isEdgeItemsCenteringEnabled, который позволяет задать компоновку элементов по изгибу экрана и расширять центральный элемент, делает список более удобным для чтения на маленьком экране.
Есть WearableLinearLayoutManager, который позволяет прокручивать список механическим колесиком на часах и доскроливать крайние элементы до середины экрана, что очень удобно на круглых интерфейсах.
Сейчас библиотека поддержки Wear включает пару десятков адаптивных View. Они все разные, и обо всех можно подробно почитать в документации.
Рисовать данные на экране – весело, но эти данные нужно откуда-то получать. В случае мобильного клиента, мы чаще используем REST API поверх привычных всем сетевых протоколов (HTTP/TCP). В Wear OS подобный подход тоже допустим, но Google его не рекомендует.
В носимой электронике большую роль играет энергоэффективность. А активное интернет-соединение будет быстро сажать батарею, и могут регулярно происходить разрывы связи. Ещё носимые устройства предполагают активную синхронизацию, которую тоже нужно реализовывать.
Все эти проблемы за нас любезно решает механизм обмена данными в Google Services под названием «Data Layer». Классы для работы с ним нашли свое место в пакете com.google.android.gms.wearable.
Data Layer
Data Layer помогает синхронизировать данные между всеми носимыми устройствами, привязанными к одному Google аккаунта пользователя. Он выбирает наиболее оптимальный маршрут для обмена данными (bluetooth, network) и реализует стабильную передачу. Это гарантирует, что сообщение дойдет до нужного девайса.
Data Layer состоит из пяти основных элементов:
- Data Items
- Assets
- Messages
- Channels
- Capabilities
Data Item
Data Item – компонент, который предназначен для синхронизации небольших объемов данных между устройствами в wearable-инфраструктуре. Работать с ними можно через Data Client. Вся синхронизация реализуется через Google сервисы.
DataItem состоит из трёх частей:
- payload – это полезная нагрузка в 100kb, представленная в виде ByteArray. Это выглядит немного абстрактно, поэтому сами Google рекомендуют класть туда какую-нибудь key-value структуру вроде Bundle или Map<String, Any>.
- patch – это путь-идентификатор, по которому мы можем опознать наш DataItem. Дело в том, что Data Client хранит все DataItem’ы в линейной структуре, что подходит не для всех кейсов. Если нам надо отразить какую-то иерархию данных, то придется делать это самостоятельно, различая объекты по URI.
- Assets – это отдельная структура, которая в самом DataItem’е не хранится, но он может иметь ссылку на нее. О ней поговорим позже.
Давайте попробуем создать и сохранить DataItem. Для этого воспользуемся PutDataRequest, которому передадим все нужные параметры. Затем PutDataRequest скормим DataClient’у в метод putDataItem().
Для удобства есть DataMapItem, в котором уже решена проблема сериализации. С его помощью мы можем работать с данными, как с Bundle-объектом, в который можно сохранять примитивы.
val dataClient = Wearable.getDataClient(context)
val dataRequest = PutDataMapRequest.create(PATCH_COFFEE).apply {
dataMap.putString(KEY_COFFEE_SPECIEES, "Arabica")
dataMap.putString(KEY_COFFEE_TYPE, "Latte")
dataMap.putInt(KEY_COFFEE_SPOONS_OF_SUGAR, 2)
}
val putDataRequest = dataRequest.asPutDataRequest()
dataClient.putDataItem(putDataRequest)
Теперь наш DataItem хранится в DataClient’е, и мы можем получить к нему доступ со всех Wearable-девайсов.
Теперь мы можем забрать у DataClient список всех Item’ов, найти тот, который нас интересует, и распарсить его:
dataClient.dataItems.addOnSuccessListener { dataItems ->
dataItems.forEach { item ->
if (item.uri.path == PATCH_COFFEE) {
val mapItem = DataMapItem.fromDataItem(item)
val coffee = Coffee(
mapItem.dataMap.getString(KEY_COFFEE_SPECIES),
mapItem.dataMap.getString(KEY_COFFEE_TYPE),
mapItem.dataMap.getInt(KEY_COFFEE_SPOONS_OF_SUGAR)
)
coffeeReceived(coffee)
}
}
}
Assets
А теперь давайте представим, что нам внезапно потребовалось отправить на часы фотографию, аудио или еще какой-то файл. DataItem с такой нагрузкой не справится, потому как предназначен для быстрой синхронизации, а вот Asset может. Механизм синхронизации ассетов предназначен для сохранения файлов размером более 100kb в wearable-инфраструктуре и плотно связан с DataClient’ом.
Как упоминалось ранее, DataItem может иметь ссылку на Asset, но сами данные сохраняются отдельно. Возможен сценарий, когда Item сохранился быстрее Asset, а файл всё еще продолжает загружаться.
Создать Asset можно с помощью Asset.createFrom[Uri/Bytes/Ref/Fd], после чего передать его в DataItem:
val dataClient = Wearable.getDataClient(context)
val dataRequest = PutDataMapRequest.create(PATCH_COFFEE).apply {
dataMap.putString(KEY_COFFEE_SPECIES, "Arabica")
dataMap.putString(KEY_COFFEE_TYPE, "Latte")
dataMap.putInt(KEY_COFFEE_SPOONS_OF_SUGAR, 2)
// Добавляем фото
val asset = Asset.createFromUri(Uri.parse(COFFEE_PHOTO_PATCH))
dataMap.putAsset(KEY_COFFEE_PHOTO, asset)
}
val putDataRequest = dataRequest.asPutDataRequest()
dataClient.putDataItem(putDataRequest)
Чтобы загрузить Asset на другой стороне, нужно открыть inputStream, получить сам массив байт, а затем представить его в нужной нам форме:
dataClient.dataItems.addOnSuccessListener { dataItems ->
dataItems.forEach { item ->
if (item.uri.path == PATCH_COFFEE) {
val mapItem = DataMapItem.fromDataItem(item)
val asset = mapItem.dataMap.getAsset(KEY_COFFEE_PHOTO)
val coffee = Coffee(
mapItem.dataMap.getString(KEY_COFFEE_SPECIES),
mapItem.dataMap.getString(KEY_COFFEE_TYPE),
mapItem.dataMap.getInt(KEY_COFFEE_SPOONS_OF_SUGAR),
// Сохраняем файл из Asset
saveFileFromAsset(asset, COFFEE_PHOTO_PATCH)
)
coffeeReceived(coffee)
}
}
}
private fun saveFileFromAsset(asset: Asset, name: String): String {
val imageFile = File(context.filesDir, name)
if (!imageFile.exists()) {
Tasks.await(dataClient.getFdForAsset(asset)).inputStream.use { inputStream ->
val bitmap = BitmapFactory.decodeStream(inputStream)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, imageFile.outputStream())
}
}
return imageFile.absolutePath
}
Capabilities
Сеть носимых девайсов может быть гораздо шире, чем два устройства, соединенные по Bluetooth, и включать в себя десятки девайсов. Представим ситуацию, когда нужно отправить сообщение не на все устройства, а на какие-то конкретные часы. Нужен способ для идентификации устройств в этой сети. Способ есть – это механизм Capabilities. Смысл его очень прост – любой девайс-участник сети с помощью CapabilitiesClient может узнать, какое множество узлов поддерживает ту или иную функцию, и отправить сообщение именно на один из этих узлов.
Для того чтобы добавить Capabilities в наше wearable-приложение, нужно создать файл res/values/wear.xml и записать туда массив строк, которые и будут обозначать наши Capabilities. Звучит довольно просто. На практике тоже ничего сложного:
wear.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="android_wear_capabilities">
<item>capability_coffee</item>
</string-array>
</resources>
На стороне другого устройства:
fun getCoffeeNodes(capabilityReceiver: (nodes: Set<Node>) -> Unit) {
val capabilityClient = Wearable.getCapabilityClient(context)
capabilityClient
.getCapability(CAPABILITY_COFFEE, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { nodes ->
capabilityReceiver.invoke(nodes.nodes)
}
}
Если у вас, как и у меня, развился Rx головного
fun <T : Any?> Task<T>.toSingle(fromCompleteListener: Boolean = true): Single<T> {
return Single.create<T> { emitter ->
if (fromCompleteListener) {
addOnCompleteListener {
if (it.exception != null) {
emitter.onError(it.exception!!)
} else {
emitter.onSuccess(it.result)
}
}
} else {
addOnSuccessListener { emitter.onSuccess(it) }
addOnFailureListener { emitter.onError(it) }
}
}
}
Тогда цепочка для получения Nodes будет выглядеть красивее:
override fun getCoffeeNodes(): Single<Set<Node>> =
Wearable.getCapabilityClient(context)
.getCapability(CAPABILITY_COFFEE, CapabilityClient.FILTER_REACHABLE)
.toSingle()
.map { it.nodes }
Messages
Все предыдущие компоненты Data Layer предполагали кэширование данных. Message помогает отправлять сообщения без синхронизации в формате «отправили и заб(ы|и)ли». Причем отправить сообщение можно только на конкретный узел или на конкретное множество узлов, которые предварительно необходимо получить через CapabilitiesClient:
fun sendMessage(message: ByteArray, node: Node) {
val messageClient = Wearable.getMessageClient(context)
messageClient.sendMessage(node.id, PATCH_COFFEE_MESSAGE, message)
.addOnSuccessListener {
// Success :)
}
.addOnFailureListener {
// Error :(
}
}
Потенциальный получатель сообщения, в свою очередь, должен подписаться на получение сообщений, и найти нужное по его URI:
val messageClient = Wearable.getMessageClient(context)
messageClient.addListener { messageEvent ->
if (messageEvent.path == PATCH_COFFEE_MESSAGE) {
// TODO: coffee processing
}
}
Channels
Каналы служат для передачи потоковых данных в режиме реального времени без кэширования. Например, если нам нужно отправить голосовое сообщение с часов на телефон, то каналы будут очень удобным инструментом. Клиент для каналов можно получить через Wearable.getChannelClient(), и дальше открыть входной или выходной поток данных (один канал может работать в обе стороны).
Google активно развивает Data Layer, и вполне вероятно, что через полгода эти клиенты снова куда-то «переедут», или их API снова поменяется.
Разумеется, Data Layer – не единственный способ общения с внешним миром, никто не запретит нам по-старинке открыть tcp-socket и разрядить устройство пользователя.
В заключение
Это был всего лишь краткий обзор актульных технических возможностей платформы. Wear OS быстро развивается. Устройств становится больше, и возможно, скоро это будут не только часы. Support Wearable Library тоже не стоит на месте и меняется вместе с платформой, радуя нас новыми UI-компонентами и чудесами синхронизации.
Как и у любой другой системы, тут есть свои тонкости и интересные моменты, о которых можно говорить долго. Многие детали остались раскрыты не полностью, поэтому пишите в комментариях, о чем хочется поговорить подробнее, и мы расскажем об этом в следующей статье. Делитесь своим опытом wearable-разработки в комментариях.
Автор: Konstantin Kulikov