自定义View入门知识
前言

这篇文章主要目的是记录原生自定义View与Compose的一些笔记,同时为了适应Kotlin,全文尽量选择使用Kotlin作为记录。
关于原生的自定义View,在Compose出来以后,还有没有必要学习?这是一个比较有争议的问题,我的评价是,建议都学😋。
且听我细说:一、即使Compose自定义View相对于原生的自定义View过程,十分不同,但是一部分Compose自定义View也会需要原生的知识,Compose 目前还不支持的绘制功能,比如绘制文字,可以通过 drawIntoCanvas 方法拿到原生 Android 环境的 Canvas 和 Paint 对象,利用原生 Android 环境的 API 方法来实现部分绘制需求。
二、即使Compose发布了几年,实际上还是有一部分公司的UI是基于原生View的。你写新代码当然可以选择 Compose,但是老代码维护的时候还是要和 View 打交道呀。
三、由于Compose 是基于 View 系统的,它的原理是通过一个自定义的 View,去对它的 onMeasure()、onLayout()、onDraw()、dispatchTouchEvent(),在遇到一些Compose无法解决的问题时,我们还是需要原始View的知识去解决。
同时由于上次的Retrofit问题,我决定减少篇幅,选择比较重要的点列出,比如感悟和经验,减少解析源码部分。让这篇文章偏向笔记化。
原生自定义View基础知识
前言
自定义View的分类
- 自定义组合控件 多个控件组合成为一个新的控件,方便多处复用
- 继承系统View控件 继承自TextView等系统控件,在系统控件的基础功能上进行扩展
- 继承View 不复用系统控件逻辑,继承View进行功能定义
- 继承系统ViewGroup 继承自LinearLayout等系统控件,在系统控件的基础功能上进行扩展
- 继承ViewGroup 不复用系统控件逻辑,继承ViewGroup进行功能定义
在原生的 Android View 体系下,我们要实现一个自定义 View 所需要的基本步骤有:
- 继承
android.view.View/ViewGroup(或者某些现成的组件),在子类的构造函数中通过AttributeSet拿到在 XML 文件中声明的各个属性值 - 重写
onMeasure和onSizeChanged两个方法,拿到View的宽高信息 (测) - 重写
onLayout方法,确定子 View的位置信息(如果是自定义 ViewGroup 的话)(布) - 重写
onDraw方法,通过Paint、Path等在Canvas绘制图形,从而实现各种自定义效果 (画) - 重写
onVisibilityChanged、onAttachedToWindow、onDetachedFromWindow等方法,在适当的时候开启动画或者停止动画,避免资源浪费和内存泄漏(如果有使用到 Animator 的话) - 重写
dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等方法,完成对用户操作的正确回馈。
在这篇笔记里主要记录一下自定义View的三大基本方法,最终目的就是能达到对原生自定义View的基本了解和使用,实际上真正重要的是Compose的相关学习。
不过接下来,先从最重要的讲起——画,也就是绘制。
Tips:记得关闭硬件加速
绘制
一切的开始:onDraw()
自定义View的绘制上手非常容易:在初始化方法中提前创建好 Paint 对象,重写 onDraw(),把绘制代码写在 onDraw() 里面,就是自定义绘制最基本的实现。
Tips:为什么我们不在onDraw方法中初始化对象,或者进行耗时操作?因为onDraw方法会不断的被调用,这时候就会出现一个十分高大的词语——内存抖动。

我们在 onDraw() 里创建的对象往往是绘制相关的对象,而这些对象又经常会包含通往系统下层的 Native 对象的引用,这就导致在 onDraw() 里创建对象所导致的内存回收的耗时往往会更高,直白地说就是——界面更卡顿。
回到正题,
class PractiseView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0)
: View(context, attrs, defStyleAttr) {
private val mContext : Context = context
private var paint : Paint = Paint()
override fun onDraw(canvas: Canvas?) {
//唯一需要注意的是别漏写了 super.onDraw()。
super.onDraw(canvas)
//画个圆
canvas?.drawCircle(200f, 200f, 100f, paint)
//drawCircle(float centerX, float centerY, float radius, Paint paint) 画圆
// 前两个参数 centerX centerY 是圆心的坐标,
// 第三个参数 radius 是圆的半径,单位都是像素,它们共同构成了这个圆的基本信息
// 第四个参数 paint提供基本信息之外的所有风格信息,例如颜色、线条粗细、阴影等。
}
}
我们先来分析下,Canvas类里面有大量的drawXXX()方法,如果我们知道Canvas的翻译是画布,那么就很简单理解了。
Canvas.drawXXX常见方法
颜色填充
drawColor(@ColorInt int color)
drawRGB(int r, int g, int b)
drawARGB(int a, int r, int g, int b)
a代表透明度
给整个画布填充颜色
如果最后调用,会直接覆盖
paint.color = Color.BLACK
canvas?.drawColor(Color.RED)
canvas?.drawRect(200f, 100f, 400f,200f, paint)

画圆
drawCircle(float centerX, float centerY, float radius, Paint paint)
画 Bitmap
drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
绘制
Bitmap对象,也就是把这个Bitmap中的像素内容贴过来。其中left和top是要把bitmap绘制到的位置坐标。
画矩形
drawRect(float left, float top, float right, float bottom, Paint paint)
left,top,right,bottom是这个椭圆的左、上、右、下四个边界点的坐标。left, right 的值是相对于x轴的
top, bottom 的值是相对于y轴的
例子:
我们可以发现left的值是等于top的值,却能生成以下正常的椭圆
canvas?.drawCircle(100f, 100f, 50f ,paint)
canvas?.drawArc(200f, 200f, 800f, 500f, 100f, 100f, true, paint)
paint.color = Color.BLACK
paint.alpha = 100
canvas?.drawOval(200f,200f, 800f, 500f, paint)

画点
drawPoint(float x, float y, Paint paint)
drawPoints(float[] pts, Paint paint) 批量画点
pts这个数组是点的坐标,每两个成一对
drawPoints(float[] pts, int offset, int count, Paint paint)
批量画点。
offset代表跳过pts数组中前几个数再开始记坐标)可以实现间隔变化的点绘制
画椭圆
drawOval(float left, float top, float right, float bottom, Paint paint)
画线
drawLine(float startX, float startY, float stopX, float stopY, Paint paint)
startX,startY,stopX,stopY分别是线的起点和终点坐标。
drawLines(float[] pts, Paint paint)
drawLines(float[] pts, int offset, int count, Paint paint)
画线(批量)
可以实现间隔变化的线绘制
画圆角矩形
drawRoundRect(float left, float top, float right, float bottom, float rx, float ry, Paint paint)
left,top,right,bottom是四条边的坐标,rx和ry是圆角的横向半径和纵向半径。
绘制弧形或扇形
drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint)
drawArc()是使用一个椭圆来描述弧形的。
left,top,right,bottom描述的是这个弧形所在的椭圆;startAngle是弧形的起始角度(x 轴的正向,即正右的方向,是 0 度的位置;顺时针为正角度,逆时针为负角度),sweepAngle是弧形划过的角度;useCenter表示是否连接到圆心,如果不连接到圆心,就是弧形,如果连接到圆心,就是扇形。
绘制文字
drawText(String text, float x, float y, Paint paint)
参数
text是用来绘制的字符串,x和y是绘制的起点坐标。
画自定义图形
drawPath(Path path, Paint paint)
drawPath()可以绘制自定义图形。当你要绘制的图形比较特殊,使用前面的那些方法做不到的时候,就可以使用drawPath()来绘制。
drawPath可以说是最重要的方法之一,实际上一些非特定的View,其实是可以通过继承系统控件魔改,或者是组合各种系统控件完成。而那些十分帅气(花里胡哨)的控件,基本上是使用drawPath相关方法绘制的。
Path 可以描述直线、二次曲线、三次曲线、圆、椭圆、弧形、矩形、圆角矩形。把这些图形结合起来,就可以描述出很多复杂的图形。Path 可以归结为两类方法:
-
直接描述路径,也可以分为两组:
-
添加子图形:
addXXX(), 此类方法在特定情况下几个Canvas.drawPath()等同于Canvas.drawXXX()。 -
画直线或曲线:
xxxTo(): 这一组和第一组addXxx()方法的区别在于,第一组是添加的完整封闭图形(除了addPath()),而这一组添加的只是一条线。
-
-
辅助设置或计算:
Path.setFillType(Path.FillType ft)设置填充方式,由于比较少使用不进行介绍。可以参考https://juejin.cn/post/7067012239223422983这篇文章。
这里面主要介绍Path的画直、曲线的方法:
lineTo(float x, float y) / rLineTo(float x, float y) 画直线
从当前位置向目标位置画一条直线, x 和 y 是目标位置的坐标。这两个方法的区别是,lineTo(x, y) 的参数是绝对坐标,而 rLineTo(x, y) 的参数是相对当前位置的相对坐标 。
当前位置:所谓当前位置,即最后一次调用画
Path的方法的终点位置。初始值为原点 (0, 0)。
paint.style = Style.STROKE
path.lineTo(100, 100) // 由当前位置 (0, 0) 向 (100, 100) 画一条直线
path.rLineTo(100, 0) // 由当前位置 (100, 100) 向正右方 100 像素的位置画一条直线

