您的位置:首页 > 移动开发 > Android开发

Android 视图框架系列2/3——SurfaceView视图框架

2016-01-06 11:51 447 查看
本篇我们来说说 SurfaceView 视图框架。

一、提出问题

SurfaceView 继承自 View ,但其实和 View 已经有了很大的区别,可以说这一层的继承,跨的有点大!来看这样一个情景:







在前一篇 视图框架系列1/3——View视图框架 中我们已经知道,利用 View 可以完成动画效果的实现,现在我们来实现这样一个需求——我们绘制一个不断做跑步动作的人,但是位置不变(在 MyView 类中通过子线程不断通知重复重绘右边两个不同状态的小兵即可),动画效果是实现了。但是如果我要求响应事件,点击屏幕让他喊
“军情、军情”,按方向键让他向相应的方向跑。这么做?按照原理来说是:给 MyView 重写 onTouchEvent() 响应触屏事件、重写 onKeyDown() 方法响应方向键。但是别忘了——View框架的重绘是不断重新执行 onDraw() 方法,而 onDraw() 是在 UI 线程中执行的,此时 UI 线程正在不断执行 onDraw() 方法,很容易导致 UI 线程阻塞而不能响应触屏和按键。虽说当线程间断休眠时间够长即相邻两个
onDraw() 之间间断时间足够长时可以响应事件,但是我们怎么可以让我们的程序处于一个不可控的状态呢?现在用 View 便不再能够满足我们的需求。

有比较才能显出区别:看完 视图框架系列1/3——View视图框架 ,在结合上边的情景,不难总结出这样的结论:View 可以实现动画效果,但是仅仅局限于两种:

★(1)“被动更新”的动画,“被动”是指这个动画(“视图”更为准确)不主动更新,它的更新依赖于 onTouchEvent、onKeyDown 等事件来触发 onDraw 的重绘

★(2)只显示一个动画而不接受事件响应,很好理解,比如我在页面上显示一个闪动的星星,这个星星只是展示作用,不接受任何事件

★另外:View 视图绘制的效率比较低,因为每一次重绘实际上是重新执行以便 onDraw 方法,onDraw 方法是重新绘制整个画布,而在 View 中,canvas 就是整个视图

而此时的窘境,正是 SurfaceView 视图大显身手的时候!SurfaceView 继承于 View ,所以同样拥有触屏监听、按钮监听等方法,但是请注意,SurfaceView 看名字就和 Surface 脱不了干系,Surface 是 Android 中一个很重要的类,有必要了解一下。每个 View 在和屏幕绑定时都会关联一个对应的 Surface,你可以把
Surface 理解成一块屏幕缓存
。但从源码可以看出 SurfaceView 还有一个 Surface 类型的成员变量,所以 SurfaceView 就拥有了两个内存区。 这里就该说 SurfaceView 的双缓冲机制了。

二、双缓冲技术

双缓冲技术是游戏开发中一个重要技术,主要原理——当一个动画争先显示时,程序又在改变它,前面的还没有显示完,程序又要求重绘,这样屏幕就会产生闪烁,为了避免这种闪烁,可以将要处理的图片先在内存中处理好之后,再将其显示到屏幕上,这样显示出来的总是完整的,便不会产生闪烁。
SurfaceView就是一个典型的双缓冲机制,其内嵌的 Surface 专门处理待绘制的内容,包括各式、尺寸等,在真正绘制时 SurfaceView 控制其绘制位置进行真正的绘制显示。所以 Surface 用来做游戏视图处理再合适不过了。

三、SurfaceView的使用

在继承 SurfaceView 开发时,自定义类要继承自 SurfaceView ,而且要实现 SurfceHolder.Callback 接口,那这个接口是干嘛的, SurfaceHolder 类又是什么呢?我们先看一段简单代码:
public class MySurfaceView extends SurfaceView implements Callback {
private SurfaceHolder holder;
private Paint paint;

public MySurfaceView(Context context) {
this(context, null);
}
public MySurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
holder = getHolder();
holder.addCallback(this);   //给Holder声明Callback回调

paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(Color.RED);
paint.setTextSize(50);
}

/* Callback方法之一,SurfaceView创建时回调 */
@Override
public void surfaceCreated(SurfaceHolder arg0) {
myDraw();
}

/* Callback方法之一,SurfaceView改变时回调 */
@Override
public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) { }

/* Callback方法之一,SurfaceView销毁时回调 */
@Override
public void surfaceDestroyed(SurfaceHolder arg0) { }

/* 我自己的绘制方法 */
private void myDraw() {
String text = "Holle-World-!";
Canvas canvas = holder.lockCanvas();    //获取画布
canvas.drawColor(Color.WHITE);          //刷屏
canvas.drawText(text, 0, 50, paint);    //绘制内容
holder.unlockCanvasAndPost(canvas);     //提交对画布所做的操作
}
}

