news 2026/10/14 23:41:41

Core Animation实战:用CAShapeLayer实现流畅数据图表动画

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Core Animation实战:用CAShapeLayer实现流畅数据图表动画

前两天我刚处理完一个足球数据播报页的需求:比赛结束之后,要把两队全场数据用动画逐项呈现出来,控球率、射门次数、传球成功率、跑动距离,每一项都要“长”出来,而且动画要顺滑,不能掉帧。当时我没有引入任何图表库,直接基于 Core Animation 写了一套圆环图和条形图,实测下来动画流畅、内存可控、后期样式定制也非常灵活。这篇文章就把这两个真实案例的实现过程、动画机制和踩过的坑完整记录下来。

这个场景在足球类 App 里很典型。赛后页面需要展示主队与客队的控球率对比,可能是 55% 对 45%,同时用两组条形图对比射门次数、角球数、扑救次数。用户对数据的准确性要求不高,但对视觉反馈很敏感。如果数据是瞬间刷出来的,页面会很“干”;用动画让圆环慢慢长满、条形一根根顶起来,观众会觉得整个产品更有活力。要实现这种效果,最顺手的技术栈就是 Core Animation,它直接操作 CALayer 和 CAShapeLayer,渲染过程在 GPU 侧完成,不需要反复触发 CPU 绘图,性能上有天然优势。

1. 为什么选 Core Animation,而不是 UIKit 动画或图表框架

1.1 需求场景拆解与方案对比

先说说这个需求的具体形态。页面结构大概由三部分组成:顶部是一块比分区域,中间是控球率圆环图,下面是一组条形图,展示两队射门、射正、传球成功率等指标。每项数据都要求在页面出现后的 1 到 2 秒内完成动画,圆环和条形必须同步推进,数值标签也要跟着动画走。

当时我评估过几条技术路线。第一条是直接用 UIKit 的 UIView 动画,把普通的形状视图做缩放或宽度变化。但圆环没法用 UIView 的 frame 动画表达,必须用贝塞尔曲线路径,于是实际上还是得落到 CAShapeLayer 上。第二条是接入成熟的图表库,功能确实齐全,但为了两个圆环和几根条子引入一整个框架,体积和绑定成本都不划算,而且图表库的动画定制能力经常不够细。第三条是自己在 drawRect 里搞,每帧重绘,CPU 压力高,转场也容易闪白。最终我选了纯 Core Animation,用 CALayer 搭图形结构,用 CABasicAnimation 驱动动画。这样做的好处是所有动画都在渲染线程处理,主线程只负责提交动画参数,交互流畅度有保障。而且图层结构完全由自己控制,加渐变、加阴影、加文字联动都方便。

1.2 Core Animation 的动画机制,几句话讲透

Core Animation 的动画模型,本质上是“模型层 + 表现层 + 渲染树”三层结构。我们代码里直接操作的 CALayer 是模型层,它持有的是动画最终状态;动画过程中我们肉眼看到的,是表现层 presentationLayer 实时计算出的插值状态;而真正把内容画到屏幕上的,是系统渲染进程维护的渲染树。当 CABasicAnimation 被 add 到图层上时,动画对象描述了属性的起点、终点、时长和步调,渲染进程会按帧对属性做插值,这就是动画能够平滑出现的原因。

需要特别注意两个属性:fillMode 和 isRemovedOnCompletion。默认情况下,动画结束后会被移除,图层的属性会立刻回到模型层的值;而 fillMode 决定动画在开始前或结束后是否保持某个状态。很多人写动画时习惯把 fillMode 设为 .forwards 且 isRemovedOnCompletion 设为 false,以为这样动画结束就能停在终值。这种做法虽然能显示正确结果,但动画对象不会被释放,图层状态也被“卡”在动画里,后续更新属性有隐患。正确做法是:先把模型层的属性设置成动画终点值,再添加动画,动画结束时不用保留任何东西,图层自然就是终值。这个原则在后面的圆环和条形代码里我会反复用到。

2. 圆环图实战:控球率双色圆环与进度联动

2.1 双色圆环的基本图层搭建