quadTo(float x1, float y1, float x2, float y2) / rQuadTo(float dx1, float dy1, float dx2, float dy2) 绘制二次贝塞尔曲线
这条二次贝塞尔曲线的起点就是当前位置,而参数中的 x1, y1 和 x2, y2 则分别是控制点和终点的坐标。和 rLineTo(x, y) 同理,rQuadTo(dx1, dy1, dx2, dy2) 的参数也是相对坐标
cubicTo(float x1, float y1, float x2, float y2, float x3, float y3) /rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3) 绘制三次贝塞尔曲线
这条三次贝塞尔曲线的起点就是当前位置,而参数中的 x1, y1 x2, y2 是控制点坐标,x3,y3是终点的坐标。和 rLineTo(x, y) 同理,rCubicTo(dx1, dy1, dx2, dy2, dx3, dy3) 的参数也是相对坐标
使用它们可以绘制很多圆润又好看的图形,这也是Path最重要的方法。
简单介绍一下Path的一些基本方法:
这里就用代码举个例子
//一般自定义view的绘画部分对象,都是全局对象
var path = Path()
// Path的起点默认为坐标为(0,0)
//画线一定要设置这个Style
paint.style = Paint.Style.STROKE
//由起点1一条直线连接到(300, 300)
path.lineTo(700f, 800f)
//将当前点移动到(500, 400)
//使用这个方法会获得新的起点
path.moveTo(500f, 400f)
//由起点2一条直线连接到(200, 700)
path.lineTo(200f, 700f)
// 闭合路径,即连接当前点和起点
// 即连接(200,700)与起点2(500, 400)
// 注:此时起点已经进行变换
//闭合失败
path.close();
// 画出路径
canvas.drawPath(path, paint);

var path = Path()
// Path的起点默认为坐标为(0,0)
//画线一定要设置这个Style
paint.style = Paint.Style.STROKE
//由起点1一条直线连接到(300, 300)
path.lineTo(700f, 800f)
//将当前点移动到(500, 400)
//使用这个方法不会获得新的起点
path.setLastPoint(500f, 400f)
//由(500, 400)一条直线连接到(200, 700)
path.lineTo(200f, 700f)
// 闭合路径,即连接当前点和起点
// 即连接(200,700)与(500, 400)
path.close();
// 画出路径
canvas.drawPath(path, paint);

通过上述大量的drawXXX()方法我们大概可以看出,绘制一个View, 我们需要一个画布(Canvas),一根笔(Paint),也要知道在哪起笔(坐标)、以及画多大。
在drawCircle方法中,圆心坐标和半径,这些都是圆的基本信息,也是它的独有信息。画圆有圆心坐标和半径,画方有吗?画椭圆有吗?这就叫独有信息。
独有信息都是直接作为参数写进 drawXXX() 方法里的(比如 drawCircle(centerX, centerY, radius, paint) 的前三个参数)。
而除此之外,其他的都是公有信息。比如图形的颜色、空心实心这些,你不管是画圆还是画方都有可能用到的,这些信息则是统一放在 paint 参数里的。
关于坐标这里插入一下:
Android中有两个比较常用的坐标系。
第一个是Android坐标系

从上到下依次是:
- 状态栏(包含通知,系统时间),
- 应用的标题栏,
- 应用的View布局栏,
- 底部的导航栏。
第二个View坐标系,也就是在View布局区域中是这这样:

基本介绍完Canvas画布,那么就可以看看Paint画笔
Paint
Paint 的 API 大致可以分为 3 类:
- 颜色
- 效果
- drawText() 相关
颜色
首先我们需要知道,在Canvas上绘制的内容,可以从三个角度来改变绘制的颜色

