iOS 26 部分适配笔记

0 阅读17分钟

概述

iOS 26 为 UIKit 带来了革命性的更新,引入了 SwiftUI 的响应式编程特性,使得 UIKit 开发更加现代化和高效。本文档详细介绍了 iOS 26 的三大核心特性:@Observable 对象、updateProperties() 方法和 flushUpdates 动画选项。

依赖要求

本文档中的所有示例代码使用 SnapKit 进行布局约束管理,以提供更简洁、更易维护的代码。

安装 SnapKit

使用 Swift Package Manager (推荐)

dependencies: [
    .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.0")
]

使用 CocoaPods

pod 'SnapKit', '~> 5.7.0'

使用 Carthage

github "SnapKit/SnapKit" ~> 5.7.0

为什么使用 SnapKit?

  1. 声明式语法:更接近 SwiftUI 的布局思维
  2. 类型安全:编译时检查,减少运行时错误
  3. 易于维护:约束代码更清晰、更易读
  4. 完美配合 iOS 26:与 updateProperties() 方法配合,实现属性更新和布局的完美分离

一、@Observable 对象支持

1.1 概述

iOS 26 让 UIKit 原生支持 Swift 的 @Observable 宏,实现了自动观察追踪功能。当 Observable 对象的属性发生变化时,UIKit 会自动更新相关的 UI,无需手动调用 setNeedsLayout()setNeedsDisplay() 等方法。

1.2 核心特性

  • 自动依赖追踪:UIKit 自动跟踪在特定方法中访问的 Observable 属性
  • 智能失效机制:属性变化时自动触发视图更新
  • 零样板代码:无需编写手动更新逻辑

1.3 使用前提

iOS 26 及以上

默认启用,无需额外配置。

iOS 18-25(向后兼容)

需要在 Info.plist 中添加以下配置:

<key>UIObservationTrackingEnabled</key>
<true/>

1.4 支持自动追踪的方法

Observable 自动追踪在以下 UIKit 方法中生效:

  • UIViewControllerviewWillLayoutSubviews()
  • UIViewlayoutSubviews()
  • iOS 26+updateProperties()(视图控制器和视图中均可用)
  • UICollectionView/UITableView:Cell 配置处理器

1.5 基础示例

定义 Observable 模型

import UIKit

@Observable class PhoneModel {
    var name: String
    var osName: String
    
    init(name: String, osName: String) {
        self.name = name
        self.osName = osName
    }
    
    static func getPhones() -> [PhoneModel] {
        return [
            PhoneModel(name: "iPhone 16", osName: "iOS 18"),
            PhoneModel(name: "iPhone 16 Plus", osName: "iOS 18"),
            PhoneModel(name: "iPhone 16 Pro", osName: "iOS 18"),
            PhoneModel(name: "iPhone 16 Pro Max", osName: "iOS 18")
        ]
    }
}

在 UIView 中使用

import SnapKit

class CustomView: UIView {
    var phoneModel: PhoneModel?
    
    private let nameLabel = UILabel()
    private let osLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        addSubview(nameLabel)
        addSubview(osLabel)
        
        nameLabel.font = .systemFont(ofSize: 40, weight: .bold)
        osLabel.font = .systemFont(ofSize: 25)
    }
    
    private func setupConstraints() {
        nameLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalToSuperview().offset(20)
            make.height.equalTo(50)
        }
        
        osLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalTo(nameLabel.snp.bottom).offset(10)
            make.height.equalTo(30)
        }
    }
    
    // 在 updateProperties 中访问 Observable 属性
    // UIKit 会自动追踪依赖并在属性变化时重新调用
    override func updateProperties() {
        super.updateProperties()
        
        // 自动追踪 phoneModel?.name 和 phoneModel?.osName
        nameLabel.text = phoneModel?.name
        osLabel.text = phoneModel?.osName
    }
}

在 ViewController 中使用

class ViewController: UIViewController {
    let phoneModels = PhoneModel.getPhones()
    private let customView = CustomView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(customView)
        customView.frame = view.bounds
        customView.phoneModel = phoneModels[0]
        
        // 3 秒后修改数据,UI 会自动更新
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            self.phoneModels[0].name = "iPhone 17"
            self.phoneModels[0].osName = "iOS 26"
            // 无需调用任何更新方法,UI 自动刷新!
        }
    }
}

1.6 UITableView/UICollectionView 集成

传统方式(iOS 25 及之前)

// ❌ 旧方式:需要手动重新加载 cell
func updateModel() {
    model.title = "New Title"
    tableView.reloadRows(at: [indexPath], with: .automatic)
}

现代方式(iOS 26)

import SnapKit

class CustomTableViewCell: UITableViewCell {
    var phoneModel: PhoneModel?
    
