Она вам не 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?
    MobileUp 65,05
    Мобильная разработка повышенной сложности
    Поделиться публикацией
    Похожие публикации
    Комментарии 10
      +1
      Удачи Вам!
      Мой энтузиазм потихоньку угасает…
        0
        Спасибо!
        Очень надеюсь, что тема носимой электроники будет развиваться.
        0
        А написание приложение было просто для себя? Или есть реальное приложение? Было бы интересно посмотреть (не встречал ещё заказов на разработку под часы)
          0
          Писал просто для себя инструмент. Пока не публиковал в Google Play, но думаю это сделать в ближайшее время. Могу поделиться пока только ссылкой на GitHub проекта:
          https://github.com/Semper-Viventem/WearHint
          –3
          Будьте так добры, добавляйте тэг что здесь есть котлин
            0
            Раздражает, что sdk для Wear не может прийти к какому-то более-менее стаблильному состоянию. Каждый раз открывая проект для Wear, который не трогал пару месяцев, замечаю, что часть классов уже deprecated. Некоторые просто переносятся в другие пакеты (например, android.support.wearable.view.* -> android.support.wear.widget.*), для некоторых приходится переписывать код (например, ChannelApi -> ChannelClient или CapabilityApi -> CapabilityClient).
            Еще момент — на Wear до сих пор не завезли support-фрагменты. Это не дает использовать lifecycle-компоненты (только через костыли). К тому же, если какой-то фрагмент нужно будет использовать и для mobile-, и для wear- приложения, придется под каждую платформу делать свой фрагмент.
              0
              Да, SDK очень активно меняется. Сам пока писал с этим успел столкнуться. Но, думаю, меняется оно все равно в лучшую сторону. Обидно только, что все довольно плохо покрыто документацией, и код Support Wearable Library сильно осфусцирован.
              А насчет фрагментов — нужны ли они на Wear? Гайдлайны не предполагают сложные интерфейсы из множества отдельных деталей, да и навигация в Wear должна быть максимально простой. Разве просто активности с этим не должны справляться именно в случае с Wear?
              0
              А можно назвать хотя бы пять полезных приложений? Я тут под андроид не могу придумать что написать ибо смысла нет, а под часы вообще не понимаю.
                0
                На самом деле, хороший вопрос. Но, например, как интерфейс для управления «умным домом», да и просто для связи с другими IoT-решениями, Wear должен быть очень удобен. Особенно в связке с голосовым ассистентом.
                  0
                  Вот и получается, что в случае как с Андроидом. Все равно без сети программа бесполезна. А раз с сетью, то 99% работает (может) на сервере. Голосовой ассистент точно не в часах живет. А раз так, то генерим HTML и пару хуков на JS и все.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое