在画布对象上绘制

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

简介

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

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

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

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

您对 Android 绘图的理解如何(超级简化!)

在 Android 中或任何其他现代系统中绘制图像是一个复杂的过程,包括抽象层以及向下到硬件的优化。Android 如何绘制是一个很有趣的主题,关于它编写了哪些代码,它的详细信息不在此 Codelab 的范围内。

在此 Codelab 及其在画布上以全屏视图显示的应用环境中,您可以通过以下方式进行解读。

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

您应当已掌握的内容

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

学习内容

  • 如何创建 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,然后再创建下一个。
if (::extraBitmap.isInitialized) extraBitmap.recycle()

第 2 步:替换 onDraw()

MyCanvasView的所有绘制工作都发生在 onDraw() 中。

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

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


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

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

为了进行绘制,您需要一个指定绘制对象样式的 Paint 对象,以及一个指定绘制对象的 Path

第 1 步:初始化绘制对象

  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 可使绘制的边缘平滑,而不会影响形状。
  • true 时,isDither 会影响精度高于设备降采样的颜色的方式。例如,抖动是将图片颜色范围缩小到 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 变量,以缓存当前触摸事件的 x 坐标和 y 坐标(MotionEvent 坐标)。将它们初始化为 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 着陆页。