控球率圆环是最典型的需求。主队 55%,客队 45%,两个圆弧拼成一个完整的圆。这里的关键是理解 CAShapeLayer 的 strokeStart 和 strokeEnd 属性。一个 CAShapeLayer 可以只描边贝塞尔路径的一部分:strokeStart 从 0 到 1 表示起点,strokeEnd 从 0 到 1 表示终点,两者夹出来的区间就是实际显示的弧段。两个圆环分别用半圆以上的弧段表示比例,视觉上就是一个完整的圆环被切割成了两段。

第一步是创建圆弧路径。圆环的路径用 UIBezierPath 生成,起始角度一般选顶部,也就是 -90 度,顺时针走一整圈。半径需要减去线宽的一半,否则粗线会超出视图边界。代码大概是这样的:

let path = UIBezierPath( arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: min(bounds.width, bounds.height) / 2 - lineWidth / 2, startAngle: -CGFloat.pi / 2, endAngle: CGFloat.pi * 1.5, clockwise: true ) let homeLayer = CAShapeLayer() homeLayer.path = path.cgPath homeLayer.fillColor = UIColor.clear.cgColor homeLayer.strokeColor = UIColor.systemBlue.cgColor homeLayer.lineWidth = lineWidth homeLayer.strokeStart = 0 homeLayer.strokeEnd = 0.55

客队圆环要覆盖剩下的弧段,strokeStart 设为 0.55,strokeEnd 设为 1。两个圆环叠加在同一个 view 上,视觉上就是一个完整体。需要注意的细节是,strokeStart 和 strokeEnd 是相对路径总长度的比例,不是角度值。如果你想让两段圆弧之间保留一点空隙,可以给主队的 strokeEnd 留出 0.01,客队的 strokeStart 从 0.56 开始,这样一个细微的缝隙会让界面显得更精致。

这里还有个坐标系问题。CAShapeLayer 的 path 使用的坐标是 layer 自身的 bounds 坐标系,所以 UIBezierPath 的 center 应该用当前控件的 bounds 中心,而不是屏幕坐标。如果视图的 frame 是 (30, 100, 200, 200),那么 center 就是 (100, 100)。很多人初次写会直接把 view.center 传进去,结果圆环位置完全跑偏,这是最常见的坑之一。

2.2 圆环动画:strokeEnd 生长效果与数字联动

圆环动画的核心是让 strokeEnd 从 0 变化到目标比例。主队圆环从 0 涨到 0.55,客队圆环从 0 涨到 0.45。CABasicAnimation 的 keyPath 直接写 "strokeEnd" 就行。为了让两段圆弧看起来是同时开始、分别生长,动画时长可以一样,也可以用不同的时序曲线。比如主队时长 1 秒,客队时长 0.85 秒,后者的动画先完成,视觉重心会落在主队这边,观感更自然。

let growAnimation = CABasicAnimation(keyPath: "strokeEnd") growAnimation.fromValue = 0 growAnimation.toValue = 0.55 growAnimation.duration = 1.0 growAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) homeLayer.strokeEnd = 0.55 homeLayer.add(growAnimation, forKey: "homeRingGrow")

注意我给 homeLayer 的 strokeEnd 直接设置了最终值,然后再添加动画。这个过程在代码顺序上看起来是先把属性改成终值,再让动画从 0 过渡到终值,听起来有点矛盾,实际上这正是 Core Animation 推荐的做法。动画对象里的 fromValue 会在动画期间“覆盖”模型层的显示,动画结束移除后模型层的值正好是终值,既不会跳变也不会卡住。如果不设置模型值,只依赖动画的 forwards 模式,一次两次没问题,但图层一旦被重用或者页面刷新,bug 就来了。

圆环生长过程中,中间的数字百分比也要跟着更新。这个联动有两个方案。第一个是利用 presentationLayer 实时读取当前 strokeEnd。动画进行中,presentationLayer 的 strokeEnd 会返回当前帧的插值,用一个 CADisplayLink 或者定时器驱动 label 更新。第二个是直接用动画时长和进度函数自己算,但这样要和 Core Animation 的时序保持一致比较麻烦,用 presentationLayer 反而最简单。

我的实测代码是开了一个 CADisplayLink,每帧读取 homeLayer.presentation()?.strokeEnd,转成整数百分比显示在圆环中心。动画结束后记得 invalidate 掉 displayLink,否则页面不销毁时会一直空转而浪费 CPU。这个做法比在动画回调里一次性更新文字要自然得多,因为文字是逐帧增长而不是跳到终值的。

