简介:本资源面向iOS开发初学者与需要自定义图表效果的中级开发者,聚焦Swift中UIBezierPath绘制贝塞尔曲线的实战应用。内容围绕三次与二次贝塞尔曲线的核心方法展开,涵盖move(to:)、addCurve(to:controlPoint1:controlPoint2:)、addQuadCurve(to:controlPoint:)等关键API,并演示如何通过重写UIView的draw(_:)方法构建平滑数据线,解决折线图生硬、图表美观度不足的问题。压缩包共25个文件,约156KB,包含6个Swift源文件、3个Objective-C头文件、4张效果图、3个plist配置、2个storyboard与1个xib界面文件,以及工程配置与说明文档,结构完整可直接运行参考。已有700余人学习下载。读者可通过示例工程理解贝塞尔曲线路径构建、控制点调节与图表平滑处理思路,掌握将理论知识落地为可复用绘图代码的方法,为后续自定义图表或动画效果打下基础。
1. 从一条贝塞尔曲线说起:为什么图表库总在关键时刻翻车
做过 iOS 数据可视化的开发者大概都有过这种经历:产品经理拿着设计稿说“这个折线图要圆滑一点,不要那种折线感”,你打开常用的图表库,发现要么不支持曲线平滑,要么平滑算法是写死的,改不了控制点。更麻烦的是,当数据量上来之后,滚动卡顿、内存飙升,最后只能硬着头皮自己用 Core Graphics 重画。
这套方案要解决的正是这个问题:不依赖任何第三方图表框架,直接用 Swift 和 UIBezierPath 从零构建一套可复用的图表绘制引擎。它适合两类人——正在做 iOS 课程设计、需要提交完整绘图模块的学生,以及需要在项目中嵌入轻量级图表、又不想引入重型依赖的开发者。核心思路是把贝塞尔曲线的控制点计算、坐标映射、手势交互这三层拆开,每一层都可以单独替换或扩展。下面从需求边界开始,把整套方案拆到能直接照着写代码的程度。
2. 需求拆解与技术选型:为什么是 UIBezierPath 而不是 CAShapeLayer 一把梭
2.1 图表类型与交互需求清单
先明确这套引擎要覆盖的范围。折线图、柱状图、饼图是课程设计和中小型项目里出现频率最高的三种,散点图和面积图可以作为折线图的变体来处理。交互方面,点击选中数据点、长按显示数值气泡、双指捏合缩放时间轴,这三个是答辩时最容易被问到的点。
把需求拆成可验证的条目:
- 折线图支持直线和曲线两种模式,曲线模式下相邻数据点之间用三次贝塞尔曲线连接,平滑系数可配置
- 柱状图支持分组和堆叠,柱体顶部圆角半径可调
- 饼图支持环形模式,扇区之间的间隙角度可配置
- 所有图表共用一套坐标轴绘制逻辑,包括刻度线、网格线、轴标签
- 点击图表任意位置,返回最近数据点的索引和数值
- 数据更新时带过渡动画,动画时长和缓动函数可配置
这些条目直接对应验收标准,后面每一条都会落到具体实现上。
2.2 绘制层选型:UIBezierPath 与 CAShapeLayer 的职责划分
很多教程一上来就用 CAShapeLayer 画一切,这在静态图表里没问题,但一旦涉及手势交互和数据更新就会踩坑。CAShapeLayer 的 path 属性是 CGPath,每次更新都要重新构建整条路径,而且它不参与 UIResponder 链,点击事件得靠 hitTest 手动算。
我一般会这样分工:UIBezierPath 负责构建路径的几何描述,CAShapeLayer 负责渲染和动画。具体来说,在ChartRenderer类里维护一个UIBezierPath实例,每次数据变化时重新计算路径点,然后把path.cgPath赋给对应的CAShapeLayer。这样既保留了 UIBezierPath 在曲线计算上的便利性,又利用了 CAShapeLayer 的隐式动画能力。
// ChartRenderer.swift final class ChartRenderer { private let shapeLayer = CAShapeLayer() private var currentPath = UIBezierPath() // 配置渲染样式 func configure(lineColor: UIColor, lineWidth: CGFloat, fillColor: UIColor?) { shapeLayer.strokeColor = lineColor.cgColor shapeLayer.lineWidth = lineWidth shapeLayer.fillColor = fillColor?.cgColor ?? UIColor.clear.cgColor shapeLayer.lineJoin = .round shapeLayer.lineCap = .round } // 数据更新入口,带过渡动画 func update(with points: [CGPoint], animated: Bool, duration: TimeInterval = 0.3) { currentPath = buildPath(from: points) if animated { let animation = CABasicAnimation(keyPath: "path") animation.duration = duration animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.fromValue = shapeLayer.path animation.toValue = currentPath.cgPath shapeLayer.add(animation, forKey: "pathAnimation") } shapeLayer.path = currentPath.cgPath } private func buildPath(from points: [CGPoint]) -> UIBezierPath { // 具体路径构建逻辑在 3.2 节展开 return UIBezierPath() } }这段代码的关键在于update方法:动画的fromValue取的是当前shapeLayer.path,而不是上一次的路径缓存。这样即使连续快速更新数据,动画也能从当前渲染状态平滑过渡,不会出现跳变。lineJoin和lineCap设为 round 是为了让曲线拐角处不出现尖刺,这在曲线模式下尤其重要。
2.3 坐标映射:把业务数据翻译成屏幕坐标
数据层的数值范围可能是 0 到 10000,而屏幕上的绘图区域只有 300pt 高。坐标映射要处理三件事:Y 轴翻转(屏幕坐标系原点在左上角)、数值范围归一化、边距预留。
struct ChartTransform { let plotRect: CGRect let xRange: ClosedRange<Double> let yRange: ClosedRange<Double> // 将数据点映射到绘图区域内的坐标 func point(for value: (x: Double, y: Double)) -> CGPoint { let xRatio = (value.x - xRange.lowerBound) / (xRange.upperBound - xRange.lowerBound) let yRatio = (value.y - yRange.lowerBound) / (yRange.upperBound - yRange.lowerBound) let screenX = plotRect.minX + CGFloat(xRatio) * plotRect.width // Y 轴翻转:数值越大,屏幕 Y 越小 let screenY = plotRect.maxY - CGFloat(yRatio) * plotRect.height return CGPoint(x: screenX, y: screenY) } }plotRect是扣除坐标轴标签和标题后的实际绘图区域。xRange和yRange通常由数据的最小值和最大值决定,但我会额外加 5% 的 padding,避免数据点贴在边缘上。这个 padding 比例在ChartTransform初始化时传入,默认 0.05。
3. 贝塞尔曲线平滑算法:从控制点计算到路径拼接
3.1 三次贝塞尔曲线的控制点推导
折线图默认是直线连接,视觉上比较生硬。要变成平滑曲线,核心是在相邻两个数据点之间插入两个控制点,用三次贝塞尔曲线连接。控制点的位置决定了曲线的弯曲程度和方向。
常用的算法是 Catmull-Rom 样条到贝塞尔曲线的转换。对于数据点 P0、P1、P2、P3,在 P1 和 P2 之间绘制曲线时,控制点 C1 和 C2 的计算方式如下:
C1 = P1 + (P2 - P0) / 6 * smoothFactor C2 = P2 - (P3 - P1) / 6 * smoothFactor其中smoothFactor是平滑系数,范围 0 到 1。取 0 时退化为直线,取 1 时曲线最圆滑。实际项目中我一般默认设 0.4,这个值在视觉上比较自然,不会出现过度弯曲导致的“过冲”现象。
// CurveInterpolator.swift struct CurveInterpolator { var smoothFactor: CGFloat = 0.4 // 输入原始数据点,输出带控制点的贝塞尔路径 func interpolate(points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard points.count > 1 else { return path } path.move(to: points[0]) for i in 0..<(points.count - 1) { let p0 = i > 0 ? points[i - 1] : points[i] let p1 = points[i] let p2 = points[i + 1] let p3 = i + 2 < points.count ? points[i + 2] : p2 // 计算两个控制点 let c1 = CGPoint( x: p1.x + (p2.x - p0.x) / 6 * smoothFactor, y: p1.y + (p2.y - p0.y) / 6 * smoothFactor ) let c2 = CGPoint( x: p2.x - (p3.x - p1.x) / 6 * smoothFactor, y: p2.y - (p3.y - p1.y) / 6 * smoothFactor ) path.addCurve(to: p2, controlPoint1: c1, controlPoint2: c2) } return path } }边界处理是这段代码里最容易翻车的地方。第一个数据点没有前驱 P0,最后一个数据点没有后继 P3,代码里用i > 0 ? points[i-1] : points[i]和i+2 < count ? points[i+2] : p2做了兜底。如果不做这个判断,数组越界直接崩溃,而且这种崩溃在数据量少的时候才会触发,测试时很容易漏掉。
3.2 路径拼接与填充区域生成
折线图通常需要在曲线下方填充半透明色块。填充区域的路径是在曲线路径的基础上,从最后一个点垂直下落到 X 轴,再水平回到起点,最后闭合。
// 在 CurveInterpolator 中追加方法 func fillPath(for curvePath: UIBezierPath, baselineY: CGFloat) -> UIBezierPath { let fillPath = curvePath.copy() as! UIBezierPath guard let lastPoint = curvePath.currentPoint as CGPoint? else { return fillPath } // 从曲线终点下落到基线,再回到起点闭合 fillPath.addLine(to: CGPoint(x: lastPoint.x, y: baselineY)) fillPath.addLine(to: CGPoint(x: curvePath.currentPoint.x, y: baselineY)) fillPath.close() return fillPath }这里有个细节:curvePath.currentPoint在copy()之后仍然指向原路径的终点,但fillPath是独立副本,后续操作不会影响原曲线。基线baselineY通常是绘图区域的底部,但如果 Y 轴不从 0 开始,基线应该取 Y 轴最小值对应的屏幕坐标。
3.3 柱状图与饼图的路径构建差异
柱状图的路径构建比折线图简单,但圆角处理有讲究。用UIBezierPath(roundedRect:cornerRadius:)只能做四角统一圆角,而柱状图通常只需要顶部两个角是圆角。
// BarPathBuilder.swift func roundedTopBar(rect: CGRect, cornerRadius: CGFloat) -> UIBezierPath { let path = UIBezierPath() let r = min(cornerRadius, rect.width / 2, rect.height) // 从左下角开始,顺时针绘制 path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) path.addQuadCurve(to: CGPoint(x: rect.minX + r, y: rect.minY), controlPoint: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + r), controlPoint: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.close() return path }饼图则是用addArc(withCenter:radius:startAngle:endAngle:clockwise:)逐扇区绘制。每个扇区的起始角度和结束角度根据数值占比计算,间隙角度通过缩小每个扇区的结束角度来实现。注意clockwise参数在 iOS 坐标系里是反的,传true实际是逆时针方向,这个坑我在第一次做饼图时踩过,画出来的扇区全是镜像的。
4. 手势交互与数据选中:让图表能点、能拖、能缩放
4.1 点击选中的命中检测
图表要响应点击,最直接的方式是在图表视图上添加UITapGestureRecognizer,然后在回调里遍历所有数据点的屏幕坐标,找距离点击位置最近的那个。
// ChartInteractionHandler.swift @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let tapLocation = gesture.location(in: self) var closestIndex: Int? var minDistance: CGFloat = .greatestFiniteMagnitude for (index, point) in renderedPoints.enumerated() { let distance = hypot(point.x - tapLocation.x, point.y - tapLocation.y) if distance < minDistance { minDistance = distance closestIndex = index } } // 命中阈值:距离超过 44pt 视为未选中 if let index = closestIndex, minDistance < 44 { delegate?.chartView(self, didSelectPointAt: index) } else { delegate?.chartViewDidDeselect(self) } }44pt 这个阈值来自 iOS 人机交互指南里最小可点击区域的定义。如果数据点很密集,可以适当缩小到 30pt,但不要低于 22pt,否则手指稍微偏一点就选不中,体验很差。
4.2 长按气泡与十字准线
长按显示数值气泡需要两个额外视图:一个垂直的十字准线(用 CAShapeLayer 画一条虚线),一个气泡标签(用 UILabel 加圆角背景)。长按手势用UILongPressGestureRecognizer,在.began状态显示,在.ended和.cancelled状态隐藏。
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { let location = gesture.location(in: self) switch gesture.state { case .began, .changed: guard let index = nearestPointIndex(to: location) else { return } let point = renderedPoints[index] // 更新十字准线位置 crosshairLayer.path = UIBezierPath( from: CGPoint(x: point.x, y: plotRect.minY), to: CGPoint(x: point.x, y: plotRect.maxY) ).cgPath crosshairLayer.isHidden = false // 更新气泡内容和位置 bubbleLabel.text = dataSource?.chartView(self, valueForPointAt: index) bubbleLabel.sizeToFit() bubbleLabel.center = CGPoint(x: point.x, y: point.y - 30) bubbleLabel.isHidden = false case .ended, .cancelled: crosshairLayer.isHidden = true bubbleLabel.isHidden = true default: break } }气泡的 Y 坐标偏移量取 30pt,是为了避免气泡遮挡数据点本身。如果数据点靠近顶部,气泡会被裁剪,这时候需要判断point.y - 30 < plotRect.minY,如果是则把气泡放到数据点下方。
4.3 双指缩放与平移的边界处理
缩放和用UIPinchGestureRecognizer和UIPanGestureRecognizer组合实现。缩放改变的是 X 轴的数据范围,平移改变的是 X 轴的起始位置。这里的关键是边界限制:缩放比例不能小于 1(不能看到比全量数据更少的范围),平移不能超出数据边界。
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { switch gesture.state { case .changed: let newScale = currentScale * gesture.scale // 限制缩放范围:1x 到 5x currentScale = min(max(newScale, 1.0), 5.0) gesture.scale = 1.0 updateVisibleRange() default: break } } private func updateVisibleRange() { let fullRange = dataXMax - dataXMin let visibleSpan = fullRange / currentScale // 确保可见范围不超出数据边界 let maxStart = dataXMax - visibleSpan visibleXStart = min(max(visibleXStart, dataXMin), maxStart) visibleXEnd = visibleXStart + visibleSpan setNeedsDisplay() }currentScale初始为 1.0,每次捏合时乘以手势的增量比例。注意gesture.scale在每次回调后要重置为 1.0,否则缩放会指数级放大。这个细节在 Apple 的文档里没有强调,但实际不重置的话,捏合两次图表就飞到不知道哪里去了。
5. 避坑与常见问题排查:那些答辩时被问住的细节
5.1 曲线过冲:控制点系数过大导致数据失真
现象:折线图在数据波动较大的区域出现明显的“鼓包”,曲线峰值超出了实际数据点的 Y 值范围。
原因:smoothFactor设得太大(比如 1.0),控制点被拉得过远,三次贝塞尔曲线在参数 t 接近 0.5 时会产生过冲。数据变化越剧烈,过冲越明显。
解决:把smoothFactor降到 0.3 到 0.5 之间,或者在计算控制点时对 Y 方向单独做限制,确保控制点的 Y 坐标不超出相邻两个数据点的 Y 范围。我一般会在CurveInterpolator里加一个clampControlPoints开关,默认开启。
5.2 数据更新时动画闪烁
现象:调用update(with:animated:true)后,图表先闪一下旧路径,再过渡到新路径。
原因:shapeLayer.path的赋值和CABasicAnimation的添加顺序不对。如果先赋path再加动画,隐式动画会和显式动画叠加,导致闪烁。
解决:先添加动画,再赋path。同时把CATransaction的disableActions设为 true,关掉隐式动画。
CATransaction.begin() CATransaction.setDisableActions(true) shapeLayer.add(animation, forKey: "pathAnimation") shapeLayer.path = currentPath.cgPath CATransaction.commit()5.3 手势冲突:点击和长按同时触发
现象:长按数据点时,UITapGestureRecognizer也被触发,导致选中状态和气泡同时出现。
原因:两个手势识别器没有设置依赖关系,系统会同时识别。
解决:调用tapGesture.require(toFail: longPressGesture),让点击手势等待长按手势失败后再识别。注意这个调用要在两个手势都添加到视图之后执行。
5.4 内存增长:每次重绘都创建新路径
现象:图表滚动或缩放一段时间后,内存占用持续上升,Instruments 里显示大量UIBezierPath实例未被释放。
原因:在draw(_:)方法里每次都创建新的UIBezierPath,而draw(_:)会被频繁调用。虽然 UIBezierPath 最终会被释放,但高频创建会导致内存峰值和 CPU 占用上升。
解决:把路径构建逻辑从draw(_:)里移出来,只在数据变化时重新计算路径并缓存。draw(_:)里只负责把缓存的cgPath渲染到上下文。如果用的是 CAShapeLayer,根本不需要重写draw(_:)。
5.5 坐标轴标签重叠
现象:X 轴数据点密集时,刻度标签挤在一起,文字互相覆盖。
原因:每个数据点都生成了一个标签,没有做间隔判断。
解决:根据标签的预估宽度和绘图区域宽度,计算最大可显示标签数,然后按等间隔抽取数据点生成标签。比如绘图区域宽 300pt,每个标签宽 40pt,最多显示 7 个标签,那就每隔count/7个数据点取一个。
6. 验收与答辩:怎么证明这套图表引擎是可靠的
验收分三个层面:功能完整性、性能指标、代码质量。功能完整性对照第 2 章的需求清单逐条过,每一条都要有对应的演示操作。性能指标主要看两个数:1000 个数据点下,首次渲染时间不超过 200ms,手势交互的帧率稳定在 55fps 以上。代码质量看的是模块拆分是否清晰,ChartRenderer、CurveInterpolator、ChartTransform、ChartInteractionHandler这四个类的职责是否单一。
答辩时最容易被问到的三个问题,提前准备好答案:
| 问题 | 回答要点 |
|---|---|
| 为什么不用第三方库 | 第三方库的曲线平滑算法不可配置,且引入额外依赖;自研引擎代码量可控,约 800 行核心代码 |
| 贝塞尔曲线的控制点怎么算的 | 基于 Catmull-Rom 样条转换,控制点由相邻四个数据点的差分决定,平滑系数可调 |
| 数据量大了怎么办 | 路径缓存 + 抽稀渲染,超过 500 个数据点时自动启用采样,保证帧率 |
演示的时候,我习惯先展示直线模式,再切换到曲线模式,让平滑系数的滑块从 0 拖到 1,直观展示控制点对曲线形态的影响。然后快速捏合缩放,展示边界限制不会让图表跑飞。最后长按几个数据点,展示气泡和十字准线的联动。这一套流程走下来,基本上评委的问题就集中在算法细节上了,而算法细节在代码注释里都有。
从那以后我每次做图表相关的模块,都会先把坐标映射和路径构建拆成独立的结构体,再写渲染层。这样即使后面要换渲染框架,前面的计算逻辑一行都不用改。希望这套方案能帮你把课程设计或项目里的图表模块稳稳落地。
本文还有配套的精品资源,点击获取