PAYATHON 2026

深浅色切换时,动态 UIColor 未更新 CALayer.borderColor,如何解决?

排障赵师傅

结论

Apple 目前公开的 UIKit / Core Animation API 没有将动态 UIColor 直接绑定到任意 CALayer 颜色属性的通用接口。borderColor、shadowColor、strokeColor 等属性保存的是 CGColor,需要在环境变化后重新解析并赋值。CALayer 文档

是否需要手写 trait 观察者,取决于系统版本和更新方式:

  • iOS 18 起,可以使用自动 trait 跟踪。在支持的更新方法中读取当前视图的 trait 后,UIKit 会安排后续更新。
  • iOS 26 起,可以通过 updateProperties() 更新颜色和样式。
  • 最低支持 iOS 16 时,集中封装仍然实用:iOS 17 及以上使用 registerForTraitChanges,iOS 16 使用 traitCollectionDidChange(_:)。

集中封装是合理的工程方案。Apple 提供了颜色解析、trait 注册和自动 trait 跟踪机制,但没有指定唯一架构。将集中封装称为“2026 年唯一主流方案”,也需要调查依据。

为什么 UIColor 会更新,CGColor 不会

动态 UIColor 保留了根据 trait 选择颜色的能力。读取它的 .cgColor 时,得到的是当次解析后的具体颜色值,这个 CGColor 不再携带动态颜色的选择逻辑。

原来的 UIColor 不受影响。只要保留动态 UIColor,之后就可以重新解析:

let color = AppColors.borderColor

layer.borderColor = color
    .resolvedColor(with: traitCollection)
    .cgColor

resolvedColor(with:) 只完成这一次解析,不会建立持续绑定。Apple 文档中也用设置 CALayer 边框颜色的例子说明了这种用法。resolvedColor(with:) 文档

背景色也有相同区别:UIView.backgroundColor 接收 UIColor,layer.backgroundColor 接收 CGColor。

iOS 26:使用 updateProperties(),省去显式注册

只运行在 iOS 26 及以上的视图,可以将样式更新放进 updateProperties()。这个方法支持自动 trait 跟踪,在 trait 更新之后、布局之前执行。自动 trait 跟踪、WWDC25:What’s new in UIKit

@available(iOS 26.0, *)
final class ModernRoundedBorderView: UIView {
    override func updateProperties() {
        super.updateProperties()

        // 显式读取依赖,供 UIKit 建立自动 trait 跟踪。
        _ = traitCollection.userInterfaceStyle
        _ = traitCollection.accessibilityContrast

        CATransaction.begin()
        CATransaction.setDisableActions(true)

        layer.borderWidth = 1
        layer.cornerRadius = 12
        layer.borderColor = AppColors.borderColor
            .resolvedColor(with: traitCollection)
            .cgColor

        CATransaction.commit()
    }
}

示例显式读取了外观和对比度 trait,不依赖颜色解析内部是否读取这些值。如果颜色还依赖自定义 trait,也应在这条更新路径中读取。这样可以省去手写观察者,颜色仍由代码重新赋给 layer,CGColor 本身仍是具体的颜色值。

iOS 18 及以上也可以在 layoutSubviews() 等受支持的方法中使用同样的机制,前提是更新路径实际读取了该视图的相关 trait。将初始化代码搬进 layoutSubviews(),并不能保证系统自动发现所有颜色依赖。

自动 trait 跟踪从 iOS 18 开始提供,无需开启 UIObservationTrackingEnabled。这个配置项用于另一套针对 Observable 模型的观察机制。WWDC24:What’s new in UIKit

最低支持 iOS 16:把兼容逻辑集中到一个入口

最低支持 iOS 16 的项目可以采用一个跨版本基类。基类负责 trait 观察和生命周期处理,各组件只实现 layer 颜色的刷新逻辑。

首次显示、颜色源变更和 trait 变更都通过下一次布局触发刷新:

import UIKit