以上算是一个五脏六腑俱全的 Surfaceiew 的使用了。仔细看代码你会发现,我们在使用 SurfaceView 时,并没有直接和 SurfaceView “交手”,而是通过 getHolder() 获取到 SurfaceHolder 类的实例进行操作,通过 holder.lockCanvas()方法获取到 Canvas 对象,就可以进行自己的绘制了,最后用
holder.unLockCanvasAndPost(canvas) 进行提交。*要知道* lockCanvas() 方法不仅仅是获取 Canvas,同时还对即将获取到的 Canvas 添加同步锁,这个机制主要是为了防止 SurfaceView 在修改的过程中被修改、摧毁等情况发生;unLockCanvasAndPost(canvas)
方法是解除同步锁并把之前所做的画布绘制工作进行提交。
*附加* SurfaceHolder 类除了提供lockCanvas() 方法用于获取当前 Canvas (默认与手机屏幕大小一致)外,还提供了其重载的 lockCanvas(Rect mRect) 方法用于获取自定义举行大小的画布,若有问题可以参看
Canvas有关问题整理。
需要重申和留意的是:SurfaceView 是通过 SurfaceHolder 来操作的,所以使用 SurfaceView 时不再使用 onDraw(Canvas canvas) 来绘图,而是通过 holder.lockCanvas() 获取Canvas 来绘图,即使重写了 onDraw() 方法, SurfaceView
在启动时也不会执行到。

四、刷屏

在上边的代码的 myDraw() 方法中,有一行 “ canvas.drawColor(Color.WHITE) ; //刷屏 ”的语句,从字面意思看就是绘制了一种颜色,什么是刷屏呢?我先看问题再解释(我们让上边代码中绘制的“Holle-World-!”随手指触摸屏幕的位置变化):
首先改造 myDraw() 方法:
String text = "Holle-World-!";
private int positionX = 0, positionY = 0;   //初始化X,Y为0
private void myDraw() {
Canvas canvas = holder.lockCanvas(); //获取画布
//这里先不画颜色(刷屏)
canvas.drawText(text, positionX, positionY + 50, paint); //Y轴方向+50是因为绘制Text时指定的是左下角位置,+50用来校正位置
holder.unlockCanvasAndPost(canvas); //提交对画布所做的操作

}
添加触屏响应:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
positionX = (int)event.getX();
positionY = (int)event.getY();
myDraw();
break;

default:
break;
}
return super.onTouchEvent(event);
}
运行一下,多点几下就会出现这个样子:



看这个结果图,说明触屏动作及时响应了,否则也不会出现触点位置的字符,但是并没有抹掉旧状态,就是因为我们没有“刷屏”。接下来我们在 canvas.drawText() 之前加上:
canvas.drawColor(Color.WHITE); //刷屏
再运行:
擦!!用白色

有坑,都看不到边界,用字给框个边界吧,我真是太聪明了!

这就是“刷屏”(娘的!别再瞎想你的朋友圈刷屏了)。这里就是区别:View类本身提供的两种重绘方法 invalidate()、postInvalidate() 内部已经封装了刷屏的操作(具体是什么操作,最好看源码,我没看,不敢妄言),所以每次重绘之后都看不到之前的历史记录;但是用 SurfaceView 时我们用lockCanvas () 获取到的是同一个画布,绘图用的是自定义绘图方法 myDraw(),系统没有帮我们刷新画布,也,没有提供给我们一张新画布,所以在进行下一次绘画时必定能看到上一次的痕迹,这时候我们就要自己动手“刷屏":
"刷屏"就是盖调上次绘画的痕迹,所以这里有两种方法:
(1)每次绘图之前,绘制一个屏幕大小的图形(或是图片,如游戏背景)覆盖在原来的画布上
(2)每次绘画之前,给画布填充一种颜色(上边我们用到的),如 drawRGB()、drawColor()

五、在 SurfaceView 中添加线程

从本篇开篇我们总结前一篇 View 视图框架的局限来看,我们用 SurfaceView 的目的就是要一边完成视图的“主动更新”,一边还要响应用户操作,所以在 SurfaceView 中添加线程完成视图主动更新是必不可少的,比如游戏背景的变化,用户无法操作,而且背景是根据游戏情景不断更新的。但是在 SurfaceView
中国添加线程会遇到一些问题,下面我们来一一探讨一下。
这是一个比较绕的问题,请集中注意力细细品味:
public class MySurfaceView extends SurfaceView implements Runnable, Callback {
private SurfaceHolder holder;
private Paint paint;
private String text = "Holle!";
private Canvas canvas;

private boolean flag;
private Thread thread;
private int positionX = 0, positionY = 0;
private int screenW, screenH;
private int speedX = 10, speedY = 13;

public MySurfaceView(Context context) {
this(context, null);
}

public MySurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
holder = getHolder();
holder.addCallback(this);

paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(Color.RED);
paint.setTextSize(50);
}

@Override
public void surfaceCreated(SurfaceHolder arg0) {
screenW = getWidth();
screenH = getHeight();       //看——>说明4
flag = true;
thread = new Thread(this);
thread.start();
}

@Override
public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) {
}

@Override
public void surfaceDestroyed(SurfaceHolder arg0) {
flag = false;
}

