在画布对象上绘制

此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。

简介

在 Android 中,您可以使用多种技术在视图中实现自定义 2D 图形和动画。

除了使用 drawable 之外,您还可以使用 Canvas 类的绘制方法来创建 2D 绘制。Canvas 是一个提供绘制方法的 2D 绘制界面。当应用需要定期重新绘制自身时,此方法非常有用,因为用户看到的内容会随时间变化。在此 Codelab 中,您将学习如何创建画布并在 View 中显示的画布上进行绘制。

您可以在画布上执行的操作类型包括:

  • 用颜色填充整个画布。
  • 绘制形状,例如矩形、弧形和路径,样式由 Paint 对象中的定义决定。Paint 对象包含有关如何绘制几何图形(例如线条、矩形、椭圆和路径)的样式和颜色信息,或者包含文本的字体信息。
  • 应用平移、缩放或自定义转换等转换。
  • 裁剪,即向画布应用形状或路径,以定义其可见部分。

Android 绘制的简单理解

在 Android 或任何其他现代系统上进行绘制都是一个复杂的过程,其中包含多层抽象和优化,一直到硬件。Android 的绘制方式是一个非常有趣的主题,相关文章有很多,但其详细信息超出了本 Codelab 的范围。

在此 Codelab 及其在画布上绘制以在全屏视图中显示的应用的上下文中,您可以按以下方式考虑它。

  1. 您需要一个视图来显示您绘制的内容。这可能是 Android 系统提供的视图之一。或者,在此 Codelab 中,您会创建一个自定义视图,用作应用的内容视图 (MyCanvasView)。
  2. 与所有视图一样,此视图也自带画布 (canvas)。
  3. 如需以最基本的方式在视图的画布上绘制内容,您可以替换视图的 onDraw() 方法,然后在画布上绘制内容。
  4. 在构建绘制内容时,您需要缓存之前绘制的内容。您可以通过多种方式缓存数据,一种方式是使用位图 (extraBitmap)。另一种方式是将您绘制的内容的历史记录保存为坐标和指令。
  5. 如需使用画布绘制 API 绘制到缓存位图 (extraBitmap),请为缓存位图创建缓存画布 (extraCanvas)。
  6. 然后,您在缓存画布 (extraCanvas) 上绘制,这会绘制到缓存位图 (extraBitmap) 上。
  7. 如需显示屏幕上绘制的所有内容,您需要告知视图的画布 (canvas) 绘制缓存位图 (extraBitmap)。

您应当已掌握的内容

  • 如何使用 Android Studio 打造包含 activity 和基本布局的应用,以及如何运行该应用。
  • 如何将事件处理脚本与视图相关联。
  • 如何创建自定义视图。

学习内容

  • 如何创建 Canvas 并根据用户触摸在其中绘制内容。

您将执行的操作

  • 创建一个应用,该应用会在用户触摸屏幕时在屏幕上绘制线条。
  • 捕获运动事件,并相应地在画布上绘制线条,该画布显示在屏幕上的全屏自定义视图中。

MiniPaint 应用使用自定义视图来显示响应用户触摸操作的线条,如下面的屏幕截图所示。

第 1 步:创建 MiniPaint 项目

  1. 使用 Empty Activity 模板创建一个名为 MiniPaint 的新 Kotlin 项目。
  2. 打开 app/res/values/colors.xml 文件并添加以下两种颜色。
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
  1. 打开“styles.xml
  2. 在给定 AppTheme 样式的父级中,将 DarkActionBar 替换为 NoActionBar。这样会移除操作栏,以便您可以绘制全屏。
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">

第 2 步。创建 MyCanvasView 类

在此步骤中,您将创建一个用于绘制的自定义视图 MyCanvasView

  1. app/java/com.example.android.minipaint 软件包中,创建一个名为 MyCanvasViewNew > Kotlin File/Class
  2. 使 MyCanvasView 类扩展 View 类并传入 context: Context。接受建议的导入内容。
import android.content.Context
import android.view.View

class MyCanvasView(context: Context) : View(context) {
}

第 3 步:将 MyCanvasView 设置为内容视图

如需显示您将在 MyCanvasView 中绘制的内容,您必须将其设置为 MainActivity 的内容视图。

  1. 打开 strings.xml 并定义一个字符串,以用于视图的内容说明。
<string name="canvasContentDescription">Mini Paint is a simple line drawing app.
   Drag your fingers to draw. Rotate the phone to clear.</string>
  1. 打开“MainActivity.kt
  2. onCreate() 中,删除 setContentView(R.layout.activity_main)
  3. 创建 MyCanvasView 的实例。