    private let phoneLabel = UILabel()
    private let osLabel = UILabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        contentView.addSubview(phoneLabel)
        contentView.addSubview(osLabel)
    }
    
    private func setupConstraints() {
        phoneLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalToSuperview().offset(10)
            make.height.equalTo(30)
        }
        
        osLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalTo(phoneLabel.snp.bottom).offset(5)
            make.height.equalTo(20)
        }
    }
    
    // ✅ 在 updateProperties 中更新 UI
    // Observable 属性变化时会自动重新调用
    override func updateProperties() {
        super.updateProperties()
        
        phoneLabel.text = phoneModel?.name
        osLabel.text = phoneModel?.osName
    }
}

// ViewController
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "custom", for: indexPath) as! CustomTableViewCell
        cell.phoneModel = phoneModels[indexPath.row]
        return cell
    }
    
    func updateData() {
        // ✅ 直接修改数据,cell 会自动更新
        phoneModels[0].name = "Updated Name"
        // 无需调用 reloadRows!
    }
}

1.7 最佳实践

✅ 推荐做法

// 1. 在 layoutSubviews 中访问 Observable 属性
override func layoutSubviews() {
    super.layoutSubviews()
    titleLabel.text = viewModel.title  // ✅ 自动追踪
    subtitleLabel.text = viewModel.subtitle  // ✅ 自动追踪
}

// 2. 保持 Observable 对象在主线程更新
DispatchQueue.main.async {
    self.viewModel.title = "New Title"  // ✅
}

// 3. 缓存昂贵的计算
override func layoutSubviews() {
    super.layoutSubviews()
    
    // ✅ 缓存计算结果
    if cachedValue != viewModel.computedProperty {
        cachedValue = viewModel.computedProperty
        updateExpensiveUI()
    }
}

❌ 避免的做法

// 1. 不要在非追踪方法中期望自动更新
func customMethod() {
    label.text = viewModel.title  // ❌ 不会自动追踪
}

// 2. 避免在后台线程直接修改 Observable 属性
DispatchQueue.global().async {
    self.viewModel.title = "New"  // ❌ 可能导致 UI 不一致
}

// 3. 注意循环引用
class MyView: UIView {
    var viewModel: ViewModel?  // ✅ 使用 weak 或正确管理生命周期
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.text = viewModel?.title
    }
}

二、updateProperties() 方法

2.1 概述

iOS 26 为 UIKit 引入了全新的生命周期方法 updateProperties(),专门用于处理属性更新,与布局逻辑分离。这个方法在 layoutSubviews() 之前运行,但独立于布局系统。

2.2 为什么需要 updateProperties()

传统 UIKit 的问题

// ❌ 传统方式:属性更新和布局耦合在一起
override func layoutSubviews() {
    super.layoutSubviews()
    
    // 属性更新
    titleLabel.text = viewModel.title
    titleLabel.textColor = viewModel.isHighlighted ? .red : .black
    
    // 布局代码(即使使用 SnapKit,也会触发不必要的约束更新检查)
    // 问题:
    // 1. 仅属性变化也会触发整个 layoutSubviews
    // 2. 布局变化也会重新执行属性更新逻辑
    // 3. 性能浪费,逻辑耦合
}

使用 updateProperties() 的优势

import SnapKit

// ✅ 现代方式:属性更新和布局分离
class ModernView: UIView {
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(titleLabel)
        addSubview(subtitleLabel)
    }
    
    private func setupConstraints() {
        titleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(10)
            make.top.equalToSuperview().offset(10)
            make.height.equalTo(30)
        }
        
        subtitleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(10)
            make.top.equalTo(titleLabel.snp.bottom).offset(5)
            make.height.equalTo(20)
        }
    }
    
    // 仅处理属性更新
    override func updateProperties() {
        super.updateProperties()
        
        titleLabel.text = viewModel.title
        titleLabel.textColor = viewModel.isHighlighted ? .red : .black
        subtitleLabel.text = viewModel.subtitle
    }
    
    // 仅在需要动态调整约束时才重写 layoutSubviews
    // 使用 SnapKit 后,大多数情况下不需要在这里做任何事情
}

2.3 更新周期

iOS 26 的 UIKit 更新周期:

1. Trait Updates(特征更新)
   ↓
2. updateProperties()(属性更新)← 新增!
   ↓
3. layoutSubviews()(布局计算)
   ↓
4. Display Pass(绘制渲染)

2.4 手动触发更新

// 标记需要属性更新(下一个更新周期执行)
view.setNeedsUpdateProperties()

// 立即执行待处理的属性更新
view.updatePropertiesIfNeeded()

// 标记需要布局更新
view.setNeedsLayout()

// 立即执行布局更新
view.layoutIfNeeded()

2.5 完整示例

@Observable class UserViewModel {
    var name: String
    var email: String
    var isVerified: Bool
    var avatarURL: URL?
    
    init(name: String, email: String, isVerified: Bool, avatarURL: URL? = nil) {
        self.name = name
        self.email = email
        self.isVerified = isVerified
        self.avatarURL = avatarURL
    }
}

