制作一个安卓的星空动态壁纸(带随机流星动画)

前言

在我之前的文章 羡慕大劳星空顶?不如跟我一起使用 Jetpack compose 绘制一个星空背景(带流星动画) 中,我们使用 Compose 实现了星空背景效果。

并且调用非常方便,只需要一行代码就可以给任意 Compose 组件添加上这个星空背景效果。

但是,只是给 Compose 添加背景效果总觉得有点"小题大作"了,这么好看的效果,不用来做壁纸实在是太可惜了。

于是,我尝试将其移植到动态壁纸中。但是,尝试了很久都没有找到怎么在动态壁纸中使用 Compose 。

最终,我还是使用安卓原生 Canvas 重新绘制了一个同样的动画效果。

实现效果如下:

s1

好在 Compose 的绘制和安卓绘制其实区别也不是很大,所以重写起来也几乎没有动多少代码。

下面我们将讲解如何实现一个动态壁纸。

仓库地址:starrySkyWallpaper

动态壁纸实现

其实安卓在很早很早的版本就已经支持了动态壁纸,只是一直都没多少人使用罢了。

今天我们就来看看动态壁纸要怎么实现吧。

WallpaperService

安卓中的动态壁纸以服务(Server)的形式来完成计算和绘制,并且这个服务需要继承自 WallpaperService

一个简单的动态壁纸模版代码如下:

class StarrySkyWallpaperServer : WallpaperService() {
    override fun onCreateEngine(): Engine = WallpaperEngine()

    inner class WallpaperEngine : WallpaperService.Engine() {

        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            super.onSurfaceCreated(holder)
            // 可以在这里写绘制代码
        }

        override fun onVisibilityChanged(visible: Boolean) {
            // 当壁纸的可见行性改变时会调用这里
            if (visible) {
                
            } else {
                
            }
        }

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

可以看到,在这个服务可供我们渲染的是 SurfaceHolder

而从 SurfaceHolder 中我们可以通过多种方式进行渲染,常用的三种方式为:

  • MediaPlayer
  • Camera
  • SurfaceView

第一个即媒体播放器,可以用来播放视频;第二个可以用来实时预览相机界面;第三个就是我们常用的 SurfaceView ,可以从中取出 Canvas 来自己绘制内容。

因为我们这里使用的是第三种方式:自定义绘制。所以前面两种这里就不再赘述,感兴趣的可以看看文末参考链接中的介绍。

在开始绘制之前,我们还有一点准备工作,因为这是一个服务,所以自然是需要在清单文件(manifest)中注册一下的:

<service
    android:name=".server.StarrySkyWallpaperServer"
    android:exported="true"
    android:label="Wallpaper"
    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>

其中的 android:resource="@xml/wallpaper" wallpaper 文件,需要我们自己在 xml 文件夹新建一个:

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:thumbnail="@mipmap/ic_launcher" />

从字段名也很容易看出,这是我们动态壁纸的一些配置信息,比如上面就写了描述信息和缩略图。

设置壁纸

经过上面的步骤,我们的动态壁纸服务就注册完成了,我们在手机上的壁纸编辑界面中选择动态壁纸就能看到我们创建的这个动态壁纸了。

然而,事实上,正因为我们上面说的,虽然安卓你的动态壁纸很久以前就有了,但是用的人一直不多。

所以国内的定制系统基本上都把这个功能阉割或魔改了。比如我现在用的 MIUI ,虽然设置壁纸时还能选动态壁纸,但是却只会显示官方的动态壁纸,第三方的都被隐藏了。

不过不用担心,我们可以在我们自己的APP中"手动"调用并设置我们自己的壁纸:

val intent = Intent()
intent.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
intent.putExtra(
    WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
    ComponentName(context.packageName, StarrySkyWallpaperServer::class.java.name)
)
context.startActivity(intent)

例如,这里我们的APP启动界面代码如下:

@Composable
fun MainScreen() {
    val context = LocalContext.current

    Column(
        Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = {
            onClickSetWallPaper(context)
        }) {
            Text(text = "设置")
        }
    }
}