val myCanvasView = MyCanvasView(this)
  1. 在此之下,请求 myCanvasView 的布局全屏显示。为此,请在 myCanvasView 上设置 SYSTEM_UI_FLAG_FULLSCREEN 标志。这样,视图就会完全填满屏幕。
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN
  1. 添加内容说明。
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
  1. 在此之下,将内容视图设置为 myCanvasView
setContentView(myCanvasView)
  1. 运行您的应用。您会看到一个完全空白的屏幕,因为画布没有大小,并且您尚未绘制任何内容。

第 1 步:替换 onSizeChanged()

每当视图大小发生变化时,Android 系统都会调用 onSizeChanged() 方法。由于视图一开始没有大小,因此在 activity 首次创建并扩充视图后,也会调用视图的 onSizeChanged() 方法。因此,此 onSizeChanged() 方法是创建和设置视图画布的理想位置。

  1. MyCanvasView 中,在类级别定义画布和位图的变量。分别称为 extraCanvasextraBitmap。这些是用于缓存之前绘制的内容的位图和画布。
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap
  1. 为画布的背景颜色定义一个类级变量 backgroundColor,并将其初始化为您之前定义的 colorBackground
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)
  1. MyCanvasView 中,替换 onSizeChanged() 方法。此回调方法由 Android 系统调用,并提供已更改的屏幕尺寸,即新宽度和高度(要更改为)以及旧宽度和高度(要更改自)。
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
   super.onSizeChanged(width, height, oldWidth, oldHeight)
}
  1. onSizeChanged() 内,使用新的宽度和高度(即屏幕尺寸)创建 Bitmap 的实例,并将其分配给 extraBitmap。第三个实参是位图颜色配置ARGB_8888 以 4 字节存储每种颜色,建议使用。
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  1. extraBitmap 创建 Canvas 实例并将其分配给 extraCanvas
 extraCanvas = Canvas(extraBitmap)
  1. 指定要填充 extraCanvas 的背景颜色。
extraCanvas.drawColor(backgroundColor)
  1. onSizeChanged() 可以看出,每次执行该函数时都会创建一个新的位图和画布。您需要新的位图,因为大小已发生变化。不过,这会导致内存泄漏,使旧位图保留在内存中。为解决此问题,请在调用 super 后立即添加以下代码,以在创建下一个 extraBitmap 之前回收当前 extraBitmap
if (::extraBitmap.isInitialized) extraBitmap.recycle()

第 2 步。替换 onDraw()

MyCanvasView 的所有绘制工作都在 onDraw() 中进行。

首先,显示画布,并使用您在 onSizeChanged() 中设置的背景颜色填充屏幕。

  1. 替换 onDraw(),并在与视图关联的画布上绘制缓存的 extraBitmap 的内容。drawBitmap() Canvas 方法有多个版本。在此代码中,您需要提供位图、左上角的 x 和 y 坐标(以像素为单位),以及 null(用于 Paint),因为您稍后会设置该值。
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}


请注意,传递给 onDraw() 并由系统用于显示位图的画布与您在 onSizeChanged() 方法中创建并用于在位图上绘制的画布不同。

  1. 运行应用。您应该会看到整个屏幕都填充了指定的背景颜色。

为了进行绘制,您需要一个 Paint 对象(用于指定绘制时的样式)和一个 Path 对象(用于指定要绘制的内容)。

第 1 步:初始化 Paint 对象

  1. MyCanvasView.kt 中,在文件级顶部定义描边宽度的常量。
private const val STROKE_WIDTH = 12f // has to be float
  1. MyCanvasView 的类级别,定义一个用于保存绘制颜色的变量 drawColor,并使用您之前定义的 colorPaint 资源对其进行初始化。
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)
  1. 在类级别,在下方添加一个用于 Paint 对象的变量 paint,并按如下方式对其进行初始化。
// Set up the paint with which to draw.
private val paint = Paint().apply {
   color = drawColor
   // Smooths out edges of what is drawn without affecting shape.
   isAntiAlias = true
   // Dithering affects how colors with higher-precision than the device are down-sampled.
   isDither = true
   style = Paint.Style.STROKE // default: FILL
   strokeJoin = Paint.Join.ROUND // default: MITER
   strokeCap = Paint.Cap.ROUND // default: BUTT
   strokeWidth = STROKE_WIDTH // default: Hairline-width (really thin)
}
  • paintcolor 是您之前定义的 drawColor
  • isAntiAlias 用于定义是否应用边缘平滑。将 isAntiAlias 设置为 true 可平滑绘制内容的边缘,而不会影响形状。
  • isDither,当值为 true 时,会影响如何对精度高于设备的颜色进行降采样。例如,抖动是将图片颜色范围减少到 256 种(或更少)颜色的最常见方法。
  • style 用于设置要对描边(本质上是一条线)进行的绘制类型。Paint.Style 指定所绘制的图元是填充、描边还是两者兼具(采用相同颜色)。默认情况下,系统会填充应用了颜料的对象。(“填充”用于为形状内部着色,而“描边”用于为形状轮廓着色。)
  • Paint.JoinstrokeJoin 指定线条和曲线段在描边路径上的连接方式。默认值为 MITER
  • strokeCap 将线条末端的形状设置为端帽。Paint.Cap 用于指定描绘的线条和路径的开头和结尾。默认值为 BUTT
  • strokeWidth,用于指定描边的宽度(以像素为单位)。默认值为极细的细线宽度,因此将其设置为您之前定义的 STROKE_WIDTH 常量。