class UserProfileView: UIView {
    var viewModel: UserViewModel?
    
    private let nameLabel = UILabel()
    private let emailLabel = UILabel()
    private let verifiedBadge = UIImageView()
    private let avatarImageView = UIImageView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(avatarImageView)
        addSubview(nameLabel)
        addSubview(emailLabel)
        addSubview(verifiedBadge)
        
        nameLabel.font = .systemFont(ofSize: 18, weight: .bold)
        emailLabel.font = .systemFont(ofSize: 14)
        emailLabel.textColor = .gray
        
        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.clipsToBounds = true
        avatarImageView.layer.cornerRadius = 30
    }
    
    private func setupConstraints() {
        let padding: CGFloat = 16
        let avatarSize: CGFloat = 60
        
        avatarImageView.snp.makeConstraints { make in
            make.leading.top.equalToSuperview().offset(padding)
            make.size.equalTo(avatarSize)
        }
        
        nameLabel.snp.makeConstraints { make in
            make.leading.equalTo(avatarImageView.snp.trailing).offset(padding)
            make.top.equalToSuperview().offset(padding)
            make.trailing.equalTo(verifiedBadge.snp.leading).offset(-4)
            make.height.equalTo(25)
        }
        
        verifiedBadge.snp.makeConstraints { make in
            make.trailing.equalToSuperview().inset(padding)
            make.centerY.equalTo(nameLabel)
            make.size.equalTo(20)
        }
        
        emailLabel.snp.makeConstraints { make in
            make.leading.equalTo(avatarImageView.snp.trailing).offset(padding)
            make.trailing.equalToSuperview().inset(padding)
            make.top.equalTo(nameLabel.snp.bottom).offset(4)
            make.height.equalTo(20)
        }
    }
    
    // ✅ 属性更新:在这里处理所有数据绑定
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        // 更新文本内容
        nameLabel.text = viewModel.name
        emailLabel.text = viewModel.email
        
        // 更新验证状态
        verifiedBadge.isHidden = !viewModel.isVerified
        verifiedBadge.image = UIImage(systemName: "checkmark.seal.fill")
        verifiedBadge.tintColor = .systemBlue
        
        // 加载头像(实际项目中应使用图片加载库)
        if let url = viewModel.avatarURL {
            loadAvatar(from: url)
        }
    }
    
    private func loadAvatar(from url: URL) {
        // 实际项目中使用 SDWebImage 或 Kingfisher
        URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
            guard let data = data, let image = UIImage(data: data) else { return }
            DispatchQueue.main.async {
                self?.avatarImageView.image = image
            }
        }.resume()
    }
}

// 使用示例
class ProfileViewController: UIViewController {
    let profileView = UserProfileView()
    let viewModel = UserViewModel(
        name: "张三",
        email: "zhangsan@example.com",
        isVerified: true
    )
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(profileView)
        profileView.frame = CGRect(x: 20, y: 100, width: view.bounds.width - 40, height: 100)
        profileView.backgroundColor = .systemBackground
        profileView.layer.cornerRadius = 12
        profileView.viewModel = viewModel
        
        // 5 秒后更新用户信息
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            self.viewModel.name = "李四"
            self.viewModel.email = "lisi@example.com"
            self.viewModel.isVerified = false
            // UI 自动更新!仅触发 updateProperties(),不触发 layoutSubviews()
        }
    }
}

2.6 性能优化对比

// 场景:仅修改文本颜色

// ❌ iOS 25 及之前
override func layoutSubviews() {
    super.layoutSubviews()
    label.textColor = viewModel.isHighlighted ? .red : .black
    // 问题:即使只改变颜色,也会触发 layoutSubviews
    // 导致 Auto Layout 引擎重新计算约束(即使约束没有变化)
}

// ✅ iOS 26:使用 updateProperties 分离属性更新
override func updateProperties() {
    super.updateProperties()
    label.textColor = viewModel.isHighlighted ? .red : .black
    // 优势:仅执行属性更新,不触发布局系统
    // 无论是 frame 还是 Auto Layout,都不会被触发
}

// 仅在需要动态调整约束时才重写 layoutSubviews
override func layoutSubviews() {
    super.layoutSubviews()
    // 使用 SnapKit 后,大多数静态布局不需要在这里处理
    // 仅在需要根据运行时条件动态调整约束时才使用
}

2.7 SnapKit 与 iOS 26 最佳实践

2.7.1 静态布局:一次性设置约束

import SnapKit

class StaticLayoutView: UIView {
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()  // 一次性设置,无需在 layoutSubviews 中处理
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(titleLabel)
        addSubview(subtitleLabel)
    }
    
    private func setupConstraints() {
        titleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(16)
            make.top.equalToSuperview().offset(16)
            make.height.equalTo(30)
        }
        
        subtitleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(16)
            make.top.equalTo(titleLabel.snp.bottom).offset(8)
            make.height.equalTo(20)
        }
    }
    
    // ✅ 仅处理属性更新
    override func updateProperties() {
        super.updateProperties()
        titleLabel.text = viewModel.title
        subtitleLabel.text = viewModel.subtitle
    }
    
    // ✅ 静态布局无需重写 layoutSubviews
}

2.7.2 动态布局:使用约束引用

import SnapKit

@Observable class DynamicViewModel {
    var isCompact: Bool = false
    var title: String = "标题"
}

class DynamicLayoutView: UIView {
    var viewModel: DynamicViewModel?
    
    private let titleLabel = UILabel()
    private let iconImageView = UIImageView()
    
    // 保存约束引用,用于动态更新
    private var titleLeadingConstraint: Constraint?
    private var iconSizeConstraint: Constraint?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(iconImageView)
        addSubview(titleLabel)
        
        iconImageView.image = UIImage(systemName: "star.fill")
        iconImageView.tintColor = .systemYellow
    }
    
    private func setupConstraints() {
        iconImageView.snp.makeConstraints { make in
            make.leading.equalToSuperview().offset(16)
            make.centerY.equalToSuperview()
            iconSizeConstraint = make.size.equalTo(24).constraint
        }
        
        titleLabel.snp.makeConstraints { make in
            titleLeadingConstraint = make.leading.equalTo(iconImageView.snp.trailing).offset(8).constraint
            make.trailing.equalToSuperview().inset(16)
            make.centerY.equalToSuperview()
        }
    }
    
    // ✅ 在 updateProperties 中动态更新约束
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        titleLabel.text = viewModel.title
        
        // 根据状态动态调整约束
        if viewModel.isCompact {
            iconSizeConstraint?.update(offset: 16)
            titleLeadingConstraint?.update(offset: 4)
        } else {
            iconSizeConstraint?.update(offset: 24)
            titleLeadingConstraint?.update(offset: 8)
        }
    }
}

2.7.3 复杂动态布局:使用 remakeConstraints

import SnapKit

@Observable class LayoutViewModel {
    enum LayoutMode {
        case horizontal
        case vertical
    }
    
    var layoutMode: LayoutMode = .horizontal
    var title: String = "标题"
    var subtitle: String = "副标题"
}

class AdaptiveLayoutView: UIView {
    var viewModel: LayoutViewModel?
    
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private var currentLayoutMode: LayoutViewModel.LayoutMode?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(titleLabel)
        addSubview(subtitleLabel)
        
        titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
        subtitleLabel.font = .systemFont(ofSize: 14)
        subtitleLabel.textColor = .gray
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        titleLabel.text = viewModel.title
        subtitleLabel.text = viewModel.subtitle
        
        // 仅在布局模式改变时重建约束
        if currentLayoutMode != viewModel.layoutMode {
            currentLayoutMode = viewModel.layoutMode
            updateLayout(for: viewModel.layoutMode)
        }
    }
    
    private func updateLayout(for mode: LayoutViewModel.LayoutMode) {
        switch mode {
        case .horizontal:
            setupHorizontalLayout()
        case .vertical:
            setupVerticalLayout()
        }
    }
    
    private func setupHorizontalLayout() {
        titleLabel.snp.remakeConstraints { make in
            make.leading.equalToSuperview().offset(16)
            make.centerY.equalToSuperview()
            make.width.equalTo(100)
        }
        
        subtitleLabel.snp.remakeConstraints { make in
            make.leading.equalTo(titleLabel.snp.trailing).offset(12)
            make.trailing.equalToSuperview().inset(16)
            make.centerY.equalToSuperview()
        }
    }
    
    private func setupVerticalLayout() {
        titleLabel.snp.remakeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(16)
            make.top.equalToSuperview().offset(16)
        }
        
        subtitleLabel.snp.remakeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(16)
            make.top.equalTo(titleLabel.snp.bottom).offset(8)
        }
    }
}

2.7.4 性能优化:避免不必要的约束更新

import SnapKit

@Observable class OptimizedViewModel {
    var count: Int = 0
    var isHighlighted: Bool = false
}

class OptimizedView: UIView {
    var viewModel: OptimizedViewModel?
    
    private let countLabel = UILabel()
    private var countHeightConstraint: Constraint?
    private var cachedCount: Int?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(countLabel)
        countLabel.textAlignment = .center
    }
    
    private func setupConstraints() {
        countLabel.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.leading.trailing.equalToSuperview().inset(20)
            countHeightConstraint = make.height.equalTo(40).constraint
        }
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        // ✅ 仅更新变化的属性
        countLabel.text = "\(viewModel.count)"
        countLabel.textColor = viewModel.isHighlighted ? .red : .black
        
        // ✅ 仅在需要时更新约束(避免每次都更新)
        if cachedCount != viewModel.count {
            cachedCount = viewModel.count
            
            // 根据数字大小动态调整高度
            let newHeight: CGFloat = viewModel.count > 99 ? 60 : 40
            countHeightConstraint?.update(offset: newHeight)
        }
    }
}

2.7.5 SnapKit 与 flushUpdates 配合

import SnapKit

@Observable class AnimatedViewModel {
    var isExpanded: Bool = false
}

class AnimatedConstraintView: UIView {
    var viewModel: AnimatedViewModel?
    
    private let containerView = UIView()
    private var heightConstraint: Constraint?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(containerView)
        containerView.backgroundColor = .systemBlue
        containerView.layer.cornerRadius = 12
    }
    
    private func setupConstraints() {
        containerView.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.width.equalTo(200)
            heightConstraint = make.height.equalTo(100).constraint
        }
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        // 动态更新约束值
        let newHeight: CGFloat = viewModel.isExpanded ? 300 : 100
        heightConstraint?.update(offset: newHeight)
    }
    
    func toggleWithAnimation() {
        // ✅ flushUpdates 自动处理 SnapKit 约束变化的动画
        UIView.animate(
            withDuration: 0.4,
            delay: 0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.5,
            options: [.flushUpdates]
        ) {
            self.viewModel?.isExpanded.toggle()
            // 约束变化会自动动画化,无需手动调用 layoutIfNeeded()
        }
    }
}

2.7.6 关键要点总结

场景推荐做法说明
静态布局init 中使用 makeConstraints一次性设置,无需后续更新
动态约束值保存 Constraint 引用,在 updateProperties 中使用 update高效更新单个约束值
布局模式切换updateProperties 中使用 remakeConstraints完全重建约束布局
约束动画updateProperties 中更新约束 + flushUpdates自动动画化约束变化
性能优化缓存值,仅在真正变化时更新约束避免不必要的布局计算

三、flushUpdates 动画选项

3.1 概述

iOS 26 为 UIView 动画引入了新的选项 .flushUpdates,它能够自动处理动画块内的 Observable 更新和约束变化,无需手动调用 layoutIfNeeded()

3.2 传统动画的问题

// ❌ iOS 25 及之前:繁琐的手动布局
UIView.animate(withDuration: 0.3) {
    self.viewModel.isExpanded = true
    self.view.layoutIfNeeded()  // 必须手动调用
}

// 约束动画也需要手动触发
heightConstraint.constant = 200
UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()  // 必须手动调用
}

3.3 使用 flushUpdates

// ✅ iOS 26:自动处理更新
UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
    self.viewModel.isExpanded = true
    // 无需调用 layoutIfNeeded(),自动处理!
}

// 约束动画也自动处理
heightConstraint.constant = 200
UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
    // 自动应用约束变化并动画
}

3.4 基础示例

简单属性动画

import SnapKit

@Observable class CardViewModel {
    var isExpanded: Bool = false
    var title: String = "点击展开"
}

class CardView: UIView {
    var viewModel: CardViewModel?
    
    private let titleLabel = UILabel()
    private let contentLabel = UILabel()
    private var contentHeightConstraint: Constraint?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(titleLabel)
        addSubview(contentLabel)
        
        contentLabel.numberOfLines = 0
    }
    
    private func setupConstraints() {
        titleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalToSuperview().offset(20)
            make.height.equalTo(30)
        }
        
        contentLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(20)
            make.top.equalTo(titleLabel.snp.bottom).offset(10)
            contentHeightConstraint = make.height.equalTo(0).constraint
        }
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        titleLabel.text = viewModel.title
        contentLabel.alpha = viewModel.isExpanded ? 1.0 : 0.0
        
        // 动态更新高度约束
        let contentHeight: CGFloat = viewModel.isExpanded ? 200 : 0
        contentHeightConstraint?.update(offset: contentHeight)
    }
    
    func toggleExpansion() {
        // ✅ 使用 flushUpdates 自动处理动画
        UIView.animate(withDuration: 0.3, options: [.flushUpdates, .curveEaseInOut]) {
            self.viewModel?.isExpanded.toggle()
            self.viewModel?.title = self.viewModel?.isExpanded == true ? "点击收起" : "点击展开"
            // 所有 UI 更新(包括约束变化)自动动画化!
        }
    }
}

Auto Layout 约束动画

class ExpandableViewController: UIViewController {
    private let containerView = UIView()
    private var heightConstraint: NSLayoutConstraint!
    
    @Observable class ViewModel {
        var isExpanded: Bool = false
    }
    
    let viewModel = ViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(containerView)
        containerView.translatesAutoresizingMaskIntoConstraints = false
        containerView.backgroundColor = .systemBlue
        
        heightConstraint = containerView.heightAnchor.constraint(equalToConstant: 100)
        
        NSLayoutConstraint.activate([
            containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            containerView.widthAnchor.constraint(equalToConstant: 200),
            heightConstraint
        ])
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        containerView.addGestureRecognizer(tapGesture)
    }
    
    @objc private func handleTap() {
        viewModel.isExpanded.toggle()
        
        // ✅ 使用 flushUpdates 自动处理约束动画
        heightConstraint.constant = viewModel.isExpanded ? 300 : 100
        
        UIView.animate(
            withDuration: 0.4,
            delay: 0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.5,
            options: [.flushUpdates],  // 关键选项
            animations: {
                // 无需手动调用 layoutIfNeeded()
                // flushUpdates 自动处理所有布局更新
            }
        )
    }
}

