此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。
简介
在 Android 中,您可以使用多种技术在视图中实现自定义 2D 图形和动画。
除了使用 drawable 之外,您还可以使用 Canvas 类的绘制方法来创建 2D 绘制。Canvas 是一个提供绘制方法的 2D 绘制界面。当应用需要定期重新绘制自身时,此方法非常有用,因为用户看到的内容会随时间变化。在此 Codelab 中,您将学习如何创建画布并在 View 中显示的画布上进行绘制。
您可以在画布上执行的操作类型包括:
- 用颜色填充整个画布。
- 绘制形状,例如矩形、弧形和路径,样式由
Paint对象中的定义决定。Paint对象包含有关如何绘制几何图形(例如线条、矩形、椭圆和路径)的样式和颜色信息,或者包含文本的字体信息。 - 应用平移、缩放或自定义转换等转换。
- 裁剪,即向画布应用形状或路径,以定义其可见部分。

Android 绘制的简单理解
在 Android 或任何其他现代系统上进行绘制都是一个复杂的过程,其中包含多层抽象和优化,一直到硬件。Android 的绘制方式是一个非常有趣的主题,相关文章有很多,但其详细信息超出了本 Codelab 的范围。
在此 Codelab 及其在画布上绘制以在全屏视图中显示的应用的上下文中,您可以按以下方式考虑它。

- 您需要一个视图来显示您绘制的内容。这可能是 Android 系统提供的视图之一。或者,在此 Codelab 中,您会创建一个自定义视图,用作应用的内容视图 (
MyCanvasView)。 - 与所有视图一样,此视图也自带画布 (
canvas)。 - 如需以最基本的方式在视图的画布上绘制内容,您可以替换视图的
onDraw()方法,然后在画布上绘制内容。 - 在构建绘制内容时,您需要缓存之前绘制的内容。您可以通过多种方式缓存数据,一种方式是使用位图 (
extraBitmap)。另一种方式是将您绘制的内容的历史记录保存为坐标和指令。 - 如需使用画布绘制 API 绘制到缓存位图 (
extraBitmap),请为缓存位图创建缓存画布 (extraCanvas)。 - 然后,您在缓存画布 (
extraCanvas) 上绘制,这会绘制到缓存位图 (extraBitmap) 上。 - 如需显示屏幕上绘制的所有内容,您需要告知视图的画布 (
canvas) 绘制缓存位图 (extraBitmap)。
您应当已掌握的内容
- 如何使用 Android Studio 打造包含 activity 和基本布局的应用,以及如何运行该应用。
- 如何将事件处理脚本与视图相关联。
- 如何创建自定义视图。
学习内容
- 如何创建
Canvas并根据用户触摸在其中绘制内容。
您将执行的操作
- 创建一个应用,该应用会在用户触摸屏幕时在屏幕上绘制线条。
- 捕获运动事件,并相应地在画布上绘制线条,该画布显示在屏幕上的全屏自定义视图中。
MiniPaint 应用使用自定义视图来显示响应用户触摸操作的线条,如下面的屏幕截图所示。

