第7章 图形与图像处理
本章要点:
- Android 的图形处理基础
- Bitmap 与 BitmapFactory 的使用
- 如何通过继承 View 在 Android 中进行绘图
- 掌握 Canvas、Paint、Path 等绘图 API 的使用方法
- 双缓冲机制的实现与应用
- 使用 Matrix 对图像进行几何变换
- 通过 drawBitmapMesh 方法扭曲图像的技巧
- 使用不同的 Shader 类来绘制图形
- 逐帧动画的实现
- 补间动画的原理与应用
- 属性动画的应用与开发
- 如何开发自定义的补间动画
- SurfaceView 的绘图机制详解
- 继承 SurfaceView 开发动画的具体方法
图形与图像处理导入
正如前面所介绍的,决定 Android 应用是否被用户接受的重要方面之一就是用户界面。为了提供友好的用户界面,应用中常常需要使用图片。Android 系统提供了丰富的图片功能支持,包括处理静态图片和动画等。
Android 系统不仅提供了用于显示普通静态图片的 ImageView
,还提供了用于开发逐帧动画的 AnimationDrawable
,以及可以对普通图片使用补间动画的 Animation
。图形和图像处理不仅对于 Android 系统的应用界面非常重要,而且在益智类游戏和 2D 游戏中也大量应用。游戏的本质就是提供一个更逼真、能够模拟某种环境的用户界面,并根据特定的规则响应用户操作。
通过学习本章内容,读者应能熟练掌握 Android 系统的图形和图像处理技术,这样就能够在 Android 平台上开发出如俄罗斯方块、五子棋等小游戏。本章将通过弹球游戏、飞机游戏等实例帮助读者掌握 Android 2D 游戏开发的入门知识。为了提供更逼真的用户界面,图形和图像处理将成为关键。
7.1 使用简单图片
在前面的 Android 应用中,我们已经广泛使用了简单图片。图片不仅可以使用 ImageView
来显示,还可以作为 Button
、Window
的背景。从广义的角度来看,Android 应用中的图片不仅包括 .png、.jpg、*.gif 等各种格式的位图,还包括使用 XML 资源文件定义的各种 Drawable
对象。
7.1.1 使用 Drawable 对象
为 Android 应用增加 Drawable
资源之后,Android SDK 会为这份资源在 R 清单文件中创建一个索引项:R.drawable.file_name
。
接下来,既可以在 XML 资源文件中通过 @drawable/file_name
访问该 Drawable
对象,也可以在 Java 或 Kotlin 代码中通过 R.drawable.file_name
访问该 Drawable
对象。
需要注意的是,R.drawable.file_name
是一个 int
类型的常量,它只代表 Drawable
对象的 ID。如果在 Java 或 Kotlin 程序中需要获取实际的 Drawable
对象,则可以调用 Resources
的 getDrawable(int id)
方法来实现。
由于前面已经介绍了大量关于 Drawable
的示例,故此处不再给出示例。
7.1.2 Bitmap 和 BitmapFactory
Bitmap
代表一个位图,BitmapDrawable
里封装的图片就是一个 Bitmap
对象。开发者可以通过调用 BitmapDrawable
的构造器,将一个 Bitmap
对象包装成 BitmapDrawable
对象。例如:
// 把一个 Bitmap 对象包装成 BitmapDrawable 对象
BitmapDrawable drawable = new BitmapDrawable(bitmap);
如果需要获取 BitmapDrawable
所包装的 Bitmap
对象,可以调用 BitmapDrawable
的 getBitmap()
方法,如下所示:
// 获取 BitmapDrawable 所包装的 Bitmap 对象
Bitmap bitmap = drawable.getBitmap();
Bitmap
还提供了一些静态方法来创建新的 Bitmap
对象,例如:
createBitmap(Bitmap source, int x, int y, int width, int height)
: 从源位图source
的指定坐标点 (给定x
、y
) 开始,从中“挖取”宽width
、高height
的一块出来,创建新的Bitmap
对象。createScaledBitmap(Bitmap src, int dstWidth, int dstHeight, boolean filter)
: 对源位图src
进行缩放,缩放成宽dstWidth
、高dstHeight
的新位图。createBitmap(int width, int height, Bitmap.Config config)
: 创建一个宽width
、高height
的新位图。createBitmap(Bitmap source, int x, int y, int width, int height, Matrix m, boolean filter)
: 从源位图source
的指定坐标点 (给定x
、y
) 开始,从中“挖取”宽width
、高height
的一块出来,创建新的Bitmap
对象,并按Matrix
指定的规则进行变换。
BitmapFactory
BitmapFactory
是一个工具类,它提供了大量的方法,这些方法可用于从不同的数据源解析、创建 Bitmap
对象。BitmapFactory
包含如下方法:
decodeByteArray(byte[] data, int offset, int length)
: 从指定字节数组的offset
位置开始,将长度为length
的字节数据解析成Bitmap
对象。decodeFile(String pathName)
: 从pathName
指定的文件中解析、创建Bitmap
对象。decodeFileDescriptor(FileDescriptor fd)
: 从FileDescriptor
对应的文件中解析、创建Bitmap
对象。decodeResource(Resources res, int id)
: 根据给定的资源 ID 从指定资源中解析、创建Bitmap
对象。decodeStream(InputStream is)
: 从指定输入流中解析、创建Bitmap
对象。
通常,只要把图片放在 /res/drawable/
目录下,就可以在程序中通过该图片对应的资源 ID 来获取封装该图片的 Drawable
对象。但由于手机系统的内存较小,如果系统不停地解析、创建 Bitmap
对象,可能会由于内存不足导致程序运行时引发 OutOfMemory
错误。
Android 为 Bitmap
提供了两个方法来判断它是否已回收,以及强制 Bitmap
回收自己:
boolean isRecycled()
: 返回该Bitmap
对象是否已被回收。void recycle()
: 强制一个Bitmap
对象立即回收自己。
此外,如果 Android 应用需要访问其他存储路径(比如 SD 卡)里的图片,则可以通过 BitmapFactory
来解析、创建 Bitmap
对象。
下面开发一个查看 /assets/
目录下图片的图片查看器。该程序界面十分简单,只包含一个 ImageView
和一个按钮。当用户单击该按钮时程序会自动搜寻 /assets/
目录下的下一张图片。
public class MainActivity extends Activity {
private String[] images;
private int currentImg;
private ImageView image;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
image = findViewById(R.id.image);
try {
// 获取 /assets/ 目录下的所有文件
images = getAssets().list("");
} catch (IOException e) {
e.printStackTrace();
}
// 获取 next 按钮
Button next = findViewById(R.id.next);
// 为 next 按钮绑定事件监听器,该监听器将会查看下一张图片
next.setOnClickListener(view -> {
// 如果发生数组越界
if (currentImg >= images.length) {
currentImg = 0;
}
// 找到下一个图片文件
while (!images[currentImg].endsWith(".png") &&
!images[currentImg].endsWith(".jpg") &&
!images[currentImg].endsWith(".gif")) {
currentImg++;
if (currentImg >= images.length) {
currentImg = 0;
}
}
InputStream assetFile = null;
try {
// 打开指定资源对应的输入流
assetFile = getAssets().open(images[currentImg++]);
} catch (IOException e) {
e.printStackTrace();
}
BitmapDrawable bitmapDrawable = (BitmapDrawable) image.getDrawable();
// 如果图片还未回收,先强制回收该图片
if (bitmapDrawable != null && !bitmapDrawable.getBitmap().isRecycled()) {
bitmapDrawable.getBitmap().recycle();
}
// 改变 ImageView 显示的图片
image.setImageBitmap(BitmapFactory.decodeStream(assetFile));
});
}
}
程序中通过 BitmapFactory
从指定输入流解析并创建 Bitmap
对象,并根据用户点击按钮切换显示不同的图片。
7.1.3 Android 9 新增的 ImageDecoder
在 Android 9 中引入了 ImageDecoder
和 OnHeaderDecodedListener
等 API,它们提供了更强大的图片解码支持,不仅可以解码 PNG、JPEG 等静态图片,还能直接解码 GIF、WEBP 等动画图片。此外,Android 9 还新增了对 HEIF 格式的支持。HEIF 格式自 iOS 11 起被 Apple 支持,具有超高的压缩比,相较于 JPEG 格式,文件大小可以压缩到其一半,并且保证近似的图像质量。
使用 ImageDecoder
解码 GIF、WEBP 等动画图片时,程序将返回一个 AnimatedImageDrawable
对象。要启动动画,只需调用 AnimatedImageDrawable
对象的 start()
方法。
使用 ImageDecoder
解码图片的步骤如下:
- 调用
ImageDecoder
的重载的createSource
方法来创建Source
对象。根据图片来源不同,createSource
方法有不同的重载形式。 - 调用
ImageDecoder
的decodeDrawable(Source)
或decodeBitmap(Source)
方法来读取代表图片的Drawable
或Bitmap
对象。
在执行第二步时,程序可以额外传入一个 OnHeaderDecodedListener
参数,该参数代表一个监听器,要实现 onHeaderDecoded(ImageDecoder, ImageInfo, Source)
方法。通过该方法可以对 ImageDecoder
进行额外设置,也可以通过 ImageInfo
获取被解码图片的信息。
示例代码
下面是使用 ImageDecoder
直接读取 GIF 动画图片的示例代码。在该程序的 LinearLayout
布局内只定义了一个 TextView
和一个 ImageView
。
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 获取 TextView 对象
TextView textView = findViewById(R.id.tv);
// 获取 ImageView 对象
ImageView imageView = findViewById(R.id.image);
try {
// ①创建 ImageDecoder.Source 对象
ImageDecoder.Source source = ImageDecoder.createSource(getResources(), R.drawable.fat_po);
// ②执行 decodeDrawable() 方法获取 Drawable 对象
Drawable drawable = ImageDecoder.decodeDrawable(source, (decoder, info, s) -> {
// 通过 info 参数获取被解码的图片信息
textView.setText("图片原始宽度: " + info.getSize().getWidth() + "\n" + "图片原始高度: " + info.getSize().getHeight());
// 设置图片解码之后的缩放大小
decoder.setTargetSize(600, 580);
});
imageView.setImageDrawable(drawable);
// 如果 drawable 是 AnimatedImageDrawable 的实例,则执行动画
if (drawable instanceof AnimatedImageDrawable) {
((AnimatedImageDrawable) drawable).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
在上面的代码中,ImageDecoder
被用来解码一张 GIF 动画图片,得到的是一个 AnimatedImageDrawable
对象,调用该对象的 start()
方法即可播放动画。
此外,ImageDecoder
比 BitmapFactory
更加智能,能够解码包含不完整或错误的图片。如果希望显示部分已解码的图片,可以为 ImageDecoder
设置 OnPartialImageListener
监听器。下面是一个相关的代码片段:
Drawable drawable = ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
// 为 ImageDecoder 设置 OnPartialImageListener 监听器
decoder.setOnPartialImageListener(e -> {
// return true 表明即使不能完整解码全部图片,也返回 Drawable 或 Bitmap
return true;
});
});
通过这个监听器,即使图片文件不完整,ImageDecoder
仍然可以解码并显示部分图片。
7.2 绘图
除使用已有的图片之外,Android 应用还常常需要在运行时动态生成图片。为了实现丰富多彩且动态变化的用户界面,尤其是游戏开发中,往往需要借助于 Android 的绘图支持。
7.2.1 Android 绘图基础:Canvas、Paint 等
在 Android 中,绘图的基本方式是继承 View
组件,并重写它的 onDraw(Canvas)
方法。在这个方法中使用 Canvas
对象进行绘图。
Canvas 类
Canvas
代表“依附”于指定 View
的画布,它提供了一系列绘制方法,如表 7.1 所示。
表 7.1 Canvas 的绘制方法
方法签名 | 简要说明 |
---|---|
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) | 绘制弧形 |
drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint) | 在指定位置绘制位图 |
drawBitmap(Bitmap bitmap, float left, float top, Paint paint) | 在指定位置绘制位图 |
drawCircle(float cx, float cy, float radius, Paint paint) | 绘制一个圆 |
drawLine(float startX, float startY, float stopX, float stopY, Paint paint) | 绘制一条直线 |
drawOval(RectF oval, Paint paint) | 绘制椭圆 |
drawRect(float left, float top, float right, float bottom, Paint paint) | 绘制矩形 |
drawRoundRect(RectF rect, float rx, float ry, Paint paint) | 绘制圆角矩形 |
drawText(String text, int start, int end, Paint paint) | 绘制文本 |
drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint) | 沿路径绘制文本 |
clipRect(float left, float top, float right, float bottom) | 剪切矩形区域 |
clipRegion(Region region) | 剪切指定区域 |
此外,Canvas
还提供了用于坐标变换的方法,如旋转、缩放、倾斜和平移。
Paint 类
Paint
代表 Canvas
上的画笔,主要用于设置绘制风格,包括颜色、笔触粗细、填充风格等。常用方法如表 7.2 所示。
表 7.2 Paint 的常用方法
方法签名 | 简要说明 |
---|---|
setARGB(int a, int r, int g, int b) | 设置颜色 |
setAlpha(int a) | 设置透明度 |
setAntiAlias(boolean aa) | 设置是否抗锯齿 |
setColor(int color) | 设置颜色 |
setShader(Shader shader) | 设置画笔的填充效果 |
setShadowLayer(float radius, float dx, float dy, int color) | 设置阴影 |
setStrokeWidth(float width) | 设置画笔的笔触宽度 |
setTextAlign(Paint.Align align) | 设置文本对齐方式 |
setTextSize(float textSize) | 设置文本大小 |
Path 类
Path
代表任意多条直线连接而成的任意图形,Canvas
可以根据 Path
绘制出任意的形状。
示例代码
下面的程序示范了如何在 Android 应用中绘制基本的几何图形。通过自定义一个 View
类,并重写 onDraw(Canvas)
方法,在 Canvas
上绘制了大量几何图形。
public class MyView extends View {
private Paint paint = new Paint();
private Path path1 = new Path();
private LinearGradient mShader = new LinearGradient(0f, 0f, 40f, 60f,
new int[]{Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW},
null, Shader.TileMode.REPEAT);
public MyView(Context context, AttributeSet set) {
super(context, set);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
paint.setAntiAlias(true);
paint.setColor(Color.BLUE);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(4f);
int viewWidth = this.getWidth();
// 绘制圆形
canvas.drawCircle(viewWidth / 10 + 10, viewWidth / 10 + 10, viewWidth / 10, paint);
// 绘制正方形
canvas.drawRect(10f, viewWidth / 5 + 20, viewWidth / 5 + 10, viewWidth * 2 / 5 + 20, paint);
// 设置渐变器后绘制
paint.setShader(mShader);
canvas.drawCircle(viewWidth / 2 + 30, viewWidth / 10 + 10, viewWidth / 10, paint);
// 设置字符大小后绘制
paint.setTextSize(48f);
paint.setShader(null);
canvas.drawText("Circle", 60 + viewWidth * 3 / 5, viewWidth / 10 + 10, paint);
}
}
上面程序中大量调用了 Canvas
的方法来绘制几何图形,同时还为 Paint
画笔设置了渐变和阴影效果。
使用 Activity
来显示上面的 MyView
类,运行程序后,可以看到各种绘制的几何图形。通过这种方式,开发者可以根据需求在 Canvas
上动态绘制出各种图形和图像。
7.2.2 Path 类
Android 提供的 Path
类是一个非常有用的类,它可以预先在 View
上将多个点连成一条“路径”,然后调用 Canvas
的 drawPath(path, paint)
方法即可沿着路径绘制图形。除此之外,Android 还提供了 PathEffect
来定义路径的绘制效果。PathEffect
包含了如下子类,每个子类代表一种绘制效果:
- ComposePathEffect
- CornerPathEffect
- DashPathEffect
- DiscretePathEffect
- PathDashPathEffect
- SumPathEffect
通过一个程序示例,您可以直观地理解这些路径效果的不同表现。以下是程序代码,它绘制了 7 条路径,分别示范了不使用效果和使用上述 6 种路径效果的效果。
示例代码:路径效果展示
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new MyView(this));
}
private static class MyView extends View {
private float phase;
private PathEffect[] effects = new PathEffect[7];
private int[] colors;
private Paint paint = new Paint();
private Path path = new Path();
public MyView(Context context) {
super(context);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(4);
path.moveTo(0f, 0f);
for (int i = 1; i <= 40; i++) {
path.lineTo(i * 25f, (float)(Math.random() * 90));
}
colors = new int[]{Color.BLACK, Color.BLUE, Color.CYAN, Color.GREEN, Color.MAGENTA, Color.RED, Color.YELLOW};
effects[0] = null; // 不使用路径效果
effects[1] = new CornerPathEffect(10f); // 使用CornerPathEffect
effects[2] = new DiscretePathEffect(3.0f, 5.0f); // 使用DiscretePathEffect
effects[3] = new DashPathEffect(new float[]{20f, 10f, 5f, 10f}, phase); // 使用DashPathEffect
Path p = new Path();
p.addRect(0f, 0f, 8f, 8f, Path.Direction.CCW);
effects[4] = new PathDashPathEffect(p, 12f, phase, PathDashPathEffect.Style.ROTATE); // 使用PathDashPathEffect
effects[5] = new ComposePathEffect(effects[2], effects[4]); // 使用ComposePathEffect
effects[6] = new SumPathEffect(effects[4], effects[3]); // 使用SumPathEffect
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.WHITE);
for (int i = 0; i < effects.length; i++) {
paint.setPathEffect(effects[i]);
paint.setColor(colors[i]);
canvas.drawPath(path, paint);
canvas.translate(0f, 90f);
}
phase += 1f;
invalidate(); // 重绘,以形成动画效果
}
}
}
在上面的程序中,当定义 DashPathEffect
和 PathDashPathEffect
时,可以指定一个 phase
参数,该参数用于指定路径效果的相位。当该 phase
参数改变时,绘制效果也会发生变化。该程序通过不断改变 phase
参数,并不停地重绘 View
组件,从而产生动画效果。
示例代码:沿路径绘制文本
Android 的 Canvas
还提供了一个 drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint)
方法,可以沿着路径绘制文本。hOffset
参数指定水平偏移,vOffset
参数指定垂直偏移。
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new PathTextView(this));
}
private static class PathTextView extends View {
private String drawStr = "Hello, Android!";
private Path[] paths = new Path[3];
private Paint paint = new Paint();
public PathTextView(Context context) {
super(context);
paths[0] = new Path();
paths[0].moveTo(0f, 0f);
for (int i = 1; i <= 20; i++) {
paths[0].lineTo(i * 30f, (float)(Math.random() * 30));
}
RectF rectF = new RectF(0f, 0f, 600f, 360f);
paths[1] = new Path();
paths[1].addOval(rectF, Path.Direction.CCW);
paths[2] = new Path();
paths[2].addArc(rectF, 60f, 180f);
paint.setAntiAlias(true);
paint.setColor(Color.CYAN);
paint.setStrokeWidth(1f);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.WHITE);
canvas.translate(40f, 40f);
paint.setTextAlign(Paint.Align.RIGHT);
paint.setTextSize(30f);
paint.setStyle(Paint.Style.STROKE);
canvas.drawPath(paths[0], paint);
paint.setStyle(Paint.Style.FILL);
canvas.drawTextOnPath(drawStr, paths[0], -8f, 20f, paint);
canvas.translate(0f, 60f);
paint.setStyle(Paint.Style.STROKE);
canvas.drawPath(paths[1], paint);
paint.setStyle(Paint.Style.FILL);
canvas.drawTextOnPath(drawStr, paths[1], -20f, 20f, paint);
canvas.translate(0f, 360f);
paint.setStyle(Paint.Style.STROKE);
canvas.drawPath(paths[2], paint);
paint.setStyle(Paint.Style.FILL);
canvas.drawTextOnPath(drawStr, paths[2], -10f, 20f, paint);
}
}
}
上面的程序使用 drawTextOnPath()
方法沿路径绘制文本,运行程序时,您将看到文本沿着路径进行绘制,而不是水平排列。
7.2.3 绘制游戏动画
掌握了 Canvas
绘图的知识之后,实现游戏动画其实并不复杂。动画的本质就是不断地重复调用 View
组件的 onDraw(Canvas canvas)
方法,并在每次绘制时让图形有所变化。通过适当的控制和调节,可以让图形的大小、位置、角度等属性随时间改变,从而形成动画效果。
1. 实现游戏动画的方式
要实现游戏动画,有两种常见的方式:
- 响应用户操作:通过编写事件监听器,在监听器中修改图形的状态数据,使得图形的变化随用户的操作而变化。
- 自动动画:通过使用定时器(
Timer
)来定期改变图形的状态数据,使得图形的变化随时间而变化。
无论采用哪种方式,图形状态数据发生变化后,都需要调用 View
组件的 invalidate()
(在UI线程中)或 postInvalidate()
(在非UI线程中)方法,来通知 View
重新绘制。
2. 示例:实现双缓冲技术的画图板
该示例实现了一个简单的画图板,用户可以在触摸屏上自由绘制线条。该程序的核心是利用了双缓冲技术来绘制图形。
双缓冲技术的概念是:程序不直接将图形绘制在 View
组件上,而是先将图形绘制到内存中的一个 Bitmap
对象(即缓冲区)中,再一次性将该 Bitmap
绘制到 View
组件上。
以下是该示例中自定义 View
组件的代码:
public class DrawView extends View {
// 定义记录前一个拖动事件发生点的坐标
private float preX;
private float preY;
private Path path = new Path();
Paint paint = new Paint(Paint.DITHER_FLAG);
private Bitmap cacheBitmap;
private Canvas cacheCanvas = new Canvas();
private Paint bmpPaint = new Paint();
public DrawView(Context context, int width, int height) {
super(context);
// 创建一个与该 View 具有相同大小的图片缓冲区
cacheBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
// 设置 cacheCanvas 将会绘制到内存中的 cacheBitmap 上
cacheCanvas.setBitmap(cacheBitmap);
// 设置画笔的颜色
paint.setColor(Color.RED);
// 设置画笔风格
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(1);
// 设置反锯齿
paint.setAntiAlias(true);
paint.setDither(true);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 获取拖动事件的发生位置
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
path.moveTo(x, y);
preX = x;
preY = y;
break;
case MotionEvent.ACTION_MOVE:
path.lineTo(x, y);
preX = x;
preY = y;
break;
case MotionEvent.ACTION_UP:
// 将路径绘制到 cacheCanvas 上
cacheCanvas.drawPath(path, paint); // ①
path.reset();
break;
}
invalidate(); // 通知 View 重新绘制
return true;
}
@Override
public void onDraw(Canvas canvas) {
// 将 cacheBitmap 绘制到该 View 组件上
canvas.drawBitmap(cacheBitmap, 0, 0, bmpPaint); // ②
// 沿着 path 绘制
canvas.drawPath(path, paint);
}
}
在这段代码中:
- ①:当用户触摸屏幕时,程序会记录当前触摸点的位置,并在缓冲区(
cacheCanvas
)上绘制路径。 - ②:在
onDraw()
方法中,程序将缓冲区中的内容绘制到View
组件上。
3. 主程序实现
主程序负责加载和显示自定义的 DrawView
组件,并为用户提供一些菜单选项以更改画笔的颜色和宽度。以下是主程序的代码:
public class MainActivity extends Activity {
private EmbossMaskFilter emboss;
private BlurMaskFilter blur;
private DrawView drawView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LinearLayout line = new LinearLayout(this);
DisplayMetrics displayMetrics = new DisplayMetrics();
// 获取屏幕的宽度和高度
getWindowManager().getDefaultDisplay().getRealMetrics(displayMetrics);
// 创建一个 DrawView
drawView = new DrawView(this, displayMetrics.widthPixels, displayMetrics.heightPixels);
line.addView(drawView);
setContentView(line);
emboss = new EmbossMaskFilter(new float[]{1.5f, 1.5f, 1.5f}, 0.6f, 6f, 4.2f);
blur = new BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// 加载菜单资源
getMenuInflater().inflate(R.menu.menu_main, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem mi) {
switch (mi.getItemId()) {
case R.id.red:
drawView.paint.setColor(Color.RED);
mi.setChecked(true);
break;
case R.id.green:
drawView.paint.setColor(Color.GREEN);
mi.setChecked(true);
break;
case R.id.blue:
drawView.paint.setColor(Color.BLUE);
mi.setChecked(true);
break;
case R.id.width_1:
drawView.paint.setStrokeWidth(1f);
break;
case R.id.width_3:
drawView.paint.setStrokeWidth(3f);
break;
case R.id.width_5:
drawView.paint.setStrokeWidth(5f);
break;
case R.id.blur:
drawView.paint.setMaskFilter(blur);
break;
case R.id.emboss:
drawView.paint.setMaskFilter(emboss);
break;
}
return true;
}
}
通过这个简单的画图板应用,您可以自由绘制图形,并通过菜单更改画笔的颜色、宽度和效果(如模糊、浮雕等)。这展示了如何使用 Canvas
和 Paint
进行基本的绘图和动画处理,并为游戏开发提供了基础。
实例:弹球游戏
以下是一个简单的弹球游戏的实现示例。在这个游戏中,小球和球拍分别由圆形区域和矩形区域代替。小球开始时以随机速度向下运动,当小球碰到边框或球拍时会反弹;球拍由用户控制,用户通过点击屏幕的左右区域来移动球拍。
MainActivity.java
public class MainActivity extends Activity {
// 屏幕的宽度和高度
private float tableWidth;
private float tableHeight;
// 球拍的垂直位置、高度和宽度
private float racketY;
private float racketHeight;
private float racketWidth;
// 小球的大小和速度
private float ballSize;
private int ySpeed = 15;
private Random rand = new Random();
private double xyRate = rand.nextDouble() - 0.5;
private int xSpeed = (int) (ySpeed * xyRate * 2.0);
private float ballX = rand.nextInt(200) + 20f;
private float ballY = rand.nextInt(10) + 60f;
private float racketX = rand.nextInt(200);
// 游戏状态标志
private boolean isLose;
private GameView gameView;
static class MyHandler extends Handler {
private WeakReference<MainActivity> activity;
MyHandler(WeakReference<MainActivity> activity) {
this.activity = activity;
}
@Override
public void handleMessage(Message msg) {
if (msg.what == 0x123) {
activity.get().gameView.invalidate();
}
}
}
private Handler handler = new MyHandler(new WeakReference<>(this));
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 去掉窗口标题,设置全屏显示
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
// 获取资源文件中定义的尺寸
racketHeight = getResources().getDimension(R.dimen.racket_height);
racketWidth = getResources().getDimension(R.dimen.racket_width);
ballSize = getResources().getDimension(R.dimen.ball_size);
// 创建 GameView 组件并显示
gameView = new GameView(this);
setContentView(gameView);
// 获取屏幕的宽和高
WindowManager windowManager = getWindowManager();
DisplayMetrics metrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(metrics);
tableWidth = metrics.widthPixels;
tableHeight = metrics.heightPixels;
racketY = tableHeight - 80;
// 设置触摸监听,控制球拍的移动
gameView.setOnTouchListener((source, event) -> {
if (event.getX() < tableWidth / 10) {
if (racketX > 0) racketX -= 10;
}
if (event.getX() >= tableWidth * 9 / 10) {
if (racketX < tableWidth - racketWidth) racketX += 10;
}
return true;
});
// 定时器控制小球移动
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
if (ballX <= ballSize || ballX >= tableWidth - ballSize) {
xSpeed = -xSpeed;
}
if (ballY >= racketY - ballSize && (ballX < racketX || ballX > racketX + racketWidth)) {
timer.cancel();
isLose = true;
} else if (ballY <= ballSize || (ballY >= racketY - ballSize && ballX > racketX && ballX < racketX + racketWidth)) {
ySpeed = -ySpeed;
}
ballY += ySpeed;
ballX += xSpeed;
handler.sendEmptyMessage(0x123);
}
}, 0, 100);
}
// 自定义的 GameView 类,用于绘制游戏场景
class GameView extends View {
private Paint paint = new Paint();
private RadialGradient mShader = new RadialGradient(-ballSize / 2, -ballSize / 2, ballSize, Color.WHITE, Color.RED, Shader.TileMode.CLAMP);
public GameView(Context context) {
super(context);
setFocusable(true);
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
}
@Override
protected void onDraw(Canvas canvas) {
if (isLose) {
paint.setColor(Color.RED);
paint.setTextSize(40f);
canvas.drawText("游戏已结束", tableWidth / 2 - 100, 200f, paint);
} else {
canvas.save();
canvas.translate(ballX, ballY);
paint.setShader(mShader);
canvas.drawCircle(0, 0, ballSize, paint);
paint.setShader(null);
canvas.restore();
paint.setColor(Color.rgb(80, 80, 200));
canvas.drawRect(racketX, racketY, racketX + racketWidth, racketY + racketHeight, paint);
}
}
}
}
代码解释
-
屏幕尺寸与对象尺寸初始化:
tableWidth
和tableHeight
用于保存屏幕的宽度和高度。racketWidth
、racketHeight
、ballSize
等保存球拍和小球的尺寸。 -
定时器控制小球运动: 使用
Timer
定时器来周期性地更新小球的位置,判断小球是否与边框或球拍发生碰撞,并进行反弹处理。 -
触摸事件控制球拍: 使用
OnTouchListener
监听触摸事件,通过判断触摸点的位置来控制球拍的左右移动。 -
自定义
GameView
类: 该类继承View
,用于绘制小球和球拍。通过onDraw()
方法来根据小球和球拍的当前状态绘制它们的位置和形状。 -
游戏逻辑处理: 当小球碰到边框时,小球反弹;当小球未能成功接住(即球拍未在正确位置),游戏结束并显示提示信息。
游戏效果
运行该程序后,会看到一个简单的弹球游戏界面。用户通过点击屏幕的左右区域来控制球拍的移动,并尽量接住下落的小球。如果未接住小球,游戏将结束,并显示“游戏已结束”的提示。
7.3 图形特效处理
在 Android 中,除了基本的图形处理功能外,还提供了一些高级的图形特效支持。这些特效可以帮助开发者创造出更加绚丽的用户界面。
7.3.1 使用 Matrix 控制变换
Matrix
是 Android 提供的一个矩阵工具类,用于控制图形或组件的变换操作,如平移、旋转、缩放、倾斜等。Matrix
本身并不能直接对图形进行变换,但可以与其他 API 结合使用,从而达到变换效果。
使用步骤:
- 获取
Matrix
对象:可以直接创建新的Matrix
对象,也可以从其他对象(如Transformation
)中获取封装的Matrix
。 - 对
Matrix
进行操作:使用Matrix
的方法进行平移、旋转、缩放、倾斜等操作。 - 应用
Matrix
变换:将变换应用到指定的图形或组件上。
常用方法:
setTranslate(float dx, float dy)
: 控制Matrix
进行平移。setSkew(float kx, float ky, float px, float py)
: 控制Matrix
以px
、py
为轴心进行倾斜。setRotate(float degrees)
: 控制Matrix
进行旋转。setScale(float sx, float sy)
: 设置Matrix
进行缩放。
示例:
下面的程序展示了如何使用 Matrix
控制图形的旋转和倾斜操作。用户通过点击按钮来触发这些变换。
public class MyView extends View {
private Bitmap bitmap;
private Matrix bmMatrix = new Matrix();
private float sx;
private int bmWidth;
private int bmHeight;
private float scale = 1.0f;
private boolean isScale;
public MyView(Context context, AttributeSet set) {
super(context, set);
// 获取位图
bitmap = ((BitmapDrawable) context.getResources().getDrawable(
R.drawable.a, context.getTheme())).getBitmap();
bmWidth = bitmap.getWidth();
bmHeight = bitmap.getHeight();
this.setFocusable(true);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
bmMatrix.reset();
if (!isScale) {
bmMatrix.setSkew(sx, 0f);
} else {
bmMatrix.setScale(scale, scale);
}
Bitmap bitmap2 = Bitmap.createBitmap(bitmap, 0, 0, bmWidth, bmHeight, bmMatrix, true);
canvas.drawBitmap(bitmap2, bmMatrix, null);
}
}
在这个自定义 View
中,Matrix
被用来控制图片的倾斜和缩放。当用户点击按钮时,相应的 sx
和 scale
参数会被修改,从而更新 Matrix
的变换效果。
界面效果
运行该程序后,用户可以通过点击按钮控制图片的倾斜和缩放,如图 7.6 所示的界面:
- 左倾斜:图片向左倾斜。
- 右倾斜:图片向右倾斜。
- 放大:图片按比例放大。
- 缩小:图片按比例缩小。
这个示例展示了 Matrix
的基本用法,可以帮助开发者在 Android 应用中实现各种图形变换效果。
7.3.2 使用 drawBitmapMesh
扭曲图像
Canvas
提供了一个非常强大的方法 drawBitmapMesh()
,它可以对 Bitmap
进行扭曲处理。这个方法非常灵活,开发者可以通过它实现如“水波荡漾”、“风吹旗帜”等效果。
方法参数说明
bitmap
:要进行扭曲的源位图。meshWidth
:控制横向上将源位图划分为多少格。meshHeight
:控制纵向上将源位图划分为多少格。verts
:一个一维数组,长度为(meshWidth+1)*(meshHeight+1)*2
,记录了扭曲后的位图各“顶点”位置。vertOffset
:控制verts
数组中从哪个元素开始应用扭曲效果。
drawBitmapMesh()
的关键在于 verts
数组,它记录了位图上各顶点在扭曲后的坐标。通过修改这个数组,可以灵活地控制图像的扭曲效果。
实例:可揉动的图片
本实例中,我们将通过 drawBitmapMesh()
方法来实现一个效果,当用户触摸图片的某个点时,该图片会像被“按”下去一样,产生凹陷效果。
代码示例:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new MyView(this, R.drawable.jinta));
}
private class MyView extends View {
// 定义图片横向、纵向划分为 20 格
private static final int WIDTH = 20;
private static final int HEIGHT = 20;
private static final int COUNT = (WIDTH + 1) * (HEIGHT + 1);
private float[] verts = new float[COUNT * 2];
private float[] orig = new float[COUNT * 2];
private Bitmap bitmap;
MyView(Context context, int drawableId) {
super(context);
setFocusable(true);
// 加载图片
bitmap = BitmapFactory.decodeResource(getResources(), drawableId);
float bitmapWidth = bitmap.getWidth();
float bitmapHeight = bitmap.getHeight();
int index = 0;
// 初始化数组
for (int y = 0; y <= HEIGHT; y++) {
float fy = bitmapHeight * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = bitmapWidth * x / WIDTH;
verts[index * 2] = fx;
orig[index * 2] = fx;
verts[index * 2 + 1] = fy;
orig[index * 2 + 1] = fy;
index++;
}
}
setBackgroundColor(Color.WHITE);
}
@Override
protected void onDraw(Canvas canvas) {
// 使用 verts 数组对 bitmap 进行扭曲
canvas.drawBitmapMesh(bitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
}
private void warp(float cx, float cy) {
for (int i = 0; i < COUNT * 2; i += 2) {
float dx = cx - orig[i];
float dy = cy - orig[i + 1];
float dd = dx * dx + dy * dy;
float d = (float) Math.sqrt(dd);
float pull = 200000 / (dd * d);
if (pull >= 1) {
verts[i] = cx;
verts[i + 1] = cy;
} else {
verts[i] = orig[i] + dx * pull;
verts[i + 1] = orig[i + 1] + dy * pull;
}
}
// 通知 View 组件重绘
invalidate();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 根据触摸点的坐标扭曲 verts 数组
warp(event.getX(), event.getY());
return true;
}
}
}
代码解析:
verts
和orig
数组记录了图片的顶点坐标。初始时,两个数组记录的坐标是相同的。- 当用户触摸图片时,
warp()
方法会根据触摸点的位置计算出每个顶点的新位置,从而产生扭曲效果。 - 每次触摸事件发生后,
invalidate()
方法会通知View
重新绘制,从而更新显示效果。
效果展示
运行该程序并触碰图片的任意位置,将会看到图片像被按下去一样发生形变,如图 7.8 所示。这种效果可以模拟出类似于图片在“极软的床上”被按压的感觉。
7.3.3 使用 Shader
填充图形
在 Android 中,Shader
是一个抽象类,用于定义填充图形的效果。通过 Paint
类的 setShader(Shader shader)
方法,可以设置画笔的填充效果,不仅限于颜色填充,还可以使用不同的 Shader
对象来创建各种渐变效果。
常见的 Shader
实现类
BitmapShader
: 使用位图平铺来填充图形。LinearGradient
: 使用线性渐变来填充图形。RadialGradient
: 使用圆形渐变来填充图形。SweepGradient
: 使用角度渐变来填充图形。ComposeShader
: 使用组合效果来填充图形。
实例:不同 Shader
的效果展示
以下是一个简单的例子,通过按钮切换不同的 Shader
,从而展示不同的填充效果。
public class MainActivity extends Activity {
// 声明 Shader 数组
private Shader[] shaders = new Shader[5];
// 声明颜色数组
private int[] colors;
private MyView myView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
myView = findViewById(R.id.my_view);
// 获得 Bitmap 实例
Bitmap bm = BitmapFactory.decodeResource(getResources(), R.drawable.water);
// 设置渐变的颜色组
colors = new int[]{Color.RED, Color.GREEN, Color.BLUE};
// 实例化不同的 Shader 对象
shaders[0] = new BitmapShader(bm, Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
shaders[1] = new LinearGradient(0, 0, 100, 100, colors, null, Shader.TileMode.REPEAT);
shaders[2] = new RadialGradient(100, 100, 80, colors, null, Shader.TileMode.REPEAT);
shaders[3] = new SweepGradient(160, 160, colors, null);
shaders[4] = new ComposeShader(shaders[1], shaders[2], PorterDuff.Mode.ADD);
// 设置按钮监听器
View.OnClickListener listener = source -> {
switch (source.getId()) {
case R.id.bn1:
myView.paint.setShader(shaders[0]);
break;
case R.id.bn2:
myView.paint.setShader(shaders[1]);
break;
case R.id.bn3:
myView.paint.setShader(shaders[2]);
break;
case R.id.bn4:
myView.paint.setShader(shaders[3]);
break;
case R.id.bn5:
myView.paint.setShader(shaders[4]);
break;
}
// 重绘界面
myView.invalidate();
};
// 为按钮设置监听器
findViewById(R.id.bn1).setOnClickListener(listener);
findViewById(R.id.bn2).setOnClickListener(listener);
findViewById(R.id.bn3).setOnClickListener(listener);
findViewById(R.id.bn4).setOnClickListener(listener);
findViewById(R.id.bn5).setOnClickListener(listener);
}
}
代码解析:
BitmapShader
: 将指定的位图以重复和镜像的方式平铺,用于填充图形。LinearGradient
: 创建一个线性渐变的填充效果,从起点到终点的颜色按指定顺序渐变。RadialGradient
: 创建一个以指定半径的圆心为中心的径向渐变效果。SweepGradient
: 创建一个以指定中心为起点,沿顺时针方向的角度渐变。ComposeShader
: 将两个Shader
组合在一起,形成更复杂的填充效果。
运行该程序后,点击不同的按钮,MyView
会根据所选的 Shader
重新绘制矩形,从而展示不同的填充效果。
效果展示
- 使用
BitmapShader
绘制矩形,可以看到平铺的位图效果,如图 7.9 所示。 - 使用
SweepGradient
绘制矩形,可以看到角度渐变的效果,如图 7.10 所示。
通过这个实例,你可以直观地体验不同 Shader
的效果,了解它们在实际应用中的具体表现。
7.4 逐帧(Frame)动画
逐帧动画是最直观的一种动画形式,通过连续播放一系列静态图片来实现动画效果。这种动画类似于播放电影的原理,利用人眼的视觉暂留现象,快速切换一系列图像帧来创造动态视觉效果。
7.4.1 AnimationDrawable
与逐帧动画
在 Android 中,逐帧动画通常通过 AnimationDrawable
类来实现。可以在 XML 文件中定义动画的各个帧,也可以在代码中动态添加帧。
定义逐帧动画的 XML 文件
逐帧动画的定义通常放在 drawable
目录下,使用 <animation-list>
元素来包含各帧图片,并设置每一帧的持续时间。
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item android:drawable="@drawable/fat_po_f01" android:duration="60"/>
<item android:drawable="@drawable/fat_po_f02" android:duration="60"/>
<item android:drawable="@drawable/fat_po_f03" android:duration="60"/>
<!-- 更多帧定义 -->
</animation-list>
在上述 XML 文件中,android:oneshot
属性控制动画是否只播放一次,如果设置为 false
,动画会循环播放。
在代码中使用 AnimationDrawable
下面是一个简单的逐帧动画示例,通过两个按钮分别控制动画的播放和停止。
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button play = findViewById(R.id.play);
Button stop = findViewById(R.id.stop);
ImageView imageView = findViewById(R.id.anim);
// 获取 AnimationDrawable 动画对象
AnimationDrawable anim = (AnimationDrawable) imageView.getBackground();
play.setOnClickListener(view -> anim.start());
stop.setOnClickListener(view -> anim.stop());
}
}
在这个例子中,AnimationDrawable
对象通过 ImageView
的背景获取。点击 “播放” 按钮后,动画开始播放;点击 “停止” 按钮后,动画停止。
7.4.2 实例:在指定点爆炸
在游戏开发中,逐帧动画通常用于展示爆炸、爆裂等效果。例如,当检测到触摸屏事件时,可以在触摸点显示一个爆炸动画。
爆炸动画的 XML 定义
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="true">
<item android:drawable="@drawable/bom_f01" android:duration="80"/>
<item android:drawable="@drawable/bom_f02" android:duration="80"/>
<!-- 更多帧定义 -->
</animation-list>
在指定点播放爆炸动画
public class MainActivity extends Activity {
public static final int BLAST_WIDTH = 240;
public static final int BLAST_HEIGHT = 240;
private MyView myView;
private MediaPlayer bomb;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
FrameLayout frame = new FrameLayout(this);
setContentView(frame);
frame.setBackgroundResource(R.drawable.back);
bomb = MediaPlayer.create(this, R.raw.bomb);
myView = new MyView(this);
myView.setBackgroundResource(R.drawable.blast);
myView.setVisibility(View.INVISIBLE);
AnimationDrawable anim = (AnimationDrawable) myView.getBackground();
frame.addView(myView);
frame.setOnTouchListener((source, event) -> {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
anim.stop();
float x = event.getX();
float y = event.getY();
myView.setLocation((int)y - BLAST_HEIGHT, (int)x - BLAST_WIDTH / 2);
myView.setVisibility(View.VISIBLE);
anim.start();
bomb.start();
}
return false;
});
}
class MyView extends ImageView {
MyView(Context context) {
super(context);
}
public void setLocation(int top, int left) {
this.setFrame(left, top, left + BLAST_WIDTH, top + BLAST_HEIGHT);
}
@Override
public void onDraw(Canvas canvas) {
try {
Field field = AnimationDrawable.class.getDeclaredField("mCurFrame");
field.setAccessible(true);
int curFrame = field.getInt(anim);
if (curFrame == anim.getNumberOfFrames() - 1) {
setVisibility(View.INVISIBLE);
}
} catch (Exception e) {
e.printStackTrace();
}
super.onDraw(canvas);
}
}
}
在这个实例中,当用户触摸屏幕时,程序会在触摸点显示一个爆炸动画,并播放爆炸音效。通过 onDraw
方法控制动画播放到最后一帧时自动隐藏 ImageView
。
这个实例展示了如何利用逐帧动画实现游戏中的爆炸效果。通过收集不同阶段的爆炸图片,并使用 AnimationDrawable
播放动画,可以轻松实现复杂的视觉效果。
7.5 补间(Tween)动画
补间动画是 Android 中的一种动画技术,它允许开发者定义动画的起点和终点,系统自动计算中间的动画帧,从而实现平滑的动画效果。补间动画与逐帧动画不同,逐帧动画需要开发者手动创建每一帧,而补间动画只需定义关键帧,系统会自动生成中间帧。
7.5.1 Tween 动画与 Interpolator
补间动画主要依赖于几个基本的动画类型:
- AlphaAnimation: 控制透明度的动画,从完全透明(0.0)到完全不透明(1.0)。
- ScaleAnimation: 控制缩放的动画,可以在 X 轴和 Y 轴方向上缩放视图。
- TranslateAnimation: 控制位置移动的动画,视图可以从一个位置平滑地移动到另一个位置。
- RotateAnimation: 控制旋转的动画,视图可以围绕某个点旋转。
Interpolator 是一个接口,用于控制动画的速度变化。它定义了动画从开始到结束时的变化曲线,如匀速、加速、减速等。Android 提供了多种 Interpolator 实现类,如:
- LinearInterpolator: 动画以均匀的速度变化。
- AccelerateInterpolator: 动画在开始时变化慢,随后逐渐加速。
- DecelerateInterpolator: 动画在开始时变化快,随后逐渐减速。
- CycleInterpolator: 动画以正弦曲线的形式循环。
7.5.2 位置、大小、旋转度、透明度改变的补间动画
补间动画可以通过 XML 文件定义,也可以通过代码实现。以下是一个定义补间动画的 XML 示例,控制视图的缩放、旋转和透明度变化:
anim.xml:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator">
<!-- 缩放动画 -->
<scale
android:fromXScale="1.0"
android:toXScale="0.01"
android:fromYScale="1.0"
android:toYScale="0.01"
android:pivotX="50%"
android:pivotY="50%"
android:fillAfter="true"
android:duration="3000"/>
<!-- 透明度动画 -->
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.05"
android:duration="3000"/>
<!-- 旋转动画 -->
<rotate
android:fromDegrees="0"
android:toDegrees="1800"
android:pivotX="50%"
android:pivotY="50%"
android:duration="3000"/>
</set>
reverse.xml:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator">
<!-- 缩放动画 -->
<scale
android:fromXScale="0.01"
android:toXScale="1.0"
android:fromYScale="0.01"
android:toYScale="1.0"
android:pivotX="50%"
android:pivotY="50%"
android:fillAfter="true"
android:duration="3000"/>
<!-- 透明度动画 -->
<alpha
android:fromAlpha="0.05"
android:toAlpha="1.0"
android:duration="3000"/>
<!-- 旋转动画 -->
<rotate
android:fromDegrees="1800"
android:toDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:duration="3000"/>
</set>
在代码中加载并应用这些动画:
public class MainActivity extends Activity {
private ImageView flower;
private Animation reverse;
private Handler handler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
flower = findViewById(R.id.flower);
// 加载动画资源
Animation anim = AnimationUtils.loadAnimation(this, R.anim.anim);
anim.setFillAfter(true); // 保持动画结束后的状态
reverse = AnimationUtils.loadAnimation(this, R.anim.reverse);
reverse.setFillAfter(true);
Button bn = findViewById(R.id.bn);
bn.setOnClickListener(view -> {
flower.startAnimation(anim); // 播放动画
new Timer().schedule(new TimerTask() {
@Override
public void run() {
handler.sendEmptyMessage(0x123);
}
}, 3500);
});
handler = new Handler(message -> {
if (message.what == 0x123) {
flower.startAnimation(reverse); // 播放反向动画
}
return true;
});
}
}
7.5.3 自定义补间动画
Android 提供了基础的动画类 Animation
及其子类用于实现常见的动画效果,但在某些复杂场景下,可能需要自定义动画。自定义动画需要继承 Animation
类,并重写 applyTransformation(float interpolatedTime, Transformation t)
方法。
applyTransformation
方法中,interpolatedTime
参数表示动画的进度,范围从 0 到 1,而 Transformation
参数则表示动画对目标视图的变换程度。
以下是一个自定义动画类的示例,使用 Camera
类实现三维旋转效果:
public class MyAnimation extends Animation {
private float centerX;
private float centerY;
private int duration;
private Camera camera = new Camera();
public MyAnimation(float x, float y, int duration) {
this.centerX = x;
this.centerY = y;
this.duration = duration;
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
setDuration(duration);
setFillAfter(true);
setInterpolator(new LinearInterpolator());
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
camera.save();
camera.translate(100.0f - 100.0f * interpolatedTime,
150.0f * interpolatedTime - 150,
80.0f - 80.0f * interpolatedTime);
camera.rotateY(360 * interpolatedTime);
camera.rotateX(360 * interpolatedTime);
Matrix matrix = t.getMatrix();
camera.getMatrix(matrix);
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);
camera.restore();
}
}
在 Activity
中应用自定义动画:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ListView list = findViewById(R.id.list);
WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
Display display = windowManager.getDefaultDisplay();
DisplayMetrics metrics = new DisplayMetrics();
display.getMetrics(metrics);
// 使用自定义动画
list.setAnimation(new MyAnimation(metrics.widthPixels / 2, metrics.heightPixels / 2, 3500));
}
}
这个例子展示了如何通过自定义动画类实现更复杂的动画效果,例如在三维空间中的旋转。通过重写 applyTransformation
方法,并结合 Camera
类,可以轻松实现具有深度感的三维动画效果。
7.6 Android 8增强的属性动画
Android 8 引入了增强版的属性动画,使动画系统变得更加强大和灵活。相比于传统的补间动画,属性动画具有更广泛的应用场景和更强的功能。属性动画可以对任何对象的任意属性进行动画操作,而不仅仅是UI组件的四个基本属性(透明度、旋转、缩放、位移)。
7.6.1 属性动画的API
属性动画的API包括以下几个核心类:
- Animator: 属性动画的基类,不直接使用,通常用于被继承和重写。
- ValueAnimator: 负责计算各个帧的属性值,并处理更新事件,是属性动画的主要引擎。
- ObjectAnimator:
ValueAnimator
的子类,允许对指定对象的属性执行动画,使用更加简单和直接。 - AnimatorSet: 用于组合多个动画,可以指定多个动画是按次序播放还是同时播放。
此外,属性动画还需要使用一个 Evaluator
(计算器)来控制属性动画如何计算属性值。Android 提供了以下几种 Evaluator
:
- IntEvaluator: 用于计算
int
类型属性值。 - FloatEvaluator: 用于计算
float
类型属性值。 - ArgbEvaluator: 用于计算颜色值(以十六进制形式表示)。
- TypeEvaluator: 计算器接口,允许开发者实现自定义计算器。
Android 8 为 AnimatorSet
增加了如下方法:
- reverse(): 反向播放属性动画。
- long getCurrentPlayTime(): 获取动画的当前播放时间。
- setCurrentPlayTime(long playTime): 设置动画的播放时间。
7.6.2 使用属性动画
属性动画可以作用于UI组件,也可以作用于普通对象。使用属性动画有两种主要方式:
- 使用
ValueAnimator
或ObjectAnimator
的静态工厂方法创建动画。 - 使用XML资源文件定义动画。
使用属性动画的步骤如下:
- 创建
ValueAnimator
或ObjectAnimator
对象。 - 根据需要为
Animator
对象设置属性。 - 如果需要监听动画事件,可以为
Animator
对象设置事件监听器。 - 如果有多个动画需要组合,可以使用
AnimatorSet
组合这些动画。 - 调用
Animator
对象的start()
方法启动动画。
示例:小球掉落动画
以下示例展示了如何使用属性动画控制一个小球在屏幕上掉落并消失的动画效果。
public class MainActivity extends Activity {
public static final float BALL_SIZE = 50f;
public static final float FULL_TIME = 1000f;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
LinearLayout container = findViewById(R.id.container);
container.addView(new MyAnimationView(this));
}
static class MyAnimationView extends View implements ValueAnimator.AnimatorUpdateListener {
List<ShapeHolder> balls = new ArrayList<>();
public MyAnimationView(Context context) {
super(context);
setBackgroundColor(Color.WHITE);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() != MotionEvent.ACTION_DOWN && event.getAction() != MotionEvent.ACTION_MOVE) {
return false;
}
ShapeHolder newBall = addBall(event.getX(), event.getY());
float endY = getHeight() - BALL_SIZE;
float startY = newBall.getY();
int duration = (int) (FULL_TIME * ((getHeight() - event.getY()) / getHeight()));
ObjectAnimator fallAnim = ObjectAnimator.ofFloat(newBall, "y", startY, endY);
fallAnim.setDuration(duration);
fallAnim.setInterpolator(new AccelerateInterpolator());
fallAnim.addUpdateListener(this);
ObjectAnimator fadeAnim = ObjectAnimator.ofFloat(newBall, "alpha", 1f, 0f);
fadeAnim.setDuration(250);
fadeAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
balls.remove(((ObjectAnimator) animation).getTarget());
}
});
fadeAnim.addUpdateListener(this);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(fallAnim).before(fadeAnim);
animatorSet.start();
return true;
}
private ShapeHolder addBall(float x, float y) {
OvalShape circle = new OvalShape();
circle.resize(BALL_SIZE, BALL_SIZE);
ShapeDrawable drawable = new ShapeDrawable(circle);
ShapeHolder shapeHolder = new ShapeHolder(drawable);
shapeHolder.setX(x - BALL_SIZE / 2);
shapeHolder.setY(y - BALL_SIZE / 2);
Paint paint = drawable.getPaint();
int color = Color.rgb((int) (Math.random() * 255), (int) (Math.random() * 255), (int) (Math.random() * 255));
paint.setColor(color);
shapeHolder.setPaint(paint);
balls.add(shapeHolder);
return shapeHolder;
}
@Override
protected void onDraw(Canvas canvas) {
for (ShapeHolder shapeHolder : balls) {
canvas.save();
canvas.translate(shapeHolder.getX(), shapeHolder.getY());
shapeHolder.getShape().draw(canvas);
canvas.restore();
}
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
this.invalidate();
}
}
}
示例:大珠小珠落玉盘
在上一个示例的基础上,添加了更多的动画,使小球在底端弹起并产生变形效果:
public class MainActivity extends Activity {
public static final float BALL_SIZE = 50f;
public static final float FULL_TIME = 1000f;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
LinearLayout container = findViewById(R.id.container);
container.addView(new MyAnimationView(this));
}
class MyAnimationView extends View {
List<ShapeHolder> balls = new ArrayList<>();
public MyAnimationView(Context context) {
super(context);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() != MotionEvent.ACTION_DOWN && event.getAction() != MotionEvent.ACTION_MOVE) {
return false;
}
ShapeHolder newBall = addBall(event.getX(), event.getY());
float startY = newBall.getY();
float endY = getHeight() - BALL_SIZE;
int duration = (int) (FULL_TIME * ((getHeight() - event.getY()) / getHeight()));
ObjectAnimator fallAnim = ObjectAnimator.ofFloat(newBall, "y", startY, endY);
fallAnim.setDuration(duration);
fallAnim.setInterpolator(new AccelerateInterpolator());
ObjectAnimator squashAnim1 = ObjectAnimator.ofFloat(newBall, "x", newBall.getX(), newBall.getX() - BALL_SIZE / 2);
squashAnim1.setDuration(duration / 4);
squashAnim1.setRepeatCount(1);
squashAnim1.setRepeatMode(ValueAnimator.REVERSE);
ObjectAnimator squashAnim2 = ObjectAnimator.ofFloat(newBall, "width", newBall.getWidth(), newBall.getWidth() + BALL_SIZE);
squashAnim2.setDuration(duration / 4);
squashAnim2.setRepeatCount(1);
squashAnim2.setRepeatMode(ValueAnimator.REVERSE);
ObjectAnimator stretchAnim1 = ObjectAnimator.ofFloat(newBall, "y", endY, endY + BALL_SIZE / 2);
stretchAnim1.setDuration(duration / 4);
stretchAnim1.setRepeatCount(1);
stretchAnim1.setRepeatMode(ValueAnimator.REVERSE);
ObjectAnimator stretchAnim2 = ObjectAnimator.ofFloat(newBall, "height", newBall.getHeight(), newBall.getHeight() - BALL_SIZE / 2);
stretchAnim2.setDuration(duration / 4);
stretchAnim2.setRepeatCount(1);
stretchAnim2.setRepeatMode(ValueAnimator.REVERSE);
ObjectAnimator bounceBackAnim = ObjectAnimator.ofFloat(newBall, "y", endY, startY);
bounceBackAnim.setDuration(duration);
bounceBackAnim.setInterpolator(new DecelerateInterpolator());
AnimatorSet bouncer = new AnimatorSet();
bouncer.play(fallAnim).before(squashAnim1);
bouncer.play(squashAnim1).with(squashAnim2);
bouncer.play(squashAnim1).with(stretchAnim1);
bouncer.play(squashAnim1).with(stretchAnim2);
bouncer.play(bounceBackAnim).after(stretchAnim2);
ObjectAnimator fadeAnim = ObjectAnimator.ofFloat(newBall, "alpha", 1f, 0f);
fadeAnim.setDuration(250);
fadeAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
balls.remove(((ObjectAnimator) animation).getTarget());
}
});
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(bouncer).before(fadeAnim);
animatorSet.start();
return true;
}
private ShapeHolder addBall(float x, float y) {
OvalShape circle = new OvalShape();
circle.resize(BALL_SIZE, BALL_SIZE);
ShapeDrawable drawable = new ShapeDrawable(circle);
ShapeHolder shapeHolder = new ShapeHolder(drawable);
shapeHolder.setX(x - BALL_SIZE / 2);
shapeHolder.setY(y - BALL_SIZE / 2);
Paint paint = drawable.getPaint();
int color = Color.rgb((int) (Math.random() * 255), (int) (Math.random() * 255), (int) (Math.random() * 255));
paint.setColor(color);
shapeHolder.setPaint(paint);
balls.add(shapeHolder);
return shapeHolder;
}
@Override
protected void onDraw(Canvas canvas) {
for (ShapeHolder shapeHolder : balls) {
canvas.save();
canvas.translate(shapeHolder.getX(), shapeHolder.getY());
shapeHolder.getShape().draw(canvas);
canvas.restore();
}
}
}
}
这段代码通过多种属性动画组合,实现了小球落地、压扁、弹起并渐隐的复杂动画效果,使得动画更加生动逼真。
7.7 使用 SurfaceView 实现动画
虽然前面介绍了大量使用自定义 View 进行绘图的内容,但在某些情况下,View 的绘图机制存在一些缺陷,特别是在涉及到高频率的绘图更新时。为了应对这些问题,Android 提供了一个更适合高效绘图的类:SurfaceView
。SurfaceView
通常与 SurfaceHolder
一起使用,它可以在一个独立的线程中更新绘图,从而提升性能,尤其是在游戏开发中表现更为出色。
7.7.1 SurfaceView 的绘图机制
SurfaceView
与 SurfaceHolder
的结合可以提供更高效的绘图机制。SurfaceHolder
用于控制和管理 SurfaceView
的绘图表面,允许在独立的线程中绘制图像,这样就不会阻塞主 UI 线程。
SurfaceHolder
提供了以下方法来获取 Canvas
对象进行绘图:
Canvas lockCanvas()
:锁定整个SurfaceView
,返回一个Canvas
对象,用于在整个SurfaceView
上绘制。Canvas lockCanvas(Rect dirty)
:锁定SurfaceView
上的一个矩形区域,只在该区域上绘图,返回一个Canvas
对象。
绘图完成后,调用 SurfaceHolder
的 unlockCanvasAndPost(Canvas canvas)
方法来解锁 Canvas
并提交绘制内容。
示例:使用 SurfaceView 实现动画
下面的代码展示了如何使用 SurfaceView
实现一个简单的动画,模拟鱼在水中游动的效果。
public class FishView extends SurfaceView implements SurfaceHolder.Callback {
private UpdateViewThread updateThread;
private boolean hasSurface;
private Bitmap back;
private Bitmap[] fishs = new Bitmap[10];
private int fishIndex;
private float initX;
private float initY = 500f;
private float fishX;
private float fishY = initY;
private float fishSpeed = 12f;
private int fishAngle = new Random().nextInt(60);
private Matrix matrix = new Matrix();
public FishView(Context ctx, AttributeSet set) {
super(ctx, set);
getHolder().addCallback(this);
back = BitmapFactory.decodeResource(ctx.getResources(), R.drawable.fishbg);
for (int i = 0; i <= 9; i++) {
try {
int fishId = (int) R.drawable.class.getField("fish" + i).get(null);
fishs[i] = BitmapFactory.decodeResource(ctx.getResources(), fishId);
} catch (Exception e) {
e.printStackTrace();
}
}
}
private void resume() {
if (updateThread == null) {
updateThread = new UpdateViewThread();
if (hasSurface) updateThread.start();
}
}
private void pause() {
if (updateThread != null) {
updateThread.requestExitAndWait();
updateThread = null;
}
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
initX = getWidth() + 50;
fishX = initX;
hasSurface = true;
resume();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
hasSurface = false;
pause();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
if (updateThread != null) updateThread.onWindowResize(w, h);
}
class UpdateViewThread extends Thread {
private boolean done;
@Override
public void run() {
SurfaceHolder surfaceHolder = FishView.this.getHolder();
while (!done) {
Canvas canvas = surfaceHolder.lockCanvas();
canvas.drawBitmap(back, 0, 0, null);
if (fishX < -100) {
fishX = initX;
fishY = initY;
fishAngle = new Random().nextInt(60);
}
matrix.reset();
matrix.setRotate(fishAngle);
fishX -= fishSpeed * Math.cos(Math.toRadians(fishAngle));
fishY -= fishSpeed * Math.sin(Math.toRadians(fishAngle));
matrix.postTranslate(fishX, fishY);
canvas.drawBitmap(fishs[fishIndex++ % fishs.length], matrix, null);
surfaceHolder.unlockCanvasAndPost(canvas);
try {
Thread.sleep(60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
void requestExitAndWait() {
done = true;
try {
join();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
void onWindowResize(int w, int h) {
System.out.println("w:" + w + " h:" + h);
}
}
}
在这个示例中,FishView
类继承了 SurfaceView
并实现了 SurfaceHolder.Callback
接口。通过重写 surfaceCreated
、surfaceDestroyed
和 surfaceChanged
方法,程序可以在 SurfaceView
创建、销毁和大小改变时执行相应的操作。动画的更新逻辑由 UpdateViewThread
线程处理,这个线程不断地在 SurfaceView
上绘制鱼的动画帧。
示例:基于 SurfaceView 开发示波器
SurfaceView 非常适合处理需要频繁更新的图形界面,如游戏或模拟器。下面的代码展示了如何使用 SurfaceView
开发一个简单的示波器应用,根据用户选择绘制正弦波或余弦波。
public class MainActivity extends Activity {
private SurfaceHolder holder;
private Paint paint = new Paint();
int HEIGHT = 400;
int screenWidth;
int X_OFFSET = 5;
int cx = X_OFFSET;
int centerY = HEIGHT / 2;
Timer timer = new Timer();
TimerTask task;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DisplayMetrics metrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metrics);
screenWidth = metrics.widthPixels;
SurfaceView surface = findViewById(R.id.show);
holder = surface.getHolder();
paint.setColor(Color.GREEN);
paint.setStrokeWidth(getResources().getDimension(R.dimen.stroke_width));
Button sin = findViewById(R.id.sin);
Button cos = findViewById(R.id.cos);
View.OnClickListener listener = source -> {
drawBack(holder);
cx = X_OFFSET;
if (task != null) task.cancel();
task = new TimerTask() {
@Override
public void run() {
int cy = source.getId() == R.id.sin ? centerY - (int)(100 * Math.sin((cx - 5) * 2 * Math.PI / 150))
: centerY - (int)(100 * Math.cos((cx - 5) * 2 * Math.PI / 150));
Canvas canvas = holder.lockCanvas(new Rect(cx, cy, cx + (int)paint.getStrokeWidth(), cy + (int)paint.getStrokeWidth()));
canvas.drawPoint(cx, cy, paint);
cx += 2;
if (cx > screenWidth) {
task.cancel();
task = null;
}
holder.unlockCanvasAndPost(canvas);
}
};
timer.schedule(task, 0, 10);
};
sin.setOnClickListener(listener);
cos.setOnClickListener(listener);
holder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
drawBack(holder);
}
@Override
public void surfaceCreated(SurfaceHolder holder) { }
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
timer.cancel();
}
});
}
private void drawBack(SurfaceHolder holder) {
Canvas canvas = holder.lockCanvas();
canvas.drawColor(Color.WHITE);
paint.setColor(Color.BLACK);
canvas.drawLine(X_OFFSET, centerY, screenWidth, centerY, paint);
canvas.drawLine(X_OFFSET, 40f, X_OFFSET, HEIGHT, paint);
holder.unlockCanvasAndPost(canvas);
holder.lockCanvas(new Rect(0, 0, 0, 0));
holder.unlockCanvasAndPost(canvas);
}
}
在这个示例中,程序通过用户点击按钮来绘制正弦波或余弦波。每次绘制时,程序只会更新当前的绘制点,而不会重绘整个波形,这样可以显著提升绘图性能。运行该程序后,用户可以在屏幕上看到波形不断变化,如同一个简单的示波器。
SurfaceView
的强大之处在于它能够处理高频率的绘图操作,同时不会阻塞主线程,适用于实现复杂的动画和游戏场景。
7.8 本章小结
本章主要介绍了 Android 的图形与图像处理技术,这些内容不仅在 Android 界面开发中占据重要地位,也是开发 Android 2D 游戏的基础。本章的学习内容包括:
-
绘图 API:
- 掌握了 Canvas、Paint、Path 等类的使用方法,这些都是 Android 绘图的核心类,能够实现各种图形、图像的绘制与处理。
-
双缓冲机制:
- 学习了 Android 中的双缓冲机制,这对提高绘图性能,减少屏幕闪烁,尤为重要。
-
Matrix 几何变换:
- 介绍了如何利用 Matrix 对图形进行平移、旋转、缩放和倾斜等几何变换,这些操作对图形的动态处理具有重要意义。
-
动画支持:
- Android 提供了多种动画机制,包括逐帧动画、补间动画和属性动画。
- 尤其是属性动画,它不仅增强了补间动画的功能,还支持对任意对象的属性进行动画处理,这在开发复杂动画效果时非常有用。
-
SurfaceView:
- 为了更好地开发游戏动画界面,Android 提供了 SurfaceView。本章详细介绍了 SurfaceView 的绘图机制,讲解了如何继承 SurfaceView 来开发高性能的动画应用。
通过本章的学习,读者应掌握 Android 中丰富的图形与动画处理技术,为后续的高级应用开发打下坚实基础。