用SurfaceView实现下雨和下雪(Kotlin)

写在前面

最近打算做一波东西巩固一下自己近期所学所得。话不多说,先看一下最终完成的效果图:

这里比较懒……第二个图片中还是降雨……不过这不是关键点……

录制的mp4,转成了gif。第一个gif设置了帧率,所以看起来可能掉帧比较严重,但是实际上并不会,因为这里我也注意了1s要绘制60帧的问题。阅读本文需要一些基本的View知识和会一些基础Kotlin语法。说实话,就知识点来说,跟Kotlin是没多大关系的,只要懂基本的语法就可以了。

理清思路

在动手前先要理一下思路,从以下几个方面来分析一下该采用什么方案来实现这个效果:

  • 工作线程:首先要想到的是:这个下雨的效果需要通过不停的绘制来实现,如果在主线程做这个操作,很有可能会阻塞主线程,导致ANR或者异常卡顿。所以需要一个能在子线程进行绘制的View,毫无疑问SurfaceView可以满足这个需求。

  • 如何实现:分析一下一颗雨滴的实现。首先,简单的效果其实可以用画线的方式代替。并不是每个人都有写轮眼,动态视力那么好的,一旦动起来谁还知道他是条线还是雨滴……当然了,Canvas绘制的API有很多,并不一定非要用这种方式来实现。所以在在设计类的时候我们将draw的方法设置成可以让子类复写就可以了,你不满意我的实现?没问题,我给你改的自由~

  • 下落的实现:让雨滴动起来,有两种方式,一种是纯按坐标来绘制,另外一种是利用属性动画,自己重写估值器,动态改变y值。最终我还是采用了前一种方案,后一种属性动画的方案我为什么放弃了呢?原因是:这里的绘制的方式是靠外部不断的触发绘制事件来实现动态绘制的,很显然第一种方式更加符合这里的情况。

以上就是我初期的一些关于实现的思考了,接下来是代码实现分析。

代码实现分析

先放代码结构图:

WeatherShape所有天气的父类,Rain和Snow是两个具体实现类。 看一下父类的代码:

package com.xiasuhuei321.gank_kotlin.customview.weather

import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF
import com.xiasuhuei321.gank_kotlin.context
import com.xiasuhuei321.gank_kotlin.extension.getScreenWidth
import java.util.*

/**
 * Created by xiasuhuei321 on 2017/9/5.
 * author:luo
 * e-mail:xiasuhuei321@163.com
 *
 * desc: All shape's parent class.It describes a shape will have
 * what feature.It's draw flows are:
 * 1.Outside the class init some value such as the start and the
 * end point.
 * 2.Invoke draw(Canvas) method, in this method, there are still
 * two flows:
 * 1) Get random value to init paint, this will affect the shape
 * draw style.
 * 2) When the shape is not used, invoke init method, and when it
 * is not used invoke drawWhenInUse(Canvas) method. It should be
 * override by user and to implement draw itself.
 *
 */
abstract class WeatherShape(val start: PointF, val end: PointF) {
    open var TAG = "WeatherShape"

    /**
     * 是否是正在被使用的状态
     */
    var isInUse = false

    /**
     * 是否是随机刷新的Shape
     */
    var isRandom = false

    /**
     * 下落的速度,特指垂直方向,子类可以实现自己水平方向的速度
     */
    var speed = 0.05f

    /**
     * shape的宽度
     */
    var width = 5f

    var shapeAlpha = 100

    var paint = Paint().apply {
        strokeWidth = width
        isAntiAlias = true
        alpha = alpha
    }

    // 总共下落的时间
    var lastTime = 0L
    // 原始x坐标位置
    var originX = 0f

    /**
     * 根据自己的规则计算加速度,如果是匀速直接 return 0
     */
    abstract fun getAcceleration(): Float

    /**
     * 绘制自身,这里在Shape是非使用的时候进行一些初始化操作
     */
    open fun draw(canvas: Canvas) {
        if (!isInUse) {
            lastTime += randomPre()
            initStyle()
            isInUse = true
        } else {
            drawWhenInUse(canvas)
        }
    }

    /**
     * Shape在使用的时候调用此方法
     */
    abstract fun drawWhenInUse(canvas: Canvas)

    /**
     * 初始化Shape风格
     */
    open fun initStyle() {
        val random = Random()
        // 获取随机透明度
        shapeAlpha = random.nextInt(155) + 50
        // 获得起点x偏移
        val translateX = random.nextInt(10).toFloat() + 5
        if (!isRandom) {
            start.x = translateX + originX
            end.x = translateX + originX
        } else {
            // 如果是随机Shape,将x坐标随机范围扩大到整个屏幕的宽度
            val randomWidth = random.nextInt(context.getScreenWidth())
            start.x = randomWidth.toFloat()
            end.x = randomWidth.toFloat()
        }
        speed = randomSpeed(random)
        // 初始化length的工作留给之后对应的子类去实现
        // 初始化color也留给子类去实现
        paint.apply {
            alpha = shapeAlpha
            strokeWidth = width
            isAntiAlias = true
        }
        // 如果有什么想要做的,刚好可以在追加上完成,就使用这个函数
        wtc(random)
    }

    /**
     * Empty body, this will be invoke in initStyle
     * method.If current initStyle method can satisfy your need
     * but you still add something, by override this method
     * will be a good idea to solve the problem.
     */
    open fun wtc(random:Random): Unit {

    }

    abstract fun randomSpeed(random: Random): Float

    /**
     * 获取一个随机的提前量,让shape在竖屏上有一个初始的偏移
     */
    open fun randomPre(): Long {
        val random = Random()
        val pre = random.nextInt(1000).toLong()
        return pre
    }
}

复制代码

说起这个代码,恩,还是经历过一番重构的……周六去找同学玩的路上顺便重构了一下,将一些可以放到基类中的操作都抽取到了基类中。这样虽然灵活不足,但是子类可以很方便的通过继承实现一个需要类似功能的东西,就比如这里的下雨和下雪。顺便吐槽一下……我注释的风格不太好,中英混搭……如果你仔细观察,可以看到gif中的雨点或者雪花形态可能都有一些些的不一样,是的,每一滴雨和雪花,都经过了一些随机的转变。

里面比较重要的两个属性是isInUse和isRandom,本来想用一个容器来作为Shape的管理类,统一管理,但是这样肯定会让使用和复用的流程更加复杂。最后还是决定用简单一点的方法,Shape内部保存一个使用状态和是否是随机的。isRandoma表示这个Shape是否是随机的,随机在目前的代码中会体现在Shape的x坐标上。如果随机标识是true,那么x坐标将是0 ~ ScreenWidth中的任意值。那么不是随机的呢?在我的实现中,同一类Shape将会被分为两类,一类常量组。会拥有相对固定的x值,但是也会有10~15px的随机偏移。另一类就是随机组,x值全屏自己随机,这样就尽量让屏幕各处都有雨滴(雪花)但会有疏密之别。initStyle就是这一随机的过程,有兴趣可以看看实现~

start和end是Shape的左上角点和右下角点,如果你对于Cavans的api有了解,就应该知道通过对start和end的转换和计算,可以绘制出大部分的形状。

接下来看一下具体实现的Snow类:

package com.xiasuhuei321.gank_kotlin.customview.weather

import android.graphics.*
import com.xiasuhuei321.gank_kotlin.context
import com.xiasuhuei321.gank_kotlin.extension.getScreenHeight
import java.util.*

/**
 * Created by xiasuhuei321 on 2017/9/5.
 * author:luo
 * e-mail:xiasuhuei321@163.com
 */
class Snow(start: PointF, end: PointF) : WeatherShape(start, end) {

    /**
     * 圆心,用户可以改变这个值
     */
    var center = calcCenter()

    /**
     * 半径
     */
    var radius = 10f

    override fun getAcceleration(): Float {
        return 0f
    }

    override fun drawWhenInUse(canvas: Canvas) {
        // 通过圆心与半径确定圆的位置及大小
        val distance = speed * lastTime
        center.y += distance
        start.y += distance
        end.y += distance
        lastTime += 16
        canvas.drawCircle(center.x, center.y, radius, paint)
        if (end.y > context.getScreenHeight()) clear()
    }

    fun calcCenter(): PointF {
        val center = PointF(0f, 0f)
        center.x = (start.x + end.x) / 2f
        center.y = (start.y + end.y) / 2f
        return center
    }

    override fun randomSpeed(random: Random): Float {
        // 获取随机速度0.005 ~ 0.01
        return (random.nextInt(5) + 5) / 1000f
    }

    override fun wtc(random: Random) {
        // 设置颜色渐变
        val shader = RadialGradient(center.x, center.y, radius,
                Color.parseColor("#FFFFFF"), Color.parseColor("#D1D1D1"),
                Shader.TileMode.CLAMP)
        // 外部设置的起始点其实并不对,先计算出半径
        radius = random.nextInt(10) + 15f
        // 根据半径计算start end
        end.x = start.x + radius
        end.y = start.y + radius
        // 计算圆心
        calcCenter()

        paint.apply {
            setShader(shader)
        }
    }