3.5 复杂示例:动态列表展开

@Observable class ListItemViewModel: Identifiable {
    let id = UUID()
    var title: String
    var detail: String
    var isExpanded: Bool = false
    
    init(title: String, detail: String) {
        self.title = title
        self.detail = detail
    }
}

class ExpandableCell: UITableViewCell {
    var viewModel: ListItemViewModel?
    
    private let titleLabel = UILabel()
    private let detailLabel = UILabel()
    private let arrowImageView = UIImageView()
    private var detailHeightConstraint: Constraint?
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        contentView.addSubview(titleLabel)
        contentView.addSubview(detailLabel)
        contentView.addSubview(arrowImageView)
        
        titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
        detailLabel.font = .systemFont(ofSize: 14)
        detailLabel.textColor = .gray
        detailLabel.numberOfLines = 0
        
        arrowImageView.image = UIImage(systemName: "chevron.down")
        arrowImageView.tintColor = .gray
    }
    
    private func setupConstraints() {
        let padding: CGFloat = 16
        
        arrowImageView.snp.makeConstraints { make in
            make.trailing.equalToSuperview().inset(20)
            make.top.equalToSuperview().offset(20)
            make.size.equalTo(20)
        }
        
        titleLabel.snp.makeConstraints { make in
            make.leading.equalToSuperview().offset(padding)
            make.trailing.equalTo(arrowImageView.snp.leading).offset(-padding)
            make.top.equalToSuperview().offset(padding)
            make.height.equalTo(25)
        }
        
        detailLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(padding)
            make.top.equalTo(titleLabel.snp.bottom).offset(8)
            detailHeightConstraint = make.height.equalTo(0).constraint
        }
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        guard let viewModel = viewModel else { return }
        
        titleLabel.text = viewModel.title
        detailLabel.text = viewModel.detail
        detailLabel.alpha = viewModel.isExpanded ? 1.0 : 0.0
        
        // 动态更新高度约束
        let detailHeight: CGFloat = viewModel.isExpanded ? 100 : 0
        detailHeightConstraint?.update(offset: detailHeight)
        
        // 箭头旋转动画
        let rotation: CGFloat = viewModel.isExpanded ? .pi : 0
        arrowImageView.transform = CGAffineTransform(rotationAngle: rotation)
    }
}

class ExpandableListViewController: UIViewController {
    private let tableView = UITableView()
    private var items: [ListItemViewModel] = [
        ListItemViewModel(title: "iOS 26 新特性", detail: "包括 @Observable 支持、updateProperties() 方法和 flushUpdates 动画选项等重大更新。"),
        ListItemViewModel(title: "UIKit 现代化", detail: "UIKit 引入了 SwiftUI 的响应式编程特性,使开发更加高效。"),
        ListItemViewModel(title: "性能优化", detail: "通过分离属性更新和布局逻辑,显著提升了应用性能。")
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(tableView)
        tableView.frame = view.bounds
        tableView.register(ExpandableCell.self, forCellReuseIdentifier: "cell")
        tableView.delegate = self
        tableView.dataSource = self
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 60
    }
}

extension ExpandableListViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! ExpandableCell
        cell.viewModel = items[indexPath.row]
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        
        // ✅ 使用 flushUpdates 实现流畅的展开/收起动画
        UIView.animate(
            withDuration: 0.3,
            delay: 0,
            options: [.flushUpdates, .curveEaseInOut]
        ) {
            self.items[indexPath.row].isExpanded.toggle()
            // 自动处理 cell 高度变化和内容动画
        } completion: { _ in
            tableView.beginUpdates()
            tableView.endUpdates()
        }
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return items[indexPath.row].isExpanded ? 180 : 60
    }
}

3.6 组合动画示例

class AnimatedCardView: UIView {
    @Observable class CardState {
        var scale: CGFloat = 1.0
        var opacity: CGFloat = 1.0
        var cornerRadius: CGFloat = 12.0
        var backgroundColor: UIColor = .systemBlue
    }
    
    let state = CardState()
    
    override func updateProperties() {
        super.updateProperties()
        
        alpha = state.opacity
        backgroundColor = state.backgroundColor
        layer.cornerRadius = state.cornerRadius
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        transform = CGAffineTransform(scaleX: state.scale, y: state.scale)
    }
    
    func animatePress() {
        // ✅ 组合多个属性动画
        UIView.animate(
            withDuration: 0.15,
            delay: 0,
            options: [.flushUpdates, .curveEaseOut]
        ) {
            self.state.scale = 0.95
            self.state.opacity = 0.8
        } completion: { _ in
            UIView.animate(
                withDuration: 0.15,
                delay: 0,
                options: [.flushUpdates, .curveEaseIn]
            ) {
                self.state.scale = 1.0
                self.state.opacity = 1.0
            }
        }
    }
    