let displayLink = CADisplayLink(target: self, selector: #selector(updatePercentLabel)) displayLink.add(to: .main, forMode: .common) @objc private func updatePercentLabel() { guard let presentation = homeLayer.presentation() else { return } let percent = Int(presentation.strokeEnd * 100) percentLabel.text = "\(percent)%" }

动画结束后,在 CAAnimationDelegate 的 animationDidStop 回调里 invalidate displayLink,顺手把百分比校准成最终值,防止最后一次插值没落到 100%。这一步容易忽略,如果只用 Int 截断而不是四舍五入,最终可能显示 54% 而不是 55%,非常尴尬。

2.3 渐变圆环和细节处理

比赛直播场景里,圆环可能不只是纯色。比如控球率圆环用从蓝到青的渐变,配合品牌色会更好看。渐变圆环的实现思路:先用 CAGradientLayer 铺一个渐变背景,再把 CAShapeLayer 作为它的 mask。mask 没有自己的颜色,它只负责决定 gradient 层的哪些像素可见,所以把 strokeEnd 动画加在 maskLayer 上,渐变圆环就会跟着生长出来。

let gradientLayer = CAGradientLayer() gradientLayer.frame = bounds gradientLayer.colors = [ UIColor.systemBlue.cgColor, UIColor.systemTeal.cgColor ] gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) gradientLayer.mask = ringMaskLayer

这里有个容易踩坑的地方:gradientLayer 的 frame 要和被 mask 的视图 bounds 完全一致,否则圆弧在渐变中的位置会偏移。还有一个细节是 startPoint 和 endPoint 的朝向,如果期望渐变从左到右,那么 startPoint 是 (0, 0.5),endPoint 是 (1, 0.5)。如果期望从上到下,则改成 (0.5, 0) 和 (0.5, 1)。CAGradientLayer 默认 startPoint 是 (0.5, 0),endPoint 是 (0.5, 1),也就是从上到下的渐变,很多人没注意就直接用了,出来跟预期不一致。

圆环结构还要考虑底环和端点的搭配。一般做法是底部先铺一个浅色全圆环作为“轨道”,表示 100% 的区间,再在上面叠加数据圆环。轨道圆环的线宽可以比数据圆环细 2 像素,视觉上更有层次。如果想让圆弧端点变成圆帽而不是平头,可以设置 lineCap = .round,这个属性在粗线条图表里能明显提升品质感。但要注意 lineCap = .round 会让圆弧两端向外突出半个线宽,当两段圆弧首尾相接时,重叠那一小截反而不好看,所以双色圆环我通常保留平头,虚线或者单弧展示时才用圆帽。

3. 条形图实战:射门与传球成功率的多组并行动画

3.1 条形图布局与图层实现

条形图的需求是横向排列的,比如左侧一列主队数据,右侧一列客队数据,中间是类别标签。每一根条子对应一个数据点。最直接的实现方式是用 CALayer 作为条形背景,通过修改 bounds 的高度和 CAShapeLayer 做 clip 来决定显示多少。但这里我推荐一个更简洁的方案:CALayer 的 anchorPoint 设为 (0.5, 1),这样高度变化时,条形会从底部往上生长,而不会向两边扩散。

创建条形图的基本流程是:先根据容器的宽度计算条宽、间距和起始 X 坐标,再计算数据比例对应的目标高度,最后创建 CALayer,设置背景色、圆角和初始高度 0。

let value = 10.0 let maxValue = 15.0 let ratio = value / maxValue let targetHeight = maxBarHeight * ratio let barLayer = CALayer() barLayer.backgroundColor = UIColor.systemBlue.cgColor barLayer.cornerRadius = 3 barLayer.masksToBounds = true barLayer.anchorPoint = CGPoint(x: 0.5, y: 1) barLayer.frame = CGRect( x: xOffset, y: bottomY - targetHeight, width: barWidth, height: targetHeight ) barLayer.bounds.size.height = 0 containerLayer.addSublayer(barLayer)

注意这里有个细节:anchorPoint 改变的是 CALayer 的变换原点。默认 anchorPoint 是 (0.5, 0.5),高度从中心向上下扩展;设置成 (0.5, 1) 后,底部固定,高度只往上长。同时要小心 frame 的设置顺序。anchorPoint 会影响 frame 与 bounds 的关系,所以先设置 anchorPoint,再设置 frame,否则计算结果不符合预期。这一点写起来很绕,我在项目里因为调换顺序被坑过一次,视图直接跑到了奇怪的位置。