第 2 步。初始化 Path 对象

Path 是用户绘制的内容的路径。

  1. MyCanvasView 中,添加一个变量 path 并使用 Path 对象对其进行初始化,以存储在屏幕上跟随用户触摸绘制的路径。为 Path 导入 android.graphics.Path
private var path = Path()

第 1 步:响应显示屏上的动作

每当用户触摸显示屏时,系统都会调用视图的 onTouchEvent() 方法。

  1. MyCanvasView 中,替换 onTouchEvent() 方法以缓存传入的 eventxy 坐标。然后,使用 when 表达式来处理在屏幕上按下、在屏幕上移动和在屏幕上松开触控的动作事件。这些是用于在屏幕上绘制线条的相关事件。对于每种事件类型,请调用一个实用程序方法,如下面的代码所示。如需查看完整的触控事件列表,请参阅 MotionEvent 类的文档。
override fun onTouchEvent(event: MotionEvent): Boolean {
   motionTouchEventX = event.x
   motionTouchEventY = event.y

   when (event.action) {
       MotionEvent.ACTION_DOWN -> touchStart()
       MotionEvent.ACTION_MOVE -> touchMove()
       MotionEvent.ACTION_UP -> touchUp()
   }
   return true
}
  1. 在类级别,添加了缺少的 motionTouchEventXmotionTouchEventY 变量,用于缓存当前触摸事件(即 MotionEvent 坐标)的 x 和 y 坐标。将它们初始化为 0f
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f
  1. 为三个函数 touchStart()touchMove()touchUp() 创建桩。
private fun touchStart() {}

private fun touchMove() {}

private fun touchUp() {}
  1. 您的代码应该可以构建和运行,但您目前不会看到任何与彩色背景不同的内容。

第 2 步。实现 touchStart()

当用户首次触摸屏幕时,系统会调用此方法。

  1. 在类级别,添加变量以缓存最新的 x 和 y 值。在用户停止移动并抬起手指后,这些点将成为下一条路径(要绘制的下一段线条)的起点。
private var currentX = 0f
private var currentY = 0f
  1. 按如下方式实现 touchStart() 方法。重置 path,移动到触摸事件的 x-y 坐标(motionTouchEventXmotionTouchEventY),并将 currentXcurrentY 分配给该值。
private fun touchStart() {
   path.reset()
   path.moveTo(motionTouchEventX, motionTouchEventY)
   currentX = motionTouchEventX
   currentY = motionTouchEventY
}

第 3 步。实现 touchMove()

  1. 在类级别,添加一个 touchTolerance 变量并将其设置为 ViewConfiguration.get(context).scaledTouchSlop
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop

使用路径时,无需绘制每个像素,也无需每次都请求刷新显示。相反,您可以(并且将会)在点之间插值路径,以获得更好的性能。

  • 如果手指几乎没有移动,则无需绘制。
  • 如果手指移动的距离小于 touchTolerance,则不进行绘制。
  • scaledTouchSlop 会返回在系统认为用户正在滚动之前,触摸可以滑动的距离(以像素为单位)。
  1. 定义 touchMove() 方法。计算行驶距离(dxdy),在两点之间创建曲线并将其存储在 path 中,更新正在运行的 currentXcurrentY 统计信息,并绘制 path。然后调用 invalidate() 以强制使用更新后的 path 重新绘制屏幕。
private fun touchMove() {
   val dx = Math.abs(motionTouchEventX - currentX)
   val dy = Math.abs(motionTouchEventY - currentY)
   if (dx >= touchTolerance || dy >= touchTolerance) {
       // QuadTo() adds a quadratic bezier from the last point,
       // approaching control point (x1,y1), and ending at (x2,y2).
       path.quadTo(currentX, currentY, (motionTouchEventX + currentX) / 2, (motionTouchEventY + currentY) / 2)
       currentX = motionTouchEventX
       currentY = motionTouchEventY
       // Draw the path in the extra bitmap to cache it.
       extraCanvas.drawPath(path, paint)
   }
   invalidate()
}