    func animateHighlight() {
        // ✅ 使用 Spring 动画
        UIView.animate(
            withDuration: 0.6,
            delay: 0,
            usingSpringWithDamping: 0.5,
            initialSpringVelocity: 0.8,
            options: [.flushUpdates]
        ) {
            self.state.scale = 1.1
            self.state.cornerRadius = 24.0
            self.state.backgroundColor = .systemPurple
        }
    }
}

3.7 最佳实践

✅ 推荐做法

// 1. 始终使用 flushUpdates 处理 Observable 动画
UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
    self.viewModel.property = newValue
}

// 2. 结合其他动画选项
UIView.animate(
    withDuration: 0.3,
    options: [.flushUpdates, .curveEaseInOut, .allowUserInteraction]
) {
    self.viewModel.isEnabled = true
}

// 3. 使用 Spring 动画增强效果
UIView.animate(
    withDuration: 0.5,
    delay: 0,
    usingSpringWithDamping: 0.7,
    initialSpringVelocity: 0.5,
    options: [.flushUpdates]
) {
    self.viewModel.scale = 1.2
}

❌ 避免的做法

// 1. 不要在使用 flushUpdates 时手动调用 layoutIfNeeded
UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
    self.viewModel.property = newValue
    self.view.layoutIfNeeded()  // ❌ 多余,会被自动处理
}

// 2. 不要混用传统方式和 flushUpdates
UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
    self.viewModel.property = newValue
    self.manuallyUpdateUI()  // ❌ 可能导致冲突
}

四、向后兼容性

4.1 iOS 18-25 支持

Observable 自动追踪功能可以向后兼容到 iOS 18,只需添加配置:

<!-- Info.plist -->
<key>UIObservationTrackingEnabled</key>
<true/>

4.2 渐进式迁移策略

import SnapKit

// 1. 保留旧代码作为后备方案
class MyView: UIView {
    var viewModel: ViewModel?
    private let titleLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(titleLabel)
    }
    
    private func setupConstraints() {
        titleLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(10)
            make.top.equalToSuperview().offset(10)
            make.height.equalTo(30)
        }
    }
    
    override func updateProperties() {
        super.updateProperties()
        
        // iOS 26+ 自动追踪
        if #available(iOS 26, *) {
            titleLabel.text = viewModel?.title
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // iOS 18-25 手动更新(在 layoutSubviews 中)
        if #unavailable(iOS 26) {
            titleLabel.text = viewModel?.title
        }
        
        // 使用 SnapKit 后,无需手动布局代码
        // 约束已在 setupConstraints 中设置
    }
}

// 2. 动画兼容处理
func animateChanges() {
    if #available(iOS 26, *) {
        UIView.animate(withDuration: 0.3, options: [.flushUpdates]) {
            self.viewModel.property = newValue
        }
    } else {
        UIView.animate(withDuration: 0.3) {
            self.viewModel.property = newValue
            self.view.layoutIfNeeded()
        }
    }
}

五、性能优化建议

5.1 减少不必要的更新

// ✅ 缓存计算结果
class OptimizedView: UIView {
    private var cachedValue: String?
    
    override func updateProperties() {
        super.updateProperties()
        
        let newValue = viewModel.expensiveComputation()
        if cachedValue != newValue {
            cachedValue = newValue
            label.text = newValue
        }
    }
}

5.2 批量更新

// ✅ 批量修改属性
func updateMultipleProperties() {
    // 所有修改会在一个更新周期内处理
    viewModel.property1 = value1
    viewModel.property2 = value2
    viewModel.property3 = value3
    // 仅触发一次 UI 更新
}

5.3 避免循环依赖

// ❌ 避免在 updateProperties 中修改 Observable 属性
override func updateProperties() {
    super.updateProperties()
    
    label.text = viewModel.title
    viewModel.lastUpdateTime = Date()  // ❌ 可能导致无限循环
}

// ✅ 使用单向数据流
override func updateProperties() {
    super.updateProperties()
    
    label.text = viewModel.title  // ✅ 仅读取,不修改
}

六、常见问题

6.1 Observable 属性不更新?

问题:修改了 Observable 属性,但 UI 没有更新。

解决方案

  1. 确保在支持的方法中访问属性(layoutSubviewsupdateProperties 等)
  2. 检查 iOS 18-25 是否添加了 UIObservationTrackingEnabled 配置
  3. 确保在主线程修改属性
// ✅ 正确做法
override func updateProperties() {
    super.updateProperties()
    label.text = viewModel.title  // 在支持的方法中访问
}

DispatchQueue.main.async {
    self.viewModel.title = "New Title"  // 主线程修改
}

6.2 updateProperties 和 layoutSubviews 的调用顺序?

