绘制三角形这个标题看起来很小,但真正动手做过的朋友都知道,它牵扯出来的东西一点也不简单。尤其当"三角形"、"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 | 中等,频繁重绘开销大 | 差,重绘代价高 | 静态简单图形 |
| CAShapeLayer | GPU | 高 | 极好,可独立动画 | 动态路径、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 tree
Core Animation里的视图层级,每一层其实对应多个layer实体:
- model layer:你设置的frame、transform、position,存的是动画最终状态。
- presentation layer:从动画开始到结束期间的"当前帧"真实值,由系统不断更新。
- render tree:GPU真正渲染的内容,就是你看到的画面。
举个例子,我用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的中心点在父坐标原点,导致三角形整体往右下偏移。
解决方式是明确设置anchorPoint:
shapeLayer.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,这一招在排查动画问题时比任何断点都好使。你要是哪天画出一个三角形,动画过程中读不到它的位置,记住这句话就够了。