第 1 步:创建 MiniPaint 项目
- 使用 Empty Activity 模板创建一个名为 MiniPaint 的新 Kotlin 项目。
- 打开
app/res/values/colors.xml文件并添加以下两种颜色。
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>- 打开“
styles.xml” - 在给定
AppTheme样式的父级中,将DarkActionBar替换为NoActionBar。这样会移除操作栏,以便您可以绘制全屏。
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">第 2 步。创建 MyCanvasView 类
在此步骤中,您将创建一个用于绘制的自定义视图 MyCanvasView。
- 在
app/java/com.example.android.minipaint软件包中,创建一个名为MyCanvasView的 New > Kotlin File/Class。 - 使
MyCanvasView类扩展View类并传入context: Context。接受建议的导入内容。
import android.content.Context
import android.view.View
class MyCanvasView(context: Context) : View(context) {
}第 3 步:将 MyCanvasView 设置为内容视图
如需显示您将在 MyCanvasView 中绘制的内容,您必须将其设置为 MainActivity 的内容视图。
- 打开
strings.xml并定义一个字符串,以用于视图的内容说明。
<string name="canvasContentDescription">Mini Paint is a simple line drawing app.
Drag your fingers to draw. Rotate the phone to clear.</string>- 打开“
MainActivity.kt” - 在
onCreate()中,删除setContentView(R.layout.activity_main)。 - 创建
MyCanvasView的实例。
val myCanvasView = MyCanvasView(this)- 在此之下,请求
myCanvasView的布局全屏显示。为此,请在myCanvasView上设置SYSTEM_UI_FLAG_FULLSCREEN标志。这样,视图就会完全填满屏幕。
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN- 添加内容说明。
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)- 在此之下,将内容视图设置为
myCanvasView。
setContentView(myCanvasView)- 运行您的应用。您会看到一个完全空白的屏幕,因为画布没有大小,并且您尚未绘制任何内容。
第 1 步:替换 onSizeChanged()
每当视图大小发生变化时,Android 系统都会调用 onSizeChanged() 方法。由于视图一开始没有大小,因此在 activity 首次创建并扩充视图后,也会调用视图的 onSizeChanged() 方法。因此,此 onSizeChanged() 方法是创建和设置视图画布的理想位置。
- 在
MyCanvasView中,在类级别定义画布和位图的变量。分别称为extraCanvas和extraBitmap。这些是用于缓存之前绘制的内容的位图和画布。
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap- 为画布的背景颜色定义一个类级变量
backgroundColor,并将其初始化为您之前定义的colorBackground。
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)- 在
MyCanvasView中,替换onSizeChanged()方法。此回调方法由 Android 系统调用,并提供已更改的屏幕尺寸,即新宽度和高度(要更改为)以及旧宽度和高度(要更改自)。
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
}- 在
onSizeChanged()内,使用新的宽度和高度(即屏幕尺寸)创建Bitmap的实例,并将其分配给extraBitmap。第三个实参是位图颜色配置。ARGB_8888以 4 字节存储每种颜色,建议使用。
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)- 从
extraBitmap创建Canvas实例并将其分配给extraCanvas。
extraCanvas = Canvas(extraBitmap)- 指定要填充
extraCanvas的背景颜色。
extraCanvas.drawColor(backgroundColor)- 从
onSizeChanged()可以看出,每次执行该函数时都会创建一个新的位图和画布。您需要新的位图,因为大小已发生变化。不过,这会导致内存泄漏,使旧位图保留在内存中。为解决此问题,请在调用super后立即添加以下代码,以在创建下一个extraBitmap之前回收当前extraBitmap。
if (::extraBitmap.isInitialized) extraBitmap.recycle()第 2 步。替换 onDraw()
MyCanvasView 的所有绘制工作都在 onDraw() 中进行。
首先,显示画布,并使用您在 onSizeChanged() 中设置的背景颜色填充屏幕。
- 替换
onDraw(),并在与视图关联的画布上绘制缓存的extraBitmap的内容。drawBitmap()Canvas方法有多个版本。在此代码中,您需要提供位图、左上角的 x 和 y 坐标(以像素为单位),以及null(用于Paint),因为您稍后会设置该值。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}
请注意,传递给 onDraw() 并由系统用于显示位图的画布与您在 onSizeChanged() 方法中创建并用于在位图上绘制的画布不同。
- 运行应用。您应该会看到整个屏幕都填充了指定的背景颜色。

