Мне нужно отображать на экране телефона техническую информацию о его состоянии, точнее, о его состоянии в составе тестового пула. Видеть эту информацию я хочу всегда, то есть на Home screen, и без дополнительных телодвижений.


Способов, которые никак не повлияют на выполнение других приложения, всего два: Widget или Live wallpaper. Я выбрал Live wallpaper, они же "живые обои", потому что они автоматически попадают на все страницы Home screen, и даже на Lock screen. Эта статья содержит практические рекомендации, как создавать "живые обои".


В поисках правды


Документации о "живых обоях" кот наплакал. С момента первого (и единственного) анонса в блоге, случившегося больше 9 лет назад, Гугл не сделал ни одного внятного примера или codelab-а на эту тему. Пришлось разбираться.


Сначала основы. Внутренняя механика Андроида такова, что на устройство мы можем установить только приложение, и устройство всех приложений одинаково. Поскольку "живые обои" — это тоже приложение, то выбор управляющего компонента не велик, и стоит ожидать, что это будет Service. Найти его легко: это WallpaperService.


Экземпляров "живых обоев" может быть несколько, и жизненный цикл у них будет не такой, как у Activity или View. Соответственно, должен быть еще один базовый класс. Это WallpaperService.Engine (и он обязательно inner для WallpaperService!). Если вглядеться, то он окажется таким же поставщиком событий жизненного цикла, как Activity, Service и иже с ними.


Жизненный цикл "живых обоев" выглядит так:


onCreate(SurfaceHolder surfaceHolder)
onSurfaceCreated(SurfaceHolder holder)
onSurfaceChanged(SurfaceHolder holder, int format, int width, int height)
onVisibilityChanged(boolean visible)
onSurfaceRedrawNeeded(SurfaceHolder holder)
onSurfaceDestroyed(SurfaceHolder holder)
onDestroy()


Из этого списка становится понятно, когда можно/нужно перерисовать картинку (либо начать перерисовывать, если у вас анимация), и когда пора прекратить всю активность и не тратить батарейку.


Метод onSurfaceRedrawNeeded() выделяется среди остальных, читайте ниже. Также в помощь есть метод isVisible() (который в Котлине превращается в свойство isVisible).


Теперь можно собирать этот конструктор. Начну с конца.


Рисуем


Рисовать придется самим на Canvas, никаких layout и inflater нам не будет. Как получить Canvas из SurfaceHolder и как на нем рисовать — за рамками этой статьи, ниже есть простой пример.


fun dummyDraw(c: Canvas) {
    c.save()
    c.drawColor(Color.CYAN)
    c.restore()
}

// surfaceHolder property is actually a call to the getSurfaceHolder() method
fun drawSynchronously() = drawSynchronously(surfaceHolder)

fun drawSynchronously(holder: SurfaceHolder) {
    if (!isVisible) return

    var c: Canvas? = null
    try {
        c = holder.lockCanvas()
        c?.let {
            dummyDraw(it)
        }
    } finally {
        c?.let {
            holder.unlockCanvasAndPost(it)
        }
    }
}

Методы жизненного цикла Engine


Все методы жизненного цикла, кроме onSurfaceRedrawNeeded, не требуют немедленной перерисовки. Поэтому хорошим тоном будет перерисовку поставить в очередь.


override fun onSurfaceCreated(holder: SurfaceHolder?) {
    super.onSurfaceCreated(holder)
    redrawHandler.planRedraw()
}

override fun onSurfaceChanged(holder: SurfaceHolder?, format: Int, width: Int, height: Int) {
    super.onSurfaceChanged(holder, format, width, height)
    redrawHandler.planRedraw()
}

override fun onVisibilityChanged(visible: Boolean) {
    super.onVisibilityChanged(visible)
    redrawHandler.planRedraw()
}    

override fun onSurfaceDestroyed(holder: SurfaceHolder?) {
    super.onSurfaceDestroyed(holder)
    redrawHandler.omitRedraw()
}     

override fun onDestroy() {
    super.onDestroy()
    redrawHandler.omitRedraw()
}

override fun onSurfaceRedrawNeeded(holder: SurfaceHolder) {
    super.onSurfaceRedrawNeeded(holder)
    redrawHandler.omitRedraw()
    drawSynchronously(holder) // do it immediately, don't plan
}

Обратите внимание на onSurfaceRedrawNeeded, который передает нам вызов одноименного коллбэка SurfaceHolder, который возникает при изменении размера и аналогичных событиях. Этот колбэк позволяет выполнить перерисовку немедленно, не допустив показа пользователю старой (и уже неверной) картинки. Система гарантирует, что пока не произошел возврат из этого метода, вывод на экран будет приостановлен.


Scheduler


Я люблю переопределять Handler, а не гонять в нем Runnable. На мой взгляд, так изящней.


В случае, если у вас анимация или регулярное обновление, то нужно будет сделать регулярную постановку сообщения в очередь (postAtTime() и postDelayed() вам в помощь). Если данные обновляются эпизодически, достаточно для обновления вызвать planRedraw().


val redrawHandler = RedrawHandler()

inner class RedrawHandler : Handler(Looper.getMainLooper()) {
    private val redraw = 1

    fun omitRedraw() {
        removeMessages(redraw)
    }

    fun planRedraw() {
        omitRedraw()
        sendEmptyMessage(redraw)
    }

    override fun handleMessage(msg: Message) {
        when (msg.what) {
            redraw -> drawSynchronously()

            else -> super.handleMessage(msg)
        }
    }
}

