ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android用Canvas手写图表库:柱形图、饼图与3D效果实现解析

Android用Canvas手写图表库:柱形图、饼图与3D效果实现解析 简介这是一份基于Android Canvas实现的图表绘制开源库以简洁API和灵活定制见长目前支持3D/非3D柱形图、背向式柱形图、横向/竖向柱形图、3D/非3D饼图、堆叠图和面积图等常见图表形态适合正在做课程设计、期末大作业或毕业设计的计算机相关专业学生也适合有一定Android基础并对自定义View绘制感兴趣的学习者。压缩包共1097个文件大小约8.93MB其中包含204个java源码文件、392个html文档页面、71个xml布局/配置、61个png图片及Gradle工程配置文件可直接导入Android Studio运行同时附带apk与jar等构建产物便于快速查看效果或集成到现有项目。项目代码经过调试下载即可运行读者可依据示例快速理解Canvas绘图流程、数据适配方式与图表定制思路在此基础上扩展新的图表类型或接入真实业务数据是学习Android图形绘制与图表组件的实用参考资料。目前已有191人学习下载。1. 为什么不用MPAndroidChart而要自己用Canvas画图表移动端图表选型时绝大多数人第一反应是引入MPAndroidChart或HelloCharts。但当你遇到定制需求——比如3D柱形图的背向视角、饼图的斜向透视、或者某块区域要跟随列表滚动做视差——第三方库的API抽象反而成了障碍。这个基于Canvas手写的图表库没有依赖任何图表框架所有柱形图、饼图、堆叠图、面积图都从onDraw开始逐像素绘制。它适合两类人一是课程设计或毕业设计需要展示完整绘图原理的学生二是业务中图表定制自由度不够、需要理解底层绘制逻辑的 Android 工程师。下载即用的源码包里有完整Demo工程跑起来就能看到每种图表的默认效果但这里重点不是教你跑通Demo而是把Canvas绘图的关键决策讲透。2. Canvas绘图的底层逻辑与图表库的架构设计2.1 从onDraw到Canvas绘制管线到底做了什么很多人在自定义View时把Canvas当成一块“画板”实际上Canvas更像一条绘制命令的录制管道。调用canvas.drawRect()、drawPath()、drawText()时命令被记录并送入RenderNode再由硬件加速器合成到Surface。理解这一点对图表库的架构至关重要——为什么这个库强调“定制灵活”因为它所有绘制都发生在onDraw里你可以在super.onDraw()之后插入自己的绘制逻辑不需要像MPAndroidChart那样通过继承某个Renderer类来扩展。这个库的核心绘制入口是重写View.onDraw(Canvas)。常见做法是将每种图表封装为独立的View子类或者在一个View内部通过状态切换不同绘制器。源码里的实现更倾向于前者——BarChartView、PieChartView、AreaChartView各管各的避免一个巨型View里塞满if-else分支。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 关闭硬件加速对复杂Path绘制的干扰 if (Build.VERSION.SDK_INT 11) { setLayerType(LAYER_TYPE_SOFTWARE, null); } // 1. 绘制网格背景 drawGrid(canvas); // 2. 绘制数据序列 drawSeries(canvas); // 3. 绘制坐标轴与标签 drawAxisAndLabels(canvas); }注意这里关闭硬件加速是处理某些机型上Path锯齿和扇形绘制异常的常见手段但代价是性能下降所以实际项目中我一般用setWillNotDraw(false)配合局部刷新而不是全局关闭硬件加速。2.2 库的核心类划分与坐标系约定打开源码包目录结构并不复杂核心类集中在chart目录下。常见设计是一个Chart基类持有数据源和绘制参数具体图表继承它并各自实现drawContent()。坐标系约定是这类库最容易踩坑的地方——Android的Canvas原点在View左上角Y轴向下为正而图表语义中Y轴向上为正。所有数值型图表的坐标变换都在这一个点上。库的默认做法是先计算View的padding区域作为绘图区再将数据值映射到绘图区坐标。映射的核心是一个线性变换pixelX left (valueX - minX) / (maxX - minX) * width pixelY top height - (valueY - minY) / (maxY - minY) * heightpixelY的公式中用height减去映射值就是把数据坐标系翻转回Canvas坐标系。很多初学者在这里直接用valueY做pixelY导致图表上下颠倒。用Android Studio的Layout Inspector查看实际View边界能明显看出这个问题。2.3 数据模型设计为什么统一用Float数组而非对象看这个库的源码会发现它的数据接口大量使用float[]或List而不是封装成ChartEntry对象。这个设计刚开始让人觉得“简陋”实际用起来却很顺手。原因在于图表数据本质上是数值序列用基本类型数组可以直接传给Canvas的drawVertices()、drawLines()等批量绘制方法减少对象创建带来的GC压力。对课程设计级别的数据量这可能无所谓但如果要做实时刷新的滚动图表每帧创建几百个Entry对象造成的内存抖动会让掉帧非常明显。另一个原因是定制灵活。当你需要把外部数据源数据库查询结果、传感器读数直接映射到图表时float[]可以零成本转换而对象模型需要逐字段拷贝。源码中饼图和柱形图的接口都接受float[]作为数据输入使用的时候只需要把业务数据塞进数组即可。2.4 MeasureSpec与自定义View的尺寸适配自定义图表View必须处理的一个问题是onMeasure。Chart库的默认实现只在onMeasure里调用了super.onMeasure并没有处理wrap_content和固定高度模式的区别。实际使用中图表经常会作为RecyclerView的item或ConstraintLayout的一部分如果高度没有正确适配就会出现图表被拉伸或挤压。我一般会在onMeasure里做两件事一是读取MeasureSpec的mode对AT_MOST模式使用预设高度而非wrap_content的默认值二是将padding纳入计算。源码的Chart基类里有一段逻辑可以借鉴Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int heightMode MeasureSpec.getMode(heightMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); if (heightMode MeasureSpec.AT_MOST) { heightSize (int) dp2px(240); } setMeasuredDimension(widthSize, heightSize); }这段代码的意图是当布局中只指定了宽度而高度不确定时给图表一个默认的240dp高度避免wrap_content计算出0或异常值。dp2px()是自己封装的工具方法确保不同屏幕密度下高度一致。参数上需要注意如果业务方明确要求高度撑满父布局需要把AT_MOST分支改掉否则默认高度会覆盖ConstraintLayout的权重分配。3. 柱形图与饼图的实现从几何计算到3D效果3.1 柱形图的两种绘制路径drawRect与drawPath柱形图在这个库里分为横向和竖向两种它们的几何计算完全不同。竖向柱形图画的是矩形直接用canvas.drawRect()即可关键参数是每个柱子的left、top、right、bottom映射逻辑在上一章已提到。横向柱形图则不同柱子的长度代表数值大小绘制时left固定right由数值映射决定。源码里横向柱形图是用drawPath构造左右两个圆角的矩形路径让柱子两端有圆弧过渡视觉效果更现代。private void drawVerticalBar(Canvas canvas, float left, float top, float right, float bottom, Paint paint) { RectF rect new RectF(left, top, right, bottom); // 圆角半径取柱宽的四分之一避免圆弧过大导致比例失真 canvas.drawRoundRect(rect, barWidth / 4f, barWidth / 4f, paint); }这里有细节drawRoundRect的圆角半径如果超过柱宽的一半会出现奇怪的胶囊形状所以取柱宽的四分之一是安全值。如果柱宽很窄比如数据点很多时我一般直接放弃圆角改用drawRect因为小圆角在低分辨率屏幕上会产生明显锯齿。3.2 饼图的角度分配与扇形绘制饼图的实现比柱形图复杂的地方在于角度计算。数据值不直接映射到坐标而是先求总和再按比例分配角度。Android的Canvas.drawArc()需要传入起始角度和扫过角度单位是度且0度在三点钟方向顺时针为正。源码里饼图的起始角度默认从90度即十二点钟方向开始这是统计图表的视觉习惯。float startAngle 90f; float total 0f; for (float v : data) { total v; } int colorIndex 0; for (float v : data) { float sweepAngle v / total * 360f; canvas.drawArc(rectF, startAngle, sweepAngle, true, paint); startAngle sweepAngle; colorIndex; }这段代码中的rectF是饼图的边界矩形它必须是一个正方形否则饼图会被绘制成椭圆。源码中有一个专门的方法计算rectF它取View宽高的较小值并居中放置。注意drawArc的第四个参数useCenter传true表示绘制扇形带两条半径传false则只绘制弧线。饼图必须传true这是最常见的错误。提示drawArc的useCenter参数传true时绘制扇形传false时只绘制弧线。饼图绘制必须传true否则会只剩一道弧。3.3 3D效果的本质偏移量与分层绘制这个库最吸引人的部分是3D效果但理解之后会发现所谓3D柱形图并不是真的三维渲染而是通过偏移绘制多个图层模拟出立体感。具体做法是先画一个暗色的侧面矩形再画一个亮色的顶面菱形最后画正面矩形。三个面组合在一起就产生了“从侧上方观察”的透视效果。3D饼图的做法则不同。普通饼图先画一个扇形3D饼图先画一个稍微偏移的暗色扇形作为底面的厚度再画一个同样角度但位置上移的亮色扇形作为顶面中间用矩形连接。偏移量是固定的几个像素通常取8dp到12dp。背向式饼图即从背面看则是把偏移方向反转底面色在上方顶面色在下方。// 3D饼图先画偏移后的底部扇形作为厚度面 canvas.drawArc(rectF, startAngle, sweepAngle, true, thicknessPaint); // 再上移offsetY绘制顶部亮面 RectF topRectF new RectF(rectF.left, rectF.top offsetY, rectF.right, rectF.bottom offsetY); canvas.drawArc(topRectF, startAngle, sweepAngle, true, topPaint);这套方案优点是简单可靠不依赖OpenGL性能开销极低。缺点是缺少光照模型所有扇面的明暗只靠两种固定颜色区分。如果要让3D效果更逼真可以给thicknessPaint设置透明度较低的深色让底面和侧面看起来像背光面。3.4 标签绘制与文字碰撞处理图表里的文字标签是最容易被忽略但工作量最大的部分。柱形图需要给每根柱子顶部标数值饼图需要给每个扇区标名称和百分比标签一旦重叠整个图表就废了。源码里对标签的处理比较朴素饼图的标签沿着扇区中心角方向绘制文字基线根据坐标象限调整柱形图的标签直接放在柱顶上方不处理碰撞。我在实际使用中会给饼图加一个简单的防重叠逻辑当两个相邻标签的boundingBox在Y方向重叠时把其中一个标签沿径向向外推同时画一条指引线连接到扇区。这个逻辑大约五十行代码但对可读性的提升非常明显。柱形图的标签碰撞则可以通过交替把标签放在柱顶上方和柱内侧来解决间隔采样时效果很好。4. 面积图与堆叠图的实战配置4.1 面积图的填充与渐变处理面积图本质上是在折线图的基础上将折线与X轴之间的区域填充颜色。这个库的面积图实现方式是构造一个Path先移动到第一个数据点然后依次lineTo其余数据点最后lineTo右下角和左下角闭合路径。填充的Paint设置为带透明度的颜色或者使用LinearGradient实现渐变效果让图表看起来更精致。Path areaPath new Path(); areaPath.moveTo(points[0].x, points[0].y); for (int i 1; i points.length; i) { areaPath.lineTo(points[i].x, points[i].y); } areaPath.lineTo(points[points.length - 1].x, bottom); areaPath.lineTo(points[0].x, bottom); areaPath.close(); LinearGradient gradient new LinearGradient( 0, top, 0, bottom, new int[]{0x66FF9800, 0x00FF9800}, null, Shader.TileMode.CLAMP ); areaPaint.setShader(gradient); canvas.drawPath(areaPath, areaPaint);这里LinearGradient的四个参数分别是渐变起点X、Y终点X、Y和颜色数组。起点用top、终点用bottom实现从上到下由半透明到全透明的渐变。颜色值中的alpha部分是0x66和0x000x00是完全透明这样过渡不会出现硬边缘。如果数据点非常密集lineTo会生成很多线段Path复杂度上升绘制耗时也随之增加此时可以提前做一个抽稀处理比如每两个点取一个。4.2 堆叠柱状图的数值累加逻辑堆叠图Stacked Bar Chart的绘制逻辑和普通柱形图有一个关键区别每一根柱子的底部不是X轴而是前一个数据序列的顶部。因此遍历数据时需要维护一个累加数组记录每个柱子在当前序列下的Y起始位置。float[] stackOffsets new float[dataSeries[0].length]; for (int seriesIndex 0; seriesIndex dataSeries.length; seriesIndex) { float[] values dataSeries[seriesIndex]; for (int i 0; i values.length; i) { float top valueToPixel(stackOffsets[i] values[i]); float bottom valueToPixel(stackOffsets[i]); canvas.drawRect(barLeft(i), top, barRight(i), bottom, seriesPaint[seriesIndex]); stackOffsets[i] values[i]; } }这段代码的要点有两个一是valueToPixel负责把数值映射到Canvas的Y坐标二是stackOffsets数组的更新顺序必须在drawRect之后。如果把累加放在绘制之前第一层就会画到错误的高度。还有一个容易被忽略的问题如果某个序列的值是负数堆叠逻辑会变复杂因为负值应该向下堆叠。这个库的源码没有处理负值堆叠我在项目里遇到时是分两个方向分开累加的——正值向上负值向下。4.3 数据刷新与动画图表库的另一个刚需是数据刷新。这个库的刷新机制比较基础调用setData()后触发invalidate()重绘没有任何过渡动画。实际项目中直接刷新数据会导致柱子从旧值跳到新值用户视觉体验较差。我一般会在库外面包一层ValueAnimator做数值插值让柱子和扇形平滑过渡。ValueAnimator animator ValueAnimator.ofFloat(0f, 1f); animator.setDuration(500); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(animation - { float fraction animation.getAnimatedFraction(); chart.setAnimationProgress(fraction); chart.invalidate(); }); animator.start();这种做法的思路是在View内部维护一个animationProgress变量所有绘制计算都乘上这个变量。例如柱形图的高度乘以progress饼图的sweepAngle乘以progress这样只需在动画回调里改一个变量并触发重绘就能实现整个图表的生长动画。注意ValueAnimator的回调在主线程执行如果图表数据量大、每帧绘制超过16ms动画会掉帧。这种情况下可以降低绘制精度比如在动画过程中跳过网格线和标签绘制。注意ValueAnimator回调如果耗时过长动画会持续掉帧。如果图表数据量大建议在动画过程中跳过网格线和坐标轴文字绘制换取更平滑的过渡。动画参数推荐值如下表参数推荐值说明duration300-600ms过短显得生硬过长用户等待焦虑interpolatorDecelerateInterpolator先快后慢符合图表生长动画的视觉规律fraction直接用animatedFraction无需额外计算动画框架已归一化标签绘制时机动画结束后动画过程中绘制文字会造成标签错位4.4 多序列图表的颜色分配策略最后一个实战问题是多序列图表的颜色分配。库的默认色板是十种固定颜色数据序列超过十个时开始循环使用。循环颜色的缺点是两个相邻序列可能颜色相近导致图表难以辨识。我一般会预设一个二十色的大色板并为每个系列指定固定颜色而非默认分配。搭配面积图的渐变填充时注意不同序列的渐变起点要错开否则两个渐变的视觉效果会几乎一致。5. 性能优化与常见坑给图表库做生产级加固5.1 硬件加速的取舍前面代码里提到setLayerType(LAYER_TYPE_SOFTWARE, null)。在低版本Android上某些Canvas操作在硬件加速下会出现锯齿或绘制缺失。如果图表只在调试机上正常、在用户手机上显示异常优先检查是否关闭了硬件加速。但软件渲染的绘制效率远低于硬件加速尤其在大屏或高分辨率设备上。折中方案是只在绘制图表内容时临时切换canvas.save(); // 只对数据区做软件渲染网格和文字仍走硬件加速 if (needSoftLayer) { setLayerType(LAYER_TYPE_SOFTWARE, null); } drawSeries(canvas); canvas.restore();把软件渲染的作用域限制在绘制图表内容的瞬间能减少性能损失。5.2 onDraw里的对象复用onDraw每帧都会调用如果在里面创建RectF、Paint、Path这些对象每帧都会产生垃圾内存触发GC后掉帧。这个库在有些地方写着写着就踩了坑比如饼图绘制时在循环里new RectF。优化方式是提前在构造方法或setData时创建好对象onDraw里只更新属性。// 优化前每帧创建新对象 for (int i 0; i n; i) { RectF r new RectF(...); canvas.drawArc(r, ...); } // 优化后复用对象 RectF r new RectF(); for (int i 0; i n; i) { r.set(...); canvas.drawArc(r, ...); }代码行数没有变化但用Profiler抓一次就能看到优化后onDraw耗时基本能降一半。5.3 深色模式与动态字体的适配深色模式下图表默认的白色背景和暗色文字会显得突兀。不要在代码里硬编码颜色可以在资源里定义一套图表颜色主题通过Theme.Material3.DayNight的色板属性读取背景色和文字色。因为图表库的Paint直接使用这些颜色使用方不需要改代码。动态字体问题则要注意如果用sp单位绘制文字标签系统字体缩放比例变化时文字可能超出图表绘制区域导致截断。比较好的做法是给标签设置最大长度阈值超过阈值的文字用省略号替代。这个库作为学习和二次开发的起点是合格的代码量不大、每个图表的实现路径清晰、修改成本低。如果只需要能跑的Demo直接改数据即可如果要上生产环境建议按这些优化点逐项过一遍。本文还有配套的精品资源点击获取
返回列表