概述
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?
- 声明式语法:更接近 SwiftUI 的布局思维
- 类型安全:编译时检查,减少运行时错误
- 易于维护:约束代码更清晰、更易读
- 完美配合 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 方法中生效:
- UIViewController:
viewWillLayoutSubviews() - UIView:
layoutSubviews() - 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 没有更新。
解决方案:
- 确保在支持的方法中访问属性(
layoutSubviews、updateProperties等) - 检查 iOS 18-25 是否添加了
UIObservationTrackingEnabled配置 - 确保在主线程修改属性
// ✅ 正确做法
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 | 初始化时设置约束 | 仅调用一次,在 init 或 setupConstraints 中 |
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 带来了革命性的更新:
- @Observable 支持:自动 UI 更新,告别手动刷新
- updateProperties():属性和布局分离,性能更优
- flushUpdates:动画更简单,代码更清晰
这些特性让 UIKit 开发更接近 SwiftUI 的响应式编程体验,同时保持了 UIKit 的灵活性和强大功能。
核心优势
- 📝 更少的代码:无需手动管理 UI 更新
- ⚡ 更好的性能:智能失效和批量更新
- 🎨 更流畅的动画:自动处理布局变化
- 🔄 向后兼容:支持 iOS 18+
开始使用
-
环境准备
- 更新到 Xcode 26 和 iOS 26 SDK
- 通过 SPM、CocoaPods 或 Carthage 集成 SnapKit
-
数据层改造
- 将数据模型转换为
@Observable类 - 确保在主线程更新 Observable 属性
- 将数据模型转换为
-
视图层重构
- 使用 SnapKit 设置布局约束(在
init或setupConstraints中) - 将属性更新逻辑移至
updateProperties() - 动态约束更新也在
updateProperties()中处理 - 移除手动的
setNeedsLayout()调用
- 使用 SnapKit 设置布局约束(在
-
动画优化
- 使用
flushUpdates选项处理 Observable 和约束动画 - 移除手动的
layoutIfNeeded()调用
- 使用
-
测试验证
- 验证 UI 自动更新是否正常
- 检查动画流畅性
- 测试性能提升效果
欢迎来到 UIKit 的新时代!🎉
参考资源
iOS 26 相关
- WWDC25: What's New in UIKit
- Apple Developer Documentation: Observable
- iOS 26 适配指南 - 腾讯云
- UIKit Gets SwiftUI Superpowers
SnapKit 相关
文档版本:1.0
最后更新:2026-03-12
适用版本:iOS 26+(部分特性支持 iOS 18+)