Kotlin & AndroidX 時代の壁紙アプリ


はじめに

本記事は Android Advent Calendar 2020 13日目の記事です。
2020年に壁紙アプリを作っている人が全然いなかったので、今のトレンド技術を使った壁紙アプリの作り方を紹介します。
アドベントカレンダーも折り返しなので、箸休めになりそうな温い記事です。

本記事で学べること

  • 壁紙アプリの作り方基本
  • 壁紙アプリでも Kotlin Coroutine で非同期処理

壁紙アプリ?

ホーム画面やロック画面に表示する壁紙を提供するアプリケーションです。
壁紙を自由に描画できる仕組みが Android のフレームワークで提供されているので、静止画以外にもいろいろ自由に描画できます。
例えば、ライトテーマとダークテーマで壁紙を切り替えたりできます。
巷では「ライブ壁紙」って言われてたりもします。

壁紙アプリの作り方基本編

Android で壁紙アプリを作るには WallpaperService を使います。

class MyWallpaperService : WallpaperService() {
    override fun onCreateEngine(): Engine {
        return MyWallpaperEngine()
    }

    inner class MyWallpaperEngine : Engine() {
        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            super.onSurfaceCreated(holder)
            holder ?: return
            val canvas = holder.lockCanvas()
            // 描画処理
            holder.unlockCanvasAndPost(canvas)
        }
    }
}

WallpaperService は Service のサブクラスで、WallpaperService.Engine のインスタンスを提供するだけのクラスです。
実際に壁紙の描画を担当するクラスは WallpaperService.Engine です。
Engine は SurfaceView とインターフェース互換のある SurfaceHolder を使って壁紙を描画します。
SurfaceHolder#lockCanvas()Canvas が取得できるのでガシガシパスを描画していけば自由に描画できます。

SurfaceHolder は普通の View と異なり、必ずしも描画スレッド (ここではonSurfaceCreated と同一のスレッド) で描画する必要はありません。
SurfaceHolder#lockCanvas() を呼び出すと、SurfaceHolder は SurfaceHolder#unlockCanvasAndPost() を呼び出すまで内部で描画対象の SurfaceView をロックしてくれます。
そのため、重たい描画処理は別スレッドで処理しても問題ありません。

実装した WallpaperService は AndroidManifest で宣言してアプリが壁紙を描画できることを Android OS に伝えましょう。
また、提供する壁紙の情報を meta-data で定義します。
meta-data はホームアプリが壁紙一覧を表示するときなどに利用されます。

AndroidManifest.xml
<application
.../>
    <service
        android:name=".MyWallpaperService"
        android:label="MyWallpaper"
        android:permission="android.permission.BIND_WALLPAPER">
        <intent-filter>
            <action android:name="android.service.wallpaper.WallpaperService" />
        </intent-filter>
        <meta-data
            android:name="android.service.wallpaper"
            android:resource="@xml/wallpaper" />
    </service>
xml/wallpaper.xml
<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:thumbnail="@mipmap/ic_launcher"
    android:description="@string/wallpaper_description">
</wallpaper>

壁紙アプリでも Kotlin Coroutine で非同期処理

壁紙の描画も普通の View と大差ない実装だということが分かってもらえたと思います。
では、壁紙の描画でも非同期処理が登場するのは自然に想像できるでしょう。
例えば、重たい描画処理を別スレッドで処理したり、壁紙の描画に使用するデータを Repository からストリームで通知したりしたいです。
そこで、壁紙アプリでも Kotlin Coroutine で非同期処理を実現する方法を紹介していきます。

Coroutine を使うには CoroutineScope が必要です。
Activity や Fragment では AndroidX で LifecycleScope が提供されています。
この Scope は LifecycleOwner のライフサイクルに応じてキャンセルされる CoroutineScope なので、非同期処理が意図せず動き続ける心配がなくなります。
WallpaperService でも同じように LifecycleScope があればよさそうです。

しかし、LifecycleOwner が実装された WallpaperService は AndroidX に無いため、WallpaperService の LifecycleScope は公式では提供されていません😇
代わりに LifecycleOwner が実装された Service が LifecycleService としてあるので、これを元に LifecycleWallpaperService を実装してみました。