    fun clear() {
        isInUse = false
        lastTime = 0
        start.y = -radius * 2
        end.y = 0f

        center = calcCenter()
    }
}
复制代码

这个类只要理解了圆心的计算和绘制,基本也就没什么东西了。首先排除干扰项,getAcceleration这玩意在设计之初是用来通过加速度计算路程的,后来发现……算了,还是匀速吧……于是都return 0f了。这里wtc()函数和drawWhenInUse可能会看的你一脸懵逼,什么函数名,drawWhenInUse倒是见名知意,这wtc()是什么玩意?这里wtc是相当于一种追加初始化,完全状态的函数名应该是wantToChange()。这些个函数调用流程是这样的:

其中draw(canvas)是父类的方法,对供外部调用的方法,在isInUse标识位为false时对Shape进行初始化操作,具体的就是调用initStyle()方法,而wtc()则会在initStyle()方法的最后调用。如果你有什么想要追加的初始化,可以通过这个函数实现。而drawWhenInUse(canvas)方法则是需要实现动态绘制的函数了。我这里就是在wtc()函数中进行了一些初始化操作,并且根据圆的特性重新计算了start、end和圆心。

接下来,就看看我们到底是怎么把这些充满个性(口胡)的雪绘制到屏幕上:

package com.xiasuhuei321.gank_kotlin.customview.weather

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.util.AttributeSet
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.xiasuhuei321.gank_kotlin.extension.LogUtil
import java.lang.Exception

/**
 * Created by xiasuhuei321 on 2017/9/5.
 * author:luo
 * e-mail:xiasuhuei321@163.com
 */
