☰
用 Core Animation 从零实现实时篮球比分折线图:CAShapeLayer 实战
2026/10/14 8:50:58 网站建设 项目流程

做体育资讯 App 的比分模块时,我决定不引入任何三方图表库,直接用 Core Animation 从零实现一个篮球比赛折线图。很多人遇到折线图第一反应是找现成图表库,但实时比分这种场景里,三方库反而会放大问题:数据刷新链路长、动画定制空间小、出了问题还得去读库的源码。而 Core Animation 最擅长的就是二维图形渲染,一条折线本质上是 path,一组得分点就是一批小 layer,动态刷新和过渡动画恰好是它的主场。这篇文章把整个实战过程拆开来讲,包括坐标系设计、CAShapeLayer 绘制、滚动视口与尾点动效、性能控制,适合想理解 iOS 动画底层机制的开发者,也适合需要在 App 内实现实时折线图但不想被三方库绑架的朋友。

1. 为什么我坚持不用三方库,Core Animation 赢在哪

先交代一下背景。当时项目组要做一个体育直播模块,其中一个需求是把主客队比分随比赛时间的变化实时画出来,大概每 20 到 30 秒出现一次得分事件,数据更新一次,折线就要跟着动一次。这种图表看起来简单,但“实时”两个字把很多常规方案挡在门外。

三方图表库的典型痛点是“适配成本”。无论库本身多好用,它都有一套自己的数据源协议和刷新机制,你得先把比赛事件转换成库能理解的数据模型,再手动触发刷新、布局、重绘。这个过程在静态图表里没问题,但在高频动态场景下,库内部的布局引擎会反复计算、重建缓存,导致明明只是加了一个点,却要付出整张图重算的代价。定制动画就更难受了,我想让最新得分点出现时有一个弹跳效果,库的接口如果不暴露这一层,就得靠 hack。还有包体积和启动时耗,为了一个折线图引入几百 KB 的依赖,怎么看都不划算。

Core Animation 的优势非常直接:图层是轻量级的,动画是在渲染服务端处理,折线路径走 GPU 渲染,整个模块零新增依赖。篮球比赛的数据量对 CA 来说属于轻松范畴,一场比赛 48 分钟,算上传球、暂停、犯规,得分事件最多也就几百次,两条折线加几百个得分点,对图层树来说是小场面。

对比项三方图表库Core Animation 手写
包体积通常几百 KB 起步零新增依赖
实时刷新需要适配库的数据源协议直接修改 model 的 path 和 position
自定义动画受限于库暴露的接口任意 Core Animation 动画组合
调试难度需要理解库内部布局逻辑所有 layer 都归你管
上手门槛低,但理解深度有限需要 CA 基础知识,中长期收益高

当然,我也不是说三方库一概不能用。如果是复杂金融图表,有成千上万个数据点、大量坐标轴交互、缩放手势、数据钻取,那就老老实实用成熟方案,手写成本太高。但如果是篮球比分这种高频、轻量、强定制的实时线条图,手写是完全正确且更长远的选择。这个项目做完之后,我对 Core Animation 的图层树、隐式动画、presentation layer 的理解,比啃一个月文档都深。

2. 从比分数据到坐标点:坐标系设计与边界处理

折线图的第一步永远不是画线,而是把数据正确地变成坐标。这一步做不对,后面所有动画都白搭。

2.1 数据模型:一个得分事件包含什么

篮球比赛的数据流是一条事件序列,我定义了一个很轻的数据结构:

struct ScoreEvent { let time: TimeInterval // 比赛开始后经过的秒数,比如 125.0 let home: Int // 此时主队总得分 let away: Int // 此时客队总得分 }

当主队进球时,就追加一个ScoreEvent,其中home加 2 或 3,away不变;客队进球则相反。这个模型的好处是忠实地保留了时间信息,后续想切换成绝对时间轴或事件索引轴都很方便。

2.2 坐标转换的两条铁律

UIKit 的坐标系是 y 轴向下,而图表里分数是 y 轴向上。所以把分数映射成 y 坐标时,必须把比例关系反一下。核心公式是这样:

func yPoint(score: Int, minScore: Int, maxScore: Int, chartRect: CGRect) -> CGFloat { let ratio = CGFloat(score - minScore) / CGFloat(maxScore - minScore) return chartRect.maxY - ratio * chartRect.height }

x 轴相对简单,按照比赛时间(或事件序号)在整个时间跨度上的占比,映射到chartRect的水平区间:

func xPoint(time: TimeInterval, duration: TimeInterval, chartRect: CGRect) -> CGFloat { let ratio = CGFloat(time / duration) return chartRect.minX + ratio * chartRect.width }

但这里有个关键决策:x 轴按绝对比赛时间还是按事件索引推进?两者差别很大。按绝对时间,比赛暂停和节间休息会在图上留下大片空白,折线会出现明显的平台区,视觉上不紧凑。按事件索引推进,也就是每次得分事件在 x 轴上等距分布一档,折线会非常饱满,适合直播场景下的快速阅读。我最终选了事件索引推进,因为篮球比赛的观众关心的是“谁在连续得分、比分差距怎么变化”,而不是“第 12 分钟这里有个两分钟的空档”。如果你做的是足球或网球,需要体现比赛节奏的波峰波谷,再用绝对时间轴更合适。

2.3 y 轴范围不能写死

比分从 0:0 打到 120:110,y 轴如果写死 0 到 150,前面的比赛过程会挤在底部一小条区域里,很难看。正确做法是随数据动态计算上界和下界:

let allScores = events.flatMap { [$0.home, $0.away] } let rawMin = allScores.min() ?? 0 let rawMax = allScores.max() ?? 0 var minScore = max(0, rawMin - 5) var maxScore = rawMax + 5 if maxScore - minScore < 10 { maxScore = minScore + 10 // 防止开局阶段折线被拉成垂直抖动 }

这里有两个容易被忽略的点。一个是“最小跨度保护”,比赛刚开始只有 2:0,如果不保底 10 分跨度,折线的第一个点会从顶部直接掉到底部,视觉上像心电图。另一个是底部留余量,篮球比分从 0 开始,但我不想让 0 分贴住底边,所以minScore在原始最小值基础上再往下压 5 分,给折线呼吸空间。

2.4 绘制区域的 padding

绘制区域不能等于整个 view 的 bounds,否则折线会撞到边界。我定义了一个布局结构体:

struct ChartLayout { var frame: CGRect var topPadding: CGFloat = 40 // 顶部留给比分标签 var bottomPadding: CGFloat = 30 // 底部留给时间或事件刻度 var leftPadding: CGFloat = 50 // 左侧留给 y 轴数字 var rightPadding: CGFloat = 20 var chartRect: CGRect { CGRect( x: frame.minX + leftPadding, y: frame.minY + topPadding, width: frame.width - leftPadding - rightPadding, height: frame.height - topPadding - bottomPadding ) } }

初学者最容易犯的错是直接拿view.bounds算坐标,结果第一个点出现在屏幕左上角。给坐标轴留出固定空间,是图表施工的第一步。

3. 图层树搭建:折线、圆点、文本的绘制分工

确定了坐标映射之后,就进入 Core Animation 的核心环节——图层树。我的思路是让每个视觉元素都对应一个或一组 layer,背景、网格、折线、得分点、文本各司其职,避免把所有绘制都塞进drawRect里。

3.1 图层的层次结构

整体结构是这个样子的:

final class ScoreChartView: UIView { private let gridLayer = CALayer() // 静态网格背景 private let containerLayer = CALayer() // 动态内容容器 private var homeLineLayer = CAShapeLayer() // 主队折线 private var awayLineLayer = CAShapeLayer() // 客队折线 private var homeDots: [CAShapeLayer] = [] // 主队得分点 private var awayDots: [CAShapeLayer] = [] // 客队得分点 private var scoreLabel = CATextLayer() // 最新比分文本 override init(frame: CGRect) { super.init(frame: frame) layer.addSublayer(gridLayer) layer.addSublayer(containerLayer) setupLineLayer(homeLineLayer, color: UIColor.systemOrange) setupLineLayer(awayLineLayer, color: UIColor.systemBlue) } }

gridLayer放在最底下,永远不变,只画网格线和坐标轴刻度。containerLayer是动态内容统一挂载的地方,折线、得分点、文本都在它下面。为什么要单独加一层 container?因为后续要让它整体向左平移模拟视口滚动,如果把折线和点都直接挂在 view 的 layer 上,移动时就要逐个改坐标,性能差且逻辑复杂。有了容器层,只需要改变它的position.x,所有子图层跟着走。

3.2 折线层的正确配置

折线是CAShapeLayer的标准用法。初始化时有一个配置很容易被忽略:

private func setupLineLayer(_ lineLayer: CAShapeLayer, color: UIColor) { lineLayer.strokeColor = color.cgColor lineLayer.fillColor = UIColor.clear.cgColor lineLayer.lineWidth = 3 lineLayer.lineJoin = .round lineLayer.lineCap = .round }

fillColor必须设为 clear,否则CAShapeLayer默认会把路径围成的区域填充成颜色。折线图是不闭合的路径,看起来无所谓,但一旦路径出现交叉或跨度,填充会在折线和坐标轴之间糊上一块颜色,非常脏。lineJoin和lineCap设置为.round也很重要,篮球比分折线在转折点非常多,直角连接会出现明显的毛刺感。

生成路径的代码同样简单直接:

private func makeLinePath(from points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard let first = points.first else { return path } path.move(to: first) for point in points.dropFirst() { path.addLine(to: point) } return path }

3.3 得分点:尽量用 path 画圆,少用 cornerRadius

得分点代表每一次得分事件在折线上的位置。很多人会直接用CALayer的cornerRadius来切圆,但那会在大量图层上触发离屏渲染,虽然一两个没问题,数量多了还是会有影响。更稳的方式是让CAShapeLayer持有一个圆形的CGPath:

private func makeDotLayer(radius: CGFloat = 4, color: UIColor) -> CAShapeLayer { let dot = CAShapeLayer() dot.bounds = CGRect(x: 0, y: 0, width: radius * 2, height: radius * 2) let circle = UIBezierPath(ovalIn: dot.bounds) dot.path = circle.cgPath dot.fillColor = color.cgColor dot.strokeColor = UIColor.white.cgColor dot.lineWidth = 1.5 return dot }

外圈加一圈白色描边,比赛图表的得分点在两条折线颜色接近时也能区分开。position属性决定点在容器坐标系里的位置,这一点后面动画章节会用到。

3.4 文本层:CATextLayer 的两个细节

比分数字我用CATextLayer而不是UILabel,因为图表内部的文本不需要参与 Auto Layout,直接用图层更轻量。但CATextLayer有一个著名的坑:默认渲染分辨率不是屏幕的,文字看起来会发虚。必须手动设置:

scoreLabel.contentsScale = UIScreen.main.scale scoreLabel.string = "98:93" scoreLabel.fontSize = 14 scoreLabel.foregroundColor = UIColor.secondaryLabel.cgColor scoreLabel.frame = CGRect(x: 0, y: 0, width: 80, height: 24)

另外,不要给每个得分点都挂一个文本标签显示具体分数。一场比赛几百个点,每个点配一个 CATextLayer,图层数翻倍,而且满屏数字根本看不清。我的策略是只在比分变化联动的位置更新一个“最新比分”文本,位于图表右上角;关键的比赛节点(反超、单节结束)单独用高亮点标记,这放到后面扩展部分讲。

4. 实时比分更新的动画策略:滚动视口与尾点动效

静态折线图谁都会画,动态刷新才是篮球比赛折线图真正考验技术的地方。每一次得分事件追加后,整个图表要平滑地跟随数据变化,既不能生硬跳变,也不能让用户头晕。

4.1 两种刷新思路,我为什么选择滚动视口

一种思路是每次数据更新后,把整条折线的所有点重新计算一遍,然后让CAShapeLayer的 path 做动画。这种做法的问题是,随着事件数量增多,旧的坐标点会被压缩到越来越小的 x 范围里,整条线像被“挤”了一样,视觉上并不符合直播场景。

另一种思路是把图表设计成一个固定宽度的内容画布,数据点从右往左追加,让containerLayer整体向左滚动,始终让最新得分点停留在可视区域的右侧。这就像是摄像镜头跟着比赛节奏慢慢平移,用户能清楚看到“历史线在向左移动,新点从右边进入视野”。我最终选择了这个滚动视口模型。

核心布局参数如下:

let step: CGFloat = 36 // 每个得分事件在容器坐标系中的水平间距 let contentWidth = CGFloat(eventCount - 1) * step + 60 let visibleWidth = chartRect.width let offsetX = max(0, contentWidth - visibleWidth)

每次新事件到达就先追加数据,然后重算出所有得分点的坐标,接着用动画把containerLayer.position.x移动到-offsetX。

4.2 用 CATransaction 统一提交动画

Core Animation 的一大特性是支持事务。我可以把多个图层的属性修改放进一个CATransaction里,让它们以同一组动画参数提交,避免各动各的:

CATransaction.begin() CATransaction.setAnimationDuration(0.5) CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) homeLineLayer.path = newHomePath.cgPath awayLineLayer.path = newAwayPath.cgPath containerLayer.position.x = -offsetX CATransaction.commit()

这里有一个非常关键的细节:直接修改图层属性,Core Animation 会自动触发隐式动画,但隐式动画的行为在各属性、各系统版本上并不完全一致。尤其path属性在很多版本里不会自动插值,可能出现“线一下跳到新位置”的情况。所以要做到绝对可控,我建议用手动显式动画,而且必须记住一个规则:先设置 model 层的最终值,再添加 animation,否则动画结束后图层会弹回旧位置。

let pathAnim = CABasicAnimation(keyPath: "path") pathAnim.fromValue = homeLineLayer.presentation()?.path ?? homeLineLayer.path pathAnim.toValue = newHomePath.cgPath pathAnim.duration = 0.5 homeLineLayer.path = newHomePath.cgPath homeLineLayer.add(pathAnim, forKey: "homePathAnimation")

presentation()方法返回的是当前屏幕上正在显示的图层状态。用它的 path 作为动画起点,而不是单纯用模型层的 path,是为了避免上一次动画还没结束时就开始下一次动画,导致闪现跳变。

容器移动同理:

let moveAnim = CABasicAnimation(keyPath: "position.x") moveAnim.fromValue = containerLayer.presentation()?.position.x ?? containerLayer.position.x moveAnim.toValue = -offsetX moveAnim.duration = 0.5 containerLayer.position.x = -offsetX containerLayer.add(moveAnim, forKey: "containerMove")

两个动画使用相同的 duration 和 timingFunction,视觉上才能保持路径延长与视口平移同步推进。

4.3 尾点动效:得分事件的“落点”反馈

篮球比赛折线图里最需要动效的其实不是整条线,而是每一次得分瞬间。当主队进球,主队折线最右侧的点要往上跳一档;客队进球,客队线的尾点同样移动。为了让比分变化有“打击感”,我给新得分点加了一个弹出动画,同时让两个队的最后一个点移动到新的坐标位置。

新得分点弹出:

let dot = makeDotLayer(color: hasScored ? homeColor : awayColor) dot.position = newPoint containerLayer.addSublayer(dot) dot.transform = CATransform3DMakeScale(0.1, 0.1, 1) let pop = CAKeyframeAnimation(keyPath: "transform.scale") pop.values = [0.1, 1.3, 0.9, 1.0] pop.keyTimes = [0, 0.55, 0.8, 1] pop.duration = 0.4 pop.timingFunction = CAMediaTimingFunction(name: .easeOut) dot.add(pop, forKey: "popIn")

尾点移动需要分情况:如果主队得分,主队最后一个点的 y 坐标变了,x 坐标没变,所以只需改变它的position.y;客队没得分,但比赛时间推进了,所以客队最后一个点的 x 坐标要向右移,y 不变。这个逻辑用两层动画实现:

if let lastHomeDot = homeDots.last { let anim = CABasicAnimation(keyPath: "position") anim.fromValue = lastHomeDot.presentation()?.position ?? lastHomeDot.position anim.toValue = newHomePoint lastHomeDot.position = newHomePoint lastHomeDot.add(anim, forKey: "tailMove") }

注意每次得分事件都会生成新的 dot,所以homeDots数组的长度实际上是事件序号的同步镜像,需要保证数组顺序和事件追加顺序一致。

有一个细节我一开始没处理好:当容器整体向左移动时,如果我只更新了 path 和容器的 position,而得分点 dot 的位置没有跟随 container 移动,就会出现“折线走了,点留在原地”的情况。因为 dot 是挂在 container 下面的,只要 container 移动,dot 自身不用动。但如果某个 dot 是动画过程中临时创建的,或者它的 position 是在 view 坐标系下计算的,就会错位。我的项目里所有 dot 的坐标都在 container 坐标系内计算,这样 container 一挪,整棵子树都跟着走,不需要手动更新旧点坐标。这也解释了为什么容器层这个设计如此重要。

4.4 动画被打断时的兜底

实时赛事流里,数据到达频率并不均匀,有时几秒内连续两个得分事件,有时几分钟没动静。如果上一次动画还没播完新事件就来了,必须优雅地处理动画冲突。我的做法是为每次动画加一个 unique key,在开始新动画之前调用removeAnimation(forKey:)把旧动画移除,然后基于presentation()层的当前值开始新动画:

homeLineLayer.removeAnimation(forKey: "homePathAnimation")

这能避免动画之间互相覆盖导致折线位置异常。实际测试中,这句话缺了话,连续得分时会看到折线路径突然闪一下,然后再慢慢归位,观感非常奇怪。这个坑在调试时花了不少时间才定位到。

5. 高频刷新下的性能护栏与踩坑清单

动态折线图做出来了,接下来的问题是在真机上一直跑会不会卡。这一章节把我实测中发现的问题和采取的优化措施整理成一份可复用的清单。

5.1 限制图层数量,而不是无脑堆点

一场 NBA 比赛按每回合都得分来算,主客队合计每场大约 200 次有效得分事件。每次事件给主队和客队各加一个 dot,一个赛季回放视图如果渲染整场就是约 400 个 dot 加两条折线,这对 Core Animation 来说完全没压力。但如果你的场景是展示历史数据,例如一个赛季 82 场比赛的累计趋势,那就绝对不能让 dot 数量无限增长。我的方案是只保留当前可见窗口内的点:超出可视范围的 dot 从容器里移除,放进一个复用池,后面新点出现时再取出来重新设置颜色和位置。这样整个图层树里的 dot 数量稳定在屏幕能容纳的数量级,而不是随数据量线性增长。

5.2 静态内容与动态内容分层

网格线、坐标轴文字是静态内容,不会随比赛推进而变,我把它们永远放在一个独立的gridLayer里,并且只在布局变化时重建一次。如果在每次得分刷新时都重绘网格,stack 了大量不必要的绘图指令,反而会影响真正需要动画的折线层。分层之后还可以把gridLayer设为shouldRasterize并指定一个合理的rasterizationScale,让它被缓存成位图,实测帧率稳定得多。但要特别注意,动态内容所在的 layer(比如 containerLayer 及其子树)不要开shouldRasterize,动画频繁变化时缓存会反复失效,性能只会更差。

5.3 隐式动画要记得关闭

在初始化图层、批量创建得分点的时候,Core Animation 默认会给每个属性变化加上隐式动画,导致图层从初始位置“飘”到目标位置,造成画面一团乱。解决办法是在批量操作区间关闭事务动画:

CATransaction.begin() CATransaction.setDisableActions(true) // 批量创建 dot layer、设置 position、path 等 CATransaction.commit()

这条规则独记得很容易漏。我当时第一次写批量创建 dot 时,忘记关闭隐式动画,结果每一帧新增的几百个点都在做 0.25 秒的隐式位移动画,视觉上像虫子爬过屏幕,直接暴露了问题。

5.4 真机调试中的两个意外现象

第一个现象是模拟器上一切正常,真机上折线出现锯齿。后来发现是模拟器和真机对contentsScale的处理不一致。我的自定义绘制 layer 在初始化时没有设置contentsScale,导致在某些缩放比例下渲染模糊。解决方案是在创建所有 layer 后统一设置正确的 scale:

layer.contentsScale = UIScreen.main.scale

第二个现象是动画过程中切后台再回前台,折线偶尔会突然跳到错误位置。原因在于系统进入后台时可能暂停了动画,回前台后 presentation layer 没有恢复。我在 App 进入后台时主动调用removeAllAnimations(),并在回到前台时根据最新数据重新设置一次path和position。这样虽然会打断正在播放的动画,但保证了数据状态的绝对正确,比继续播放到一半的状态更可靠。

5.5 性能观测方法

优化不能凭感觉,我用 Instruments 的 Core Animation 模板做过几轮测量。主要观察两个指标:图层渲染数量(Layer Count)和当前帧绘制耗时(Draw Time)。做完上述优化后,常规比赛过程中帧率稳定在 60,图层渲染数量控制在千级以内,绘制耗时在十几毫秒以内。如果发现某个操作触发离屏渲染(Instruments 会高亮标记),优先检查是不是用了 cornerRadius 且同时开了 masksToBounds,或者是不是有 shadow 和 masking 组合。处理办法是把圆点改用CAShapeLayer.path画圆,或者预先生成离屏位图缓存,尽量减少实时合成。

6. 再往前走:按节背景、反超标记与手势缩放

核心功能稳定之后,我开始考虑怎样让这个图表更像一个真正的体育直播可视化组件。这个阶段不是单纯加功能,而是借机会验证图表的扩展性和 Core Animation 图层树的表达能力。

6.1 按节背景:用静态图层区分比赛阶段

篮球比赛分四节,每节 12 分钟。在折线图背景里叠加四块半透明色带,能帮助用户快速定位当前比分对应第几节。实现上,我给gridLayer增加四个子图层,每个子图层占四分之一宽度,用非常浅的灰色或品牌色填充。因为背景不参与动画,这个分组不需要任何性能上的担忧。

for quarter in 0..<4 { let section = CALayer() section.frame = CGRect( x: chartRect.minX + CGFloat(quarter) * chartRect.width / 4, y: chartRect.minY, width: chartRect.width / 4, height: chartRect.height ) section.backgroundColor = quarter % 2 == 0 ? UIColor.black.withAlphaComponent(0.03).cgColor : UIColor.clear.cgColor gridLayer.addSublayer(section) }

6.2 反超标记:在数据变化瞬间发现剧情转折

篮球比赛最有看点的瞬间是某支球队从落后变成领先。捕捉这个转折并不难,只需要分别记录两条折线上一帧的相对高低,并在事件追加后判断大小关系是否发生了翻转。如果是,就在该事件点的坐标位置添加一个高亮的圆环标记:

let ring = makeDotLayer(radius: 6, color: highlightColor) ring.position = currentPoint containerLayer.addSublayer(ring) ring.opacity = 0 let fadeIn = CABasicAnimation(keyPath: "opacity") fadeIn.fromValue = 0 fadeIn.toValue = 1 fadeIn.duration = 0.3 ring.add(fadeIn, forKey: "ringFadeIn")

这个标记能在直播场景里自动生成比赛转折点,非常受产品经理喜欢。它证明手写图表的扩展成本很低——只是在既有坐标系里算一个交点并加一个 layer,换用三方库还不一定有这么顺手的接口。

6.3 手势缩放:调整事件间距让折线更舒展

最后我加了一个轻量级的手势支持:UIPinchGestureRecognizer控制事件间距step,捏合放大时step变大,折线整体横向拉开;捏合缩小时step变小,折线回归紧凑。实现逻辑很简单,先调整step,然后重新计算所有点的坐标,更新 path 和 dot,再用关闭动画的方式一次性重绘。由于历史点的坐标都保存在事件数组里,重算并不复杂:

@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { let newStep = max(20, min(80, step * gesture.scale)) step = newStep gesture.scale = 1 CATransaction.begin() CATransaction.setDisableActions(true) rebuildAllPathsAndDots() CATransaction.commit() }

手势缩放的实际体验比较顺手,而且没有引入 UIScrollView 的滚动嵌套,也不会和系统手势冲突。如果需要更精细的坐标轴缩放,可以在这个基础上继续扩展,但折线图做到这一步已经足够撑起一个完整的体育可视化模块。

实战后的一些体会

篮球比赛折线图的核心并不是画线,而是把数据变化、坐标变换和动画节奏放在同一套坐标系里统一调度。CAShapeLayer负责形状,containerLayer负责视口,CATransaction负责提交动画,三者配合才能让动态图表既稳定又生动。做完这个模块之后,我再看三方图表库反而会更谨慎:很多用户要的效果,自己写只需要几十行代码,却能完全掌握每一帧的状态。如果你也想练习 Core Animation,强烈建议找一个类似实时折线图的小项目从头写一遍,踩过 presentation layer、隐式动画、离屏渲染这些坑之后,你对 iOS 动画层的理解会上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询