答案updateProperties() 总是在 layoutSubviews() 之前调用。

Trait Updates → updateProperties() → layoutSubviews() → Display

6.3 何时使用 flushUpdates?

使用场景

  • ✅ 动画化 Observable 属性变化
  • ✅ 动画化 Auto Layout 约束变化
  • ✅ 需要自动处理布局更新的动画

不需要使用

  • ❌ 简单的 transform 或 alpha 动画(不涉及布局)
  • ❌ 非 Observable 属性的动画

6.4 性能影响?

答案:iOS 26 的新特性实际上提升了性能:

  • 属性更新和布局分离,减少不必要的计算
  • 智能失效机制,仅在真正需要时更新
  • 自动批量处理多个属性变化

6.5 SnapKit 约束何时更新?

问题:在 updateProperties() 中更新约束,何时会真正应用到视图?

答案

override func updateProperties() {
    super.updateProperties()
    
    // 1. 更新约束值
    heightConstraint?.update(offset: 200)
    
    // 2. 约束更新会标记视图需要布局
    // 3. 在下一个布局周期(layoutSubviews)中应用
}

// 如果需要立即应用约束:
override func updateProperties() {
    super.updateProperties()
    
    heightConstraint?.update(offset: 200)
    layoutIfNeeded()  // 立即应用(通常不需要)
}

6.6 makeConstraints vs updateConstraints vs remakeConstraints?

使用场景

方法使用场景说明
makeConstraints初始化时设置约束仅调用一次,在 initsetupConstraints
updateConstraints更新现有约束的值保存 Constraint 引用,在 updateProperties 中调用 update(offset:)
remakeConstraints完全重建约束布局模式改变时,在 updateProperties 中使用

示例

// ✅ 初始化:使用 makeConstraints
private func setupConstraints() {
    label.snp.makeConstraints { make in
        heightConstraint = make.height.equalTo(40).constraint
    }
}

// ✅ 更新值:使用 update
override func updateProperties() {
    super.updateProperties()
    heightConstraint?.update(offset: viewModel.isExpanded ? 100 : 40)
}

// ✅ 重建布局:使用 remakeConstraints
override func updateProperties() {
    super.updateProperties()
    
    if layoutModeChanged {
        label.snp.remakeConstraints { make in
            if viewModel.isHorizontal {
                make.leading.trailing.equalToSuperview()
            } else {
                make.top.bottom.equalToSuperview()
            }
        }
    }
}

6.7 SnapKit 约束冲突如何调试?

解决方案

// 1. 设置约束优先级
label.snp.makeConstraints { make in
    make.height.equalTo(100).priority(.high)
    make.height.greaterThanOrEqualTo(50).priority(.required)
}

// 2. 使用条件约束
label.snp.makeConstraints { make in
    if shouldUseFixedHeight {
        make.height.equalTo(100)
    } else {
        make.height.greaterThanOrEqualTo(50)
    }
}

// 3. 启用约束调试
// 在 Xcode Scheme 中添加环境变量:
// Name: UIViewLayoutFeedbackLoopDebuggingThreshold
// Value: 100

// 4. 打印约束信息
override func updateProperties() {
    super.updateProperties()
    print(label.constraints)  // 查看当前约束
}

七、总结

iOS 26 为 UIKit 带来了革命性的更新:

  1. @Observable 支持:自动 UI 更新,告别手动刷新
  2. updateProperties():属性和布局分离,性能更优
  3. flushUpdates:动画更简单,代码更清晰

这些特性让 UIKit 开发更接近 SwiftUI 的响应式编程体验,同时保持了 UIKit 的灵活性和强大功能。

核心优势

  • 📝 更少的代码:无需手动管理 UI 更新
  • 更好的性能:智能失效和批量更新
  • 🎨 更流畅的动画:自动处理布局变化
  • 🔄 向后兼容:支持 iOS 18+

开始使用

  1. 环境准备

    • 更新到 Xcode 26 和 iOS 26 SDK
    • 通过 SPM、CocoaPods 或 Carthage 集成 SnapKit
  2. 数据层改造

    • 将数据模型转换为 @Observable
    • 确保在主线程更新 Observable 属性
  3. 视图层重构

    • 使用 SnapKit 设置布局约束(在 initsetupConstraints 中)
    • 将属性更新逻辑移至 updateProperties()
    • 动态约束更新也在 updateProperties() 中处理
    • 移除手动的 setNeedsLayout() 调用
  4. 动画优化

    • 使用 flushUpdates 选项处理 Observable 和约束动画
    • 移除手动的 layoutIfNeeded() 调用
  5. 测试验证

    • 验证 UI 自动更新是否正常
    • 检查动画流畅性
    • 测试性能提升效果

欢迎来到 UIKit 的新时代!🎉


参考资源

iOS 26 相关

SnapKit 相关


文档版本:1.0
最后更新:2026-03-12
适用版本:iOS 26+(部分特性支持 iOS 18+)