
1. 先搞清楚你天天用的UIView其实只是个“壳”做了几年iOS开发我发现自己对CALayer的认知曾经长期停留在“给view加个圆角、加个阴影”这个层面上。直到有一次做列表页性能优化用Instruments一测发现掉帧的元凶就是几个圆角阴影组合拳才逼着我老老实实把CALayer从底层学了一遍。所以这篇“初步学习”我尽量往深处写——不只是API怎么调更重要的是这些API背后的渲染机制。1.1 从热搜问题切入UIView和CALayer到底是什么关系很多人搜过“uiview和calayer的区别和联系”网上说法五花八门但核心其实一句话能讲透UIView是事件响应和布局管理的门面CALayer才是真正负责把像素画到屏幕上的那个人。你写view.backgroundColor .red表面上是给UIView设置颜色实际上UIView内部把这个颜色传递给了它的layer属性——一个CALayer实例由这个Layer在渲染阶段把红色填充到对应的像素区域。UIView本身没有绘制能力它更像一个“壳”承载着触摸事件touchesBegan、gestureRecognizer、Auto Layout约束、frame布局等一套高层逻辑而CALayer是Core Animation框架里的主角负责内容的管理、合成和渲染。两者是has-a的关系每个UIView内部都有一个layer属性可以通过view.layer访问。反过来CALayer可以脱离UIView独立存在比如你想在后台线程用CALayer生成一张图片完全可以不经过UIView。这也是为什么很多高性能绘制方案——比如异步绘制、Core Graphics离屏渲染——都是基于Layer而不是View来做的。1.2 为什么要多此一举包一层UIView刚接触的时候我也觉得这个设计“多此一举”既然CALayer能干所有显示的事UIView不是冗余吗深入用过才明白这个分离是刻意的。CALayer不是UIResponder的子类它不能响应触摸事件。如果把显示和交互耦合在一个类里会导致两个职责互相干扰——你只是想给某个视图加个动画效果结果还要考虑它会不会误触、会不会影响手势识别。苹果把显示CALayer和交互UIView拆开UIView负责“用户能摸到的逻辑”CALayer负责“屏幕上画出的像素”各司其职组合起来就是完整的交互界面。另外一个实际原因是CALayer是QuartzCore框架下的轻量级对象它的设计目标之一就是可以被大量创建而不心疼性能。一个复杂的界面动辄几百上千个View如果每个View都直接管理一堆绘制状态、响应链、约束内存和CPU开销会大得多。Layer层更薄、更纯粹渲染相关的很多任务还能交给GPU并行处理。苹果官方文档里也提到图层树Layer Tree是独立于视图层级View Hierarchy的一套结构系统在渲染时会遍历图层树把它转成渲染树Render Tree。这套分层设计从一开始就是为性能和职责分离服务的。所以在开始学CALayer之前先建立这个认知模型UIView是门面CALayer是引擎。你后面看所有Layer相关的API、动画、性能优化都会顺畅得多。2. 图层树的三个副本Model、Presentation与Render很多资料讲CALayer会提到“图层树”这个概念但说得含糊。我一开始以为图层树就是视图层级对应的一个镜像直到做动画调试时发现动画过程中layer.frame的值根本没变而屏幕上图层却在移动——这才意识到图层树远比我想象的复杂。2.1 三层树的职责划分Core Animation在渲染管线中维护了三套平行且独立的树模型树Model Tree就是开发者直接操作的这棵树。你设置layer.frame、layer.opacity、layer.backgroundColor改的都是模型树节点上的属性值。它是图层状态的真实记录也是动画开始前的初始状态和动画结束后的最终状态。呈现树Presentation Tree动画进行中屏幕上的画面其实是由呈现树决定的。presentation()方法拿到的那一层反映了某一时刻动画中间帧的真实状态。比如你做一个position从(0,0)到(100,0)的动画动画执行到一半时modelLayer.position还是(0,0)但presentationLayer.position已经大约是(50,0)。渲染树Render Tree这棵树是私有的开发者无法直接访问。它由系统在每一帧渲染时根据呈现树生成负责真正把图层内容提交给渲染服务。可以这么理解模型树是剧本呈现树是演员在台上的实时表演渲染树是摄像师最终拍下来的画面。你写代码时改的是剧本屏幕上动的是表演而最终呈现受摄像机渲染服务的调度影响。2.2 动画过程中到底发生了什么这个机制在调试动画时会反复用到。比如你给一个Layer加了CABasicAnimation想在动画过程中获取它当前的位置直接读layer.presentation()?.position。如果不理解呈现树你很可能读到的是初始值然后一脸困惑“啊怎么没动”实际项目里有个典型场景拖拽手势中实时移动一个图层同时判断它是否进入某个目标区域。手势回调里你应该用presentation()来获取手指当前对应的视觉位置而不是modelLayer的frame——尤其是在连续动画切换的时候模型值和呈现值可能差得很远。这里插一个常见误区很多人以为UIView动画结束后修改view.layer的属性会自动触发动画然后发现“没动画”——这跟图层树的机制有关。UIView层默认关闭了隐式动画后面细讲而CALayer默认开启。所以你在UIView.animate里改view.layer.cornerRadius经常会出现动画瞬间完成的情况。理解模型树和呈现树就能明白问题的本质是你在改模型值的时候系统没有为这个Layer生成动画事务。3. CALayer核心属性逐一拆解把“玄学”变成公式学CALayer最绕不开的就是那一堆属性。很多教程把它们罗列一遍就完了但真正用的时候你会被anchorPoint和position的关系、contentsGravity的取值、mask的frame问题坑得怀疑人生。这一节我把核心属性拆开讲清楚附带“为什么会这样设计”的原理解释。3.1 frame、bounds、position与anchorPoint一个锚点引发的错位先说一个最经典的坑直接修改layer.anchorPoint会导致图层位置跳动。frame大家都很熟它是图层在父图层坐标系中的矩形位置。bounds是图层自身的坐标系矩形大小和frame一致不考虑旋转缩放时。而position是anchorPoint在父图层坐标系中的位置anchorPoint则是图层边界矩形内部的一个参考点取值范围默认是(0.5, 0.5)——也就是图层的中心点。它们之间的关系可以写成frame.origin.x position.x - anchorPoint.x * bounds.size.width frame.origin.y position.y - anchorPoint.y * bounds.size.height也就是说position指向的是“锚点所在的位置”锚点决定了frame.origin离position有多远。当你修改anchorPoint的时候如果position不变frame.origin就会随之改变视觉上图层就“跳”了。这个现象在给图层做缩放、旋转动画时特别常见你想让图层绕着左下角旋转于是把anchorPoint改成(0,0)结果图层一下子飘到了右上角。解决办法是在修改anchorPoint时同步修正position让frame.origin保持原位// 在一个UIView的layer上把锚点设置到左下角同时保持视觉位置不动 let oldFrame layer.frame layer.anchorPoint CGPoint(x: 0, y: 0) layer.frame oldFrame或者手动计算position的偏移量// 根据新锚点计算position让frame.origin保持不变 let newAnchorPoint CGPoint(x: 0, y: 0) let oldOrigin layer.frame.origin layer.anchorPoint newAnchorPoint layer.position CGPoint( x: oldOrigin.x newAnchorPoint.x * layer.bounds.width, y: oldOrigin.y newAnchorPoint.y * layer.bounds.height )理解了anchorPoint和position你还能反过来解释一个现象为什么UIView动画中同时改frame和transform图层的旋转中心永远是中心点因为UIView默认锚点是(0.5, 0.5)而且bounds不变时锚点对应的位置就是position。这也是为什么苹果文档不建议在动画中同时修改frame、bounds、center和transform——因为它们共享anchorPoint和position的底层逻辑互相覆盖容易出问题。3.2 contents与contentsGravity直接给Layer“喂图”的正确姿势CALayer有contents属性类型是Any?但实际上它接受的是CGImage在iOS上如果你传UIImage会直接无效这是很多人踩过的坑。也就是说想让Layer直接显示一张图片你得先把UIImage转成CGImagelet image UIImage(named: example) imageView.layer.contents image?.cgImage imageView.layer.contentsGravity .resizeAspectcontentsGravity和UIViewContentMode很像但注意它是字符串类型的枚举。常用取值包括.resize拉伸填满相当于scaleToFill.resizeAspect等比缩放完整显示相当于scaleAspectFit.resizeAspectFill等比缩放并填满可能裁掉边缘相当于scaleAspectFill.center、.top、.bottomLeft等不缩放按对齐方式摆放这里有个性能上的小技巧如果layer内容是不需要变形的静态图片尽量设置contentsGravity为.resizeAspectFill之类的非拉伸模式减少GPU的纹理采样开销。另外contentsScale这个属性也很重要——它对应屏幕的scale1x、2x、3x。如果你设置的Layer显示区域很大但contents的图片分辨率很小图层会显得模糊。常规做法是layer.contentsScale UIScreen.main.scale layer.contents image.cgImage手动管理contents的一个典型场景是做头像、图标这类高频更新且不需要UIView参与的小图块。直接用CALayer可以减少很多无谓的视图层级开销。但要注意内存contents是强引用如果你频繁换图记得把旧图释放避免内存峰值。实际项目里图像的字节大小和bounds大小关系不大关键在于contents指针指向的CGImage本身的分辨率所以大图塞进小Layer也要小心内存占用。3.3 圆角、边框、阴影三个最常见的修饰属性及其副作用这三个属性几乎是日常开发里最常用的Layer修饰也是引起性能问题的重灾区。圆角cornerRadius会同时作用于背景色和contents。有一个反直觉的细节cornerRadius只对Layer的背景和边框有裁剪效果如果你给Layer设置了contents图片或者子图层超出边界默认是不会被裁掉的。必须配合masksToBounds true等价于clipsToBounds才会裁剪内容。但masksToBounds true一旦开启阴影就失效了——因为裁掉的内容会影响阴影的形状。于是很多人发现“又要圆角又要阴影”代码写成什么样都怪怪的。边框borderWidth和borderColor是最没争议的属性但注意borderColor是CGColor而不是UIColor。系统不会自动帮你做颜色空间转换在某些色彩管理严格的项目里直接传UIColor可能导致编译不过或者颜色异常。阴影阴影有几个相关属性shadowOpacity、shadowRadius、shadowOffset、shadowColor、shadowPath。很多人只设置了shadowOpacity发现没效果以为是自己写错了——其实是因为默认shadowColor是黑色shadowOpacity是0且shadowRadius是3如果只设shadowOpacity0.5但shadowRadius太大阴影看起来会很淡。关于shadowPath必须多说一句只要图层形状是固定不变的矩形一定要设置shadowPath。否则Core Animation每一帧都要重新计算阴影的几何形状这对列表滚动是致命的性能杀手。设置方式layer.shadowPath UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath还要注意bounds变化时要同步更新shadowPath否则阴影形状会和图层对不上。3.4 mask遮罩与maskedCorners不规则裁剪的两种思路mask属性是一个很有意思的东西——它本身也是一个CALayer。当图层设置了mask只有mask中不透明区域的内容才会被显示出来。注意mask的frame不会自动跟随图层的bounds变化。你需要手动同步通常的做法是let maskLayer CALayer() maskLayer.backgroundColor UIColor.black.cgColor view.layer.mask maskLayer // 布局时同步 override func layoutSubviews() { super.layoutSubviews() maskLayer.frame bounds }用mask可以做非常复杂的裁剪比如渐变透明的头像边缘、文字镂空效果等。核心思路是mask的alpha通道决定显示强度白色不透明区域完全显示黑色透明区域完全隐藏灰色按透明度混合。从iOS 11开始CALayer还多了maskedCorners属性配合cornerRadius可以只给部分角加圆角这在卡片样式里非常常用layer.cornerRadius 16 layer.maskedCorners [.layerMinXMinYCorner, .layerMaxXMinYCorner] // 只保留顶部两个圆角这个方法比手动用UIBezierPath控制四个角方便得多也避免了mask带来的额外离屏渲染开销。要注意maskedCorners只有在cornerRadius不为0且masksToBounds为true时才有明显裁剪效果如果只设cornerRadius而不裁剪它截断的还是背景和边框而已。4. 隐式动画与CATransactionCALayer“自带特效”的背后机制初次接触CALayer的人几乎都会遇到一个“诡异”现象直接改layer.opacity发现它是平滑过渡的但改同一个UIView.layer的属性却是瞬变的。这就是隐式动画在起作用也是理解CALayer和UIView差异的一道坎。4.1 为什么CALayer改属性会自动动画UIView却不会CALayer对部分可动画属性position、opacity、backgroundColor、cornerRadius、transform等默认注册了一个action——当属性变化时Core Animation会创建一个默认的CABasicAnimation持续0.25秒让属性值从旧值过渡到新值。这就是所谓的隐式动画。那为什么UIView包着的Layer不会这样因为UIView在创建自己的layer时把对应的layer的actions给“禁用”了。那是不是所有UIView的layer都没有隐式动画不准确。UIView是通过UIView.animate来显式启用动画的但它内部给layer设置了action为NSNull让属性变化变成瞬变除非你在UIView.animate代码块里修改属性。理解这个机制对调试很有用。比如你直接改layer.backgroundColor然后在CATransaction里包一层就能控制动画时长CATransaction.begin() CATransaction.setAnimationDuration(0.3) layer.backgroundColor UIColor.blue.cgColor CATransaction.commit()还可以用CATransaction.setDisableActions(true)来临时关闭隐式动画CATransaction.begin() CATransaction.setDisableActions(true) layer.frame newFrame // 立即跳变不带动画 CATransaction.commit()这在拖动、滚动、布局更新时特别有用——你不想让每个属性变化都带一个默认动画把你卡顿放大就显式关闭。4.2 CATransaction与显式动画接管动画控制权隐式动画适合简单场景但一旦涉及组合动画、关键帧、动画停止/恢复、动画进度回调就必须用显式动画CAAnimation的子类配合CATransaction来精确控制。CATransaction是Core Animation的“事务机制”它把一系列属性修改合并成一个渲染事务。你在事务内改的多个属性会在同一帧提交给渲染服务。这在做多属性联动动画时很重要——如果每个属性单独提交会造成不必要的重绘。显式动画的核心API是CABasicAnimationlet animation CABasicAnimation(keyPath: position) animation.fromValue CGPoint(x: 0, y: 0) animation.toValue CGPoint(x: 100, y: 100) animation.duration 0.5 animation.timingFunction CAMediaTimingFunction(name: .easeInEaseOut) layer.add(animation, forKey: positionAnimation) // 最终状态要手动设置否则动画结束后会跳回原值 layer.position CGPoint(x: 100, y: 100)这里有个大坑显式动画不会修改模型树的值。动画提交后layer.position依然是你动画开始前的值。如果你不手动在动画结束后把模型值设为最终值动画完成后图层会瞬间跳回起点。我在最初写动画时真被这个坑过好几回动画跑完了视图“啪”一下闪回原地。正确的做法是在动画开始前就设置好模型值然后让动画填充区间或者用delegate在动画结束后补设模型值。更现代的做法是用UIViewPropertyAnimator或者SwiftUI省去这些手动同步——但理解原理仍然必要因为你还需要读speed、timeOffset、beginTime来做动画的暂停、继续和同步。CATransaction还提供了一些全局控制能力比如动画完成后的回调CATransaction.begin() CATransaction.setCompletionBlock { // 动画结束后执行 self.updateUI() } layer.add(animation, forKey: key) CATransaction.commit()注意这个completion block的触发时机是基于渲染树提交的而不是你代码里调完add就立刻回调。如果你在这个block里又改了模型值可能会出现动画继续跑完才回调的现象——这在实际调试中经常让人困惑。5. 离屏渲染与性能陷阱为什么你的列表一滑动就掉帧这一部分是对前面那些属性副作用的一个系统总结也是我认为CALayer“初步学习”里最值钱的内容。因为仅仅是知道cornerRadius怎么写算不上理解CALayer真正理解它的渲染边界你才能写出不卡顿的界面。5.1 离屏渲染是什么为什么它这么贵正常情况下图层的渲染流程是CPU准备好图层内容纹理GPU直接合成并绘制到屏幕framebuffer上一帧完成。但某些效果无法在单个图层内直接合成需要先在一个离屏的缓冲区里渲染出中间结果再拿这个中间结果去做后续合成。这个额外开辟缓冲区、切换上下文、渲染再切回来的过程就叫离屏渲染Offscreen Rendering / Rendering Off-screen。离屏渲染的开销比普通绘制高得多原因之一是上下文切换GPU从当前framebuffer切到另一个offscreen buffer再切回来这个开销是性能杀手。所以系统能避免就尽量避免。触发离屏渲染的常见场景包括设置cornerRadius且masksToBounds true设置shadow且没有指定shadowPath使用mask使用shouldRasterize光栅化某些UIBlurEffect、groupOpacity组合注意圆角本身不一定会离屏渲染——如果只是图层背景色带圆角GPU可以一步到位。但是如果你给圆角配上了masksToBounds系统就需要先渲染出圆角以内的内容再丢弃外面的部分这个“丢弃”就需要离屏缓冲区。所以在复杂层级里大量带圆角裁剪的图片/视图是掉帧重灾区。5.2 圆角、阴影、光栅化三个典型场景的性能对比先看一个我实际优化过的列表页问题卡片视图用了圆角阴影图片裁剪三层组合滚动时帧率从60掉到30都不到。排查后发现三个问题叠加阴影没有shadowPath系统每帧都要重新计算阴影几何形状。图片层开了cornerRadius加masksToBounds。有些卡片设置了shouldRasterize true但光栅化后的内容超过2x屏幕分辨率且图层内容频繁更新——光栅化的缓存反而成了累赘。优化后场景优化前优化后卡片阴影无shadowPath每帧计算设置shadowPath仅布局变化时更新图片圆角CALayer直接加圆角masksToBounds使用贝塞尔曲线路径裁剪或用预先处理好的圆角图片光栅化无脑shouldRasterizetrue只在图层静止时开启动态内容关闭关于shouldRasterize很多人有个误解以为开了就一定能提升性能。实际上光栅化会把图层内容缓存成位图下次直接复用避免重复渲染。但如果图层内容频繁变化比如文本刷新、图片异步加载缓存就频繁失效反而更慢。而且光栅化缓存的大小上限是2.5x屏幕尺寸大约超出后会退化为直接渲染。所以光栅化适合内容相对静态的图层比如一个复杂的渐变色圆角卡片的背景层可以把花哨的绘制结果缓存下来。再说一个实战技巧给图片切圆角与其用Layer的cornerRadius不如在异步绘制阶段就把圆角画到图片上func roundedImage(_ image: UIImage, radius: CGFloat) - UIImage { let rect CGRect(origin: .zero, size: image.size) let format UIGraphicsImageRendererFormat() format.scale image.scale let renderer UIGraphicsImageRenderer(size: image.size, format: format) return renderer.image { ctx in let path UIBezierPath(roundedRect: rect, cornerRadius: radius) path.addClip() image.draw(in: rect) } }这样屏幕上的圆角图片在渲染层就是一个普通矩形纹理完全绕开了离屏渲染的代价。缺点是失去了动态圆角能力——适合“圆角已经是最终形态”的静态图片比如头像。5.3 用Instruments确认离屏渲染的实战方法很多时候肉眼判断不出界面哪里离屏了需要用工具。打开Xcode的Instruments选择Core Animation模板或者直接在模拟器里跑Xcode的Debug菜单里的Color Offscreen-Rendered检查项。开启后离屏渲染的区域会被黄色/红色高亮标记。你操作界面看哪些区域被高亮就能快速锁定问题图层。我用这个方式抓出过一个隐蔽的坑一个UILabel设置了layer.masksToBounds true加上cornerRadius结果黄色高亮范围覆盖了整个label区域滚动列表时卡顿明显。后来把label的圆角去掉了用一张预渲染的圆角背景图替代帧率立刻回升。记住一个判断原则离屏渲染不是绝对不能有而是要控制数量和范围。少量静态圆角视图可以接受但如果你在UITableViewCell里堆五六个带阴影圆角的图层不卡才怪。6. 实战收尾用CAShapeLayerCAGradientLayer实现一个可复用的环形进度条前面讲了不少原理最后来个能直接抄作业的实战。我选“环形进度条”这个案例是因为它几乎用到了CALayer的几个核心特性CAShapeLayer路径绘制、CAGradientLayer渐变、mask遮罩、CABasicAnimation动画。做完这个组件你对CALayer的认识基本就“初步毕业”了。6.1 组件设计思路环形进度条从内到外通常有三层底环灰色轨迹占满整个圆环宽度。渐变色进度弧一段随进度增长的渐变圆弧。进度文字显示百分比用UILabel或CATextLayer。渐变圆弧的难点在于CAGradientLayer本身只能绘制矩形渐变无法直接画出一条渐变的弧线。标准做法是先有一个CAGradientLayer生成从起始色到结束色的渐变矩形再用一个圆环形状的CAShapeLayer作为mask把渐变矩形裁剪成一个圆弧形状。这样渐变色的方向会沿矩形对角线方向变化看起来像是沿圆弧渐变——对大多数UI需求足够。CAShapeLayer的关键是它的path属性。圆环路径用UIBezierPath(arcCenter:radius:startAngle:endAngle:clockwise:)生成。注意进度弧的起点角度用-CGFloat.pi / 2表示12点钟方向endAngle根据进度计算。6.2 完整代码实现先建一个继承自UIView的组件import UIKit class RingProgressView: UIView { private let trackLayer CAShapeLayer() private let progressLayer CAShapeLayer() private let gradientLayer CAGradientLayer() private let percentLabel UILabel() /// 进度值范围0.0 ~ 1.0 var progress: CGFloat 0 { didSet { setProgress(progress, animated: false) } } var lineWidth: CGFloat 12 { didSet { updateLayerPaths() } } var trackColor UIColor(white: 0.93, alpha: 1) { didSet { trackLayer.strokeColor trackColor.cgColor } } var startColor UIColor.systemBlue { didSet { gradientLayer.colors [startColor.cgColor, endColor.cgColor] } } var endColor UIColor.systemTeal { didSet { gradientLayer.colors [startColor.cgColor, endColor.cgColor] } } override init(frame: CGRect) { super.init(frame: frame) setupLayers() } required init?(coder: NSCoder) { super.init(coder: coder) setupLayers() } private func setupLayers() { trackLayer.fillColor UIColor.clear.cgColor trackLayer.strokeColor trackColor.cgColor trackLayer.lineWidth lineWidth trackLayer.lineCap .round layer.addSublayer(trackLayer) // 渐变色圆弧用mask剪裁出圆环 gradientLayer.colors [startColor.cgColor, endColor.cgColor] gradientLayer.startPoint CGPoint(x: 0, y: 0) gradientLayer.endPoint CGPoint(x: 1, y: 1) layer.addSublayer(gradientLayer) progressLayer.fillColor UIColor.clear.cgColor progressLayer.strokeColor UIColor.black.cgColor progressLayer.lineWidth lineWidth progressLayer.lineCap .round progressLayer.strokeEnd 0 // 用progressLayer作为gradientLayer的mask gradientLayer.mask progressLayer percentLabel.textAlignment .center percentLabel.font UIFont.monospacedDigitSystemFont(ofSize: 18, weight: .semibold) percentLabel.textColor .darkGray addSubview(percentLabel) } override func layoutSubviews() { super.layoutSubviews() updateLayerPaths() percentLabel.frame bounds gradientLayer.frame bounds } private func updateLayerPaths() { let center CGPoint(x: bounds.midX, y: bounds.midY) let radius min(bounds.width, bounds.height) / 2 - lineWidth / 2 let startAngle -CGFloat.pi / 2 let endAngle startAngle 2 * CGFloat.pi let trackPath UIBezierPath( arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true ) trackLayer.path trackPath.cgPath trackLayer.lineWidth lineWidth // progressLayer 复用同一路径strokeEnd控制显示比例 progressLayer.path trackPath.cgPath progressLayer.lineWidth lineWidth } func setProgress(_ value: CGFloat, animated: Bool) { let clamped min(max(value, 0), 1) let adjusted max(clamped, 0.01) // strokeEnd为0时画不出圆角帽避开0 CATransaction.begin() CATransaction.setDisableActions(!animated) if animated { CATransaction.setAnimationDuration(0.6) CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut)) } progressLayer.strokeEnd adjusted CATransaction.commit() percentLabel.text \(Int(clamped * 100))% } }这段代码有几个细节值得说明progressLayer同时作为gradientLayer.maskmask只需要不透明/透明的alpha信息所以strokeColor设为黑色即可——黑的不透明区域表示显示无前景色区域表示隐藏。CATransaction.setDisableActions(!animated)是关键如果animated为false直接跳变到目标值避免隐式动画干扰如果animated为true就让系统默认的隐式动画帮我们平滑过渡不需要手动写CABasicAnimation。strokeEnd设置为0时round线帽可能画不出完整效果所以做了0.01的最小值规避。layoutSubviews里要同步更新所有layer的路径和frame否则旋转屏幕或约束变化时视图会错位。6.3 扩展玩法与调用示例环形进度条在项目里的变体很多下载进度、运动目标、打卡完成度、消费预算条……我这里再给一个组合用法把它和一个定时器配合模拟点击刷新进度override func viewDidLoad() { super.viewDidLoad() let ring RingProgressView(frame: CGRect(x: 50, y: 100, width: 120, height: 120)) view.addSubview(ring) // 模拟进度增长 var current: CGFloat 0 Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in current 0.01 if current 1 { timer.invalidate() } ring.setProgress(current, animated: true) } }如果要让圆环支持点击打点、步进动画、颜色随进度变化只需要在setProgress里额外操作gradientLayer.colors、progressLayer.lineCap等属性即可。如果你希望进度条两端的“圆帽”更明显记得把lineCap设为.round如果希望是平头进度条用.butt或.square。还能做一些更进阶的定制比如把trackLayer改成虚线trackLayer.lineDashPattern [6, 4]这样底环就变成了一圈点状轨道视觉上会活泼很多。同理progressLayer.lineDashPattern [6, 4]可以让进度弧也是点状适合那种“拼接感”的进度条风格。最后提醒一个我在实际项目里反复踩过的点RingProgressView的backgroundColor不要设置给gradientLayer否则渐变会被背景色盖住其次如果组件要支持Auto Layout约束frame不是唯一驱动布局的方式确保layoutSubviews更新所有layer路径就对了。CALayer的“初步学习”到这里其实已经覆盖了一个合格iOS开发者日常工作中会用到的绝大部分Layer知识。建议你拿到代码后自己在模拟器跑一跑打开Color Offscreen-Rendered看一眼哪些地方亮了再对比一下加了shadowPath前后的帧率曲线——这种直观感受比记住十个API更值钱。