3.2 条形生长动画与数据标签的时序协调

条形生长的动画本质是修改 bounds.size.height。我用 CABasicAnimation 的 keyPath 写成 "bounds.size.height",fromValue 是 0,toValue 是目标高度。模型层直接设置 bounds.size.height 为目标高度,动画对象只负责插值,结束移除后条子刚好停在目标位置。

let growAnimation = CABasicAnimation(keyPath: "bounds.size.height") growAnimation.fromValue = 0 growAnimation.toValue = targetHeight growAnimation.duration = 0.6 growAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) barLayer.bounds.size.height = targetHeight barLayer.add(growAnimation, forKey: "barGrow")

这里会遇到一个和多条条形图相关的经典问题:所有条形的动画是同时开始的,如果希望它们有先后错落的节奏,比如从第一项到最后一项依次弹出来,就需要给 CABasicAnimation 设置 beginTime。beginTime 的单位是相对于图层时间轴的秒数,通常用 CACurrentMediaTime() 做基准。第 n 根条子的动画 beginTime 等于当前时间加 n 乘以间隔,比如 0.1 秒。这样视觉上会有一条接一条生长的层次感,比所有条子一起动要好看得多。

还要搭配 fillMode 设置。因为没有 animatonGroup,单条动画在 beginTime 到来之前,图层会呈现模型层的最终高度,这显然不对。解决方案是给动画对象设置 fillMode = .backwards,这样在 beginTime 之前,图层会显示动画的 fromValue,也就是高度 0。动画开始后插值正常推进,结束后模型层恢复终值。同时间隔不要超过动画的一半时长,否则第一条已经长完了,最后一条还没动,节奏会断裂。我常用间隔是动画时长的三分之一,比如 0.6 秒动画配 0.15 秒间隔,整体看起来像波浪一样连贯推进。

条形图顶部的数值标签,可以做成跟随条形生长的模式。最简单的做法是把标签放在条形上方固定位置,动画开始时同步进行 alpha 透明度从 0 到 1 的过渡,再加一个轻微的上移。这种方案实现成本低,效果也能接受。如果要求标签数值从 0 数到最终值,同样可以复用圆环章节提到的 CADisplayLink 方案,每帧根据某根条子的 presentationLayer 计算比例。需要注意条形图的数量通常多于圆环,多个 displayLink 同时跑会浪费资源,我的习惯是只开一个 displayLink,在里面同时更新所有需要计数的标签。

3.3 条形图配色与数据轴标记

条形图除了对比高度,还需要一眼能看出哪根条子属于哪个队。配色上我推荐一深一浅的搭配,或者两个完全不撞的品牌色。例如主队用蓝色,客队用橙色,避免左右两侧都用近似颜色导致视觉混淆。条形背景可以铺一层很淡的同色系轨道,代表最大值或者目标值区间。如果数据比较接近,比如射门 10 比 9,条高差异很小时,可以在每条的上方增加一个数值标签,直接用数字说话,这样用户不需要非常仔细地比较条长。

对于最大值,我建议根据数据类型动态计算。射门次数通常最高是 20 左右,传球成功率则天然是 0 到 100 的百分比。如果所有条子共用同一个最大值,那么衡量标准才是一致的。千万不要为每一根条子单独计算自己的最高比例,那样低的条子会显得很高,数据会被严重误读。我在初版实现时犯过这个错误,像 2 次射门和 10 次射门会被画成一样高的条子,改过来以后图表才有说服力。

4. 动画性能与视图生命周期管理

4.1 让图层动画更流畅的实践

Core Animation 的好处是把渲染放在 GPU 上,但如果我们使用不当,照样会卡。首当其冲的是离屏渲染。CALayer 的 cornerRadius 配合 masksToBounds,以及 shadow 系列属性,都可能导致离屏渲染。离屏渲染不是绝对禁止,而是要注意频率和数量。我们的圆环和条形图如果只是少量几个 shadow,影响可以忽略,但如果图表所在的页面同时出现在列表的多行 cell 里,shadow 数量会成倍增长,每次都触发离屏渲染,卡顿就非常明显了。