class WeatherView(context: Context, attributeSet: AttributeSet?, defaultStyle: Int) :
        SurfaceView(context, attributeSet, defaultStyle), SurfaceHolder.Callback {
    private val TAG = "WeatherView"

    constructor(context: Context, attributeSet: AttributeSet?) : this(context, attributeSet, 0)

    constructor(context: Context) : this(context, null, 0)

    // 低级并发,Kotlin中支持的不是很好,所以用一下黑科技
    val lock = Object()
    var type = Weather.RAIN
    var weatherShapePool = WeatherShapePool()

    @Volatile var canRun = false
    @Volatile var threadQuit = false

    var thread = Thread {
        while (!threadQuit) {
            if (!canRun) {
                synchronized(lock) {
                    try {
                        LogUtil.i(TAG, "条件尚不充足,阻塞中...")
                        lock.wait()
                    } catch (e: Exception) {
                    }
                }
            }
            val startTime = System.currentTimeMillis()
            try {
                // 正式开始表演
                val canvas = holder.lockCanvas()
                if (canvas != null) {
                    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
                    draw(canvas, type, startTime)
                }
                holder.unlockCanvasAndPost(canvas)
                val drawTime = System.currentTimeMillis() - startTime
                // 平均16ms一帧才能有顺畅的感觉
                if (drawTime < 16) {
                    Thread.sleep(16 - drawTime)
                }
            } catch (e: Exception) {
//                e.printStackTrace()
            }
        }
    }.apply { name = "WeatherThread" }

    override fun surfaceChanged(holder: SurfaceHolder?, format: Int, width: Int, height: Int) {
        // surface发生了变化
//        canRun = true

    }

    override fun surfaceDestroyed(holder: SurfaceHolder?) {
        // 在这里释放资源
        canRun = false
        LogUtil.i(TAG, "surfaceDestroyed")
    }

    override fun surfaceCreated(holder: SurfaceHolder?) {
        threadQuit = false
        canRun = true
        try {
            // 如果没有执行wait的话,这里notify会抛异常
            synchronized(lock) {
                lock.notify()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    init {
        LogUtil.i(TAG, "init开始")
        holder.addCallback(this)
        holder.setFormat(PixelFormat.RGBA_8888)
//        initData()
        setZOrderOnTop(true)
//        setZOrderMediaOverlay(true)
        thread.start()
    }

    private fun draw(canvas: Canvas, type: Weather, startTime: Long) {
        // type什么的先放一边,先实现一个
        weatherShapePool.drawSnow(canvas)
    }

    enum class Weather {
        RAIN,
        SNOW
    }

    fun onDestroy() {
        threadQuit = true
        canRun = true
        try {
            synchronized(lock) {
                lock.notify()
            }
        } catch (e: Exception) {
        }
    }
}
复制代码

init{}是kotlin中提供给我们用于初始化的代码块,在init进行了一些初始化操作并让线程start了。看一下线程中执行的代码,首先会判断一个叫做canRun的标识,这个标识会在surface被创建的时候置为true,否则将会通过一个对象让这个线程等待。而在surface被创建后,则会调用notify方法让线程重新开始工作。之后是进行绘制的工作,绘制前后会有一个计时的动作,计算时间是否小于16ms,如果不足,则让thread sleep 补足插值。因为16ms一帧的绘制速度就足够了,不需要绘制太快浪费资源。

这里可以看到我创建了一个Java的Object对象,主要是因为Kotlin本身对于一些并发原语支持的并不好。Kotlin中任何对象都是继承与Any,Any并没有wait、notify等方法,所以这里用了黑科技……创建了Java对象……

代码中关键代码绘制调用了WeatherShapePool的drawRain(canvas)方法,最后在看一下这个类:

package com.xiasuhuei321.gank_kotlin.customview.weather

import android.graphics.Canvas
import android.graphics.PointF
import com.xiasuhuei321.gank_kotlin.context
import com.xiasuhuei321.gank_kotlin.extension.getScreenWidth

/**
 * Created by xiasuhuei321 on 2017/9/7.
 * author:luo
 * e-mail:xiasuhuei321@163.com
 */
class WeatherShapePool {
    val constantRain = ArrayList<Rain>()
    val randomRain = ArrayList<Rain>()

    val constantSnow = ArrayList<Snow>()
    val randomSnow = ArrayList<Snow>()

    init {
        // 初始化
        initData()
        initSnow()
    }

    private fun initData() {
        val space = context.getScreenWidth() / 20
        var currentSpace = 0f
        // 将其均匀的分布在屏幕x方向上
        for (i in 0..19) {
            val rain = Rain(PointF(currentSpace, 0f), PointF(currentSpace, 0f))
            rain.originLength = 20f
            rain.originX = currentSpace
            constantRain.add(rain)
            currentSpace += space
        }

        for (j in 0..9) {
            val rain = Rain(PointF(0f, 0f), PointF(0f, 0f))
            rain.isRandom = true
            rain.originLength = 20f
            randomRain.add(rain)
        }
    }

    fun drawRain(canvas: Canvas) {
        for (r in constantRain) {
            r.draw(canvas)
        }
        for (r in randomRain) {
            r.draw(canvas)
        }
    }

    private fun initSnow(){
        val space = context.getScreenWidth() / 20
        var currentSpace = 0f
        // 将其均匀的分布在屏幕x方向上
        for (i in 0..19) {
            val snow = Snow(PointF(currentSpace, 0f), PointF(currentSpace, 0f))
            snow.originX = currentSpace
            snow.radius = 20f
            constantSnow.add(snow)
            currentSpace += space
        }

        for (j in 0..19) {
            val snow = Snow(PointF(0f, 0f), PointF(0f, 0f))
            snow.isRandom = true
            snow.radius = 20f
            randomSnow.add(snow)
        }
    }

    fun drawSnow(canvas: Canvas){
        for(r in constantSnow){
            r.draw(canvas)
        }

        for (r in randomSnow){
            r.draw(canvas)
        }
    }
}
复制代码

这个类还是比较简单的,只是一个单纯的容器,至于叫Pool……因为刚开始自己想的是自己管理回收复用之类的,所以起了个名叫Pool,后来感觉这玩意好像不用实现的这么复杂……

总之,这玩意,会者不难,我的代码也非尽善尽美,如果我有任何纰漏或者你有什么好的意见,都可以提出,邮件或者是在文章下评论最佳。

项目地址:https://github.com/ForgetAll/GankKotlin

后记

后面我发现我的代码虽然实现了这个功能,但是setZOrderOnTop(true)这样的代码会有副作用,影响到了后续其他的Activity的交互。之后试了很多网上的方法,但是都不行。后来又想到了 PorterDuff.Mode.DST_OVER ,这种绘制虽然可以实现上面的效果,但是效率非常低,雨下的异常的卡顿,可能是计算量比较大?不管这个,后来看到了别人的实现,自己绘制surfaceview的背景,而之后的view作为surfaceview之上的遮盖物,不设置背景,这样也能实现之前的效果,并且不影响交互,关键是还不卡。放上解决代码:

private val skyBackgroud = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, RAIN_D)
// 感谢 mixiaoxiao 大神的实现思路
skyBackgroud.setBounds(0, 0, measuredWidth, measuredHeight)
skyBackgroud.draw(canvas)
复制代码
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值