private void myDraw() {
try {                             //看——>说明2
canvas = holder.lockCanvas();
if (canvas != null) {
canvas.drawColor(Color.WHITE);
canvas.drawText(text, positionX, positionY + 50, paint);
}
} catch (Exception e) {
// TODO: handle exception
} finally {
holder.unlockCanvasAndPost(canvas);       //看——>说明3
}
}

/**
* 模拟一个逻辑
*/
private void myLogic() {
if (positionX < 0 || positionX > screenW - 100) {
speedX = -speedX;
}
if (positionY < 0 || positionY > screenH) {
speedY = -speedY;
}
positionX += speedX;
positionY += speedY;
}

@Override
public void run() {
while (flag) {              //看——>说明1
long start = System.currentTimeMillis();        //看——>说明5
myDraw();
myLogic();
long end = System.currentTimeMillis();
try {
if (end - start < 100) {
Thread.sleep(100 - (end - start));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

结果不用看,就是一个跑动的字符串,重点看代码的设计思路
这是一个完成视图“主动更新”的部分,不包含用户动作响应,但是“主动”这一模块是完整的。下边对代码中涉及到的问题进行说明:

(一)说明1:线程标识 flag 的作用

线程标志 flag 主要有两个作用:
(1)便于线程的消亡,资源释放
一个线程一旦启动就开始执行 run() 函数,run() 函数执行完毕后退出,该线程随之消亡,这是一般的线程。二般的需求是,拿游戏背景举例,要在线程中 While 循环,不断更新背景的显示,在游戏暂停或退出时便需要有个 flag 判断 run() 完成。
(2)防止线程重复创建及线程启动异常
大家都知道,Android 退出当前界面有两种方式:Back 键、Home 键。对于一个 SurfaceView ,通过这两种方式退出,再返回程序中,有什么区别呢?
Back键:surfaceDestroyed ——> 构造函数 ——> surfaceCreated ——> surfaceChanged;
Home键:surfaceDestroyed ——> surfaceCreated ——> surfaceChanged
很明显,Back 键返回再进入额外执行了一次构造函数,也就是说,Back 返回再进入时 SurfaceView 会重新加载一次。也正是这个原因,线程的初始化和线程启动的位置就很有讲究了:



有 3 种方式(总不能 thread 先启动再初始化吧):
蓝色线:线程初始化在构造函数(以下称为 struct)中,线程启动在视图创建函数(以下称为 surfaceCreated)中,在这种情况下会有线程启动异常情况发生:点 Home 键返回再进入时,会尝试启动线程,此时报错如下:



原因是 Home 情况下未执行 struct 便没有新创建 thread,尝试启动是线程仍是之前已经启动过的线程,系统报错线程已经启动。(附加:Back 情况正确运行);
黑色线:线程初始化和启动都在 struct 中,这种情况更明显:Home 情况下,再次进入后线程不会再运行,视图不动了;
最好的办法就是红色线:线程初始化和启动都在 surfaceCreated 中,并且在 surfaceDestroy 时将 flag 置为 false 使线程停止运行而销毁。这样既避免了线程的重复创建(flag 的作用),又避免了“线程已启动”的异常。

(二)说明2:视图绘制时的异常 try

因为 SurfaceView 在未创建或者不可编辑时,调用 lockCanvas() 时将返回 null,Canvas 在绘图时也将出现不可预知的错误,所以用 try...catch...进行处理,并且为避免 lockCanvas() 为 null 时的问题,在绘制之前判断 canvas 是否为空进行处理。

(三)说明3:画布提交的时机

绘图的时候可能出现不可预知的 Bug,虽然用 try...catch...包裹起来保证程序不会崩溃;但是如果在画布提交之前出现异常,本次将跳过提交,在下次获取画布时就会抛出异常,原因就是上次画布就没有解锁也没有提交。所以要将 unlockCanvasAndPost()
放在 finally 语句中。
并且提交之前也要判断 canvas 不为 null,保证提交的是有效的画布。

(四)说明4:视图尺寸的获取时机

SurfaceView 的 getWidth()、getHeight() 要在视图创建之后即 surfaceCreated 调用之后执行,否则得到的结果永远为
0,因为在这之前试图还没有创建,宽高当然是 0。

(五)说明5:刷新频率一致性的保证

不管是动画还是变形,都要保证变动的效果要保持流畅,所以很有必要保证线程每运行完一次的时间相同,即刷新频率的一致。就像代码中做的那样,设定一个刷新周期,并记录下线程开始时的时间戳,和绘图、逻辑运行完后的时间戳,比较运行时间和刷新周期,如果“运行时间 < 刷新周期”,就让线程把刷新周期中剩余的时间“睡”过去,如果“运行时间
> 刷新周期”时,当然不要耽搁,尽快开始下一个周期。

好了,SurfaceView 主要内容就差不多了,可是累死了!如感觉对你有帮助,那就一起来继续下一篇 “Android
视图框架系列3/3——View和SurfaceView之间的抉择” 吧!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: