做一个Android 动态壁纸

TL;DR:https://gitlab.com/snow-star/SlidesWallpaper

最近想写个自动换壁纸的小工具, 在桌面状态下每隔几秒钟就换一张壁纸, 同时每次切换到桌面的时候也立即更换一张壁纸. 如果只是实现前半句功能的话, 其实只需要一个后台服务, 定时调用 WallpaperManager.getInstance(this).setBitmap() 就行了, 但是要实现后一种功能必须监听什么时候返回桌面, 感觉最方便的方式就是用动态壁纸来实现了.

Android 要想实现动态壁纸, 需要依靠 WallpaperService 这个服务, 同时还需要有一个 WallpaperService.Engine 来执行具体的绘制操作.

原则上来说是不需要 Activity 的, 不过为了方便进行设置, 也可以加上一个 Activity.

实现 Service

首先当然是需要继承 WallpaperService 这个服务并且实现他

class MyWallpaperService() : WallpaperService() {
    private val pics: MutableList<Bitmap> = mutableListOf()

    private val rand = Random()

    override fun onCreateEngine(): Engine {
        return MyEngine()
    }

    inner class MyEngine : WallpaperService.Engine() {
         private var current = 0
    }
}

这是基本需求, Service 必须重写 onCreateEngine 方法, 并且返回一个 Engine, WallpaperService.Engine() 则是一个内部类, 因此使用 kotlin 继承的时候需要加上 inner 声明.

当然现在这个 Service 什么都做不了. 真正的绘制实际上是在 Engine 里面实现的. Engine 提供了getSurfaceHolder 方法来返回一个 SurfaceHolder, 可以从中获取到壁纸区域的 Canvas 对象, 对这个 Canvas 直接绘制就可以更改桌面显示的内容了.

监听桌面是否可见

Engine 提供了一个 onVisibilityChanged 回调, 只需要重写这个方法, 当桌面状态发生变化的时候就会调用这个方法

 override fun onVisibilityChanged(visible: Boolean) {
    if (visible) {
    // 执行绘制逻辑
    } else {
    // 当壁纸区域不在前台的时候停止绘制, 节省电量
    }
}

绘制

由于没有 onDraw 之类的方法, 绘制方法需要自己去用定时器或者别的什么方法进行调度.
首先实现一个用来绘制的方法.

private fun drawPicture() {
    val c = surfaceHolder.lockCanvas()
    // c.drawBitmap()  绘制
    surfaceHolder.unlockCanvasAndPost(c)
}

在这个方法里面来实现对壁纸的绘制.

定时器

实现一个定时器有很多种方法, 我就用一个 Handler 来实现. 在 MyEngine 里面加一个 Handler.

private val handler: Handler = Handler()

以及一个任务(Runnable), 这个任务开始的时候要随机一个数字, 作为本次绘制时候用到的图片索引.

private val task = Runnable {
    if (!pics.isEmpty())
        current = rand.nextInt(pics.size)
    loop()
}

在这个任务当中不去直接调用之前的 drawPicture, 是因为这个绘制方法除了被定时调用之外, 还会在滑动桌面的时候调用, 而滑动的时候定时器也应该是单独运行, 不能被取消, 因此需要一个单独的 loop 方法来实现我们的定时器.

private fun loop() {
    handler.removeCallbacks(task)
    drawPicture()
    handler.postDelayed(task, 5000)
}

loop 所做的事情就非常简单了, 首先删除掉其他的回调, 然后执行真正的绘制方法, 然后利用 postDelayed 方法来设置 5 秒之后再次执行这个任务.

于是我们之前的 onVisibilityChanged 就可以改成这样

override fun onVisibilityChanged(visible: Boolean) {
    if (visible) {
    // 切换时延迟 0.1s 刷新屏幕
    handler.postDelayed(task, 100)
    } else handler.removeCallbacks(task)  // 不可见时取消定时器. 节省电量
}

当桌面状态变成可见的时候延迟 0.1s 来启动循环任务, 而当桌面不可见的时候取消掉任务, 让服务处于空闲状态.

桌面滑动事件

当桌面滑屏的时候, 我们需要显示出图片的不同部分, 比如当首屏的时候显示图片的最中间部分, 当第二屏的时候显示中间靠右的部分.

因此需要重写 onOffsetsChanged 这个方法, 这个方法会在滑动的时候被调用.

override fun onOffsetsChanged(xOffset: Float, yOffset: Float, xOffsetStep: Float, yOffsetStep: Float, xPixelOffset: Int, yPixelOffset: Int) {
    this.xOffset = xOffset
    drawPicture()
}

这个函数有很多个参数, 其中第一个参数 xOffset 代表了当前所处的位置, 当首屏的时候这个值是 0, 再最后一屏的时候是 1.0. 其余地方则平分 0 ~ 1.0 的位置.
因此我们需要根据这个值来计算要绘制图片的具体坐标.

当然这个滑动其实只有在图片的长宽比大于设备显示器长宽比的时候才会有, 否则图片是没有左右空余的地方进行滑动的.

首先考虑一下为 0 的时候, 图片应该绘制中间部分.

 ------------------------
 |       |      |       |
 |  pic  |device|       |
 |       |      |       |
 |       |      |       |
 ------------------------
         ^--------------^
         计算这部分长度

首先需要计算上述的这个部分备用.

val tmpWidth = ((pic.width + deviceWidth) / 2).toInt()
 ------------------------
 |       |      |       |
 |  pic  |device|       |
 |       |      |       |
 |       |      |       |
 ------------------------
 ^-------^
 计算这部分长度

接下来还需要计算这部分, 作为一个偏移量.

val halfWidth = ((pic.width - deviceWidth) / 2).toInt()

有了这两个数值之后, 那么实际的截取起始坐标就是

val start = ((tmpWidth - deviceWidth) * xOffset * 0.3).toInt() + halfWidth

至于为什么是这样, 找支笔和纸画一下就知道, 起始坐标和 xOffset 其实是一个一次函数的关系, 而乘以 0.3 的原因是为了让这个滚动更加平滑, 如果不乘的话, 在只有两屏的情况下, 首屏是图像中间的部分, 而第二屏就变成了结尾, 这中间过度太大了, 乘以 0.3 之后第二屏显示的图像只会相对于首屏位移了相对较小的位置. 加上 halfWidth 则是为了截取到图像中间的位置.

同理截取终点坐标就是.

val end = start + (deviceWidth).toInt()  // 绘制偏移终点(右)

实际裁剪区域和绘制就是

val rect = Rect(start, 0, end, pic.height)
c.drawBitmap(pic, rect, targetRect, paint)

其中 targetRect 其实就是 Rect(0, 0, deviceWidth, deviceHeight), 表示绘制到整个显示区域上.

而当图片是竖着的时候, 就不需要这么麻烦了, 因为不需要滚动了. 直接截取图片的中心部分绘制就可以了.

// 竖向长的图片, 绘制垂直方向中心部分.
val top = ((pic.height - deviceHeight) / 2).toInt()
val rect = Rect(0, top, pic.width, top + (deviceHeight).toInt())
c.drawBitmap(pic, rect, targetRect, paint)

于是最终的 drawPicture 方法就是

private fun drawPicture() {
    if (pics.isEmpty()) return

    val c = surfaceHolder.lockCanvas()

    val pic = pics[current]

    val picDim: Double = pic.height.toDouble() / pic.width      // 图像长宽比
    if (picDim < dim) {
        // 对于横向长的图片, 首屏绘制中间部分, 横向拓展
        val tmpWidth = ((pic.width + deviceWidth) / 2).toInt()
        val halfWidth = ((pic.width - deviceWidth) / 2).toInt()

        // 根据当前所在屏计算绘制偏移量(左), 乘以 0.3 使滚动幅度减小
        val start = ((tmpWidth - deviceWidth) * xOffset * 0.3).toInt() + halfWidth
        val end = start + (deviceWidth).toInt()  // 绘制偏移终点(右)

        // 实际裁剪图片区域
        val rect = Rect(start, 0, end, pic.height)
        c.drawBitmap(pic, rect, targetRect, paint)
    } else {
        // 竖向长的图片, 绘制垂直方向中心部分.
        val top = ((pic.height - deviceHeight) / 2).toInt()
        val rect = Rect(0, top, pic.width, top + (deviceHeight).toInt())
        c.drawBitmap(pic, rect, targetRect, paint)
    }
    surfaceHolder.unlockCanvasAndPost(c)
}

缩放

上面说的情况是在图片正好适应屏幕的状态, 也就是当图片的横的时候, 图片的高度恰好等于设备显示器的高度, 长度只要比显示器长度长就可以了. 而当图片是竖着的时候, 图片宽度恰好等于显示器的宽度, 而长度比显示器长.

但大部分时候并不满足上面的要求, 就需要对图片进行缩放, 使得图片满足上述需求.

那么什么时候缩放? 谁来缩放?

第一种解决方案就是让系统去缩放, 也就是 drawBitmap 的时候, 第二个参数只要比例和显示器的比率一样就行, 这样绘制的时候系统会自动进行缩放操作, 使得图片符合显示器.