使用 shadow 的正确方式是给出 shadowPath。圆环可以用 UIBezierPath 的 cgPath 作为阴影路径,条形直接用 rect 路径。有了显式 shadowPath,系统就不需要每帧动态计算阴影轮廓,性能好很多。另一块需要注意的问题是 shouldRasterize。它可以让图层内容预渲染成位图,之后复用,但只适合静态图层。动画进行中的图层开 shouldRasterize 反而会降低性能,因为每帧都要重新栅格化。如果你要在 cell 里复用包含图表的 view,可以等动画结束之后再开启 shouldRasterize,或者干脆不开,仅靠核心动画本身已经足够高效。

除此之外,需要控制同一时刻的图层数量。一个页面几十个 CALayer 不是问题,几百个就需要警惕。条形图每组数据 2 根条子,每根条子 1 个 layer,就算十几组数据也就三四十个 layer,完全没压力。圆环部分涉及底环、数据环、渐变环、文字 label,也就不到 10 个。除非是把动画用在列表滚动视图中频繁插入和移除,否则不用过度焦虑。

4.2 动画对象的生命周期管理

动画结束时,CABasicAnimation 默认会被从图层上移除,这是合理的行为。但如果你把 need 一直用 fillMode = .forwards 保留,动画对象会一直挂在 layer 上,图层后续如果在 setValue 时又要强制去更新表现状态,就会出现各种奇怪的不生效问题。我的习惯是:所有 CABasicAnimation 都不设置 fillMode 和 removedOnCompletion 的持久化,而是把模型值直接改到位。配合 fillMode = .backwards 的场景,也只有延迟播放的 stagger 动画才会用到。

还有一点是关于 reuseCount。同一个页面如果数据在短时间内从 A 状态切到 B 状态,比如用户点了“半场统计”再切回“全场统计”,需要先移除所有正在进行的动画,再更新模型值,最后添加新动画。移除动画的方法是 removeAllAnimations()。如果不做这个清理,旧动画的 fromValue 和新动画的 toValue 会互相干扰,页面会出现闪烁甚至数值错乱。重置的代码最好封装成一个 resetLayer 的方法,统一处理:移除动画、恢复初始状态、重新创建路径。对于已经被多个动画叠加的 layer,最保险的做法是直接把旧的 layer 从父图层移除,新建一个,避免隐藏状态残留。

4.3 页面切换与视图销毁时的处理

足球数据页往往在比赛结束后会被用户反复打开和退出。如果控制器在 viewWillDisappear 时没有停止动画,可能出现两种情况:一是动画继续在后台渲染,浪费资源;二是控制器释放了但 layer 还挂在渲染树,产生野指针。处理方式是:页面消失时暂停当前容器 layer 的所有动画,可以用 layer.speed = 0 配合 layer.timeOffset 记录当前进度;也可以直接 removeAllAnimations() 再渲染一个静态终态。对于简单的展示型图表,我倾向于直接移除动画,保留终值。这样用户回来看不到动画中断的尴尬,也避免动画回放带来的额外性能消耗。

如果需要在某次动画结束后做事件处理,比如播放一个音效或者弹出推荐内容,要使用 CAAnimationDelegate 的 animationDidStop(_:finished:) 方法,并且判断 finished 是否为 true。不要只判断动画存在与否,因为 removeAllAnimations() 也会触发这个回调,那时 finished 是 false。忽略这个参数会导致原本应该只在动画结束后执行的逻辑被意外触发,比如数据页莫名弹出推荐弹窗,这种 bug 排查起来很花时间。

5. 实战中常见的坑与快速排查

5.1 圆环不显示、圆弧方向不对

  • 现象:圆环图层加上了,但界面上一片空白。
  • 可能性 1:fillColor 忘了设置成 clear,默认黑色填充会把路径围起来的区域全部涂黑,看起来像一块黑色圆饼,不是圆环。
  • 可能性 2:strokeColor 设置的是 UIColor 而不是 CGColor。直接写 UIColor.systemBlue 不起作用,必须 .cgColor。
  • 可能性 3:lineWidth 没有设置或者线条被其他图层遮住。
  • 可能性 4:path 的中心点用了错误的坐标系。

