接手这个需求的时候,我本来想着偷个懒,找个现成的图表库直接套上去。结果一打开某体育App项目里的比赛页面,看到密密麻麻的控球率、传球成功率、射门次数统计时,我立刻打消了这个念头。用 Core Animation 手写圆环图和条形图,在当时的场景下反而是更稳、更可控的选择。这篇文章就记录一下我在足球数据可视化这个具体案例里,用 Core Animation 实现圆环图和条形图的全过程,包括选型逻辑、核心原理、踩过的坑和性能调优思路,希望能给做同类功能的朋友一个能直接参考的实战样本。
这次要呈现的数据维度并不复杂,但很典型:一阵容 A 对阵阵容 B 的控球率、射门次数、射正次数、传球成功率、抢断次数和扑救次数。需求方要求页面必须有一眼就能看懂的动画效果,比如圆环从零开始填充到目标百分比、条形图从底部弹起代表两队的射门对比。这些效果用 UIKit 的 UIView 动画也能做,但一旦涉及多图层叠加、路径裁剪和更精确的动画节奏控制时,Core Animation 明显是更舒服的底层工具。
1. 为什么在这个场景下值得手写 Core Animation 而不是直接上图表库
1.1 需求回顾:足球比赛页里到底要画哪些东西
先还原一下我面对的真实需求:比赛结束后,要在详情页里展示两队的核心数据卡。其中控球率和传球成功率用圆环图,射门次数、射正次数、抢断次数用条形图。页面是嵌入一个横向滑动的数据卡容器里的,每张卡片独立滑动,并且在第一次完全出现时再触发动画,不能一开始就全部播放。
要素看起来不多,但有一个关键约束:设计稿上的圆环不是死板的纯色,而是有渐变,而且条形图的柱子顶端要带圆角,柱子底部还要有一条浅色的背景轨道。这些细节如果交给开源图表库,往往需要大量 override 去凑,有些库甚至根本不支持渐变圆环的 stroke 动画,最后还是会回到自己画图层的路上。与其绕一圈再回头,不如一开始就用 Core Animation 把最基本的东西捏在手里。
1.2 图表库的短板与手写方案真正补上的是什么
我并不是说图表库一无是处。如果项目里需要饼图、雷达图、折线图这些高频复杂图表,而且对包体积不太敏感,引入一个成熟库确实省事。但在足球数据卡这块,图表库有几个实际痛点:
第一,样式定制成本高。默认配色和我的需求完全不同,圆环线的线帽想做成圆头,库没暴露这个属性,就只能把源码翻出来改。第二,动画时机不可控。很多库的动画是随视图出现自动触发的,我想在滑动到某个位置后再手动触发,就很难优雅地嵌入现有滚动机制。第三,依赖增加体积和风险。为了几张卡片引一个几十 KB 甚至更大的库,对于一个已经很庞大的 App 来说并不划算。
手写方案的好处恰恰在这里:圆环的本质是一段只有 stroke 没有 fill 的弧线,条形图则是一个从高度 0 长到目标高度的矩形图层,这两件事用 Core Animation 表达起来非常自然,代码量也不算夸张。后面我会把具体思路拆开讲。
2. 圆环图:把“58%控球率”变成一段有说服力的动画
2.1 原理拆解:CAShapeLayer 的 strokeEnd 才是圆环的核心
先明确一个概念:Core Animation 里并没有“圆环”这个现成控件。我们画圆环,实际上是创建一个CAShapeLayer,给它设置一条圆弧路径,然后通过控制路径的绘制比例来实现填充效果。这里最关键的属性是strokeStart和strokeEnd。默认情况下strokeEnd是 1,表示整条路径全部绘制;如果设为 0.58,就只绘制 58% 的弧线。
所以圆环从 0 到目标值的动画,本质上就是对strokeEnd做动画,而不是对路径做动画。这一点很多人会搞混,以为要不断修改path才能实现增长效果。其实只要路径固定,动画strokeEnd就能得到平滑的圆环填充效果,性能也会更好,因为路径没有变化时,图层可以直接复用已有的渲染结果。
具体到代码上,第一步是确定圆弧路径。比如控球率圆环的中心点、半径和起始角度:
let center = CGPoint(x: bounds.midX, y: bounds.midY) let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 let path = UIBezierPath( arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: .pi * 3 / 2, clockwise: true )2.2 数据到动画的映射:值、时长和缓动函数的选择
把数据变成动画时,最朴素的思路是把目标百分比直接赋给strokeEnd。但这里有个容易被忽略的问题:模型层(model layer)和展示层(presentation layer)的区别。如果只设置目标值然后加一个动画,动画结束时如果直接移除,图层会跳到完整状态。更稳妥的做法是明确从 0 开始动画,并且在动画结束后,把最终的strokeEnd真实值落到模型层。
我这里用的是一个很常见的套路:
let valueLayer = CAShapeLayer() valueLayer.strokeColor = UIColor.systemTeal.cgColor valueLayer.fillColor = UIColor.clear.cgColor valueLayer.lineWidth = 10 valueLayer.lineCap = .round valueLayer.path = path.cgPath valueLayer.strokeStart = 0 valueLayer.strokeEnd = 0 layer.addSublayer(valueLayer) let progress = CABasicAnimation(keyPath: "strokeEnd") progress.fromValue = 0 progress.toValue = 0.58 progress.duration = 1.0 progress.timingFunction = CAMediaTimingFunction(name: .easeOut) CATransaction.begin() CATransaction.setCompletionBlock { // 动画结束后,把模型层的 strokeEnd 固定为最终值 valueLayer.strokeEnd = 0.58 valueLayer.removeAnimation(forKey: "progress") } valueLayer.add(progress, forKey: "progress") CATransaction.commit()这里我个人的经验是:不用fillMode = .forwards去强撑动画结束态,而是利用CATransaction的completionBlock把真实值设置回去。原因是fillMode强撑的“本帧状态”并不可靠,一旦图层被复用或者发生刷新,很容易出现视觉上的闪烁回退。用 completion 设置模型值,才是真正稳定落地的做法。
缓动函数上也有一点讲究:数据填充圆环时,不要全程线性。线性动画看起来像机器在干活,缺少一种“生长感”。我尝试过easeOut和自定义贝塞尔曲线,最终选定的是一段稍微带一点回弹感的曲线,让它先快后慢,又不会像弹簧一样多余晃动,适合作足球数据的口感。
2.3 渐变、圆头线和中心数字:三个最容易翻车的地方
圆环做成纯色虽然也能看,但设计稿上要的是渐变。对CAShapeLayer做渐变有两条路:一是自己用CAGradientLayer+ mask 组合,二是给CAShapeLayer单独加渐变属性(实际上前者更常见)。
我这里用的是CAGradientLayer作为背景层,把圆环的 shapeLayer 作为它的 mask。这样做有个很不显眼的坑:mask 的 frame 必须和渐变层 frame 一致,否则渐变层裁切的范围会偏离。我最初只设置了 mask 的 path 和 frame 的一致性,结果在圆环缩小时,渐变颜色边缘对不上,圆环看起来脏脏的。后来直接在布局阶段把两者的 frame 统一处理,问题才消失。
圆头线是另一个细节。lineCap = .round之后,圆环两端会出现半圆帽,视觉上很精致。但要注意:线帽会让整体渲染范围超出 path 的弧线边界。如果你的圆环 lineWidth 是 10,那么渲染出的实际半径会向外扩 5 个点。如果容器尺寸算得刚刚好,圆帽可能会被边缘裁掉。稳妥的做法是在计算 radius 时把 lineWidth 的一半预留出去,也就是radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2。
中心数字在圆环里其实是独立布局的,它不归 CAShapeLayer 管。我一开始把数字做成了圆环图层的 sublayer,结果旋转圆环或者设置锚点时,数字也跟着跑偏。改成独立的 UILabel 或者 CATextLayer,再通过约束跟圆环容器对齐,就再也没出过问题。
3. 条形图:射门对比与抢断数据的动态柱状
3.1 两种实现路线:从 bounds 动画到 layer 高度动画
条形图的核心逻辑比圆环更直观:一根柱子的高度,等于一个数值相对于最大值的比例。最大的问题是动画怎么做,因为一个CALayer的尺寸变化,可以用两种完全不同的方式驱动。
第一种是改变 layer 的 bounds。把柱子的anchorPoint放在底部,也就是(x: 0.5, y: 1),这样修改 bounds 的高度时,柱子只会向上生长,底部不会移动。然后对bounds做CABasicAnimation。这条路很直接,但在某些特殊布局下需要小心:如果你同时用 Auto Layout 约束控制这个宿主视图的尺寸,再对内部 layer 做 bounds 动画,可能会出现布局系统把你的动画值覆盖的情况。
第二种是直接用CAShapeLayer画一个矩形,然后动画它的path。这种方式的灵活度更高,尤其是柱子顶端要带圆角时,只需要生成一段带圆角的矩形路径,动画path就行。代价是路径动画的计算量比 bounds 动画大一点,数量少时完全无感,数量多时可能会有压力。
我在这个项目中用的是 bounds 动画,因为数据量不大,而且柱子底部的背景轨道可以用另一条浅色 layer 来做,不需要让路径参与复杂的动态重绘。当柱子的数据值是 0 时,bounds 的高度会退化成 0,视觉上就会消失,这也是我们后面会提到的边界处理问题。
3.2 多根柱子同时入场时的时序设计
条形图往往是多根柱子并列对比的,比如 A 队射门 12 次、B 队射门 8 次,如果两根本身都是同一屏数据,那同时入场还是先后入场,观感差别很大。
我实测下来的结论是:同一组数据同时入场且使用相同动画时长,视觉上最整齐,适合快速对比。但如果你希望做出“先看 A 再看 B”的引导效果,可以给每根柱子设置一个从 0 开始的递增 delay。这个 delay 的方式很多,最基础的是用DispatchQueue.main.asyncAfter逐个触发动画。不过更好的做法是直接用CAAnimation的beginTime,把所有动画加到一个独立的 time offset 上。
这里我用了一个简单但效果不错的方式:把根柱子的动画时间偏移设为index * 0.08秒,并且加上一个轻微的fadeIn。你看到的效果会是一根根柱子像多米诺骨牌一样依次弹起来,数据对比的节奏感特别清楚。
let anim = CABasicAnimation(keyPath: "bounds.size.height") anim.fromValue = 0 anim.toValue = targetHeight anim.duration = 0.5 anim.beginTime = CACurrentMediaTime() + Double(index) * 0.08 anim.timingFunction = CAMediaTimingFunction(name: .easeOut) barLayer.add(anim, forKey: "barHeight")要注意,beginTime如果不加CACurrentMediaTime(),动画会以图层添加到图层树的时间为基点,可能造成延迟全部失效的现象。这是新手最容易踩的坑。
3.3 选中态和其他柱子的联动:CATransaction 与反选效果
数据卡还有个交互需求:点击某根柱子,它会高亮,其他柱子变暗,然后下方浮层显示具体数值。这个交互本质上也是一个动画,核心是用CATransaction关闭隐式动画,手动控制各图层的透明度变化。
在 Core Animation 里,直接修改一个 layer 的opacity或backgroundColor往往会触发隐式动画,时长通常是 0.25 秒左右。如果你不想让每个属性都走隐式动画,就得在事务里显式关闭:
CATransaction.begin() CATransaction.setDisableActions(true) highlightLayer.opacity = 1.0 otherLayer.opacity = 0.3 CATransaction.commit()但如果你想要它平滑过渡,反而可以利用隐式动画的默认行为,或者在事务里手动设置一个更长的时长。我在这里的经验是:选中态用 0.2 秒的淡入淡出,反选态用 0.4 秒,这样点击时响应够快,松开时也不会觉得冷冰冰。
点击高亮的另一细节是 hitTest 的处理。柱子的 layer 本身是视图的子层,直接加手势到一个宿主视图,然后通过判断触摸点的 x 坐标落在哪根柱子的区域来判断点击对象,比给每个 layer 单独加手势更简单。实测这样即使柱子高度为 0,也不会影响点击区域的判断,因为区域判断用的是整根柱子的预设容器宽度,而不是视觉高度。
4. 从一帧卡顿到满帧:性能调优的真实过程
4.1 最初为什么掉帧:mask、阴影和隐式动画的叠加
功能写完第一次真机测试时,整体动画其实已经能跑,但总觉得不够顺滑。尤其是在 iPhone 12 这种机型上,页面滚动时圆环和条形图如果反复出现在可视区外,会出现轻微卡顿。我用 Instrument 的 Core Animation 工具看了一下,发现问题集中在三处。
第一处就是CAGradientLayer的 mask。mask 本身会触发离屏渲染,如果页面里同时存在多个渐变圆环,每帧都要离屏合成一次,开销不小。第二处是条形图柱子上的shadowPath或shadowOpacity,默认情况下阴影是每帧参与计算的,除非指定了 shadowPath。第三处是隐式动画。我在某个地方直接改了 layer 的 backgroundColor,没有用事务包住,结果 Core Animation 默认做了一个隐式过渡,频繁地被列表刷新打断后反而增加了开销。
盘子敲到这一步,答案就很明确了:不是 Core Animation 本身不快,而是我在不合适的位置叠加了太多“每帧必须重新合成的东西”。
4.2 几个实测有效的优化手段
针对上面的问题,我做了几个相对保守但非常有效的调整。
第一个是给卡片内容所在的容器层开启shouldRasterize。这意味着容器内的图层会先被渲染成位图缓存,之后滚动时直接复用这份缓存,不需要每帧重新算渐变和 mask。但这里有个前提:shouldRasterize开的对象不能太多太大,否则内存占用量会上升,适得其反。我把它开在单张数据卡的根层,并且搭配rasterizationScale = UIScreen.main.scale,保证缓存清晰度。
第二个是给不需要实时的图层设置drawsAsynchronously = true。这个属性特别适合绘制型图层,比如我们自己画的 shapeLayer。它可以让绘制操作放到后台线程去做,不阻塞主线程的动画提交。不过这个属性不是万能的,如果你同时用shouldRasterize,两者叠加时需要测试,否则可能出现异步绘制和缓存之间互相等待的诡异状态。
第三个是给阴影固定shadowPath。条形图柱子如果只有几个像素的阴影,完全不需要 Core Animation 动态计算阴影边界。直接传一个与柱子形状匹配的 UIBezierPath,渲染开销会立刻降下来。
barLayer.shadowPath = UIBezierPath(roundedRect: barBounds, cornerRadius: 4).cgPath4.3 列表滑动中的复用问题:动画残留与数据错乱
数据卡不是单独一屏,而是横向滑动列表中的多个 item。一旦涉及复用,Core Animation 的坑就非常明显了:复用出队列时,旧 layer 可能还带着上一次的 animation,新的数据触发动画时,如果不先移除旧动画,会出现“两根柱子互相抢高度”的视觉抖动。
我最后在复用回调里做了三件事:先把所有相关 layer 的removeAllAnimations()调一遍,再把 model layer 的bounds、strokeEnd等属性重置为初始状态,最后再通过一个小的 delay 或滚动停止信号来触发新动画。这三步的顺序不能乱,尤其是“清除动画”必须在“设置新数据”之前,否则先赋了新值再加上动画,很可能从错误的基础值开始播放,看起来像是跳变。
另外还有一个容易被忽视的问题:如果页面在动画播放过程中被滑出可视区,再滑回来时,动画已经结束了,但 model layer 的值可能因为 completion 没执行而停留在初始状态。我这里因为用了CATransaction的 completion 去写模型值,这个问题基本被规避了。如果你用的是fillMode = .forwards,就一定记得在复用时手动写最终值。
5. 真实比赛数据里的边界情况:0、溢出和悬殊比例
5.1 数据为 0 时的观感处理:别画一个尴尬的圆
足球场景里有很多数据天然是 0,比如某一方全场零射正、零扑救。如果直接把 0 传进去,圆环的strokeEnd就会停在 0,页面上只剩一个光秃秃的背景轨道。这个视觉结果其实很差:用户会觉得这个数据卡是不是加载失败了。
我的处理方式是分两步:第一步,当目标比例小于某个阈值(比如 3%)时,不播放从 0 到 0 的空动画,而是直接让背景轨道保持浅色,同时中心数字显示为 0%,再加一个半透明的“暂无数据”标签覆盖在图表区域。第二步,如果比例在 3% 到 5% 之间,我会人为给它一个最小绘制值,比如 0.04,让圆环看起来是“有一点但很少”,同时在中心数字上仍然显示真实数据值,避免误导。这个方法属于设计取舍,不算数据造假,因为对用户来说,核心信息来自数字,而不是那一圈弧线的长度。
5.2 悬殊比例下的条形图:最短可见高度与极长柱子的协调
篮球、足球数据里经常出现 12 比 1、15 比 0 这种极端情况。如果完全按最大值等比例映射,最小值那根柱子可能只有 2 个像素高,几乎看不见。更麻烦的是,如果底部还有圆角,那个圆角半径可能已经大于柱子高度,圆角渲染出来会形成一个小椭圆,观感很怪。
我给条形图设计了一个“可见高度下限”机制:targetHeight = max(value / maxValue * maxHeight, minVisibleHeight),这个minVisibleHeight我设置的是 6 个点。这样既能保证更小的数据肉眼可见,又不至于让真实的数值比例失真太多。同时当柱子高度小于圆角半径的两倍时,我还会把圆角半径临时改小,防止出现椭圆形状。
5.3 动态数据与模型层的同步:避免新旧动画互相打架
实际场景中,数据不一定是常量,比赛过程中可能会推送更新。每次新数据进来,如果直接加新动画,旧动画可能还没结束,层上同时挂两个动画,最终表现就是数值忽高忽低、来回震荡。比较可靠的做法是:更新前先把图层上的相关动画 key 全部移除,然后用CATransaction的animationDuration = 0临时把模型层设置成新数据的最终值,再通过一次新的动画过渡过去。
我在这里踩过一个小坑:如果直接把 model layer 的新值设置完之后立即 add 动画,动画的 fromValue 会取当前 presentation layer 的值,这在大多数情况下是对的,但如果图层刚好处于一个被移除动画后的静态状态,fromValue 可能会拿错。所以稳妥的方式仍然是显式设置 fromValue 和 toValue,别依赖隐式取值。这个经验在频繁更新数据的场景下非常实用,比如比分直播页里的射门次数变化。
5.4 关于圆环旋转方向与阅读习惯:起点到底放哪里
还有一个可能不会被注意到的细节:圆环的起点角度。足球数据卡通常使用中文和英语两种语言,用户阅读习惯都是从左到右、从上到下,圆环的起点放在顶部正中央,也就是-π/2,然后顺时针旋转,是最符合直觉的方案。如果你用默认的 0 度起点,也就是圆弧从水平右侧开始,配合某些设计很可能看起来像断掉的一截表盘,用户第一眼无法立刻感知百分比从哪里开始。这个角度甚至可以在数据卡适配多语言时再做一次微调,但总体原则是顶部起始、顺时针增长。
在实际项目里,我还遇到过一个特殊需求:某比赛详情页需要同时展示六个圆环卡片,还要支持左右滑屏。如果每个圆环都在容器初始化时立刻触发动画,你会发现滑到后面几页时动画早就播完了。后来我把动画触发机制改成:让数据卡所在容器实现一个代理回调,在卡片被当前屏幕占据超过一半宽度时才触发动画。这个逻辑用UIScrollView的contentOffset或者UICollectionView的cellForItemAt都能实现,关键在于不要在视图创建时就急着播放。
这段过程让我对 Core Animation 和页面性能的关系有了更具体的理解。核心动画其实不复杂,复杂的是知道哪些系统行为在背后偷偷消耗性能,以及如何在模型层、展示层和动画状态之间保持数据一致性。如果你也在做类似的数据可视化页面,我建议别急着引库,先把圆环和条形图用图层手写一遍,再去看库的源码,你会对动画的本质有完全不一样的感觉。