尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Android Canvas自绘图表库:从坐标映射到3D柱形图与饼图

用Android Canvas自绘图表库:从坐标映射到3D柱形图与饼图 简介这是一套基于Android Canvas绘制的图表库完整源码面向Android应用开发者及有课程设计、期末大作业或毕业设计需求的学生可快速实现移动端数据可视化。项目支持柱形图、饼图、堆叠图、面积图等多种图表覆盖3D、非3D、横向、竖向、背向式等形态使用简便且定制灵活。压缩包共1097个文件大小约8.93MB以Java源码、XML布局与配置、HTML帮助文档为主并包含PNG、GIF等演示素材目录结构清晰便于定位图表绘制核心逻辑。全部代码经过严格调试导入后即可运行同时保留了完整的工程配置与索引信息适合需要直接复用或深入理解Canvas自定义绘图原理的学习者。目前已有191人学习下载是Android数据可视化开发的实用参考。1. 用 Android Canvas 自绘图表库把柱形图和饼图的绘制权握在自己手里产品要加统计图需求却有点“怪异”柱形图要横向和竖向混用还要一个从中间零线向两侧延伸的背向式柱形图饼图又要求能切到 3D 效果。这些样式在重型开源库里要么靠扩展一堆接口要么被某个版本的行为限制住。一个基于 Android Canvas 自己封装的图表库反而能用两三个自定义 View 和一份坐标映射代码解决。这种方案的价值不在造轮子而在于把绘制逻辑完全握在自己手里。Canvas 是 Android 自带的绘图引擎会写 onDraw就能把列表里的数值变成 Bar Chart、饼图甚至是带厚度的 3D 饼图。它没有 xml 配置文件的玄学调试时断点直接打在画笔和 Path 上。下面这套做法就按一个轻量图表库的搭法讲先搞定坐标系和数据映射再实现竖向、横向、背向式柱形图和 3D/非 3D 饼图。适合对包体积敏感、需要定制图表的 Android 开发。2. 图表库的 Canvas 绘图内核坐标系、Paint 和统一的数据映射这个图表库的顶层设计很简单一个 View 对应一种图表所有绘制都发生在onDraw里。动手前先把 Canvas 绘制里最容易被忽略的三件事说透坐标系变换、Paint 的状态、数据到坐标的映射。这三件事不解决后面画 3D 柱形图一定会返工。2.1 为什么自绘图表绕不开 Canvas 的平移与缩放很多人看过 canvas 教程画圆、画直线都会但一画柱形图就卡在坐标换算上。Canvas 的坐标原点在 View 左上角y 轴向下而业务数据里的“大”“小”“正”“负”没有直接对应屏幕方向。图表库最常见的做法是先canvas.save()用translate把原点移到绘图区的左下角或中心线再用scale把数据单位缩放到像素。canvas.save() canvas.translate(paddingLeft, paddingTop) // 把原点平移到绘图区左上角 canvas.scale(1f, -1f) // 翻转 y 轴让数值越大越靠上 // 此时按“向上为正”的坐标系绘制柱体 canvas.restore()这个组合里有一个高频坑translate和scale的顺序不能反。先scale再translate平移量也会被 y 轴的负缩放翻转原本往右下方移动 20px 的逻辑可能变成往左上方移动。所以我一般只在需要翻转数值轴时才用scale(1f, -1f)标签、文字、触摸命中全部放在正常屏幕坐标系里做避免反复在脑子里做矩阵逆运算。2.2 Paint 三件套颜色、抗锯齿和描边模式Paint 不是一个对象而是一整套画笔状态。自绘图表里最常见的错误是只用一把画笔一会儿FILL一会儿STROKE画完柱形图再画描边结果strokeWidth忘记复位。我给图表库定义了两把全局画笔fillPaint和linePaint。所有填充区域用fillPaint轴线、文字和描边用linePaint。3D 效果里的顶面、侧面再单独建画笔不动态改style。Paint 参数图表场景用法容易踩的坑isAntiAlias饼图、3D 侧边必须开启大圆面积下抗锯齿有开销但静态图表可接受style柱体用FILL圆环用STROKE共用一把画笔切换状态容易漏恢复strokeWidth圆环宽度、轴线粗细不按 density 缩放时不同屏粗细不一致strokeCap横向柱形图圆角端点竖向柱形图顶部也会被改成圆角要按方向选择shader柱体渐变、饼图中心渐变设置 shader 后color不再生效改色需要重建 shader这些参数里shader最容易被误用。你想要一个从底部到顶部渐变的竖向柱形图给fillPaint设置LinearGradient后再调用fillPaint.color color是不会生效的。调试时先看paint.shader是否为 null而不是反复换颜色。2.3 数据归一化让 Bar Chart 和饼图共用一套换算无论柱形图还是饼图第一步都不是画矩形、画弧线而是把业务数据映射成画布坐标。柱形图要把数值除以最大值再乘以可用高度饼图要把数值除以总和再乘以 360。为了避免除零还要处理 total 或 max 为 0 的情况。我习惯把这套逻辑抽成一个独立的ChartDataMapper让两个图表共用。object ChartDataMapper { fun barHeight(value: Float, maxValue: Float, chartHeight: Float): Float { if (maxValue 0f) return 0f return value / maxValue * chartHeight } fun sectorAngle(value: Float, total: Float): Float { if (total 0f) return 0f return value / total * 360f } fun horizontalLength(value: Float, maxAbs: Float, chartWidth: Float): Float { if (maxAbs 0f) return 0f return abs(value) / maxAbs * chartWidth } }这里maxValue不是写死的数据源最大值而是所有数据绝对值的最大值尤其是画背向式柱形图时负方向的柱体长度必须按最大绝对值计算否则 -10 和 -100 的比例会完全乱掉。三个函数的返回单位都是像素不是 0~1 的归一化值后续画RectF、做点击命中测试都能直接用。3. 用自定义 View 实现柱形图Bar Chart竖向、横向、背向式和 3D 效果从这个章节开始做核心的BarChartView。它继承View对外暴露values: ListFloat和direction两个主要属性内部在onDraw里按方向分发绘制。先定义方向枚举和基础数据入口class BarChartView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { enum class Direction { VERTICAL, HORIZONTAL, BACK_TO_BACK } var values: ListFloat emptyList() set(value) { field value invalidate() } var direction: Direction Direction.VERTICAL set(value) { field value invalidate() } var barColor: Int 0xFF3F51B5.toInt() var depthPx: Float dp(8f) private val fillPaint Paint().apply { isAntiAlias true } }setter里主动调invalidate()让外部只需要设置数据源不需要关心刷新时机。depthPx是柱体或 3D 饼图的厚度用 dp 转像素避免在低分辨率手机上看起来又扁又小。3.1 竖向柱形图的最小实现竖向柱形图的绘制顺序是先算柱间距再算每根柱子的宽度最后把 value 映射成高度。关键点在于top的计算Canvas 的 y 轴向下所以柱体顶部应该是paddingTop chartHeight - barHeight。override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (values.isEmpty()) return val maxValue values.maxOrNull()?.takeIf { it 0f } ?: 1f val chartWidth width - paddingLeft - paddingRight val chartHeight height - paddingTop - paddingBottom val barCount values.size val gap dp(6f) // 柱间距离 val barWidth (chartWidth - gap * (barCount 1)) / barCount values.forEachIndexed { index, value - val barHeight value / maxValue * chartHeight val left paddingLeft gap * (index 1) index * barWidth val top paddingTop chartHeight - barHeight fillPaint.color barColor canvas.drawRoundRect( left, top, left barWidth, bottom - paddingBottom, dp(4f), dp(4f), fillPaint ) } }这里gap是两侧和柱子之间的间距总和所以(barCount 1)。如果柱数很多barWidth会小于 0需要在进入循环前做一次判断当barWidth 1f时直接返回避免drawRoundRect参数异常。圆角半径不要超过barWidth / 2否则圆角会互相吃掉看起来像胶囊而不是柱子。3.2 横向柱形图数据映射到宽度横向柱形图和竖向的唯一区别是value 从映射高度改成映射宽度柱子的 y 方向变成等分排列。实际工程里横向柱形图往往用于类别名很长的场景所以左侧需要预留标签区域。我把labelWidth设为 24dpvalue 映射到剩余宽度。Direction值映射轴起点坐标适用场景VERTICALy 轴高度图表区底部常规统计对比HORIZONTALx 轴宽度paddingLeft labelWidth类别名长、数量多BACK_TO_BACKx 轴宽度centerX向两边正负对比、人口结构横向柱形图的绘制代码只贴核心换算部分val labelWidth dp(24f) val chartWidth width - paddingLeft - paddingRight - labelWidth val barHeight (height - paddingTop - paddingBottom - gap * (barCount 1)) / barCount values.forEachIndexed { index, value - val barLength value / maxValue * chartWidth val top paddingTop gap * (index 1) index * barHeight canvas.drawRoundRect( paddingLeft labelWidth, top, paddingLeft labelWidth barLength, top barHeight, dp(4f), dp(4f), fillPaint ) }注意barLength可能是 0如果 value 为 0 就不要调用drawRoundRect。chartWidth的计算已经去掉了labelWidth所以横坐标起点必须从paddingLeft labelWidth开始否则所有柱子都会向左侵占标签区域。3.3 背向式柱形图零线和负值背向式可以看成横向柱形图的一个变体以中线为界正方向向右负方向向左。很多朋友直接传负值进去发现柱子画到 View 外面原因是忘记用abs(value)算长度同时用maxAbs作为最大值。val maxAbs values.maxOfOrNull { abs(it) }?.takeIf { it 0f } ?: 1f val centerX width / 2f val barHeight (height - paddingTop - paddingBottom - gap * (barCount 1)) / barCount val usableWidth width - paddingLeft - paddingRight values.forEachIndexed { index, value - val barLength abs(value) / maxAbs * usableWidth / 2f val top paddingTop gap * (index 1) index * barHeight val left: Float val right: Float if (value 0) { left centerX right centerX barLength } else { left centerX - barLength right centerX } canvas.drawRect(left, top, right, top barHeight, fillPaint) }这个分支里要注意usableWidth / 2f表示左右各占一半但实际数据最大值如果集中在正方向右半边会把左半边挤掉。更稳的做法是maxAbs取正负两组的最大值再乘以总宽度而不是固定分成两半。绘制顺序是先画一条centerX的轴线再画柱子这样零线的视觉锚点不会被柱子覆盖。3.4 柱形图 3D 效果顶面和侧面的 Path 拼法3D 柱形图不是真的三维投影而是在二维平面上给每根柱子补一个顶面和侧面。最常见做法是顶面是一个向右上偏移的平行四边形侧面是柱子右缘和顶面右缘连成的四边形。绘制顺序一定要是先顶面、再侧面、最后正面否则正面矩形会盖住侧面的接缝。private fun draw3DRect( canvas: Canvas, left: Float, top: Float, right: Float, bottom: Float, depth: Float ) { val topPath Path().apply { moveTo(left, top) lineTo(left depth, top - depth) lineTo(right depth, top - depth) lineTo(right, top) close() } canvas.drawPath(topPath, topPaint) val sidePath Path().apply { moveTo(right - 1f, top) lineTo(right depth - 1f, top - depth) lineTo(right depth - 1f, bottom - depth) lineTo(right - 1f, bottom) close() } canvas.drawPath(sidePath, sidePaint) canvas.drawRect(left, top, right, bottom, fillPaint) }right - 1f是为了让侧面和正面矩形之间不产生半透明缝隙1px 的重叠在大多数屏幕上不会引起视觉瑕疵。顶面颜色要比正面浅 15%侧面比正面深 25%光源默认在左上角。ColorUtils.blendARGB(barColor, Color.WHITE, 0.15f)和ColorUtils.blendARGB(barColor, Color.BLACK, 0.25f)可以直接调。depth建议取 6dp~12dp太大会让柱形图的重心偏移看起来像倒了一面墙。4. 饼图与 3D 饼图扇区、圆环和视觉中心饼图在 Android 里的实现看起来比柱形图简单因为Canvas.drawArc原生支持画扇区。但真正做图表库时角度起点、sweepAngle方向、3D 厚度、标签位置每一个都是独立问题。下面从最基础的非 3D 饼图开始。4.1 饼图的基础扇区绘制drawArc 的边界与角度drawArc的边界是一个RectF如果矩形的宽高不相等画出来就是椭圆饼图。要画正圆需要先根据width和height算出最小边长再居中裁剪。默认角度起点是时钟的三点钟方向且角度顺时针增加要让 12 点方向作为起点startAngle要设置成-90f。val side min(width - paddingLeft - paddingRight, height - paddingTop - paddingBottom) val bounds RectF( (width - side) / 2f, (height - side) / 2f, (width side) / 2f, (height side) / 2f ) var startAngle -90f val total values.sum() values.forEachIndexed { index, value - val sweep value / total * 360f sectorPaint.color sectorColors[index % sectorColors.size] canvas.drawArc(bounds, startAngle, sweep - 1f, true, sectorPaint) startAngle sweep }sweep - 1f是制造扇区之间的细缝视觉上比完全无缝更干净。如果某个 value 占比例太小sweep可能小于 1f减出来是负数drawArc会画出反方向的弧线所以遇到sweep 1f的扇区直接跳过。useCenter参数为true是饼图为false是空心圆环圆环常用于“中心放总数”的仪表盘场景。drawArc 参数非 3D 饼图设置3D 饼图设置bounds正方形 RectF正方形 RectF底部偏移 depth 画暗层startAngle-90f与正面一致sweepAnglevalue / total * 360f与正面一致useCentertrue底部暗层也必须是true4.2 3D 饼图的厚度离屏缓存叠加法最省事的 3D 饼图实现是把整个非 3D 饼图先画到一张离屏 Bitmap然后在主画布上先画一次偏移depthPx的暗色版本再画一次原位置版本。这样不需要为每个扇区单独计算侧边 Path视觉效果上已经能看出“厚度”。private val shadowPaint Paint().apply { alpha 160 } private var cacheBitmap: Bitmap? null private fun drawSectors(canvas: Canvas, bounds: RectF) { var startAngle -90f val total values.sum() values.forEachIndexed { index, value - val sweep value / total * 360f sectorPaint.color sectorColors[index % sectorColors.size] canvas.drawArc(bounds, startAngle, sweep - 1f, true, sectorPaint) startAngle sweep } } override fun onDraw(canvas: Canvas) { if (cacheBitmap null || cacheBitmap.width ! width || cacheBitmap.height ! height) { cacheBitmap?.recycle() cacheBitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) Canvas(cacheBitmap).also { drawSectors(it, bounds) } } canvas.drawBitmap(cacheBitmap, 0f, depthPx, shadowPaint) canvas.drawBitmap(cacheBitmap, 0f, 0f, null) }这个方案的缺点是底部暗层是整块平移侧壁只有上下两个方向的“厚度”在 90 度和 270 度附近会轻微漏缝。要做得更精细需要为每个扇区动态生成侧面 Path把扇区的两条半径端点向下平移 depth再连回原扇区边缘。工程上大多数需求用离屏缓存叠加法已经足够先把饼图画出来再让用户看到厚度比纠结几个像素的侧边更务实。4.3 饼图的标签与引线标签的位置由扇区中心角决定通常放在半径的 0.75 倍处。注意 Android 的canvas.drawText绘制的是文字 baseline不是文字中心所以labelY需要减去一半的行高否则文字会整体偏下。val midAngle Math.toRadians((startAngle sweep / 2).toDouble()) val labelRadius side / 2f * 0.75f val labelX bounds.centerX() labelRadius * cos(midAngle).toFloat() val labelY bounds.centerY() labelRadius * sin(midAngle).toFloat() - (textPaint.descent() textPaint.ascent()) / 2f textPaint.textAlign if (cos(midAngle) 0) Paint.Align.LEFT else Paint.Align.RIGHT canvas.drawText(label, labelX, labelY, textPaint)如果扇区太窄两个 label 容易重叠。常见做法是当sweep 15f时不直接放文字只在扇区边缘画一条引线引线末端统一放在图表外沿再水平拉出一段短线。引线用linePaint画不要复用扇区的画笔避免把引线也填上扇区颜色。5. 把图变活动画、触摸事件和自适应尺寸5.1 ValueAnimator 驱动的图表动画图表库如果只能静态展示在 Android 里会显得很生硬。常见的做法是维护一个progress变量在onDraw里把所有 value 乘以 progress再用 ValueAnimator 驱动重绘。private var progress 1f fun animateTo(data: ListFloat, duration: Long 600L) { val oldValues values ValueAnimator.ofFloat(0f, 1f).apply { this.duration duration interpolator DecelerateInterpolator() addUpdateListener { progress it.animatedValue as Float values data.mapIndexed { index, value - val old oldValues.getOrNull(index) ?: 0f old (value - old) * progress } } }.start() }动画回调里是重新计算values而不是乘进度后直接覆盖原始数据这样动画结束后数据就是最终值不需要额外保存一份“真实数据”。如果progress和values同时参与计算会产生二次缩放图表会有先快后慢再反弹的错觉。5.2 触摸命中角度和半径双重判断饼图和柱形图都需要点击交互。饼图的命中测试是角度判断先算触摸点相对圆心的距离超过半径直接返回再算atan2角度和绘制时的startAngle对齐最后在扇区列表里找对应下标。override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action MotionEvent.ACTION_DOWN) { val x event.x - bounds.centerX() val y event.y - bounds.centerY() val distance sqrt(x * x y * y) if (distance side / 2f) return true var angle Math.toDegrees(atan2(y.toDouble(), x.toDouble())).toFloat() if (angle 0) angle 360f angle (angle - 90f 360f) % 360f selectedIndex getSectorIndex(angle) invalidate() } return true }这里的angle - 90f必须和绘制时startAngle -90f对应。如果以后改成从 0 点方向开始绘制命中角度也要同步调整。还有一个隐蔽坑如果绘制时用了canvas.translate或scale触摸坐标是 View 坐标系和 Canvas 坐标系不一致。稳妥的做法是把绘制时的变换矩阵保存下来在触摸事件里用matrix.invert()把event.x、event.y映射回 Canvas 坐标再做命中。5.3 自适应尺寸与密度适配自绘 View 最容易被忽略的是onMeasure。不重写它wrap_content会当作match_parent处理图表布局时会占满全屏。图表库应该为每种图表给出合理默认尺寸并用resolveSize兼容外部约束。图表类型推荐默认宽度推荐默认高度竖向柱形图撑满父容器200dp横向柱形图撑满父容器barCount * 32dp3D 饼图300dp300dp depthPxoverride fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val density resources.displayMetrics.density val defaultWidth (300 * density).toInt() val defaultHeight when (direction) { Direction.HORIZONTAL - (values.size * 32 * density).toInt() else - (200 * density).toInt() } val w resolveSize(defaultWidth, widthMeasureSpec) val h resolveSize(defaultHeight, heightMeasureSpec) setMeasuredDimension(w, h) }values.size在onMeasure里可能为 0需要给一个最小高度否则横向柱形图在数据加载完成前高度为 0导致首次布局后不触发重绘。所有尺寸参数都要用density换算不能直接把 dp 数值当作 px 用。如果 3D 饼图使用了离屏缓存记得在values变化时把cacheBitmap置空这个缓存不刷新比任何 Paint 参数都难排查。本文还有配套的精品资源点击获取
返回列表