Service & Engine


Собирается эта марешка из Service и Engine вот так:


class FooBarWallpaperService : WallpaperService() {
    override fun onCreateEngine() = FooBarEngine()

    inner class FooBarEngine : Engine() {

        ....

    }
}

AndroidManifest и другие заклинания


Заклинаниями в разработке софта я называю то, что невозможно понять, но нужно точно повторить.


В .../app/src/main/res/xml должен лежать XML файл с описанием "живых обоев". Имя этого файла должно быть указано в AndroidManifest (ищите в примере ниже слово foobarwallpaper)


<?xml version="1.0" encoding="UTF-8"?>
<wallpaper
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:thumbnail="@drawable/some_drawable_preview"
        android:description="@string/wallpaper_description"
/>

Не потеряйте в описании Service-а permission, meta-data и intent-filter:


<service android:name=".FooBarWallpaperService"
         android:enabled="true"
         android:label="Wallpaper Example"
         android:permission="android.permission.BIND_WALLPAPER">

    <meta-data
            android:name="android.service.wallpaper"
            android:resource="@xml/foobarwallpaper" >
    </meta-data>

    <intent-filter>
        <action android:name="android.service.wallpaper.WallpaperService">
        </action>
    </intent-filter>
</service>

Как добавить


"Живые обои" прячутся, поэтом подсказка. Описываю, как это выглядит на моем Samsung.


Для начала long press где-нибудь на Home screen, телефон перейдет в режим настройки рабочих столов, появится иконка Wallpapers.


Нажимаем на иконку Wallpapers, несколько разделов, нам нужен My wallpapers, жмем надпись View all в правом верхнем углу раздела, открывается список во весь экран.


Жмем "три точки" вызова меню, в нем пункт LIve wallpapers (у меня он единственный), появляется список доступных "живых обоев".


Выбираем наши обои, и выбираем "Home and lock screen".


Появится "превью", которое уже отрисовывается нашим приложением (чтобы распознать этот момент, есть метод isPreview()), жмем Set as wallpaper… И ничего не видим, потому что возвращаемся в список доступных обоев.


Жмем "Home" и наслаждаемся.


Причем тут Android Watch?!


Интересное наблюдение по ходу, что Faces в Android Watch сделаны по точно такой же схеме (с точностью, что у них свои базовые классы со своей реализацией): такие же Service + Engine, почти те же метаданные и intent filter для Service в манифесте (в которых слово wallpaper встречается четыре раза:), также надо писать свой шедулер на основе Handler-а.


В базовых классах Watch Faces есть готовый onDraw(), куда передается Canvas, и есть invalidate() для его вызова. Но это не принципиальное различие, а реализованная часть бойлерплейта.


В отличие от Live Wallpaper, для Watch Faces есть примеры, в них можно покопаться (ссылки здесь, в самом начале).


Что получилось


Скриншотики для приложения, которое красит экран в зеленый цвет, имеют мало смысла. Но пара фоток, что на базе этого получилось сделать для боевого стенда, под спойлером.


Пара фоток



Стикеры — это оставшаяся система обнаружения проблем предыдущего поколения.


Благодарности


Если бы не эти две статьи, я бы блуждал в потьмах намного дольше. Не представляю себе, как их можно было написать аж в 2010 году при таком качестве документации?!


Kirill Grouchnikov, Live wallpapers
Vogella, Android Live Wallpaper — Tutorial


Исходники


> GitHub

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


  1. stoplinux
    23.07.2019 01:21

    Круто, но скриншотика не хватает


    1. tse Автор
      23.07.2019 04:20

      Скриншотик для этого приложения — это будет зеленый квадрат Малевича, в нем мало смысла. А пару фоток того, что на базе этого было сделано, я добавил.


  1. savostin
    23.07.2019 12:18

    iOS, как я понимаю, курит в сторонке?


    1. tse Автор
      23.07.2019 12:48

      У нас нет пула живых iOS устройств, поэтому для iOS проблема плохих проводов и сошедших с ума девайсов не стоит. Если я правильно понял вопрос, конечно.


      1. savostin
        23.07.2019 12:50

        Нет, я про возможность делать/обновлять программно «живые обои».


        1. tse Автор
          23.07.2019 13:32

          Тогда не знаю. Девопсить приходится весь CI, но программирую только под Android.


        1. expeon
          23.07.2019 13:51

          Насколько я знаю, на iOS «живые» обои — это просто live photo:
          support.apple.com/en-us/HT207310
          developer.apple.com/library/archive/releasenotes/General/WhatsNewIniOS/Articles/iOS9_1.html

          Наверное, их можно генерировать программно, складывать в альбом и тогда использовать, как «живые» обои.


  1. hotMule
    26.07.2019 12:03

    Выгорания амоледов не боитесь


    1. tse Автор
      26.07.2019 12:09

      Во-первых, а куда деваться? Мы гоняем тесты 24/7. Время, когда на экране этот индикатор, на порядок меньше, чем когда там тест, а во время теста экран включен всегда.

      Во-вторых, на ходовые это не влияет. Тесту наплевать, в каком состоянии экран.

      В-третьих, да выгорают. На каких-то старых телефонах неубиваемая нотификация от Самсунга уже впечаталась. Но это не так, что теперь на экране только этот диалог, и за ним ничего не видно, оно скорее как тень: надо вглядываться, чтобы различить, хотя неоднородность картинки видна.

      Пока писал, пришла идея: надо добавить небольшую случайность к позиции надписи, чтобы выгорание чуть размазать. Спасибо.