1. 先想清楚这个仪表盘到底要解决什么问题做安卓自定义View这些年我越来越觉得仪表盘是个特别有意思的练手项目。它不像列表、按钮那样有现成的控件可以用从圆弧刻度到指针旋转每一根线、每一个角度都得自己算。但恰恰是这种“手撸”的过程能把Canvas绘制、三角函数、属性动画、自定义属性这一整套东西串起来。你如果能把仪表盘吃透再去看其他自定义View基本就是降维打击。这个项目的目标很明确做一个带科技感的仪表盘控件能显示数值、有动态指针、有刻度线、有渐变圆弧最好还能支持手势拖动或者数值动画过渡。它适合谁呢如果你已经写过简单的自定义View知道onDraw和onMeasure是怎么回事但一遇到圆弧、角度、动画就发怵那这个项目就是给你准备的。如果你是完全的新手也别慌我会把每个参数怎么来的、为什么这么算都讲清楚你跟着敲一遍基本就能理解个七七八八。科技感这个词听起来玄乎落到代码上其实就是几个视觉元素的组合深色背景、霓虹色渐变、发光效果、细密的刻度线、流畅的动画过渡。这些东西单独拿出来都不难难的是把它们协调地组合在一起让整个控件看起来不廉价。我踩过的坑主要集中在这几个地方圆弧起始角度算错导致刻度对不上、指针旋转中心偏移、动画过程中数值跳变、不同屏幕密度下线条粗细不一致。后面我会一个个拆开讲。2. 整体设计思路与方案选型2.1 为什么选择自定义View而不是组合控件一开始我也想过偷懒用ImageView做背景上面叠一个旋转的指针View再用TextView显示数值。但很快发现这条路走不通。首先背景图在不同分辨率下会拉伸变形刻度线模糊其次指针旋转和数值更新是两套逻辑同步起来很麻烦最重要的是科技感需要动态效果比如圆弧渐变扫过、刻度线逐一亮起这些用静态图根本做不了。所以最终方案是继承View完全用Canvas绘制。这样做的好处是所有元素都在一个坐标系里角度、半径、数值之间的换算关系一目了然动画可以直接驱动数值变化onDraw里根据当前数值重新计算指针角度和圆弧扫过角度适配不同屏幕只需要在onSizeChanged里重新计算半径和中心点不用准备多套切图。2.2 核心参数体系的设计仪表盘的核心参数其实就几个中心点坐标、半径、起始角度、扫过角度、当前值、最大值、最小值。但怎么组织这些参数直接决定了代码的可维护性。我的做法是分成三组第一组是几何参数包括圆心cx、cy外圆半径radius刻度线长度tickLength指针长度needleLength。这些参数在onSizeChanged里根据控件宽高计算保证在不同尺寸下比例协调。第二组是数值参数包括currentValue、maxValue、minValue、startAngle、sweepAngle。这里有个关键决策起始角度和扫过角度到底用多少常见的仪表盘有270度扫过下方留缺口和360度全圆两种。我选270度因为缺口朝下符合大多数人的视觉习惯而且指针在底部时不会和数值文字重叠。起始角度设为135度扫过角度270度这样从135度开始顺时针扫到405度也就是45度缺口正好在正下方。第三组是视觉参数包括圆弧宽度、刻度线宽度、指针宽度、颜色渐变数组。这些参数我全部做成了自定义属性方便在XML里调整。比如圆弧颜色我用了从青色到紫色的线性渐变刻度线用半透明白色指针用亮青色加发光效果。2.3 绘制层次的规划onDraw里的绘制顺序很重要画错了要么被覆盖要么透明度叠加出问题。我的顺序是这样的先画背景圆弧灰色底环再画进度圆弧渐变彩色然后画刻度线接着画数值文字最后画指针。指针放最后是因为它要盖在其他元素上面而且指针根部有个圆形装饰需要覆盖圆弧。这里有个细节进度圆弧和背景圆弧的宽度要一致但进度圆弧的扫过角度是根据当前值动态计算的。计算方式是sweepAngle * (currentValue - minValue) / (maxValue - minValue)。注意这里要用float计算别用int否则精度丢失会导致圆弧末端抖动。3. 核心细节解析与实操要点3.1 圆弧绘制的角度换算与常见陷阱Android的Canvas.drawArc方法接收的是角度制但起始角度是3点钟方向为0度顺时针为正。这和数学里的极坐标系不一样数学里0度是3点钟方向但逆时针为正。所以你在计算刻度线端点坐标时如果用Math.cos和Math.sin需要把角度转成弧度并且注意y轴方向是向下的。我举个例子假设起始角度是135度要画第一条刻度线。刻度线的内端点在外圆半径减去刻度线长度的位置外端点在外圆半径上。计算方式是这样的float angleRad (float) Math.toRadians(startAngle); float outerX cx radius * Math.cos(angleRad); float outerY cy radius * Math.sin(angleRad); float innerX cx (radius - tickLength) * Math.cos(angleRad); float innerY cy (radius - tickLength) * Math.sin(angleRad);这里有个坑Math.sin和Math.cos接收的是弧度而且因为屏幕y轴向下所以算出来的y坐标是往下增加的。如果你按数学坐标系去理解会觉得角度反了但其实是对的。我一开始没注意画出来的刻度线全跑到上半圆去了。另一个坑是drawArc的起始角度。如果你用drawArc画背景圆弧起始角度传135扫过角度传270它会从135度开始顺时针画270度结束在405度即45度。但如果你用同样的参数去算刻度线位置刻度线的角度范围也是135到405。注意405度要取模360变成45度否则cos和sin算出来的值虽然正确但逻辑上不直观。3.2 刻度线的分级绘制策略科技感仪表盘的刻度线不能都一样长否则看起来像梳子。我的做法是分三级主刻度、次刻度、微刻度。主刻度对应大数值间隔比如每20个单位一条线最长最粗次刻度每10个单位一条中等长度微刻度每2个单位一条最短最细。具体实现时我先算出总刻度数然后循环绘制。判断当前刻度是主刻度还是次刻度用取模运算。比如int totalTicks (int) ((maxValue - minValue) / minorStep); for (int i 0; i totalTicks; i) { float angle startAngle sweepAngle * i / totalTicks; boolean isMajor (i % (majorStep / minorStep) 0); float length isMajor ? majorTickLength : minorTickLength; float width isMajor ? majorTickWidth : minorTickWidth; // 绘制刻度线 }这里要注意浮点数取模的精度问题。如果minorStep是2.5这种小数取模结果可能不准确。我的建议是全部用整数运算把数值放大10倍或100倍来算最后再除回去。或者直接用BigDecimal但那样性能差没必要。刻度线的颜色也有讲究。主刻度用纯白色次刻度用半透明白色alpha 150微刻度用更淡的白色alpha 80。这样在深色背景上层次分明不会糊成一片。3.3 指针的旋转与动画驱动指针的绘制有两种方式一种是直接旋转Canvas画一个固定方向的指针另一种是计算指针端点坐标用Path画线。我选第二种因为旋转Canvas会影响后续所有绘制容易出问题。计算端点坐标的方式更可控。指针的形状我设计成一个细长的三角形根部宽尖端窄。用Path来画Path needlePath new Path(); needlePath.moveTo(cx, cy - needleWidth / 2); needlePath.lineTo(cx needleLength, cy); needlePath.lineTo(cx, cy needleWidth / 2); needlePath.close();但这样画出来的指针是朝右的需要根据当前角度旋转。旋转可以用Matrix也可以用Canvas.save和Canvas.rotate。我推荐用Matrix因为可以精确控制旋转中心Matrix matrix new Matrix(); matrix.postRotate(currentAngle, cx, cy); needlePath.transform(matrix); canvas.drawPath(needlePath, needlePaint);动画驱动方面我用ValueAnimator从旧值动画到新值在onAnimationUpdate里更新currentValue并调用invalidate。这里有个细节如果动画过程中频繁调用invalidate性能会有压力。我的做法是限制刷新率或者用postInvalidateOnAnimation让系统在下一帧统一刷新。指针根部还要画一个圆形装饰盖住指针和圆弧的交界处看起来更精致。这个圆的半径比指针根部宽度略大颜色用深色加一点渐变。4. 完整实操流程与核心代码实现4.1 自定义属性的定义与读取先在res/values/attrs.xml里定义属性declare-styleable nameTechDashboard attr nameminValue formatfloat / attr namemaxValue formatfloat / attr namestartAngle formatfloat / attr namesweepAngle formatfloat / attr namearcWidth formatdimension / attr nametickLength formatdimension / attr nameneedleLength formatdimension / attr namearcStartColor formatcolor / attr namearcEndColor formatcolor / /declare-styleable然后在构造函数里读取TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.TechDashboard); minValue ta.getFloat(R.styleable.TechDashboard_minValue, 0f); maxValue ta.getFloat(R.styleable.TechDashboard_maxValue, 100f); startAngle ta.getFloat(R.styleable.TechDashboard_startAngle, 135f); sweepAngle ta.getFloat(R.styleable.TechDashboard_sweepAngle, 270f); arcWidth ta.getDimension(R.styleable.TechDashboard_arcWidth, dp2px(12)); // ... 其他属性 ta.recycle();注意ta.recycle()一定要调用否则内存泄漏。另外dp2px的转换要用TypedValue.applyDimension别自己写死系数。4.2 onMeasure与onSizeChanged的处理onMeasure里我让控件默认是正方形因为仪表盘是圆的宽高不一致会变形Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width MeasureSpec.getSize(widthMeasureSpec); int height MeasureSpec.getSize(heightMeasureSpec); int size Math.min(width, height); setMeasuredDimension(size, size); }onSizeChanged里计算圆心和半径Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); cx w / 2f; cy h / 2f; radius Math.min(w, h) / 2f - arcWidth / 2f - dp2px(8); // 重新计算刻度线长度、指针长度等 }这里减掉arcWidth的一半是因为圆弧是画在半径线上的如果不减圆弧会超出控件边界。再减8dp是留一点边距避免视觉上太挤。4.3 背景圆弧与进度圆弧的绘制背景圆弧用灰色进度圆弧用渐变。渐变用SweepGradient从起始角度到结束角度SweepGradient sweepGradient new SweepGradient(cx, cy, new int[]{arcStartColor, arcEndColor}, new float[]{0f, 1f}); arcPaint.setShader(sweepGradient);但SweepGradient有个问题它的起始角度是3点钟方向而且颜色是围绕圆心一圈渐变的。如果你只画270度的圆弧渐变会从0度开始导致颜色和圆弧起始角度对不上。解决办法是用Matrix旋转渐变Matrix gradientMatrix new Matrix(); gradientMatrix.postRotate(startAngle, cx, cy); sweepGradient.setLocalMatrix(gradientMatrix);这样渐变就会从startAngle开始。进度圆弧的扫过角度根据当前值计算float progressSweep sweepAngle * (currentValue - minValue) / (maxValue - minValue); canvas.drawArc(rectF, startAngle, progressSweep, false, arcPaint);rectF是圆弧的外接矩形left cx - radius, top cy - radius, right cx radius, bottom cy radius。4.4 数值文字的绘制与对齐数值文字我放在圆心偏下的位置用Paint的setTextAlign(CENTER)居中。字体大小根据半径动态计算比如radius的1/5。颜色用白色加一点发光效果可以用setShadowLayertextPaint.setShadowLayer(dp2px(4), 0, 0, arcStartColor);注意setShadowLayer在硬件加速下可能不生效需要在构造函数里关闭硬件加速setLayerType(LAYER_TYPE_SOFTWARE, null);但关闭硬件加速会影响性能所以只在必要时用。我的做法是只在文字和指针上开软件层其他元素还是硬件加速。4.5 手势拖动与数值联动为了让仪表盘能交互我加了手势拖动。在onTouchEvent里判断ACTION_DOWN和ACTION_MOVE计算触摸点相对于圆心的角度然后映射到数值float touchAngle (float) Math.toDegrees(Math.atan2(event.getY() - cy, event.getX() - cx)); // 把角度归一化到startAngle到startAnglesweepAngle之间 float normalizedAngle (touchAngle - startAngle 360) % 360; if (normalizedAngle sweepAngle) { // 超出范围取最近边界 } float value minValue (maxValue - minValue) * normalizedAngle / sweepAngle;这里要注意atan2返回的是弧度范围是-180到180度。需要转换到0到360度再和startAngle比较。如果触摸点在缺口区域要判断是靠近起始角度还是结束角度取最近的那个。5. 常见问题与排查技巧实录5.1 刻度线对不上圆弧起始点这是最常见的问题。原因通常是角度换算时没有统一坐标系。Canvas的角度是3点钟为0度顺时针为正而Math.cos和Math.sin用的是数学坐标系0度也是3点钟但逆时针为正。所以如果你直接用Canvas的角度去算cos和siny轴会反。解决办法在计算刻度线端点时把角度转成弧度后y坐标用cy radius * Math.sin(angleRad)而不是cy - radius * Math.sin(angleRad)。因为屏幕y轴向下sin值为正时y坐标增加正好对应顺时针旋转。5.2 指针旋转中心偏移指针旋转中心必须是圆心但如果你用Canvas.rotate旋转中心默认是原点(0,0)需要先translate到圆心。我推荐用Matrix.postRotate(angle, cx, cy)这样旋转中心明确。另外指针的Path坐标是相对于圆心的不要用绝对坐标否则旋转后会跑偏。5.3 动画过程中数值跳变ValueAnimator默认的插值器是AccelerateDecelerateInterpolator动画过程中数值变化不均匀。如果你在onAnimationUpdate里直接setCurrentValue然后invalidate可能会看到指针一顿一顿的。解决办法是用LinearInterpolator或者自己写一个平滑的插值器。另外如果动画时间太短比如100ms人眼会觉得跳变建议至少300ms。5.4 不同屏幕密度下线条粗细不一致这个问题通常是因为用了px而不是dp。所有尺寸参数都应该用dp定义然后在代码里转成px。转换公式是px dp * (dpi / 160)。用TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, getResources().getDisplayMetrics())最稳妥。5.5 圆弧渐变颜色错乱SweepGradient的颜色是围绕圆心一圈渐变的如果你只画部分圆弧渐变会从0度开始导致颜色和圆弧起始角度不匹配。解决办法是用Matrix旋转渐变旋转角度等于startAngle。另外如果渐变颜色数组有多个颜色positions数组也要对应否则颜色分布不均匀。5.6 性能优化减少onDraw里的对象创建onDraw里不要new Paint、new Path、new RectF这些对象应该在构造函数或onSizeChanged里创建好onDraw里只做绘制。我见过有人在onDraw里new LinearGradient结果滑动时卡成幻灯片。记住onDraw每秒调用60次任何对象创建都会被放大60倍。问题现象可能原因排查方法解决方案刻度线位置偏移角度坐标系不统一打印角度和坐标值统一用Canvas坐标系计算指针旋转中心不对旋转中心未设置检查Matrix.postRotate参数传入cx, cy作为旋转中心动画卡顿onDraw里创建对象用Profiler看内存分配对象提前创建onDraw只绘制渐变颜色错乱SweepGradient起始角度不对检查渐变Matrix旋转渐变到startAngle线条粗细不一致用了px而不是dp检查尺寸单位统一用dp转px6. 科技感视觉增强的几个技巧6.1 发光效果的低成本实现真正的发光效果需要BlurMaskFilter但在低端机上性能很差。我的替代方案是用两层绘制先画一层半透明的粗线再画一层不透明的细线。粗线模拟光晕细线是核心。比如指针先用alpha 60的青色画宽度8dp的线再用纯青色画宽度3dp的线视觉上就有发光感了。6.2 刻度线的动态亮起科技感的一个重要特征是动态反馈。我让刻度线根据当前值动态改变颜色当前值以下的刻度线用亮青色以上的用暗灰色。实现方式是在绘制刻度线时判断当前刻度对应的数值是否小于currentValue如果是就用亮色否则用暗色。这样拖动指针时刻度线会逐一亮起效果很带感。6.3 背景网格与扫描线如果觉得纯色背景太单调可以加一层细密的网格线透明度很低alpha 20左右营造一种仪表盘底纹的感觉。再在圆弧外侧加一条细的扫描线用SweepGradient做一个从透明到亮色再到透明的渐变旋转动画驱动它转圈科技感直接拉满。但注意扫描线不要转太快否则眼花建议一圈3到5秒。6.4 数值变化的缓动动画数值变化时不要直接跳到目标值用ValueAnimator做缓动。我试过几种插值器LinearInterpolator太机械AccelerateDecelerateInterpolator又太柔最后选了一个自定义的OvershootInterpolator数值会稍微超过目标值再回弹像真实仪表指针的惯性。参数tension设1.5左右比较自然。7. 我在实际项目里踩过的坑与经验总结第一个坑是圆弧宽度和半径的关系。一开始我把半径设成控件宽度的一半圆弧宽度12dp结果圆弧有一半画到控件外面去了。后来才明白圆弧是沿着半径线画的圆弧宽度会向内外各扩展一半。所以半径应该是控件宽度的一半减去圆弧宽度的一半再留一点边距。第二个坑是文字阴影的性能。setShadowLayer在软件层下每帧都要重新计算阴影如果文字很大帧率直接掉到30以下。我的做法是文字不用阴影改用两层文字叠加底层用半透明颜色偏移1dp顶层用纯色模拟阴影效果性能好很多。第三个坑是手势拖动和动画的冲突。如果用户正在拖动动画还在跑指针会抖。解决办法是拖动时取消动画动画时忽略拖动。用一个boolean标志位控制简单有效。第四个坑是刻度线数量太多导致绘制慢。我一开始每1个单位画一条刻度线100个单位的量程就是100条线加上主次刻度的判断onDraw里循环100次滑动时明显掉帧。后来改成每5个单位一条微刻度每10个单位一条次刻度每20个单位一条主刻度总刻度数降到20条性能立刻上来了。刻度线的密度要平衡视觉效果和性能不是越密越好。第五个坑是SweepGradient在部分机型上的兼容性问题。有些老机型对SweepGradient的Matrix变换支持不好渐变会错位。我的兼容方案是如果检测到API低于21就用LinearGradient代替虽然效果差一点但至少不会错乱。最后分享一个小技巧如果你想让仪表盘看起来更精致可以在圆弧的内侧和外侧各加一条细线颜色比圆弧略亮宽度1dp。这样圆弧就有了立体感像嵌在面板里一样。这个细节花不了多少代码但视觉效果提升很明显。这个仪表盘控件我前后改了大概五版从最开始的能看到后来的好看再到现在的有科技感每一步都是踩坑踩出来的。你如果照着这个思路做第一版可能只要两三个小时但调细节可能要花两三天。别急慢慢调每个参数都试一下找到最顺眼的那组。等你做出来一个自己满意的仪表盘再去看其他自定义View会发现思路完全通了。
阅读完成 · 觉得有帮助?