基础颜色
在调用方法确定颜色:颜色直接指定,或是由Paint决定
除了drawColor\ARBG\Bitmap 也就是图形和文字的绘制,它们的颜色就需要使用 paint 参数来额外设置了。
Paint 设置颜色的方法有两种:一种是直接用 Paint.setColor/ARGB() 来设置颜色,另一种是使用 Shader 来指定着色方案。
这里就直接略过Paint.setColor/ARGB() ,讲一讲Shader着色器。
第一种是LinearGradient 线性渐变:
LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1, Shader.TileMode tile)
从它的构造方法看出,需要设置两个点和两种颜色,以这两个点作为端点,使用两种颜色的渐变来绘制颜色。
参数:
x0y0x1y1:渐变的两个端点的位置
color0color1是端点的颜色
tile:端点范围之外的着色规则,类型是TileMode。TileMode一共有 3 个值可选:CLAMP,MIRROR和REPEAT。CLAMP:如果着色器超出原始边界,超出的部分延展着色器的边缘颜色
REPEAT :如果着色器超出原始边界,复制着色器的图片,包含横向或者竖向
MIRROR:镜像着色器的内容,倒影形式
DECAL:渲染着色器原本边界的图像像素,如果超出,绘制黑色透明(可以理解为超出着色器就剪裁)
其他形状以及文字都可以这样设置颜色
注意:在设置了
Shader的情况下,Paint.setColor/ARGB()所设置的颜色就不再起作用。
var lg1 =
LinearGradient(50f, 200f, 150f, 200f, Color.YELLOW, Color.BLUE, Shader.TileMode.CLAMP)
paint.shader = lg1
canvas?.drawCircle(200f, 400f, 100f, paint)
val lg2 =
LinearGradient(400f, 650f, 500f, 650f, Color.YELLOW, Color.BLUE, Shader.TileMode.REPEAT)
paint.shader = lg2
canvas?.drawRect(200f, 500f, 600f, 800f, paint)
val lg3 =
LinearGradient(200f, 900f, 300f, 900f, Color.YELLOW, Color.BLUE, Shader.TileMode.DECAL)
paint.shader = lg3
canvas?.drawRect(200f, 900f, 600f, 1200f, paint)

第二种,RadialGradient 辐射渐变
RadialGradient(float centerX, float centerY, float radius, int centerColor, int edgeColor, TileMode tileMode)。
参数:
centerXcenterY:辐射中心的坐标
radius:辐射半径
centerColor:辐射中心的颜色
edgeColor:辐射边缘的颜色
tileMode:辐射范围之外的着色模式。
var shader = RadialGradient(300f, 300f, 200f, Color.parseColor("#E91E63"),
Color.parseColor("#2196F3"), Shader.TileMode.CLAMP)
paint.setShader(shader)
...
canvas.drawCircle(300f, 300f, 200f, paint)

第三种,SweepGradient 扫描渐变
SweepGradient(float cx, float cy, int color0, int color1)
参数:
cxcy:扫描的中心
color0:扫描的起始颜色
color1:扫描的终止颜色
var shader = SweepGradient(300f, 300f, Color.parseColor("#E91E63"),
Color.parseColor("#2196F3"))
paint.shader = shader
...
canvas.drawCircle(300f, 300f, 200f, paint)

第四种, BitmapShader
BitmapShader(Bitmap bitmap, Shader.TileMode tileX, Shader.TileMode tileY)
参数:
bitmap:用来做模板的Bitmap对象
tileX:横向的TileMode
tileY:纵向的TileMode。
var bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.batman)
var shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
paint.shader = shader
...
canvas.drawCircle(300f, 300f, 200f, paint)