class LayerAppearanceView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        configureTraitObservation()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        configureTraitObservation()
    }

    private func configureTraitObservation() {
        if #available(iOS 17.0, *) {
            registerForTraitChanges(
                UITraitCollection.systemTraitsAffectingColorAppearance
            ) { (view: LayerAppearanceView, _: UITraitCollection) in
                view.setNeedsLayout()
            }
        }
    }

    override func didMoveToWindow() {
        super.didMoveToWindow()
        setNeedsLayout()
    }

    override func traitCollectionDidChange(
        _ previousTraitCollection: UITraitCollection?
    ) {
        super.traitCollectionDidChange(previousTraitCollection)

        if #available(iOS 17.0, *) {
            // 新系统由注册的回调负责。
            return
        }

        if traitCollection.hasDifferentColorAppearance(
            comparedTo: previousTraitCollection
        ) {
            setNeedsLayout()
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        CATransaction.begin()
        CATransaction.setDisableActions(true)
        applyLayerColors()
        CATransaction.commit()
    }

    // 子类重写;只更新颜色,不触发布局失效。
    func applyLayerColors() {}
}

业务组件保留动态颜色,并实现刷新方法。borderWidth 和 cornerRadius 仍可放在组件原有的初始化代码中:

final class RoundedBorderView: LayerAppearanceView {
    var borderUIColor: UIColor = AppColors.borderColor {
        didSet {
            setNeedsLayout()
        }
    }

    override func applyLayerColors() {
        super.applyLayerColors()

        layer.borderColor = borderUIColor
            .resolvedColor(with: traitCollection)
            .cgColor
    }
}

systemTraitsAffectingColorAppearance 覆盖了系统定义的颜色外观相关 trait。对于单纯的 Any / Dark 变体,只观察 UITraitUserInterfaceStyle 就够了;颜色包含其他外观条件时,使用这个语义 trait 集合更合适。注册会随被观察对象释放而自动清理,通常不必保存 token。Adapting your app when traits change

这个实现每次布局都会重新赋值颜色,兼容逻辑比较简单。如果组件布局频繁、颜色数量很多,可以集中增加一个标记,记录颜色是否需要刷新,减少重复解析。

已经继承 UITableViewCell、UICollectionViewCell 或其他控件的类型,可以将颜色解析和赋值逻辑抽成 helper,再由对应的组件基类转发生命周期。普通 Swift extension 无法替所有现有视图统一重写生命周期方法,增加一个计算属性也不会自动获得 trait 通知。

阴影、描边和渐变使用同样的处理方式

在同一个刷新入口中,使用所属视图的 traitCollection 解析全部动态颜色。假设组件已经持有以下 layer 和动态 UIColor:

layer.shadowColor = shadowUIColor
    .resolvedColor(with: traitCollection)
    .cgColor

shapeLayer.strokeColor = strokeUIColor
    .resolvedColor(with: traitCollection)
    .cgColor

gradientLayer.colors = gradientUIColors.map {
    $0.resolvedColor(with: traitCollection).cgColor
}

渐变需要重新生成颜色数组。如果缓存第一次得到的 [CGColor],之后再赋回同一个数组,外观仍然不会切换。

实现时容易遗漏的细节

  • 使用所属视图的 trait,避免用全局深浅色状态代替。窗口或局部视图可能设置了 overrideUserInterfaceStyle,同一应用中也可能存在不同的外观环境。
  • 安排可靠的首次更新。注册 trait 回调不会执行初始化刷新,而视图初始化时可能尚未进入最终层级。
  • 保留动态 UIColor。将颜色长期存成 CGColor,或先用 RGB 分量重建为普通 UIColor,都会丢失原有的动态选择能力。
  • 明确控制动画。示例通过 CATransaction.setDisableActions(true) 避免自建 sublayer 出现意外的隐式颜色动画。如果需要外观切换过渡,应由组件控制。
  • 保持 backing layer 的 delegate 关系。UIView 管理自己的 backing layer,Apple 明确要求不要修改它的 delegate 来接管更新。CALayer 文档

最低支持 iOS 16 时,集中管理颜色源和刷新入口可以消除大部分重复代码。新组件运行在较新系统上时,可以使用自动 trait 跟踪和 updateProperties(),进一步减少显式注册。

备注:内容仅供参考。