abstract class LifecycleWallpaperService : WallpaperService(), LifecycleOwner {
    private val serviceLifecycleDispatcher by lazy {
        ServiceLifecycleDispatcher(this)
    }

    @CallSuper
    override fun onCreate() {
        serviceLifecycleDispatcher.onServicePreSuperOnCreate()
        super.onCreate()
    }

    @Suppress("deprecation")
    @CallSuper
    override fun onStart(intent: Intent?, startId: Int) {
        serviceLifecycleDispatcher.onServicePreSuperOnStart()
        super.onStart(intent, startId)
    }

    @CallSuper
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return super.onStartCommand(intent, flags, startId)
    }

    @CallSuper
    override fun onDestroy() {
        serviceLifecycleDispatcher.onServicePreSuperOnDestroy()
        super.onDestroy()
    }

    override fun getLifecycle(): Lifecycle {
        return serviceLifecycleDispatcher.lifecycle
    }

    final override fun onCreateEngine(): Engine {
        // Workaround:
        // WallpaperService#onBind is final method,
        // so dispatcher cannot be received a ON_START event on bind.
        serviceLifecycleDispatcher.onServicePreSuperOnBind()
        return onCreateEngineWithLifecycle()
    }

    abstract fun onCreateEngineWithLifecycle(): LifecycleEngine

    open inner class LifecycleEngine : Engine(), LifecycleOwner {
        private val lifecycleRegistry by lazy {
            LifecycleRegistry(this)
        }

        override fun getLifecycle(): Lifecycle {
            return lifecycleRegistry
        }

        override fun onCreate(surfaceHolder: SurfaceHolder?) {
            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
            super.onCreate(surfaceHolder)
        }

        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
            super.onSurfaceCreated(holder)
        }

        override fun onSurfaceDestroyed(holder: SurfaceHolder?) {
            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
            super.onSurfaceDestroyed(holder)
        }

        override fun onDestroy() {
            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
            super.onDestroy()
        }
    }
}

上記のコードは WallpaperService の LifecycleOwner、ついでに WallpaperService.Engine の LifecycleOwner を実装した例です。

基本的に Service の LifecycleEvent の制御はヘルパークラスの ServiceLifecycleDispatcher がやってくれます。
Service の各ライフサイクルメソッドで適切な ServiceLifecycleDispatcher のメソッドを呼びましょう。
しかし、onBind は 親クラスの WallpaperService 側で final 宣言されているため、serviceLifecycleDispatcher#onServicePreSuperOnBind() だけは onBind のタイミングで呼べません。
WallpaperService は onBind で壁紙を要求するアプリ (ホームアプリなど) と対話するため、サブクラスでのオーバーライドは禁止されているようです。

そこで、LifecycleWallpaperService では代わりに onCreateEngineserviceLifecycleDispatcher#onServicePreSuperOnBind() を呼んでいます。
onBind よりはタイミングが少し遅くなりますが、bind された WallpaperService が次にやることは Engine を作ることなので、Engine を作る直前に Lifecycle.Event.ON_START が 発火していれば、CoroutineScope の動作上は問題ないと思っています。

これで LifecycleOwner が実装された WallpaperService ができたので WallpaperService と Engine の寿命にあった CoroutineScope が利用できます。
使い方は Activity などと一緒で、getLifecycle.coroutineScope か lifecycle-runtime-ktx を使っていれば lifecycleScope で取り出せます。

class MyWallpaperService : LifecycleWallpaperService() {
    override fun onCreateEngineWithLifecycle(): LifecycleEngine {
        lifecycleScope.launch {
            // suspend 関数が使える
        }
        return MyWallpaperEngine()
    }

    inner class MyWallpaperEngine : LifecycleEngine() {
        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            super.onSurfaceCreated(holder)
            lifecycleScope.launch {
                // suspend 関数が使える
            }
        }
    }
}

これで WallpaperService 内でも Coroutine が使えるようになりました!
細かい実装例は、僕が作った壁紙アプリのコードを参考にしてみてください。

まとめ

  • 壁紙アプリは WallpaperService を使って実装する!
  • WallpaperService 内で Coroutine を使うには LifecycleOwner を実装する!

本当は Jetpack Compose を使って壁紙を描画したかったけど、WallpaperService から View は操作できなそうだったので諦めました。
Canvas 操作を頑張ってくれ。😇