利用SurfaceView实现下雨与下雪动画效果详解(Kotlin语法)
前言
最近打算做一波东西巩固一下自己近期所学所得。话不多说,先看一下最终完成的效果图:
下雨.gif
这里比较懒……第二个图片中还是降雨……不过这不是关键点……
下雪.gif
录制的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
本地下载:http://xiazai.jb51.net/201709/yuanma/gankkotlin(jb51.net).rar
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对的支持。