
绘制三角形这个标题看起来很小但真正动手做过的朋友都知道它牵扯出来的东西一点也不简单。尤其当三角形、Presentation和Image views这三个词凑在一起本质上是在问一件事你要画的这个图形到底应该在哪个层级上存在是画在视图的draw方法里画在图层上还是先渲染成一张UIImage再喂给UIImageView不同的选择后续的性能、动画表现、复用方式完全不一样。这篇文章我就围绕Drawing a triangle -- Presentation -- Image views这条线把从路径构建、图像渲染到CALayer的presentation layer读值这一整条链路用实际代码和经验讲透。先说结论如果你只是想快速画个三角形UIBezierPath加CAShapeLayer是最省事的如果你想把这个三角形当素材反复用用UIGraphicsImageRenderer渲染成UIImage再放进UIImageView更合理而你一旦打算让三角形动起来比如旋转、平移、缩放那就绕不开Presentation layer这个概念。下面按我的实际经验一步步拆。1. 先把画三角形这件事拆成三层很多人一上来就写代码结果画出来的三角形要么位置偏了要么模糊要么动画一抖一抖。我踩过这些坑之后发现画任何图形之前先想清楚三个问题路径从哪来、绘制到哪一层、最终呈现给谁看。1.1 为什么选三角形这种基础图形做原型三角形几乎是图形绘制领域的最小可验证单元。它只有三个顶点、三条边逻辑足够简单却又具备闭合路径的完整特征起点、终点、填充、描边、内部区域判定。用它做演示可以快速验证坐标系、缩放因子、图层层级这些底层机制。另一个原因是三角形在UI里真实出现频率极高比如气泡角标、播放按钮、方向指示、遮罩蒙层本质都是三角形或三角形的变体。你把三角形画明白了箭头、星形、气泡尖角就是在这个路径上追加两条边的事。1.2 三条绘制路线的取舍draw(_:)、CAShapeLayer、UIImage画三角形的第一层选择是决定绘制载体。我实际用下来有三种主流方案各有各的适用场景。UIView的draw(_:)方法属于CPU绘制每次重绘都走一遍Core Graphics调用链。适合静态、低频更新的内容。缺点是性能一般而且一旦有动画会频繁触发draw卡顿风险很高。CAShapeLayer把路径交给GPU去渲染独立于视图层级支持动画、mask、transform性能最好。这是绝大多数动态图形场景的最优解。UIGraphicsImageRenderer渲染成UIImage把绘制结果固化成一幅图像喂给UIImageView或任何需要image对象的地方。适合多次使用、缓存、跨模块传递的场景。三条路线的核心差异我用一个表格说明方案渲染主体性能动画友好度适用场景draw(_:)CPU中等频繁重绘开销大差重绘代价高静态简单图形CAShapeLayerGPU高极好可独立动画动态路径、mask、特效UIImage渲染CPU/GPU混合高但生成一次有开销取决于imageView复用、缓存、图片处理我给的默认建议是如果图形需要和应用已有图片内容叠加、混合、导出走UIImage路线如果图形要承载复杂动画走CAShapeLayer路线啥都不需要静态摆在那里draw(_:)也无妨。所以标题里这三个词其实正好对应了三种身份Drawing a triangle是路径构建Presentation是图层呈现机制Image views是图像承载层。2. 从UIBezierPath开始三角形路径构建的细节路径构建是整个流程里的地基。地基偏了后面所有渲染都是歪的。这里有非常多的细节需要注意。2.1 三角形路径的标准写法假设我们要画一个等边三角形底边水平。最直接的方式是用UIBezierPath的move(to:)和addLine(to:)import UIKit let size CGSize(width: 200, height: 200) let path UIBezierPath() // 顶点从顶部中心开始顺时针绕一圈 path.move(to: CGPoint(x: size.width / 2, y: 0)) path.addLine(to: CGPoint(x: size.width, y: size.height)) path.addLine(to: CGPoint(x: 0, y: size.height)) path.close()这里有个关键点close()会被漏掉。不调close()路径的起点和终点之间就没有连接线填充可能正常但描边时会发现缺了一条边。我第一次犯这个错时三角形的描边只剩两条边排查了半天才意识到是路径没闭合。另一个细节是路径的方向。顺时针还是逆时针在大多数场景下没有区别但当你要做非零缠绕数规则填充、或者把三角形路径和别的路径做组合裁剪时方向决定了内外区域的判定。比如你在三角形中心再画一个反向的小三角形就可以做一个挖洞的效果这时候方向就非要不可。2.2 坐标系里的坑center、frame和bounds新手最容易翻车的地方是把CGPoint直接写成屏幕坐标。记住一句话UIBezierPath里的坐标是相对于它所在图层/视图的bounds的不是相对于屏幕的。用UIView的draw(_:)时坐标系原点在视图左上角x向右为正y向下为正。用CAShapeLayer时坐标系原点是layer的左上角默认情况下layer的position是在父layer坐标系里定位的。如果你把路径里的点写成绝对坐标比如(100, 300)当layer移动到别的位置时三角形会跟着跑。我常用的做法是把路径构建封装成一个函数接收size参数所有坐标都基于传入的size计算func makeTrianglePath(in size: CGSize) - UIBezierPath { let path UIBezierPath() path.move(to: CGPoint(x: size.width * 0.5, y: size.height * 0.1)) path.addLine(to: CGPoint(x: size.width * 0.9, y: size.height * 0.9)) path.addLine(to: CGPoint(x: size.width * 0.1, y: size.height * 0.9)) path.close() return path }这样做的好处是无论视图多大三角形都会按比例缩放不会因为屏幕尺寸变化而变形。我在实际项目中给角标、指示器做适配都是用这种相对比例坐标。2.3 UIGraphicsImageRenderer如何烤出一张三角形图片路线三是把三角形直接渲染成UIImage。早期iOS开发者用的是UIGraphicsBeginImageContextWithOptions现在官方推荐的是UIGraphicsImageRenderer它自动处理了scale和color space兼容性好得多。import UIKit extension UIImage { static func triangleImage(size: CGSize, tintColor: UIColor) - UIImage { let renderer UIGraphicsImageRenderer(size: size) return renderer.image { ctx in let path makeTrianglePath(in: size) tintColor.setFill() path.fill() } } }这里有个容易被忽略的参数renderer的format。默认情况下renderer的scale是设备scale也就是说iPhone 3x屏上生成的是3倍图。如果你想统一处理可以指定let format UIGraphicsImageRendererFormat() format.scale 1 let renderer UIGraphicsImageRenderer(size: size, format: format)但正常情况下我建议不要手动改成scale 1否则在Retina屏上放进UIImageView图像会被放大产生模糊。让renderer用默认scale图像自带清晰度。生成出来的UIImage你可以直接赋值给UIImageView.image也可以作为遮罩、占位图、缓存对象这是它最大的优势。3. Image views里放图片的正确姿势标题里的Image views到底指什么我的理解是UIImageView以及一切用来呈现图像内容的视图。这是大多数图形视觉效果最终落地的地方。但把一张画好三角形的UIImage塞进imageView远没有看起来那么简单。3.1 contentMode是第一个分水岭同样一张200x200的三角形图片放进一个400x300的UIImageView显示效果完全由contentMode决定contentMode效果典型使用场景.scaleToFill拉伸填充可能变形不推荐除非刻意要拉伸.scaleAspectFit等比缩放完整显示可能有留白图标、缩略图.scaleAspectFill等比缩放填满可能裁剪背景图、封面.center不缩放原尺寸居中小icon我在做三角形素材时首选scaleAspectFit。因为它保证三角形完整可见不会出现尖角被裁剪的情况。但要注意的是AspectFit会把图像在imageView里居中如果你还需要精确控制三角形在imageView内的位置那单纯靠contentMode是不够的。3.2 想要精确控制就用ImageView的子类如果三角形只是作为imageView的内容但你还想在上面叠加文字、描边、其他图形那我建议你做一个UIView的自定义子类把三角形作为一个layer加进去UIImageView作为它的专属图像层。我的做法如下final class TriangleImageView: UIView { private let imageView UIImageView() private let shapeLayer CAShapeLayer() override init(frame: CGRect) { super.init(frame: frame) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { imageView.frame bounds imageView.autoresizingMask [.flexibleWidth, .flexibleHeight] imageView.contentMode .scaleAspectFit addSubview(imageView) shapeLayer.fillColor UIColor.systemBlue.cgColor layer.addSublayer(shapeLayer) } override func layoutSubviews() { super.layoutSubviews() shapeLayer.path makeTrianglePath(in: bounds.size).cgPath } }注意我在layoutSubviews里重新设置shapeLayer.path这是为了在视图尺寸变化时让三角形路径同步更新。如果你漏掉这一步旋转屏幕后三角形的大小会保持原样看起来像是粘在图层里不动。实测中这是最常见的视觉bug来源。3.3 UIImageView和CAShapeLayer共存时谁在上面当UIImageView作为子视图CAShapeLayer作为父视图的layer时覆盖顺序是UIImageView会盖在父视图layer之上。因为UIImageView是独立子视图它的layer是父视图layer的sublayer。所以如果我在setup()里先把imageView添加为子视图再给父视图的layer加shapeLayer形状会被imageView盖住。想让三角形在最上面有两种思路把shapeLayer加到imageView的layer上作为imageView.layer的sublayer。不把imageView当子视图而是把UIImage转为CGImage后手动设置给shapeLayer的contents属性。第二种方法更灵活适合把图像和路径完全融合在一起shapeLayer.contents triangleImage.cgImage这样图像内容直接作为layer的显示内容任何其他图形都可以叠加在前后。我在做带纹理三角形、渐变三角形时基本都是用这种方式操作。4. Presentation层动画过程中最容易被忽略的真相标题既然是Drawing a triangle -- Presentation -- Image views那这个Presentation指什么在一开始的示例里一般指UIKit/Core Animation里的presentation layer也就是视图树在动画过程中的实时表现层。很多人在动画期间读view.frame发现读到的永远是动画终点值这就是典型的不理解presentation layer。4.1 model layer、presentation layer和render treeCore Animation里的视图层级每一层其实对应多个layer实体model layer你设置的frame、transform、position存的是动画最终状态。presentation layer从动画开始到结束期间的当前帧真实值由系统不断更新。render treeGPU真正渲染的内容就是你看到的画面。举个例子我用UIView.animate让三角形图片从屏幕左边平移到右边动画时长1秒。当时间过去0.5秒时你去读UIImageView的frame永远等于终点值但读presentationLayer的frame才能得到此刻屏幕上的中间位置。这就是presentation层存在的全部意义它反映的是此刻用户看到了什么而不是代码模型认为它应该在哪。4.2 从presentation layer里拿实时坐标具体代码是这样if let presentation shapeLayer.presentation() { let currentPosition presentation.position let currentTransform presentation.transform // 做你需要的判断或同步 }需要注意presentation()方法只在动画进行中返回非nil。动画结束后presentation layer大概率会被清掉。所以我通常在CADisplayLink或手势回调里读而不是动画开始前或结束后读。这个特性在做拖拽交互、碰撞检测时特别有用。比如我上一版项目中三角形图片跟随手势拖拽同时有吸附动画动画没有结束前用户又能再次拖拽此时必须通过presentation layer拿到当前真实位置才能让新手势从当前可见位置无缝衔接而不是从动画起点重新开始。4.3 一个实用的碰撞判断封装我用presentation layer做了一次三角形图片与目标区域的碰撞判断核心思路是把presentation layer的frame转换到目标视图坐标系然后做CGRectIntersectsRect判断func isTriangleImage(in targetView: UIView) - Bool { guard let view triangleImageContainer, let presenting view.layer.presentation() else { return false } let targetFrame targetView.convert(view.bounds, from: view) let presentingFrame targetView.layer.convert(presenting.frame, to: targetView.layer) return presentingFrame.intersects(targetFrame) }我在工程里发现很多明明点击了目标却响应不到或者动画视觉上已经到了逻辑上还没到的问题根本原因都是读了model layer而不是presentation layer。把这类取值统一封装成一个工具方法能省下大量排查时间。5. 常见问题与排查技巧实录按下这条链路做过的人几乎都会遇到下面这几个问题。我把坑位和对应的排查方法列出来基本都是实测结论。5.1 三角形边缘发虚、发糊原因通常是绘制出来的UIImage没有匹配屏幕scale。排查步骤检查UIGraphicsImageRenderer是否用了默认format如果是自带scale正确。检查UIImageView的frame是否是整点比如frame CGRect(x: 100.5, y: 10, width: 200, height: 200)会导致插值模糊。检查layer的contentsScale是否等于屏幕scale如果对layer做离屏操作要设置layer.contentsScale UIScreen.main.scale。这个三角形在4K大图上显示还行放到小组件里糊得没法看基本就是scale出了问题。5.2 三角形位置明明设置对了显示出来偏移半截多半是anchorPoint的问题。layer的position决定的是anchorPoint在父坐标系的位置默认anchorPoint是(0.5, 0.5)。如果你把position设成(0, 0)实际是layer的中心点在父坐标原点导致三角形整体往右下偏移。解决方式是明确设置anchorPointshapeLayer.anchorPoint CGPoint(x: 0, y: 0) shapeLayer.position CGPoint(x: 0, y: 0)或者干脆用frame来定位。我在绘制角标时就踩过这个坑三角形的尖角本来应该贴着父视图左上角结果飘到中间去了。5.3 动画没结束时读不到presentation layer的值这是比较典型的误用场景。presentation()方法返回nil或旧值主要有两种情况动画还没开始CAAnimation尚未加入到layer上。此时presentation layer不存在读不到当前值。动画已经结束presentation layer被丢弃。排查手法看看动画的beginTime、duration设置如果是负的beginTime或者delay太大动画根本没跑起来用CADisplayLink的timer去读看动画期间是否有值读之前先判断nil。5.4 图像被拉伸变形三角形变成了歪三角形这个问题十有八九是contentMode设成了scaleToFill。UIImageView默认会把它显示的图拉伸到填满整个frame。如果你的imageView是矩形而图片是正方形图片里的三角形必然变形。我建议所有图片展示类视图除非你明确知道自己在做什么否则一律用scaleAspectFit或scaleAspectFill。只考虑三角形本身不变形AspectFit永远安全。5.5 同屏大量三角形图片导致掉帧如果你在UICollectionView或列表中放了大量的图片视图尽量复用UIImage而不是每行重新绘制。我做过一次优化把不同颜色、不同大小的三角形UIImage缓存到一个字典里key是size-颜色-圆角这样的组合复用率极高掉帧问题立刻缓解。6. 实操阶段小结以及我自己最后的建议整套Drawing a triangle -- Presentation -- Image views链路的可落地顺序我整理成一个可直接套用的流程用UIBezierPath构建三角形路径注意相对坐标和close()闭合。根据使用场景选择载体静态复用选UIImage渲染动态动画选CAShapeLayer。用UIGraphicsImageRenderer渲染出图片设置好imageView的contentMode。叠加图层时确认层级顺序需要精确控制就做自定义View在layoutSubviews里刷新path。做动画时用presentation layer取实时状态避免拿到model layer的终点值。我个人在实际项目里最深的感受是画三角形本身五分钟就能搞定真正花时间的是理解它所在的显示链路。UIKit里所有图形最终都归Core Animation管而Core Animation至少有model、presentation、render三层。你只有把这层抽象扒开才能在复杂交互、动画、多图层混合的时候不迷糊。再送一个小技巧如果你不确定一个视图的真实状态在调试控制台里输入po view.layer.presentation()系统会直接打印当前presentation layer的内容包括position、transform、bounds这一招在排查动画问题时比任何断点都好使。你要是哪天画出一个三角形动画过程中读不到它的位置记住这句话就够了。