圆弧方向不对的现象多是路径起点或终点理解有偏差。UIBezierPath 中顺时针方向是相对于 iOS 坐标系而言的,也就是原点在左上角、y 轴向下。如果画的是比赛计时环形,要注意起始角度。用 startAngle = -pi/2 作为顶部起点,endAngle = pi * 1.5 作为一整圈的结束,这是最常用的写法。很多次我改参数时把 endAngle 直接写成 2 * .pi,虽然数学上一整圈等于 2pi,但顺时针路径如果从 -pi/2 开始走一圈,终点应该是 1.5pi,不是 2pi。虽然 2pi 也画出了完整圆弧,但 startAngle 和 endAngle 的数学关系会导致 start 和 end 之间的区间定义不同,圆弧比例复杂时容易出错。

5.2 动画结束后回弹、卡在错误位置

  • 现象:动画过程中很流畅,但动画一结束,图形要么瞬间缩回原点,要么跳到另一个状态。
  • 回弹原因:模型层没有设置最终值,动画对象默认 removedOnCompletion = true,动画结束后图层直接显示模型层的旧值。解决方法是先设置模型层再添加动画。
  • 卡住原因:fillMode = .forwards + isRemovedOnCompletion = false 会让图层保持在动画最后一帧的状态,但这种状态是“被动画冻结”的,后续想再次修改属性会被覆盖。这种情况下不要懒惰地继续沿用旧写法,把图层重置再重新赋值往往更高效。

还有一种情况是动画没有执行,直接显示终值。这通常发生在模型值被设置之后,动画对象的 fromValue 没有写上,而系统把 fromValue 隐式理解为当前模型值,导致动画区间是“当前值到当前值”。所以编写 CABasicAnimation 时,fromValue 和 toValue 至少有一个明确设置,否则动画没有插值空间。

5.3 数据切换、屏幕旋转和复用场景

数据从一批切换成另一批时,旧动画必须清除。这里分享一个我的标准流程:先调用 containerLayer.sublayers?.forEach { $0.removeAllAnimations(); $0.removeFromSuperlayer() },然后重新构建所有子图层。这种方式简单粗暴,但最可靠。重建图层的成本也就是几十个 CALayer,在图表页面里完全可以让步于正确性。

屏幕旋转时,layer 的 bounds 会变化,原来的 path 和 frame 需要重新计算。所以圆环和条形图的创建方法要抽成独立的 build() 函数,在 viewDidLayoutSubviews 里判断 bounds 尺寸是否变化,发生变化就重新 build。不要在 layoutSubviews 里直接 add 新的 layer,要先 remove 旧的,否则每次 layout 都会多一层,图层列表里挤满幽灵图层,内存逐渐膨胀。我遇到过这类报告,页面每转一次屏就卡一点,最后检查发现 sublayers 已经叠加了上百层。

如果要支持深色模式,颜色需要在 traitCollectionDidChange 或视图的 userInterfaceStyle 变化时刷新。CALayer 的 backgroundColor、strokeColor 不能自动适配动态颜色,必须监听 trait 变化后手动更新,否则在界面切换深浅色时图表颜色会变得非常奇怪。这也是 Core Animation 和 UIKit 控件的一个明显差异,UIKit 原生控件能自动处理的动态颜色,在 CALayer 层面必须手动同步。

5.4 快速排查自查表

症状最常见原因排查方式
图层完全不显示fillColor/strokeColor 未用 cgColor打印 layer 描述,check layer.sublayers 数量
圆环是实心圆盘fillColor 没有 clear设置 fillColor = .clear
动画直接跳到终值fromValue 缺失或模型值先被设置为终值检查 fromValue 与动画添加顺序
动画结束后回弹未设置模型层终值先设模型值,再加动画
条形从中间向两边生长anchorPoint 未设置到底部anchorPoint = (0.5, 1)
多条条形同时出现而不是依次生长未设置 beginTime 或 fillMode 不对设置 beginTime 和 .backwards
渐变方向不对startPoint/endPoint 写反确认 (0,0) 是左上角,(1,1) 是右下角
页面二次进入时图表错乱旧动画未移除removeAllAnimations 或重建 sublayers
深色模式切换后颜色不对CALayer 不支持动态 color监听 traitCollectionDidChange

6. 动手复刻一套的注意事项

如果你在自己的项目里复刻这套方案,我建议先写一个独立的 SwiftUI 或 UIKit 独立页面,把圆环和条形分别封装成两个可复用组件。圆环组件接收一组比例值和配色,条形组件接收一组数据数组和最大值。组件内部不要再依赖任何 viewController,方便后续嵌入不同的比赛数据模块。

