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

Комментарии (9)


  1. longtolik
    19.04.2018 20:30

    Удачи Вам!
    Мой энтузиазм потихоньку угасает…


    1. Semper-Viventem Автор
      20.04.2018 00:52

      Спасибо!
      Очень надеюсь, что тема носимой электроники будет развиваться.


  1. terrakok
    19.04.2018 22:07

    А написание приложение было просто для себя? Или есть реальное приложение? Было бы интересно посмотреть (не встречал ещё заказов на разработку под часы)


    1. Semper-Viventem Автор
      20.04.2018 00:56

      Писал просто для себя инструмент. Пока не публиковал в Google Play, но думаю это сделать в ближайшее время. Могу поделиться пока только ссылкой на GitHub проекта:
      https://github.com/Semper-Viventem/WearHint


  1. eskander_service
    19.04.2018 22:35

    Будьте так добры, добавляйте тэг что здесь есть котлин


  1. anegin
    20.04.2018 12:03

    Раздражает, что sdk для Wear не может прийти к какому-то более-менее стаблильному состоянию. Каждый раз открывая проект для Wear, который не трогал пару месяцев, замечаю, что часть классов уже deprecated. Некоторые просто переносятся в другие пакеты (например, android.support.wearable.view.* -> android.support.wear.widget.*), для некоторых приходится переписывать код (например, ChannelApi -> ChannelClient или CapabilityApi -> CapabilityClient).
    Еще момент — на Wear до сих пор не завезли support-фрагменты. Это не дает использовать lifecycle-компоненты (только через костыли). К тому же, если какой-то фрагмент нужно будет использовать и для mobile-, и для wear- приложения, придется под каждую платформу делать свой фрагмент.


    1. Semper-Viventem Автор
      20.04.2018 15:21

      Да, SDK очень активно меняется. Сам пока писал с этим успел столкнуться. Но, думаю, меняется оно все равно в лучшую сторону. Обидно только, что все довольно плохо покрыто документацией, и код Support Wearable Library сильно осфусцирован.
      А насчет фрагментов — нужны ли они на Wear? Гайдлайны не предполагают сложные интерфейсы из множества отдельных деталей, да и навигация в Wear должна быть максимально простой. Разве просто активности с этим не должны справляться именно в случае с Wear?


  1. dmxrand
    20.04.2018 12:26

    А можно назвать хотя бы пять полезных приложений? Я тут под андроид не могу придумать что написать ибо смысла нет, а под часы вообще не понимаю.


    1. Semper-Viventem Автор
      20.04.2018 15:25

      На самом деле, хороший вопрос. Но, например, как интерфейс для управления «умным домом», да и просто для связи с другими IoT-решениями, Wear должен быть очень удобен. Особенно в связке с голосовым ассистентом.