ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS Swift自定义View实战:从绘制、布局到命中链的完整指南

iOS Swift自定义View实战:从绘制、布局到命中链的完整指南 两年前做课程表App的时候产品要一个顶部带圆弧缺口、底部还要有一条渐变色分割线的卡片容器。我第一反应特别简单Storyboard里拖个UIView把class改成自定义类名再加两个属性不就行了结果跑起来屏幕上只有一片空白。后来被这个问题折腾了一整晚才真正意识到iOS Swift里做自定义View不是改个类名加两个属性这么简单背后其实是UIView的绘制生命周期、布局触发机制、事件命中链三套系统在同时工作。这篇文章就把我做自定义View这些年攒下来的完整思路捋一遍从要不要自定义、怎么选实现方式到draw(_:)里到底画什么、性能怎么救全都带上代码和理由。1. 自定义View之前先想清楚这个需求真的需要自定义吗很多人第一次接到做个自定义View的需求下意识就开始写类、重写draw(_:)、加一堆属性。但我想先泼一盆冷水搞清楚需求到底想要什么比急着写代码重要得多。我见过太多项目里一个简简单单的带圆角阴影的卡片被写成了几百行的自定义View性能还一塌糊涂。1.1 三类典型场景什么需求适合自定义View按我的经验自定义View的需求大致分三类处理方式完全不同外观样式组合型比如带圆角、边框、阴影的卡片或者几个系统控件堆叠出来的复合组件。这类需求通常用系统的UIView子类、CALayer属性甚至XIB就能搞定并不需要真的重写draw(_:)。你只需要设置layer.cornerRadius、layer.shadowOffset最多再加一个mask效果就出来了。需要个性化绘制型比如进度环、波形图、仪表盘、带缺口的特殊容器。系统控件画不出来必须自己控制每一根线条、每一种颜色这时候才需要重写draw(_:)或者用Core Graphics / CAShapeLayer。交互行为定制型比如某个区域要响应点击但形状不规则、某个按钮要扩大点击热区、某个视图要拦截子视图之外的手势。这类需求的核心不在绘制而在事件命中链的改写后面第4章专门讲。判断标准很简单如果你的自定义只是把系统的几个控件拼在一起那优先用组合而不是重绘。重写draw(_:)的代价是绘制过程脱离系统优化你得自己管理重绘时机和缓存性能风险高很多。1.2 继承谁最关键UIView、UIControl还是直接上CALayer确定要写类之后紧接着的问题是继承谁。很多人上来就继承UIView然后自己加UITapGestureRecognizer去处理点击。说实话这不是不行但在处理高亮状态、禁用状态、事件回调的时候很别扭。UIView和UIControl的选择我一般是这么判断的场景推荐的基类原因静态展示、纯外观自定义UIView不需要事件机制结构最轻需要点击/选中/禁用等控件语义UIControl自带target-action、isEnabled、isHighlighted只是想把图层形状改一下CALayer子类不需要完整的UIView生命周期举个例子如果你做的是一个标签或者角标继承UIView就够了如果你做的是一个可点击的评分星星组件老老实实继承UIControl把addTarget(_:action:for:)提供给调用方比自己在内部暴露一个onTap闭包要符合UIKit的直觉。系统按钮在按下时高亮、禁用时灰化这些都是UIControl帮你处理好的UIView做不了。还有一类特殊情况是直接操作CALayer。比如做一个持续转动的Loading圆环很多高性能方案是创建CAShapeLayer并设置path完全不需要重写UIView的draw(_:)。UIView的优势在于布局和交互而Layer的优势在于轻量和动画。两者没有绝对的谁更好只看你的需求落在哪一层。1.3 纯代码、XIB、Storyboard三条路线怎么选同样一个自定义View实现路线也有讲究。我自己的选择逻辑是这样的纯代码适合逻辑复杂、需要高度复用的组件。所有frame在代码里算清楚所有属性通过代码暴露。缺点是写起来累视觉效果要等运行才知道。XIB适合一个项目内复用、界面结构比较固定的组件。把子控件拖好、约束加好类里只控制业务逻辑。配合IBDesignable能在Xcode里实时预览效率很高。Storyboard适合页面级的设计不太适合做独立组件。因为Storyboard里的自定义View没法像XIB那样直接通过awakeFromNib加载代码量反而多。我一般情况下更推荐纯代码为主、XIB辅助。原因是纯代码的自定义View不受工程结构影响往哪个项目里一扔都能用也不容易因为XIB文件被误删导致编译错误。但你如果团队里设计师要实时调样式那IBDesignable XIB的组合会更舒服。第6章会专门讲这个组合。2. draw(_:)的触发真相为什么你的绘制代码总在看运气回到开头的空白问题。很多初学者把draw(_:)理解成我在View里写的绘制代码只要View一出现就会执行这个理解差得很远。draw(_:)不是自动执行的它必须由系统按特定时机调用而触发它的方式只有一个标记视图需要重绘也就是setNeedsDisplay()。2.1 从setNeedsDisplay到draw(_:)一次异步绘制的完整链路我把这条链路完整拆给你看你在代码里调用view.setNeedsDisplay()。这个方法只做一件事把视图标记为需要重绘。系统把这个标记提交给当前RunLoop等当前这一轮事件处理完。在下一个RunLoop周期的某个时刻系统检查有标记的视图调用它的draw(_:)方法。你draw(_:)里用Core Graphics画的任何内容最终会被渲染进视图背面的图层位图里。这里的关键点是异步。因为draw(_:)不是同步执行的你在viewDidLoad里调用setNeedsDisplay()然后立刻去做别的逻辑可能等你逻辑跑完了draw(_:)还没执行。很多人在viewDidLoad里画完直接读view.layer.contents发现还是空的就是这个原因。我在实际项目里也反复遇到一个场景某个属性比如进度值变化后想立刻看到UI变化但画面总是慢半拍。解决方案就是不要直接改属性然后等系统反应而是在属性的didSet里主动调用setNeedsDisplay()让系统知道你改了内容。写多了之后你会发现自定义View里几乎所有可能影响外观的属性都应该走属性变化 - 标记重绘这条线。2.2 容易误解的addSubview后立即绘制逻辑第二个常见的误解addSubview之后视图为什么还是空白的要解释这个得看addSubview到底触发了什么。addSubview只负责把视图加入视图层级改变的是视图树结构。真正让视图出现在屏幕上的是Core Animation在下一帧提交事务时把图层树渲染出来的过程。此时如果自定义View还没有任何内容它就是一个透明空图层。更极端的情况如果你在viewDidLoad里addSubview然后立刻去截图或者查layer.contents大概率拿到的是nil。正确做法是把这类操作放到viewDidAppear或者下一轮RunLoop之后。这不是运气问题是绘制提交的时序问题。2.3 bounds、contentMode与重绘的连锁反应还有一个隐藏很深的触发源bounds变化。很多自定义View在布局变化、屏幕旋转后会突然画糊或者不重绘。原因在于默认情况下bounds尺寸变化只会导致布局调整不一定会触发draw(_:)。UIView给你留了一个开关叫contentMode如果把它设为.redraw那么只要bounds的尺寸发生变化系统就会把视图标记为需要重绘继而调用draw(_:)。其余contentMode的值比如.scaleToFill、.scaleAspectFit意味着系统会直接把原有的绘制结果按规则拉伸缩放而不会重新执行draw(_:)。对自定义绘制类视图我把contentMode .redraw当作默认值因为绘制内容通常依赖bounds尺寸尺寸变了就必须重画。同时注意bounds.origin变化不会触发draw(_:)因为它更多影响的是坐标原点而不是内容本身。class ProgressRingView: UIView { var progress: CGFloat 0 { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) contentMode .redraw } required init?(coder: NSCoder) { super.init(coder: coder) contentMode .redraw } override func draw(_ rect: CGRect) { guard let ctx UIGraphicsGetCurrentContext() else { return } let radius rect.width / 2 - 8 let center CGPoint(x: rect.midX, y: rect.midY) ctx.setStrokeColor(UIColor.systemGray5.cgColor) ctx.setLineWidth(12) ctx.strokeEllipse(in: CGRect(x: 8, y: 8, width: rect.width - 16, height: rect.height - 16)) ctx.setStrokeColor(UIColor.systemBlue.cgColor) ctx.setLineWidth(12) ctx.setLineCap(.round) ctx.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: -.pi / 2 .pi * 2 * progress, clockwise: false) ctx.strokePath() } }这段代码基本概括了draw(_:)绘制的常规步骤拿上下文算半径和中心先画背景再画前景。值得提醒的是draw(_:)的坐标系和bounds是保持一致的不需要额外处理屏幕scaleUIKit已经帮你把points映射到物理像素。但如果你自己创建CGBitmapContext那就要显式乘以UIScreen.main.scale这点特别容易漏。3. 尺寸与布局自定义View里的frame、bounds和Auto Layout分工自定义View不只是画出来就完事它必须知道自己在多大尺寸下要长成什么样。布局这块是很多人的知识盲区因为写UIView子类时完全可以用frame但一旦接入Auto Layout问题就来了为什么约束都加对了视图还是不给它设定位置为什么我的自定义View在StackView里缩成了一点3.1 frame和bounds到底差在哪layoutSubviews何时被调用先夯实基础概念。frame是视图在父视图坐标系里的矩形描述在哪儿。bounds是视图自身坐标系里的矩形通常origin是(0,0)描述自己有多大。对自定义View来说绘制时永远看bounds布局子视图时也看bounds父视图通过frame决定你占据的位置。layoutSubviews是布局环节的关键方法。当以下情况发生时它会被调用bounds尺寸改变子视图约束变更UIScrollView滚动导致contentOffset变化间接影响某些布局调用setNeedsLayout()或layoutIfNeeded()你要知道setNeedsLayout()和setNeedsDisplay()是两套独立的机制前者排的是重新计算位置后者排的是重新绘制内容不要搞混。在layoutSubviews里改子视图的frame在draw(_:)里画自己的内容职责非常清晰。3.2 用layoutSubviews手动摆放子视图的正确姿势如果你的自定义View内部有多个子视图应该尽量让它们走的还是Auto Layout而不是手写frame。但有些场景必须手写frame比如进度条的渐变进度层、带mask的图片视图Auto Layout处理起来反而绕。手写frame时要注意不要在layoutSubviews里创建视图也不要在这里做昂贵计算。这个方法在布局变动时会被反复调用你把子视图放进数组逻辑全写在这里很容易出现重复添加或性能抖动。我习惯的做法是init里创建子视图并addSubview。layoutSubviews里只计算并设置frame。子视图的frame计算全部基于bounds而不是frame。override func layoutSubviews() { super.layoutSubviews() backgroundImageView.frame bounds gradientLayer.frame CGRect(x: 0, y: bounds.height - 80, width: bounds.width, height: 80) titleLabel.frame bounds.inset(by: UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) }这段代码很直白但有一个细节gradientLayer是CALayer需要手动管理它被加到layer上的时机。很多人习惯在layoutSubviews里写if gradientLayer.superlayer nil { layer.addSublayer(gradientLayer) }这种写法不是不行但每次布局都要判断一次不如在init里一次性加好。3.3 intrinsicContentSize让自定义View被Auto Layout正确对待Auto Layout环境里自定义View最容易出现的缩成一点问题十有八九是因为没有实现intrinsicContentSize。这个属性表示视图在没有外部约束时天然想要多大。UIButton、UILabel都有所以它们只要写上文字和约束系统就知道给多大空间。自定义View从UIView直接继承默认的intrinsicContentSize是UIView.noIntrinsicMetric系统完全不知道你愿意要多大。所以当你的自定义View参与Auto Layout布局比如放进UIStackView一定要覆写这个属性override var intrinsicContentSize: CGSize { CGSize(width: 120, height: 120) } override func setNeedsLayout() { super.setNeedsLayout() invalidateIntrinsicContentSize() }invalidateIntrinsicContentSize()告诉Auto Layout我的内在尺寸变了请重新算。如果你在属性变化时没调用它StackView和约束都不会意识到这个View变了尺寸就会出现明明内容变了但布局纹丝不动的情况。顺带提一嘴安全区域。如果你的自定义View里还有需要避开刘海屏的控件别在layoutSubviews里硬写一个safeAreaInsets。更稳的做法是让外层约束处理safeArea或者在你的View里覆写safeAreaInsetsDidChange()并根据它更新子视图位置。否则在旋转屏幕时你的子视图位置会变得非常诡异。4. 触摸响应的改写让超出边界的子视图也能被点中自定义View很少只做展示基本都要处理点击。这里有个经典需求按钮的视觉范围内其实有个子视图超出了父视图的bounds导致超出部分明明看到了却点不中。第一次遇到这种问题我一度以为是系统判断触摸位置有bug后来才明白是hitTest机制在起作用。4.1 hitTest的工作流程iOS如何判断点到谁iOS在收到一次触摸事件后会从UIWindow开始沿着视图树向下找到谁该接收这个点。这个过程通过hitTest(_:with:)实现核心逻辑可以理解为检查当前视图是否isUserInteractionEnabled、isHidden、alpha 0.01不满足直接返回nil。调用point(inside:with:)判断触摸点是否落在自己的bounds内不在就返回nil。如果当前视图包含子视图从最上层子视图开始倒序遍历递归调用子视图的hitTest(_:with:)只要有一个子视图命中就返回那个子视图。所有子视图都没命中返回自己。注意第2步父视图本身就要求触摸点落在它的bounds内它才会去遍历子视图。这就是超出父视图边界的子视图点不中的根因——父视图在第一步就把触摸点过滤掉了根本没有机会问子视图你要不要接收。4.2 重写point(inside:with:)扩大热区边界与取舍所以解决方案很明确让父视图在判断触摸点在不在我内部的时候放宽条件。override func point(inside point: CGPoint, with event: UIEvent?) - Bool { let expandedBounds bounds.insetBy(dx: -20, dy: -20) return expandedBounds.contains(point) }这段代码把命中区域扩大20pt。这是扩大按钮热区的标准做法比通过布局层加一个透明父视图去兜住触摸要干净得多。需要注意point(inside:with:)和hitTest(_:with:)虽然名字像但职责不同。前者只回答这个点在不在我范围里后者才负责到底返回谁。大部分场景重写point(inside:with:)就够了不用动hitTest。但这里有一个取舍问题点(inside:)扩大后原本应该被你的View让位给兄弟视图的区域也可能被你的View截获。比如你把热区上下各扩大20pt那紧挨着你顶部20pt范围内的触摸都会落到你身上如果那里恰好还有一个按钮两个控件的响应区域就重叠了。命中链是最上层视图优先后果就是下面的按钮永远收不到触摸。解决方法是不要无脑扩大太多或者配合point(inside:)里做坐标判断只对特定方向/区域放行。4.3 一个把触摸转移到父视图的真实案例还有一种更隐蔽的情况你的自定义View是容器它包含了一个超出自身边界的浮动气泡气泡上有一句文案需要点击复制。你重写了point(inside:)让父视图的命中范围包含了气泡区域但气泡本身也是一个UIView或者UILabel当触摸落在气泡内部时hitTest返回的应该是气泡而不是父视图。这种情况下父视图只需要放行由系统继续向下找到气泡就行了。如果你想让所有落在父视图范围内的触摸都交给父视图自己处理而不经过子视图可以在hitTest里返回selfoverride func hitTest(_ point: CGPoint, with event: UIEvent?) - UIView? { guard point(inside: point, with: event) else { return nil } // 不让子视图参与命中全部由自己接管 return self }不过这种写法要格外小心它会彻底屏蔽子视图的触摸除非你自己在后续逻辑里再分发。我实际项目中就只在一处用过一个全屏遮罩层的中间区域需要模块化接管所有子控件的触摸并统一收起面板才用这种方式做了一个接管器。日常需求里重写point(inside:with:)扩大热区基本够用了。5. 圆角、阴影与离屏渲染自定义View最容易在这里掉帧自定义View写出来能看只是第一步放进复杂的列表里还能保持60帧才是真正考验。iOS的渲染引擎对图层的圆角、阴影、mask处理都会引入离屏渲染开销。我在一个信息流项目里做过一次性能优化只是把几十个头像的圆角方案改了一下首屏掉帧率明显下降。5.1 cornerRadius和masksToBounds为什么会成为性能杀手先说结论设置layer.cornerRadius本身通常不触发离屏渲染但当你同时设置layer.masksToBounds true时图层必须把内容先绘制到一块离屏缓冲区切掉四角之后再合成到最终画面这个切的过程就是离屏渲染。如果列表滚动时每一帧都在做这件事GPU的压力会很大。为什么系统不直接在主渲染通道里处理圆角因为圆角裁剪本质上是对图层内容的形状重定义等于一次额外的绘制Pass。对于静态图层系统会有各种优化可能感觉不明显但在UICollectionView的cell里有十几个带圆角的图片滚动就很容易卡。我在项目里的判断标准是如果圆角是静态不变的尽量用一次性绘制或预先切好背景图如果圆角会跟着约束动态变化再考虑cornerRadius方案但也尽量少叠加mask。5.2 用draw(_:)一次性绘制圆角背景替代运行时大量离屏对那种背景是纯色/渐变色的圆角容器最稳的方式其实是用draw(_:)自己画。这种方法的好处是绘制结果直接进入图层位图没有任何离屏裁剪过程。override func draw(_ rect: CGRect) { let path UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 16, height: 16)) UIColor.systemBackground.setFill() path.fill() }这里的UIBezierPath本身是一个矢量路径绘制时也只在这一帧发生一次。相比系统圆角裁剪它没有额外Pass。如果圆角背景还需要渐变用CGGradient画到当前上下文同样可以一次搞定。唯一的代价是你得在bounds变化时主动触发重绘也就是前面说的contentMode .redraw。当然不是说任何时候都该用draw画圆角。如果你的视图内容是一张网络图片图片本身有缩略图和缓存机制直接用imageView.layer.cornerRadius masksToBounds反而简单。重要的是分清场景别让所有东西都走同一条路。5.3 shadowPath与shouldRasterize阴影优化的正确顺序阴影比圆角更容易被忽视。默认情况下给layer.shadowOpacity设置一个值就会产生离屏渲染因为系统需要根据图层不透明区域的外轮廓去计算阴影形状。如果你的图层内容很复杂有子图层、有mask、有半透明背景这个轮廓计算会非常耗时。优化阴影的第一步是显式指定shadowPathlayer.shadowOpacity 0.3 layer.shadowOffset CGSize(width: 0, height: 4) layer.shadowRadius 8 layer.shadowPath UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath设置shadowPath后系统不再实时计算轮廓直接沿指定路径生成阴影。这里的坑是路径和bounds绑定在一起当frame变化时阴影路径不会自动更新必须在layoutSubviews里同步重新设置。很多人在frame变化后发现阴影形状怪异基本就是忘了更新path。第二步才是考虑shouldRasterize。它的作用是把图层内容缓存成一幅位图下次直接复用它。对静态的阴影视图这很香因为缓存一次后不再需要重复离屏渲染。但如果你把它用在了动态变化的视图上比如动画中、列表滚动中内容不断变化系统反而要频繁刷新缓存性能比不开还差。我给的建议是只在确认视图内容稳定的时候用并且都要记得把rasterizationScale设成UIScreen.main.scale否则缓存位图在Retina屏上会发虚。6. 面向Xcode实时渲染与代码组织让自定义View更好维护最后聊两件和工程效率相关的事怎么让自定义View在Xcode里实时预览以及怎么把它组织得能被团队里其他人快速看懂。很多人自定义View写完后只有跑起来才能看到效果改一个颜色要重新编译一次效率很低。6.1 IBDesignable和IBInspectable在Storyboard里直接调参数IBDesignable允许Xcode在Storyboard画布上直接渲染你的自定义ViewIBInspectable则把你的属性暴露到右侧的Attributes Inspector里可以直接改参数。搭配使用就是所见即所得。IBDesignable class BadgeView: UIView { IBInspectable var cornerRadius: CGFloat 0 { didSet { layer.cornerRadius cornerRadius } } IBInspectable var fillColor: UIColor .systemBlue { didSet { setNeedsDisplay() } } override func draw(_ rect: CGRect) { fillColor.setFill() UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).fill() } }注意IBInspectable只支持有限的类型Int、CGFloat、Double、String、Bool、UIColor、UIImage等。如果你想暴露一个自定义枚举建议用Int存原始值再通过计算属性转成枚举。这里有一个团队协作的隐性收益设计师和开发在同一个Storyboard里改样式不需要反复跑起来 - 看效果 - 改代码 - 再跑沟通成本低很多。所以哪怕我自己写组件偏好纯代码遇到要跟设计紧密配合的情况也会用IBDesignable。6.2 prepareForInterfaceBuilder实时渲染时的特殊处理IBDesignable在Xcode里渲染时会真的实例化你的View并调用相关代码。如果你的draw(_:)或初始化逻辑里做了耗时操作、网络请求、读取文件会让实时渲染卡住甚至崩溃。这时候就要用到prepareForInterfaceBuilder()。override func prepareForInterfaceBuilder() { super.prepareForInterfaceBuilder() progress 0.75 isShowingPlaceholder true }这个方法只有在Xcode画布渲染时才会调用App运行时不会执行。所以你可以放心在里面设置假数据、占位状态保证编辑器里能看到一个合适的预览效果。我遇到过一种常见情况某个封面的自定义View在运行时通过网络加载图片结果Storyboard里一直是空白。加上prepareForInterfaceBuilder()里设置一张本地占位图就正常了。另一个小坑是IBDesignable渲染失败以后Xcode并不会明确报错只会在画布上提示Failed to render。这时候优先检查有没有在初始化时调用依赖外部环境的API其次检查draw(_:)里是否使用了动态资源。把这两类代码全部挡在prepareForInterfaceBuilder外面问题基本能解决。6.3 代码复用的组织习惯用extension按职责拆开自定义View的代码很容易越写越长属性、初始化、布局、绘制、事件处理全堆在一个类里几百行后自己都难找方法。我建议按职责用extension拆分文件或区段。final class ProfileHeaderView: UIView { // 核心属性 var avatarURL: URL? var displayName: String? } // MARK: - 初始化与布局约束 extension ProfileHeaderView { override init(frame: CGRect) { ... } required init?(coder: NSCoder) { ... } private func setupSubviews() { ... } } // MARK: - 绘制 extension ProfileHeaderView { override func draw(_ rect: CGRect) { ... } } // MARK: - 事件响应 extension ProfileHeaderView { override func point(inside point: CGPoint, with event: UIEvent?) - Bool { ... } }这样一个文件里如果继续用// MARK: -分区块代码搜索效率会高很多。Swift里extension不只是代码组织工具它还能访问类内部的private属性所以拆出去不影响封装。还有一点是公共属性的设计。自定义View最终要给别的模块用属性命名和文档注释比内部实现重要得多。我习惯给所有会暴露给外部的属性都写上注释并且尽量提供带默认值的初始化参数让调用方不必关心实现细节。最后分享一个我自己的真实教训以前写自定义View总想着通用给一个页面写了一个万能容器结果参数越加越多最后维护成本比直接写死还要高。后来我学会了一个更务实的思路——先为当前需求写最简版本等第二个页面出现同样需求再抽公共部分。实践下来这个两次使用才抽象的原则帮我避免了大量过度设计。如果你也是刚开始写自定义View不妨也试试第一次就写清楚第二次再抽通用第三次再上IBDesignable。这样踩过的坑才真正变成了你的判断力。
返回列表