圆环组件的接口我一般设计成:

final class RingChartView: UIView { func setData(homePercent: CGFloat, awayPercent: CGFloat, homeColor: UIColor, awayColor: UIColor, animated: Bool) } final class BarChartView: UIView { func setData(homeValues: [CGFloat], awayValues: [CGFloat], maxValue: CGFloat, animated: Bool) }

这样页面只要准备好数据,调用一次 setData 就能看到完整的动画效果。动画的时长、间隔、配色都做成内部配置,统一管理。后面如果产品要求“先显示半场数据,用户点击后再切换全场数据”,只需要对组件重复调用 setData,组件内部处理动画重置即可,调用方不需要关心图层的生命周期。

也要配合静态预览。如果页面是图文混排或者用来做分享卡片,图表可能需要导出为图片。导出时如果动画尚未结束,直接截屏会截到动画中间态。我的做法是在导出前强制让所有图层跳到模型层终值,也就是移除动画,等待一帧主线程渲染完成,再截屏。这个细节只在踩过一次坑之后才会想到,但确实很常见:用户分享卡片时,图表要么没长完,要么状态是乱的。

从实现层面看,这套方案完全可以用在足球比赛播报、篮球赛事统计、健身数据仪表盘,甚至任何需要百分比或对比值的展示场景。核心总量不大,但核心机制是一致的:理解模型层与表现层的关系,正确使用 strokeEnd 和 bounds 动画,管理好动画生命周期,剩下的就是样式细节的打磨了。

我个人在实际操作中的体会是,这类图表动画最难的部分不是写动画代码,而是理解 Core Animation 的状态模型。只要把“模型值负责最终状态、动画对象负责中间过程、表现层只负责当前帧”这三件事想透,再复杂的效果都能拆成几个基础动画的组合。最后再分享一个小技巧:动画时长别太夸张,数据图表不是演示动画,越干脆越好。控球率圆环用 0.9 到 1.2 秒,条形图用 0.5 到 0.7 秒,错落间隔别超过 0.2 秒,视觉节奏最舒服。希望这篇实践笔记能帮你在做类似数据可视化时少走几条弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 23:41:35

TensorFlow与Python高级技巧二合一:中级程序员工程化实战指南

1. 从“会跑通”到“能落地”:中级程序员为什么需要这份二合一指南如果你已经能用 Python 写脚本、能照着教程跑通一个 MNIST 手写数字识别,甚至能自己搭一个三四层的全连接网络,但一到真实项目就卡壳——模型不收敛、显存爆了、训练慢得离谱…

作者头像 李华
网站建设 2026/10/14 23:41:32

OSPF协议深度解析:从Hello握手到SPF计算的完整工作流

1. 项目概述:这不是一道选择题,而是一次对OSPF协议肌理的现场解剖你点开这道题的时候,大概率正卡在某个实验报告的最后一页,光标在“实验结论”四个字上反复闪烁,手边是Wireshark抓了一半的包、GNS3里三台路由器接口状…

作者头像 李华
网站建设 2026/10/14 23:41:17

个人AI Agent架构设计:记忆、权限与跨App协同实战

如果你试着用现成的对话式 AI 当生活里的私人助理,大概率撑不过两天就会放弃:它记不住你和同事之间的默契,搞不清哪些数据能碰、哪些不能碰,更别提让它老老实实地在邮件、日历、笔记之间来回跑腿。我前前后后折腾了大半年 Persona…

作者头像 李华
网站建设 2026/10/14 23:41:07

Libtorch部署实战:从模型导出到C++高性能推理的完整指南

在深度学习落地这条路上,我折腾过不少推理框架,但最后上生产环境时,Libtorch是我最常用的选择之一。很多人觉得Libtorch部署是个“高大上”的事,其实说白了就是把PyTorch训练好的模型,从Python环境搬到C环境里跑起来。…

作者头像 李华
网站建设 2026/10/14 23:40:58

从零上手Hermes Agent:用大语言模型驱动工具自动化的实战指南

1. 项目概述与核心思路我最近在折腾智能体(Agent)方向的个人项目,翻来覆去试了不少框架,最后在一个叫 Hermes Agent 的开源项目上停留了很久。它的定位很朴素:用大语言模型当“调度大脑”,把你要做的事拆解…

作者头像 李华