如果试过drawBitmap,我们会发现这样如果要对Bitmap进行几何裁剪,那么使用BitmapShader是比drawBitmap更加方便。
第五种,ComposeShader 混合着色器
ComposeShader(Shader shaderA, Shader shaderB, PorterDuff.Mode mode)
参数:
shaderA,shaderB:两个相继使用的Shader,B绘制上A
mode: 两个Shader的叠加模式,即shaderA和shaderB应该怎样共同绘制。它的类型是PorterDuff.Mode,等下详细展开
// 第一个 Shader:头像的 Bitmap
var bitmap1 = BitmapFactory.decodeResource(getResources(), R.drawable.batman)
var shader1 = BitmapShader(bitmap1, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
// 第二个 Shader:从上到下的线性渐变(由透明到黑色)
var bitmap2 = BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo)
var shader2 = BitmapShader(bitmap2, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
// ComposeShader:结合两个 Shader
var shader = ComposeShader(shader1, shader2, PorterDuff.Mode.SRC_OVER)
paint.shader = shader
...
canvas.drawCircle(300f, 300f, 300f, paint);

在刚刚的构造方法中,我们发现了一个新的参数类型
PorterDuff.Mode,是用来指定两个图像共同绘制时的颜色策略的,就是说把源图像绘制到目标图像处时应该怎样确定二者结合后的颜色。
PorterDuff.Mode一共有 17 个,可以分为两类:
- Alpha 合成 (Alpha Compositing):关于 Alpha 通道(也就是「透明度」)的计算的
- 混合 (Blending):是 Photoshop 等制图软件里都有的那些混合模式,一般是配合实现设计师各种逆天UI使用
ColorFilter、Xfermode
这两个也是使用的非常少的了,如果需要对相片滤镜之类的研究的话,可以进行了解。
ColorFilter主要是为绘制设置颜色过滤,为绘制的内容设置一个统一的过滤策略。
Xfermode是以绘制的内容作为源图像,以 View 中已有的作为目标图像,选取一个 PorterDuff.Mode 作为结果的颜色处理方案。
效果
效果类的 API ,指的就是抗锯齿、填充/轮廓、线条宽度等等这些。
setAntiAlias (boolean aa) 抗锯齿
设置抗锯齿,这应该是绘制图形一般都会启用

在Android S以上自动开启
setStrokeWidth(float width) 线条宽度
设置线条宽度。单位为像素,默认值是 0。
线条宽度 0 和 1 的区别
默认情况下,线条宽度为 0,但你会发现,这个时候它依然能够画出线,效果和设置宽度为 1 像素是一样的。
实际上这和Canvas的几何变换有关,可以为
Canvas设置Matrix来实现几何变换(如放大、缩小、平移、旋转),在几何变换之后*Canvas绘制的内容就会发生相应变化,包括线条也会加粗,例如 2 像素宽度的线条在Canvas*放大 2 倍后会被以 4 像素宽度来绘制。而当线条宽度被设置为 0 时,它的宽度就被固定为 1 像素。线条宽度为 0 时这叫做发际线模式*「hairline mode」。*🐶
setStyle(Paint.Style style)
用来设置图形是线条风格还是填充风格的
如果绘制的不是闭合图案,也就是线条之类的,务必启用Paint.Style.STROKE,否则会显示不出。
paint.style = Paint.Style.STROKE // STROKE 模式,画线

paint.styl e= Paint.Style.FILL // FILL 模式,填充

setStrokeJoin(Paint.Join join)
设置拐角的形状,一般是绘画Path使用。有三个值可以选择:MITER 尖角、 BEVEL 平角和 ROUND 圆角。默认为 MITER。

如果使用MITER的话,如果转角过尖锐,可以了解***setStrokeMiter(float miter)***方法,用于设置 MITER 型拐角的延长线的最大值。

setStrokeCap(Paint.Cap cap)
设置线头的形状,一般是绘画Path使用。线头形状有三种:BUTT 平头、ROUND 圆头、SQUARE 方头。默认为 BUTT。

setFilterBitmap(boolean filter)
设置是否使用双线性过滤来绘制 Bitmap 。
在Android S以上也是默认开启。
图像在放大绘制的时候,默认使用的是最近邻插值过滤,这种算法简单,但会出现马赛克现象;而如果开启了双线性过滤,就可以让结果图像显得更加平滑。也是基本都会开启。

drawText() 相关
布局
//TODO
触摸反馈/事件分发
//TODO
参考资料:
https://rengwuxian.com/ui-1-1/
https://juejin.cn/post/6996568363581308959
https://www.jianshu.com/p/705a6cb6bfee
https://blog.csdn.net/axi295309066/article/details/59798506
https://zh.wikipedia.org/wiki/%E5%8F%8C%E7%BA%BF%E6%80%A7%E8%BF%87%E6%BB%A4


