圆角 & 离屏渲染实践
下文主要针对各种加圆角的情况进行实践,看看会不会出现离屏渲染的情况。
测试条件
- 测试平台:iPhone 12,iOS 16.3.1,Xcode 14.2。
 - 测试方法:运行项目后,通过 Xcode 设置 “Debug -> View Debugging -> Rendering -> Color Off-screen Rendered” 来打开离屏渲染检测。
 - 主要测试点:
- 是否开启 
masksToBounds - 是否设置图片
 - 是否设置背景色”
 - 父视图是否具有透明度
 
 - 是否开启 
 
结论
这里先把结论放到最前面,后面您可以跳过实践过程,直接切到解决方案一节。
✅ 代表不会触发离屏渲染;❌ 代表会触发离屏渲染。
| UIView | UIImageView | UIButton(只有文字) | UIButton(图片/背景图) | |
|---|---|---|---|---|
| 直接设置 | ✅ | ✅ | ✅ | ❌ | 
| 背景色(非透明) | ✅ | ❌ | ✅ | ❌ | 
父视图具有透明度(alpha) | 
❌ | ❌ | ❌ | ❌ | 
下面重点说一下 UIImageView 和 UIButton 的测试过程:
UIImageView
iOS 9 对 UIImageView 进行了一系列优化,相比较过去而言,触发离屏渲染的场景要小了不少
直接设置
lazy var testView = UIImageView().then {
    $0.image = UIImage(named: "Forms/share-cover")
    $0.layer.masksToBounds = true
    $0.layer.cornerRadius = 30
}
一个设置了图片的 UIImageView。示例中同时设置了 masksToBounds 和 cornerRadius。从图片上可以看出来,并不会触发离屏渲染:
UIImageView 不设置
masksToBounds,只设置cornerRadius的话不会显示圆角。

添加背景色
那如果我们手贱再给它添加一个背景色呢?
lazy var testView = UIImageView().then {
    $0.image = UIImage(named: "Forms/share-cover")
    $0.layer.masksToBounds = true
    $0.layer.cornerRadius = 30
    $0.backgroundColor = .white // 添加一个白色的背景色
}

果然手贱是没有好处的,设置了背景色后则会触发离屏渲染。
UIButton
UIButton 默认也是没有背景色的,需要同时考虑文字和图片两种情况。
文字
如果不设置背景色的话,只有文字的 UIButton 对象是显示不出来圆角的,不过我也是分别测了一下。
lazy var testView = UIButton(type: .custom).then {
    $0.setTitle("这是一个标题", for: .normal)
    $0.layer.cornerRadius = 30
}
结论是:不论设置不设置背景色,只有文字的 UIButton 对象添加圆角后都不会触发离屏渲染。
同时只需要设置 cornerRadius,不需要开启 masksToBounds 就可以显示出来圆角。
图片 & 背景图
因为现象一致,所以图片和背景图在这里归位一类进行讨论。
带图片的 UIButton 是重点测试对象,分下面几种情况:
直接设置
lazy var testView = UIButton(type: .custom).then {
    $0.setTitle("这是一个标题", for: .normal)
    $0.layer.masksToBounds = true
    $0.layer.cornerRadius = 30
    $0.setImage(UIImage(named: "Forms/share-cover"), for: .normal)
}
如果
UIButton只设置cornerRadius而不开启masksToBounds,那么图片是不会显示出圆角的。

从图上看,是会触发离屏渲染。
尝试解决离屏渲染问题
实践了哪些情况会触发离屏渲染,接下来就想办法尝试解决这些问题。
用父视图进行包裹
有的文章会提到用一个父视图包裹需要添加圆角的视图,然后将圆角添加到父视图上。
实践发现该方法并不能解决离屏渲染问题。
避免开启 masksToBounds
UIImageView 视图可以在不开启 masksToBounds,仅设置 cornerRadius 的情况下显示圆角,只包含文字的 UIButton 对象也一样。
所以在这种情况下,不开启 masksToBounds 也可以避免触发离屏渲染。
为图片添加圆角
直接为图片添加圆角是比较常用的避免离屏渲染的方法。只不过现在比较常用的 UIImageView 视图已经不会触发离屏渲染了,但是 UIButton 依然可以这么做。
下面提几点实际需求里可能会遇到的问题:
“拼接图片”
例如微信的群聊头像,可能涉及到多个图片在一个视图控件中进行展示。
这个时候最好将多张图片拼到一起,然后对这张图片整体设置圆角。而不是在一个 UIView 中尝试添加多个 UIImageVIew,再对 UIView 设置圆角。
视图尺寸不固定
一般 UIButton 会遇到 “尺寸不固定 + 需要圆角 + 背景色” 的情况。
例如页面某个位置有一个 “距离屏幕两侧 10px,可用状态背景色为蓝色,不可用状态下为灰蓝色” 的按钮。
此时可以考虑找 UI 切一个带圆角的纯色图片直接用作底色,但是我们开发也可以自己生成这样一张图片,然后通过拉伸来达到相同的效果。
首先我们先找喵神借用一个非常好用的枚举,来表示圆角:
extension UIImage {
    enum Radius {
        /// 圆角半径应该按照图片**宽度**的比例计算。
        /// 通常关联的值应该在0和0.5之间,其中0表示没有圆角,0.5表示使用图片宽度的一半作为圆角半径。
        case widthFraction(CGFloat)
        
        /// 圆角半径应该按照图片**高度**的比例计算。
        /// 通常关联的值应该在0和0.5之间,其中0表示没有圆角,0.5表示使用图片高度的一半作为圆角半径。
        case heightFraction(CGFloat)
        
        /// 使用一个固定的点值作为圆角半径。
        case point(CGFloat)
        
        func compute(with size: CGSize) -> CGFloat {
            let cornerRadius: CGFloat
            switch self {
            case .point(let point):
                cornerRadius = point
            case .widthFraction(let widthFraction):
                cornerRadius = size.width * widthFraction
            case .heightFraction(let heightFraction):
                cornerRadius = size.height * heightFraction
            }
            return cornerRadius
        }
    }
}
之后生成纯色图片:
extension UIImage {
    static func color(
        _ color: UIColor,
        size: CGSize = .init(width: 1, height: 1),
        radius: Radius? = nil
    ) -> UIImage {
        let cornerRadius = radius?.compute(with: size)
        
        let renderer = UIGraphicsImageRenderer(size: size)
        let image = renderer.image { _ in
            let rect = CGRect(origin: .zero, size: size)
            let path: UIBezierPath
            
            if let cornerRadius = cornerRadius {
                path = .init(roundedRect: rect, cornerRadius: cornerRadius)
            } else {
                path = .init(rect: rect)
            }
            
            color.setFill()
            path.fill()
        }
        
        guard let cornerRadius = cornerRadius else { return image }
        
        let insets = { UIEdgeInsets(top: $0, left: $0, bottom: $0, right: $0) }(cornerRadius)
        return image.resizableImage(withCapInsets: insets, resizingMode: .stretch)
    }
}
你还可以扩展这个方法,配置需要圆角的位置,而不是为四个边都添加圆角,比较简单,这里就不再赘述了。