这也是我一开始选择的方式, 但这方式有个非常严重的问题, 那就是反锯齿和性能的问题. 如果什么都不做的话, 默认画出来的图片因为是缩放过的. 锯齿特别严重, 即便是把一个分辨率超过设备的图片缩小, 也会有很严重的锯齿. 而要想反锯齿, 需要给画笔设置 FilterBitmap 属性为 true, 也就是对图片进行滤波处理, 这样画出来的图片才不会有肉眼可见的锯齿(如果你的眼睛是能看到大果粒的显微镜的话另说).

而这个锯齿是设置 AntiAliasDither 没用的.

虽然给画笔设置 FilterBitmap 可以解决锯齿问题. 但由于我们监听了滑动操作, 并且在每次滑动的时候重新绘制图片, 也就是滑动的时候会有几十次的绘制操作, 每次都缩放+滤波是非常消耗性能的, 带来的后果就是滑动变的非常卡. 这是无法接受的.

于是需要事先对图片进行缩放处理, 也就是在加载图片的时候, 通过 Bitmap.createScaledBitmap 方法来创建一张新的缩放过的图片, 同时设置第四个参数为 true 表示进行滤波处理.

于是就有了这样的操作

val bitmap = BitmapFactory.decodeStream(file)

val picDim: Double = bitmap.height.toDouble() / bitmap.width      // 图像长宽比
val scale: Double = deviceHeight.toDouble() / bitmap.height    // 缩放比

val scaledBitmap: Bitmap
scaledBitmap = if (picDim < dim) {
    Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt(), deviceHeight, true)
    } else {
    Bitmap.createScaledBitmap(bitmap, deviceWidth, (bitmap.height * scale).toInt(), true)
}

scaledBitmap 就是经过缩放满足上述需求的位图, 同时因为进行了滤波处理, 画笔不再需要设置 FilterBitmap, 在高速绘制的时候也不会出现卡顿现象.

获取设备显示器的长宽

虽然 Engine 提供了两个属性, desiredMinimumWidthdesiredMinimumHeight, 这两个属性代表了系统希望壁纸运行的长宽, 但经过实际测试, desiredMinimumWidth 返回的宽度要比设备实际宽度多, 比如我的设备实际宽度是 1080, 但 desiredMinimumWidth 返回的值却是 1298 这个奇怪的数值, 在 Google 上搜索一番也发现了一个和我有同样问题的人, 但并没有原因和解决方式.

由于desiredMinimumWidth返回了错误的宽度, 这直接导致了在上一步绘制的时候绘制到了错误的位置上, 使得图片不再是显示器的中心部分, 变成了偏右. 这实在是不能忍.

于是只能尝试使用另外的方法. 在搜索的过程当中找到了 DisplayMetrics 这个类, 可以通过 resources.displayMetrics 直接获取一个实例, 但问题来了, 这样直接获取的实例, heightPixels 又是错误的, 这个高度不包括虚拟键占用的位置.

于是只能换一种方式来获取 DisplayMetrics

val displayMetrics = DisplayMetrics()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
windowManager.defaultDisplay.getRealMetrics(displayMetrics)

deviceHeight = displayMetrics.heightPixels
deviceWidth = displayMetrics.widthPixels

终于是获取到了正确的设备长宽. 可以把图片绘制到争取的位置了.

注册服务

只完成了上面的一堆步骤, 此时我们的壁纸服务并不能正常运行.

首先需要创建 xml 目录, 并且创建一个 wallpaper.xml 名字无所谓, 重点是内容

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:thumbnail="@drawable/ic_launcher_foreground" />

在这个文件里面指定了一个缩略图, 用于在选择壁纸的时候的预览.

然后需要在 manifest 里面注册我们的服务.

<service
    android:name=".MyWallpaperService"
    android:label="@string/app_name"
    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.permission.BIND_WALLPAPER 权限才能正常使用, 同时还需要有 android.service.wallpaper.WallpaperServiceintent. 最关键的是要有一个 meta-data, 将 resource 指向上面的那个 xml 文件.

注册好服务之后, 就可以打包成 apk 安装到设备上了. 之后就会在壁纸选择界面出现我们的这个小工具并且可以选择了.

结束.

折腾了一天之后总算是把这个东西写出来了. 上面只是总结了一下关键性的东西, 而其他的无关紧要的事情就懒得描述了, 诸如如何读取一堆图片文件什么的.

完整代码

发表评论

电子邮件地址不会被公开。 必填项已用*标注