为了进行绘制,您需要一个 Paint 对象(用于指定绘制时的样式)和一个 Path 对象(用于指定要绘制的内容)。
第 1 步:初始化 Paint 对象
- 在 MyCanvasView.kt 中,在文件级顶部定义描边宽度的常量。
private const val STROKE_WIDTH = 12f // has to be float- 在
MyCanvasView的类级别,定义一个用于保存绘制颜色的变量drawColor,并使用您之前定义的colorPaint资源对其进行初始化。
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)- 在类级别,在下方添加一个用于
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)
}paint的color是您之前定义的drawColor。isAntiAlias用于定义是否应用边缘平滑。将isAntiAlias设置为true可平滑绘制内容的边缘,而不会影响形状。isDither,当值为true时,会影响如何对精度高于设备的颜色进行降采样。例如,抖动是将图片颜色范围减少到 256 种(或更少)颜色的最常见方法。style用于设置要对描边(本质上是一条线)进行的绘制类型。Paint.Style指定所绘制的图元是填充、描边还是两者兼具(采用相同颜色)。默认情况下,系统会填充应用了颜料的对象。(“填充”用于为形状内部着色,而“描边”用于为形状轮廓着色。)Paint.Join的strokeJoin指定线条和曲线段在描边路径上的连接方式。默认值为MITER。strokeCap将线条末端的形状设置为端帽。Paint.Cap用于指定描绘的线条和路径的开头和结尾。默认值为BUTT。strokeWidth,用于指定描边的宽度(以像素为单位)。默认值为极细的细线宽度,因此将其设置为您之前定义的STROKE_WIDTH常量。
第 2 步。初始化 Path 对象
Path 是用户绘制的内容的路径。
- 在
MyCanvasView中,添加一个变量path并使用Path对象对其进行初始化,以存储在屏幕上跟随用户触摸绘制的路径。为Path导入android.graphics.Path。
private var path = Path()第 1 步:响应显示屏上的动作
每当用户触摸显示屏时,系统都会调用视图的 onTouchEvent() 方法。
- 在
MyCanvasView中,替换onTouchEvent()方法以缓存传入的event的x和y坐标。然后,使用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
}- 在类级别,添加了缺少的
motionTouchEventX和motionTouchEventY变量,用于缓存当前触摸事件(即MotionEvent坐标)的 x 和 y 坐标。将它们初始化为0f。
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f- 为三个函数
touchStart()、touchMove()和touchUp()创建桩。
private fun touchStart() {}
private fun touchMove() {}
private fun touchUp() {}- 您的代码应该可以构建和运行,但您目前不会看到任何与彩色背景不同的内容。
第 2 步。实现 touchStart()
当用户首次触摸屏幕时,系统会调用此方法。
- 在类级别,添加变量以缓存最新的 x 和 y 值。在用户停止移动并抬起手指后,这些点将成为下一条路径(要绘制的下一段线条)的起点。
private var currentX = 0f
private var currentY = 0f- 按如下方式实现
touchStart()方法。重置path,移动到触摸事件的 x-y 坐标(motionTouchEventX和motionTouchEventY),并将currentX和currentY分配给该值。
private fun touchStart() {
path.reset()
path.moveTo(motionTouchEventX, motionTouchEventY)
currentX = motionTouchEventX
currentY = motionTouchEventY
}第 3 步。实现 touchMove()
- 在类级别,添加一个
touchTolerance变量并将其设置为ViewConfiguration.get(context).scaledTouchSlop。
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop使用路径时,无需绘制每个像素,也无需每次都请求刷新显示。相反,您可以(并且将会)在点之间插值路径,以获得更好的性能。
- 如果手指几乎没有移动,则无需绘制。
- 如果手指移动的距离小于
touchTolerance,则不进行绘制。 scaledTouchSlop会返回在系统认为用户正在滚动之前,触摸可以滑动的距离(以像素为单位)。
- 定义
touchMove()方法。计算行驶距离(dx、dy),在两点之间创建曲线并将其存储在path中,更新正在运行的currentX和currentY统计信息,并绘制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()
}此方法的详细说明:
- 计算已移动的距离 (
dx, dy)。 - 如果移动距离超过触控容差,则向路径添加一个线段。
- 将下一个片段的起点设置为此片段的终点。
- 使用
quadTo()而不是lineTo()可创建平滑的直线,而不会出现拐角。请参阅贝塞尔曲线。 - 调用
invalidate()以(最终调用onDraw()并)重新绘制视图。
第 4 步:实现 touchUp()
当用户抬起手指时,只需重置路径,使其不再被绘制。未绘制任何内容,因此无需失效。
- 实现
touchUp()方法。
private fun touchUp() {
// Reset the path so it doesn't get drawn again.
path.reset()
}- 运行代码,然后用手指在屏幕上绘图。请注意,如果您旋转设备,屏幕会清空,因为绘制状态未保存。对于此示例应用,这是有意为之,旨在为用户提供一种简单的方法来清除屏幕。