private fun onClickSetWallPaper(context: Context) {
    val intent = Intent()
    intent.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
    intent.putExtra(
        WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
        ComponentName(context.packageName, StarrySkyWallpaperServer::class.java.name)
    )
    context.startActivity(intent)
}

代码很简单,就一个居中的设置按钮,点击后就会跳转到系统的壁纸设置界面,并会显示我们自定义壁纸的动态预览:

s2

绘制图案时机

在上面的 WallpaperService 服务模版中,我们在注释中写了可以在 onSurfaceCreated 回调中写我们的绘制代码。

但是这里我们为了更好的控制绘制过程,就不在 onSurfaceCreated 写我们的绘制代码了,而是在 onVisibilityChanged 回调中写:

override fun onVisibilityChanged(visible: Boolean) {
    if (visible) {
        // 启动绘制
        continueDraw()
    } else {
        // 停止绘制
        stopDraw()
    }
}

当壁纸可见时调用 continueDraw 开始绘制;当壁纸不可见时调用 stopDraw 停止绘制。

同时为了能够更好的停止绘制代码,我们这里用了协程,其实这里有点多余,因为我们的绘制内容都是在服务中,不会存在阻塞的情况。

continueDrawstopDraw 定义如下:

private var coroutineScope = CoroutineScope(Dispatchers.IO)

private var drawStarrySky = DrawStarrySky()

private fun continueDraw() {
    coroutineScope.launch {
        drawStarrySky.startDraw(surfaceHolder)
    }
}

private fun stopDraw() {
    drawStarrySky.stopDraw()
    coroutineScope.coroutineContext.cancelChildren()
}

上面的 DrawStarrySky 类即我们的绘制代码,这里它只公开了两个方法:startDrawstopDraw

其实一开始我只对外暴露了 startDraw 方法,没有暴露停止方法,但是我在测试时发现,仅靠 coroutineScope.coroutineContext.cancelChildren() 并不能及时的取消掉协程。

这会导致可能绘制对象已经被销毁了,但是由于我的协程不是立即被取消的,依旧会调用已被销毁的绘制对象,这就会导致闪退。

所以我额外加了一个停止方法,并且在内部自己维护一个停止标志 isRunning 避免上述情况的出现。

绘制实现类 DrawStarrySky

在开始之前,先介绍一下如何从 SurfaceHolder 中拿到 Canvas 用于绘制。

在上面的代码中我们可以看到,我们的开始绘制方法 drawStarrySky.startDraw(surfaceHolder) 接收了一个参数,就是 SurfaceHolder。

那么如何从 SurfaceHolder 中拿到 Canvas ,并且当我们绘制完成后如何将这个 Canvas 写回呢?

其实很简单,依旧是一个模版代码:

var canvas: Canvas? = null

try {
    // 锁定并返回当前 Surface 中的 Canvas
    canvas = surfaceHolder.lockCanvas()
    if (canvas != null) {
        // 在这里对 Canvas 进行绘制
    }
} finally {
    if (canvas != null) {
        // 解锁 Canvas 并写回到 Surface 中
        holder.unlockCanvasAndPost(canvas)
    }
}

当然,我们的绘制代码有很多,总不能每次都写这一大堆模版代码吧?

所以,我们写了一个函数 getCanvas :

private fun getCanvas(
    holder: SurfaceHolder,
    drawContent: (canvas: Canvas) -> Unit
) {
    var canvas: Canvas? = null

    try {
        canvas = holder.lockCanvas()
        if (canvas != null) {
            drawContent(canvas)
        }
    } finally {
        if (canvas != null) {
            try {
                holder.unlockCanvasAndPost(canvas)
            } catch (tr: Throwable) {
                tr.printStackTrace()
            }
        }
    }
}

了解了怎么拿到 Canvas 以及怎么写回 Canvas ,下一步就是正式开始绘制:

suspend fun startDraw(
    holder: SurfaceHolder,
    randomSeed: Long = 1L
) {

    isRunning = true

    // 初始化参数
    val random = Random(randomSeed)
    val paint = Paint()
    var canvasWidth = 0
    var canvasHeight = 0

    // 这里仅仅是为了拿到画布大小,其实有点多余了,拿画布大小的方法很多,没必要这样拿。不过这里偷了个懒
    getCanvas(holder) { canvas ->
        canvasWidth = canvas.width
        canvasHeight = canvas.height
    }

    // 背景缓存
    val bitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888)
    // 绘制静态背景
    drawFixedContent(Canvas(bitmap), random)

    while (isRunning) {

        // 绘制动态流星
        val safeDistanceStandard = canvasWidth / 10
        val safeDistanceVertical = canvasHeight / 10
        val startX = random.nextInt(safeDistanceStandard, canvasWidth - safeDistanceStandard)
        val startY = random.nextInt(safeDistanceVertical, canvasHeight - safeDistanceVertical)

        for (time in 0..meteorTime) {
            if (!isRunning) break

            getCanvas(holder) { canvas ->
                // 清除画布
                canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

                // 绘制背景
                paint.reset()
                canvas.drawBitmap(bitmap, 0f, 0f, paint)

                // 绘制流星
                drawMeteor(
                    canvas,
                    time.toFloat(),
                    startX,
                    startY,
                    paint
                )
            }
            
            delay(1)
        }

        delay(meteorScaleTime)
    }
}

从上面的绘制代码可以看到,我们先调用 drawFixedContent 方法绘制了静态背景,这里的具体绘制代码就不贴了,因为和上次我们用 Compose 实现的几乎没有区别,有需要的可以看我的上篇文章或者直接看项目源码了解。

我们只需要知道这个方法最终绘制的是黑色背景和其中固定不变的星星即可。

但是,不知道你们有没有注意到,这里我并不是直接把内容绘制到从 Surface 中拿到的 Canvas 中,而是绘制到了一个 Bitmap 中。

这是因为我们从 Surface 中拿到的 Canvas 并不是空白的 Canvas 而是当前 Surface 显示内容的 Canvas。

换句话说,我们每次拿到的 Canvas 都是之前所有绘制叠加起来的 Canvas。

为了实现动画效果,我们会在每次绘制之前使用 canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) 将当前画布 “清空”。

而我们这里绘制的明明是固定不变的背景,却在每次被清空后都重新计算并绘制。

这显然不合理,我们需要循环绘制的明明只有流星相关内容就可以了。

所以,这里我们在循环之外将背景计算并绘制到 Bitmap 中缓存。

每次需要更新 Canvas 时都只需要将这个缓存 Bitmap 绘制上去就可以了。

了解了我们的固定背景之后,再往下看。

下面我们用了两层循环,一层 while 死循环,用于持续生成流星。

一层 for 循环,用于绘制一次流星的动画。

在 while 循环中我们初始化参数(主要是随机生成一个流星起点坐标)后,开启 for 循环开始绘制流星的每一帧。

for 循环的参数即为我们的模拟时间参数。

同样的,drawMeteor 方法用于绘制流星,具体绘制代码我们也不贴了,各位可以看我上篇文章的解析,也可以直接看源码。

自此,我们的所有代码就完成了。

最终实现效果如下:

s1.gif

总结

通过上面的代码可以看到,其实安卓的动态壁纸并没有想象中的那么困难,无非就是自定义绘制这一套,如果熟悉自定义绘制的话,写起来还是非常容易的。

不过我们这里只展示了使用 Canvas 的绘制,事实上,由 SurfaceHolder 我们可以有更多的"骚操作”,例如调用第三方成熟的动画库直接刷新 Surface 等,感兴趣的可以去搜一搜。

下一步

虽然现在我们已经实现了我们的需求,即将星空背景做成动态壁纸,

但是从代码中也可以看到,我们所有的参数都是写死的。

这显然不符合常理。

所以我们下一步目标是将这些参数抽出,作为用户可配置的配置项。

参考资料

  1. Android壁纸还是B站玩得花
  2. Building an Android Live Wallpaper