MobileUp corporate blog
Development of mobile applications
Development for Android
Development for IOT
April 2018 19

Она вам не Android. Особенности разработки под Wear OS



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, который умеет адаптировать дочерние элементы под круглый дисплей. Он помещает их в прямоугольную область, вписанную в окружность экрана. Для квадратных дисплеев подобные преобразования, само собой, игнорируются.


BoxInsetLayout


Таким образом, одна и та же верстка будет примерно одинаково выглядеть для всех форм экранов часов.


Правильная верстка


Выглядит лучше, не правда ли?


WearableRecyclerView


Списки – удобный паттерн, который активно используется в мобильном (и не только) UX. Wear-интерфейсы исключением не стали. Но из-за закругления углов дисплея верхние View у списка могут обрезаться. WearableRecyclerView помогает исправить такие недоразумения.
Например, есть параметр isEdgeItemsCenteringEnabled, который позволяет задать компоновку элементов по изгибу экрана и расширять центральный элемент, делает список более удобным для чтения на маленьком экране.
Есть WearableLinearLayoutManager, который позволяет прокручивать список механическим колесиком на часах и доскроливать крайние элементы до середины экрана, что очень удобно на круглых интерфейсах.


Wearable RecyclerView


Сейчас библиотека поддержки 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 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 головного мозга, то от себя порекомендую расширение для объекта Task. Этот объект довольно часто фигурирует во фреймворках от Google (в т.ч. Firebase):


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-разработки в комментариях.

А вы работаете с Wear OS?
8.5% Да, разрабатываю 7
58.5% Пока нет, но хочу попробовать 48
29.2% Нет, не интересно 24
3.6% Apple Watch круче 3
Voted 82 users. Passed 18 users.
+18
9.1k 31
Support the author
Comments 10
Top of the day