第 5 步:在草图周围绘制一个框架
当用户在屏幕上绘制时,应用会构建路径并将其保存在位图 extraBitmap 中。onDraw() 方法会在视图的画布中显示额外的位图。您可以在 onDraw() 中进行更多绘画。例如,您可以在绘制位图后绘制形状。
在此步骤中,您将在图片边缘周围绘制一个边框。
- 在
MyCanvasView中,添加一个名为frame的变量,用于保存Rect对象。
private lateinit var frame: Rect- 在
onSizeChanged()的末尾定义一个边衬区,并添加代码以使用新尺寸和边衬区创建将用于框架的Rect。
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)- 在
onDraw()中,绘制位图后,绘制一个矩形。
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)- 运行应用。注意帧。

任务(可选):在路径中存储数据
在当前应用中,绘制信息存储在位图中。虽然这是一个不错的解决方案,但它并不是存储绘制信息的唯一可能方式。存储绘画历史记录的方式取决于应用和您的各种需求。例如,如果您要绘制形状,可以保存包含形状及其位置和尺寸的列表。对于 MiniPaint 应用,您可以将路径保存为 Path。如果您想尝试此操作,请参阅下面的大致步骤。
- 在
MyCanvasView中,移除extraCanvas和extraBitmap的所有代码。 - 为到目前为止的路径和当前正在绘制的路径添加变量。
// Path representing the drawing so far
private val drawing = Path()
// Path representing what's currently being drawn
private val curPath = Path()- 在
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)- 在
touchUp()中,将当前路径添加到上一个路径,然后重置当前路径。
// Add the current path to the drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()- 运行应用,是的,应该没有任何区别。
下载已完成的 Codelab 的代码。
$ git clone https://github.com/googlecodelabs/android-kotlin-drawing-canvas
或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。
Udacity 课程:
Android 开发者文档:
Canvas类Bitmap类View类Paint类Bitmap.config配置Path类- 贝塞尔曲线维基百科页面
- 画布和可绘制对象
- 图形架构系列文章(高级)
- 可绘制对象
- onDraw()
- onSizeChanged()
MotionEventViewConfiguration.get(context).scaledTouchSlop
此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:
- 根据需要布置作业。
- 告知学生如何提交家庭作业。
- 给家庭作业评分。
讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。
如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。
回答以下问题
问题 1
以下哪些组件是使用 Canvas 所必需的?请选择所有适用的选项。
▢ Bitmap
▢ Paint
▢ Path
▢ View
问题 2
对 invalidate() 的调用(一般而言)会执行什么操作?
▢ 使应用失效并重新启动。
▢ 从位图中擦除绘制内容。
▢ 表示不应运行之前的代码。
▢ 告知系统必须重新绘制屏幕。
问题 3
Canvas、Bitmap 和 Paint 对象有何作用?
▢ 2D 绘制表面、屏幕上显示的位图、绘制样式信息。
▢ 3D 绘制表面、用于缓存路径的位图、用于绘制的样式信息。
▢ 2D 绘图表面、屏幕上显示的位图、视图的样式。
▢ 用于绘制信息的缓存、要绘制的位图、用于绘制的样式信息。
如需本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页。