Fork me on GitHub

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

写在前面

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

下雨.gif

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

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

理清思路

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

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

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

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

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

代码实现分析

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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和圆心。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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)方法,最后在看一下这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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之上的遮盖物,不设置背景,这样也能实现之前的效果,并且不影响交互,关键是还不卡。放上解决代码:

1
2
3
4
private val skyBackgroud = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, RAIN_D)
// 感谢 mixiaoxiao 大神的实现思路
skyBackgroud.setBounds(0, 0, measuredWidth, measuredHeight)
skyBackgroud.draw(canvas)