此方法的详细说明:

  1. 计算已移动的距离 (dx, dy)。
  2. 如果移动距离超过触控容差,则向路径添加一个线段。
  3. 将下一个片段的起点设置为此片段的终点。
  4. 使用 quadTo() 而不是 lineTo() 可创建平滑的直线,而不会出现拐角。请参阅贝塞尔曲线
  5. 调用 invalidate() 以(最终调用 onDraw() 并)重新绘制视图。

第 4 步:实现 touchUp()

当用户抬起手指时,只需重置路径,使其不再被绘制。未绘制任何内容,因此无需失效。

  1. 实现 touchUp() 方法。
private fun touchUp() {
   // Reset the path so it doesn't get drawn again.
   path.reset()
}
  1. 运行代码,然后用手指在屏幕上绘图。请注意,如果您旋转设备,屏幕会清空,因为绘制状态未保存。对于此示例应用,这是有意为之,旨在为用户提供一种简单的方法来清除屏幕。

第 5 步:在草图周围绘制一个框架

当用户在屏幕上绘制时,应用会构建路径并将其保存在位图 extraBitmap 中。onDraw() 方法会在视图的画布中显示额外的位图。您可以在 onDraw() 中进行更多绘画。例如,您可以在绘制位图后绘制形状。

在此步骤中,您将在图片边缘周围绘制一个边框。

  1. MyCanvasView 中,添加一个名为 frame 的变量,用于保存 Rect 对象。
private lateinit var frame: Rect
  1. onSizeChanged() 的末尾定义一个边衬区,并添加代码以使用新尺寸和边衬区创建将用于框架的 Rect
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)
  1. onDraw() 中,绘制位图后,绘制一个矩形。
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
  1. 运行应用。注意帧。

任务(可选):在路径中存储数据

在当前应用中,绘制信息存储在位图中。虽然这是一个不错的解决方案,但它并不是存储绘制信息的唯一可能方式。存储绘画历史记录的方式取决于应用和您的各种需求。例如,如果您要绘制形状,可以保存包含形状及其位置和尺寸的列表。对于 MiniPaint 应用,您可以将路径保存为 Path。如果您想尝试此操作,请参阅下面的大致步骤。

  1. MyCanvasView 中,移除 extraCanvasextraBitmap 的所有代码。
  2. 为到目前为止的路径和当前正在绘制的路径添加变量。
// Path representing the drawing so far
private val drawing = Path()

// Path representing what's currently being drawn
private val curPath = Path()
  1. onDraw() 中,绘制存储的路径和当前路径,而不是绘制位图。
// Draw the drawing so far
canvas.drawPath(drawing, paint)
// Draw any current squiggle
canvas.drawPath(curPath, paint)
// Draw a frame around the canvas
canvas.drawRect(frame, paint)
  1. touchUp() 中,将当前路径添加到上一个路径,然后重置当前路径。
// Add the current path to the drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()
  1. 运行应用,是的,应该没有任何区别。

下载已完成的 Codelab 的代码。

$  git clone https://github.com/googlecodelabs/android-kotlin-drawing-canvas


或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

下载 Zip 文件

  • Canvas 是一个提供绘制方法的 2D 绘制界面。
  • Canvas 可以与显示它的 View 实例相关联。
  • Paint 对象包含有关如何绘制几何图形(例如线条、矩形、椭圆和路径)和文本的样式和颜色信息。
  • 使用画布的常见模式是创建自定义视图并替换 onDraw()onSizeChanged() 方法。
  • 替换 onTouchEvent() 方法以捕获用户触摸操作,并通过绘制内容来响应这些操作。
  • 您可以使用额外的位图来缓存随时间变化的绘制信息。或者,您也可以存储形状或路径。

Udacity 课程:

Android 开发者文档:

此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:

  • 根据需要布置作业。
  • 告知学生如何提交家庭作业。
  • 给家庭作业评分。

讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。

如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。

回答以下问题

问题 1

以下哪些组件是使用 Canvas 所必需的?请选择所有适用的选项。

Bitmap

Paint

Path

View

问题 2

invalidate() 的调用(一般而言)会执行什么操作?

▢ 使应用失效并重新启动。

▢ 从位图中擦除绘制内容。

▢ 表示不应运行之前的代码。

▢ 告知系统必须重新绘制屏幕。

问题 3

CanvasBitmapPaint 对象有何作用?

▢ 2D 绘制表面、屏幕上显示的位图、绘制样式信息。

▢ 3D 绘制表面、用于缓存路径的位图、用于绘制的样式信息。

▢ 2D 绘图表面、屏幕上显示的位图、视图的样式。

▢ 用于绘制信息的缓存、要绘制的位图、用于绘制的样式信息。

如需本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页。