阅读视图

发现新文章,点击刷新页面。

MVVM 本质解构 + RxSwift 与 Combine 深度对决与选型指南

作为 iOS 开发演进的核心架构,MVVM彻底解决了原生 MVC 的 Massive View Controller 顽疾;而响应式编程是 MVVM 落地的灵魂 —— 脱离响应式的 MVVM 只是伪架构。本文从资深开发工程化视角,深度拆解 MVVM 的底层设计逻辑,全方位对比 RxSwift 与 Combine 两大 iOS 响应式框架,结合实战、踩坑与选型策略,为中大型 iOS 项目的架构设计提供专业参考。

一、深刻理解 MVVM:不止是分层,是 iOS UI 开发的范式升级

绝大多数 iOS 开发者对 MVVM 的理解停留在「View-ViewModel-Model」三层结构,这是表层认知。从资深开发和工程化角度,MVVM 的核心是UI 与业务逻辑的彻底解耦数据驱动 UI的编程范式升级。

1.1 原生 MVC 的致命困境

iOS 官方推荐的 MVC 架构,在实际工程中会快速腐化:

  • ViewController 身兼数职:UI 渲染、用户交互、网络请求、数据解析、业务逻辑、状态管理;
  • 千行 VC 是常态,不可测试、难复用、难维护
  • View 与 Model 强耦合,UI 修改会牵连业务逻辑,业务逻辑变动会破坏 UI 渲染。

这是 iOS 原生开发的历史痛点,也是 MVVM 诞生的核心原因。

1.2 MVVM 的核心本质(资深开发必掌握)

MVVM 的设计目标不是「分层」,而是让 UI 层彻底被动化,让业务逻辑彻底纯净化

核心角色职责(严格边界)

表格

角色 核心职责 禁忌
View(ViewController/UIView) 仅负责:转发用户交互事件、响应数据渲染 UI 不写任何业务逻辑、不直接操作 Model、不持有网络 / 数据库对象
ViewModel 核心中间层:持有 Model、处理业务逻辑(校验 / 网络 / 数据转换)、暴露可观察数据流 不导入 UIKit、不持有任何 UI 对象、完全脱离 iOS 平台,可独立单元测试
Model 纯数据结构(实体类 / 结构体) 不包含任何业务逻辑、不与 UI/ViewModel 耦合

MVVM 的灵魂:双向绑定

View 与 ViewModel 之间不直接调用方法,而是通过可观察数据流实现自动绑定:

  1. ViewModel 数据变化 → 自动驱动 View 更新 UI;
  2. View 用户交互(点击 / 输入)→ 自动触发 ViewModel 业务逻辑。

这是 MVVM 的核心价值,也是原生 iOS 无法高效实现的能力 ——KVO/Notification/Delegate 代码冗余、易泄漏、难以维护,必须依赖响应式编程框架落地。

1.3 MVVM 黄金法则(工程化落地准则)

  1. View 只做「UI 转发 + 渲染」,无任何业务逻辑;
  2. ViewModel 无 UIKit 依赖,100% 可单元测试;
  3. 所有通信通过响应式数据流,禁止反向引用;
  4. 单一职责:复杂 ViewModel 拆分 UseCase/Service,拒绝臃肿。

二、响应式编程:MVVM 的唯一高效落地方案

MVVM 的核心是「绑定」,而响应式编程(RP) 是实现绑定的最优解:

  • 一切异步事件(UI 点击、网络请求、数据变化、定时器)抽象为可观察的数据流
  • 声明式语法处理数据流,实现自动化绑定;
  • 彻底告别代理、通知、闭包嵌套的异步噩梦。

iOS 生态中,只有两个选择:

  1. RxSwift:跨平台响应式标准 ReactiveX 的 iOS 实现,成熟稳定;
  2. Combine:苹果原生官方响应式框架,iOS13 + 内置,未来主流。

三、RxSwift 深度解析:成熟的响应式事实标准

3.1 核心定位

RxSwift 是ReactiveX的 iOS 移植版本(跨平台响应式规范,Java/RxJS 通用),是 iOS 响应式编程的「事实标准」,历经多年迭代,生态极致完善。

3.2 核心抽象

  • Observable:数据流生产者(发送数据 / 错误 / 完成);
  • Observer:数据流消费者;
  • Disposable:资源回收器(避免内存泄漏);
  • Operator:操作符(map/filter/flatMap/zip),数据流处理核心;
  • Scheduler:线程调度器(主线程 / 后台线程切换)。

3.3 iOS 生态矩阵

  • RxCocoa:UIKit 全扩展(UIButton.rx.tap/UITextField.rx.text);
  • RxDataSources:UITableView/CollectionView 极简数据绑定;
  • RxAlamofire:网络请求响应式封装;
  • 几乎所有主流第三方库都提供 Rx 扩展。

3.4 优劣势

优势

  • 全版本兼容:iOS8+,覆盖所有存量项目;
  • 生态天花板:社区成熟,无实现不了的场景;
  • 操作符丰富:复杂数据流开箱即用;
  • 文档 / 社区完善,问题秒解。

劣势

  • 学习成本极高:冷 / 热 Observable、背压等概念抽象;
  • 第三方依赖:增加包体积;
  • 非官方维护,未来迭代放缓。

四、Combine 深度解析:苹果原生的响应式未来

4.1 核心定位

苹果在 iOS13 推出的原生响应式框架,深度集成 SwiftUI、UIKit、Swift Concurrency(async/await),是苹果生态的未来标准

4.2 核心抽象(与 RxSwift 无缝映射)

表格

RxSwift Combine 功能一致
Observable Publisher 数据流生产者
Observer Subscriber 数据流消费者
Disposable Cancellable 资源销毁
BehaviorSubject CurrentValueSubject 带缓存值
PublishSubject PassthroughSubject 无缓存值

4.3 原生杀手锏

  • @Published:属性包装器,一行代码生成可观察数据流,ViewModel 绑定极简;
  • 原生集成 GCD/Operation,线程调度零成本;
  • 无缝衔接 Swift Concurrency,现代 Swift 编程体验拉满。

4.4 优劣势

优势

  • 官方原生:无第三方依赖,系统级优化;
  • 轻量无体积:内置系统,无需引入库;
  • 语法极简:贴合 Swift 语法,学习成本低;
  • 未来兼容:随 Swift/SwiftUI 迭代,长期维护。

劣势

  • 版本硬限制:iOS13 以下完全不支持
  • 生态贫瘠:第三方库远少于 RxSwift;
  • 操作符精简:复杂场景需自定义。

五、RxSwift vs Combine:全方位深度对比(资深开发核心参考)

5.1 基础能力对比

表格

维度 RxSwift Combine
兼容性 iOS8+,全平台覆盖 iOS13+,低版本无支持
依赖方式 第三方库(CocoaPods/SPM) 系统内置,无依赖
语法风格 标准 ReactiveX 链式调用 Swift 原生语法,极简简洁
核心简化 无属性包装器,需手动创建 Subject @Published 一行实现绑定
生态完善度 极致完善(UI / 网络 / 列表全覆盖) 原生生态完善,第三方薄弱
背压支持 需额外处理 原生内置支持
错误处理 灵活,无强类型约束 强类型泛型约束,更安全
测试工具 RxTest/RxBlocking,功能强大 原生 XCTest,简洁轻量化
学习成本 高(ReactiveX 抽象概念) 低(Swift 原生,易上手)

5.2 性能与内存

  • Combine:系统级优化,内存占用更低,线程调度更高效;
  • RxSwift:社区优化多年,性能稳定,资源回收严格可控;
  • 内存管理:两者均需手动管理订阅(DisposeBag/Set),否则泄漏。

5.3 工程化适配

  • 存量旧项目 → RxSwift(兼容低版本);
  • 全新 SwiftUI 项目 → Combine(原生最佳搭配);
  • 团队新手 → Combine(学习成本低);
  • 复杂数据流 / 列表 → RxSwift(生态完善)。

六、实战对比:MVVM + 登录页面(两种实现)

用最经典的登录场景,直观感受两种方案的编码差异。

核心需求

  • 账号 / 密码输入 → 实时校验按钮是否可点击;
  • 点击登录 → 触发网络请求 → 响应结果;
  • 严格遵循 MVVM:ViewModel 无 UIKit,View 仅绑定。

方案 1:MVVM + RxSwift

swift

// ViewModel (无UIKit依赖)
import RxSwift
import RxCocoa

class LoginViewModel {
    // 输入:账号、密码
    let account = BehaviorSubject<String>(value: "")
    let password = BehaviorSubject<String>(value: "")
    // 输出:登录按钮可点击、登录结果
    let isLoginEnabled = Observable<Bool>
    let loginResult = PublishSubject<Bool>()
    
    private let disposeBag = DisposeBag()
    
    init() {
        // 数据流绑定:实时校验输入
        isLoginEnabled = Observable.combineLatest(account, password)
            .map { account, pwd in
                return account.count >= 6 && pwd.count >= 6
            }
        
        // 业务逻辑:登录方法
        func login() {
            // 模拟网络请求
            Observable.just(true)
                .delay(.seconds(1), scheduler: ConcurrentDispatchQueueScheduler(qos: .default))
                .observe(on: MainScheduler.instance)
                .subscribe(onNext: { [weak self] result in
                    self?.loginResult.onNext(result)
                })
                .disposed(by: disposeBag)
        }
    }
}

// View (ViewController)
import UIKit
import RxSwift
import RxCocoa

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // 1. UI输入 → ViewModel
        accountTF.rx.text.orEmpty.bind(to: vm.account).disposed(by: disposeBag)
        passwordTF.rx.text.orEmpty.bind(to: vm.password).disposed(by: disposeBag)
        
        // 2. ViewModel状态 → UI渲染
        vm.isLoginEnabled.bind(to: loginBtn.rx.isEnabled).disposed(by: disposeBag)
        
        // 3. UI交互 → ViewModel逻辑
        loginBtn.rx.tap.subscribe(onNext: { [weak self] in
            self?.vm.login()
        }).disposed(by: disposeBag)
        
        // 4. 业务结果 → UI响应
        vm.loginResult.subscribe(onNext: { success in
            print("登录结果:(success)")
        }).disposed(by: disposeBag)
    }
}

方案 2:MVVM + Combine

swift

// ViewModel (无UIKit依赖)
import Combine

class LoginViewModel {
    // 输入:@Published 极简声明
    @Published var account = ""
    @Published var password = ""
    // 输出
    @Published var isLoginEnabled = false
    let loginResult = PassthroughSubject<Bool, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // 实时校验
        $account.combineLatest($password)
            .map { account, pwd in
                account.count >= 6 && pwd.count >= 6
            }
            .assign(to: &$isLoginEnabled)
    }
    
    func login() {
        // 模拟网络请求 + 异步
        Future<Bool, Never> { promise in
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                promise(.success(true))
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] success in
            self?.loginResult.send(success)
        }
        .store(in: &cancellables)
    }
}

// View (ViewController)
import UIKit
import Combine

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // UI输入 → ViewModel
        accountTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$account)
        
        passwordTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$password)
        
        // ViewModel → UI
        vm.$isLoginEnabled
            .assign(to: .isEnabled, on: loginBtn)
            .store(in: &cancellables)
        
        // 点击事件
        loginBtn.publisher(for: .touchUpInside)
            .sink { [weak self] in
                self?.vm.login()
            }
            .store(in: &cancellables)
        
        // 登录结果
        vm.loginResult
            .sink { success in
                print("登录结果:(success)")
            }
            .store(in: &cancellables)
    }
}

七、资深开发选型决策树

无需盲目追新,工程化落地是第一准则:

  1. 项目最低支持 < iOS13 → 唯一选择:RxSwift
  2. 全新项目 ≥iOS13 / SwiftUI 项目 → 首选:Combine
  3. 存量项目逐步升级 → 混合方案:旧页面保留 RxSwift,新页面用 Combine;
  4. 团队无响应式基础 → 优先:Combine(学习成本低,原生规范);
  5. 重度复杂数据流(电商 / 金融) → 优先:RxSwift(生态完善);
  6. 长期维护、追求苹果原生标准 → 必选:Combine

八、工程化避坑指南(资深实战经验)

8.1 MVVM 通用误区

  1. ❌ ViewModel 持有 UIKit 对象 → 破坏可测试性,严格禁止;
  2. ❌ ViewModel 过度臃肿 → 拆分 UseCase/Service,单一职责;
  3. ❌ 为了绑定而绑定 → 简单 UI 用原生,复杂数据流用响应式。

8.2 RxSwift 避坑

  • 内存泄漏:必须DisposeBag管理订阅;
  • 冷 / 热 Observable 误用:网络请求用Single,事件用PublishSubject
  • UI 更新必须切MainScheduler

8.3 Combine 避坑

  • 订阅销毁:必须Set<AnyCancellable>存储,否则订阅立即失效;
  • iOS13 存在 APIbug,建议最低支持 iOS14;
  • 缺少操作符时,用async/await补充。

九、总结

  1. MVVM 的核心:不是三层结构,而是数据驱动 UI+UI 与业务彻底解耦,响应式编程是其唯一高效落地方式;
  2. RxSwift:成熟稳定、生态完善、全版本兼容,是存量项目的最优解
  3. Combine:苹果原生、轻量简洁、未来主流,是新项目的标准答案
  4. 资深 iOS 开发的核心能力:不迷信框架,根据项目场景选型,落地可维护、可测试的工程化架构

iOS 开发已进入SwiftUI+Combine+async/await的原生现代化时代,MVVM 作为核心架构,将长期主导中大型项目的设计。


关键点回顾

  1. MVVM 核心:解耦 + 数据驱动,无响应式则无落地价值;
  2. RxSwift:存量项目、低版本兼容、生态为王;
  3. Combine:新项目、原生未来、简洁轻量;
  4. 选型看系统版本+项目阶段+团队成本,不盲目追新。

iOS App 真实包大小:你以为的大小为什么是错的

核心结论:你在本地看到的 .ipa 大小,和用户在 App Store 实际下载的大小,可能差距超过 40%。

前言

每次发版前,你是否盯着 Xcode 给出的包大小报告,心里觉得"还好,没超标"?

但用户在 App Store 看到的下载大小,往往和你本地看到的完全不一样。

这篇文章会告诉你:

  • 为什么本地看到的大小不准
  • App Store 里的大小是怎么算出来的
  • 如何在本地提前得到接近真实的数值
  • 包大小由哪些部分构成,怎么针对性优化

一、先搞清楚:你在看哪个大小?

很多开发者混淆了这几个概念:

大小类型 含义 在哪里看
编译产物大小 本地 .app 文件夹大小 Finder
Archive 大小 .ipa 文件大小 Xcode Organizer
下载大小 用户在 App Store 实际下载的大小 App Store / TestFlight
安装大小 安装到手机后占用的磁盘空间 设置 → 通用 → iPhone 储存空间

大多数开发者盯着的是前两个,但用户感知的是下载大小

这四个数字,通常都不一样,而且差距可能很大。


二、App Store 对你的包做了什么?

你打包上传的 .ipa,到达用户手机之前,Apple 会做一系列处理。

2.1 App Thinning:按设备裁剪

你上传的 .ipa 包含了所有设备的资源和二进制:

MyApp.ipa
├── Binary(arm64 + x86_64 全架构)
├── 1x / 2x / 3x 图片资源
├── iPhone 专属资源
└── iPad 专属资源

但 iPhone 15 用户实际下载的只有:

用户实际下载
├── Binary(仅 arm64)
├── 3x 图片
└── iPhone 专属资源

其余全部被 Apple 裁掉。这一步通常能减少 30%~50% 的大小。

2.2 On-Demand Resources 不计入下载大小

如果你使用了 ODR(按需下载资源),这部分不会在首次下载时打包,**不计入下载大小 **,用到时才下载。

2.3 资源文件的二次处理

Apple 会对部分资源做再处理:

PNG       →  pngcrush 再压缩(通常变更小)
Plist     →  转成 binary plist(通常变小)
Storyboard → 编译成 nib

2.4 最终 ZIP 压缩

处理完之后,Apple 用 DEFLATE 对整个包重新压缩,压缩参数由 Apple 服务器控制。

整个流程如下:

你上传的 .ipa
    ↓ App Thinning(按设备裁剪)
    ↓ 剥离 ODR 资源
    ↓ 资源文件二次处理
    ↓ 重签名
    ↓ DEFLATE 压缩
    = 用户实际下载的大小

三、Xcode 的估算为什么也不准?

Xcode Archive 后会提供一份 App Size Report,展示各设备的估算大小。这个估算是本地模拟的,存在几个主要误差来源:

误差一:__TEXT 段压缩处理

Mach-O 二进制的 __TEXT 段(代码段)在 Apple 服务器端会做私有的布局优化和压 缩,本地无法完全复现,只能用经验系数近似估算。

误差二:重签名影响二进制布局

App Store 上传后 Apple 会重新签名,这会改变二进制部分结构,进而影响最终压缩率。

误差三:编译工具链差异

Apple 服务器的编译工具链版本可能与本地不一致,在开启 Bitcode 的历史版本中差异尤为明显。

💡 结论:Xcode 报告的大小仅供参考,误差可能在 5%~15% 之间。


四、如何得到接近真实的包大小?

方法一:TestFlight(最准确)

上传后在 App Store Connect 后台可以看到各设备的真实下载大小。

优点: 走了 Apple 完整处理流程,最准确。 缺点: 需要先上传,无法在开发阶段提前预知。

方法二:手动模拟 App Thinning

# 1. 提取 arm64 单架构 binary
lipo MyApp.app/MyApp -extract arm64 -output MyApp_arm64

# 2. 查看各 section 大小
size -m MyApp_arm64

# 3. 重新打包压缩,估算下载大小
zip -r MyApp_thinned.zip MyApp.app

优点: 快速,无需上传。 缺点: 未考虑资源裁剪,误差相对较大。

方法三:linkmap 归因分析(推荐)

在 Xcode Build Settings 开启:

WRITE_LINK_MAP_FILE = YES

编译后生成 linkmap 文件,记录了每个符号的大小和所属模块:

# Object files
[  1] /path/to/MyModule.o
[  2] /path/to/Pods/Alamofire.o

# Symbols
# Address      Size       File    Name
0x100001000   0x000001A0  [ 1]   -[MyViewController viewDidLoad]
0x100001200   0x00000080  [ 2]   _Alamofire_request

解析这个文件,可以精确知道每个库、每个类占了多少二进制大小,帮助定位膨胀来 源。


五、包大小由哪些部分构成?

下载大小
├── 二进制(通常占 40%~60%)
│   ├── 业务代码
│   ├── 三方库(Pods / SPM)
│   └── Swift 标准库(旧系统版本需要内嵌)
├── 资源文件(图片、音频、字体)
├── Frameworks(动态库)
└── 其他(Storyboard、Plist、配置文件)

根据经验,大多数 app 包大小增长的主要来源是:

  1. 三方库无节制累积
  2. 图片资源未压缩/未清理
  3. 内嵌了不必要的字体文件

六、针对性优化方向

二进制瘦身

# 查看未使用代码(Dead Code Stripping 默认开启,确认一下)
# Build Settings → Dead Code Stripping = YES

# Swift 编译优化
# Build Settings → Swift Optimization Level = Optimize for Speed [-O]
  • 定期审查并移除不再使用的三方库
  • 合并功能重叠的库
  • 使用 periphery 扫描未使用的代码

资源文件优化

  • 使用 WebP 替代 PNG/JPEG,体积可减少 25%~35%
  • 用 Asset Catalog 管理图片,配合 On-Demand Resources
  • 使用 FengNiao 扫描并删除未引用的图片

字体优化

  • 只内嵌实际用到的字重
  • 使用系统字体替代自定义字体(San Francisco 系列无需内嵌)

七、接入 CI 监控,让大小可见

单次优化效果有限,更重要的是防止包大小悄悄增长

建议在 CI 流程中加入大小检查:

# 示例:GitHub Actions 中检查 linkmap
- name: Check App Size
  run: |
    python3 scripts/parse_linkmap.py \
      --linkmap build/MyApp-LinkMap.txt \
      --threshold 50MB \
      --fail-on-exceed

每次 PR 都能看到大小变化,问题在合入前就能发现。


总结

说明
本地 .ipa 大小 不准,包含多架构和全量资源
Xcode App Size Report 估算值,有一定误差
TestFlight 后台数据 最准确,需上传后查看
linkmap 分析 精确归因,找到膨胀来源

推荐的工作流:

日常开发用 linkmap 监控增量 → 发版前用 TestFlight 确认真实大小 → CI 集成大小检测防止劣化


包大小看似是个小问题,但研究表明包大小每增加 6MB,下载转化率下降约 1%。在竞争激烈的 App Store,这是值得持续关注的指标。

如果这篇文章对你有帮助,欢迎点赞收藏 🙏,有问题欢迎在评论区讨论。


参考资料

《SwiftUI 进阶第6章:列表与滚动视图》

6.1 List 组件详解

List 介绍

List 是 SwiftUI 中用于显示有序数据集合的强大组件,它自动处理滚动、单元格复用、分割线等功能。

基本用法

import SwiftUI

struct SimpleListView: View {
    // 示例数据
    let items = ["项目 1", "项目 2", "项目 3", "项目 4", "项目 5"]
    
    var body: some View {
        List {
            ForEach(items, id: \.self) {
                Text($0)
            }
        }
        .navigationTitle("简单列表")
    }
}

#Preview {
    NavigationStack {
        SimpleListView()
    }
}

数据模型

对于更复杂的数据,建议创建符合 Identifiable 协议的数据模型:

// 符合 Identifiable 协议的数据模型
struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool
}

struct TodoListView: View {
    // 待办事项数据
    let todos: [TodoItem] = [
        TodoItem(title: "学习 SwiftUI", isCompleted: false),
        TodoItem(title: "完成作业", isCompleted: true),
        TodoItem(title: "购买 groceries", isCompleted: false)
    ]
    
    var body: some View {
        List(todos) { todo in
            HStack {
                Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                    .foregroundStyle(todo.isCompleted ? .green : .gray)
                Text(todo.title)
                    .strikethrough(todo.isCompleted, color: .gray)
            }
        }
        .navigationTitle("待办事项")
    }
}

列表样式

SwiftUI 提供了多种列表样式:

List {
    // 列表内容
}
// 不同的列表样式
.listStyle(.plain)         // 简单样式
.listStyle(.grouped)       // 分组样式
.listStyle(.insetGrouped)  // 内嵌分组样式
.listStyle(.sidebar)       // 侧边栏样式(macOS)
.listStyle(.automatic)     // 自动适应平台

可编辑列表

struct EditableListView: View {
    @State private var items = ["项目 1", "项目 2", "项目 3", "项目 4", "项目 5"]
    
    var body: some View {
        List {
            ForEach(items, id: \.self) {
                Text($0)
            }
            .onDelete(perform: deleteItems)
            .onMove(perform: moveItems)
        }
        .navigationTitle("可编辑列表")
        .toolbar {
            EditButton()
        }
    }
    
    // 删除项目
    private func deleteItems(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }
    
    // 移动项目
    private func moveItems(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
    }
}

6.2 Section 分组

Section 介绍

Section 用于将列表内容分组,每个分组可以有标题和页脚。

基本用法

struct SectionedListView: View {
    let fruits = ["苹果", "香蕉", "橙子"]
    let vegetables = ["胡萝卜", "土豆", "西红柿"]
    
    var body: some View {
        List {
            Section("水果") {
                ForEach(fruits, id: \.self) {
                    Text($0)
                }
            }
            
            Section("蔬菜") {
                ForEach(vegetables, id: \.self) {
                    Text($0)
                }
            }
        }
        .navigationTitle("分组列表")
        .listStyle(.insetGrouped)
    }
}

带页脚的分组

struct SectionWithFooter: View {
    let popularMovies = ["电影 A", "电影 B", "电影 C"]
    let recentMovies = ["电影 D", "电影 E"]
    
    var body: some View {
        List {
            Section(
                "热门电影",
                footer: Text("这些是当前最受欢迎的电影")
            ) {
                ForEach(popularMovies, id: \.self) {
                    Text($0)
                }
            }
            
            Section(
                "最近上映",
                footer: Text("这些是最近上映的电影")
            ) {
                ForEach(recentMovies, id: \.self) {
                    Text($0)
                }
            }
        }
        .navigationTitle("电影列表")
        .listStyle(.insetGrouped)
    }
}

动态分组

// 按首字母分组的联系人
struct Contact: Identifiable {
    let id = UUID()
    let name: String
    let section: String  // 用于分组的首字母
}

struct ContactListView: View {
    // 模拟联系人数据
    let contacts: [Contact] = [
        Contact(name: "张三", section: "Z"),
        Contact(name: "李四", section: "L"),
        Contact(name: "王五", section: "W"),
        Contact(name: "赵六", section: "Z")
    ]
    
    // 按 section 分组
    var groupedContacts: [String: [Contact]] {
        Dictionary(grouping: contacts, by: \.section)
    }
    
    var body: some View {
        List {
            ForEach(groupedContacts.keys.sorted(), id: \.self) { section in
                Section(section) {
                    ForEach(groupedContacts[section]!) {
                        Text($0.name)
                    }
                }
            }
        }
        .navigationTitle("联系人")
        .listStyle(.insetGrouped)
    }
}

6.3 ForEach 动态列表

ForEach 介绍

ForEach 用于根据数据动态生成视图,是创建动态列表的核心组件。

基本用法

// 使用数组索引
ForEach(0..<5) { index in
    Text("项目 \(index + 1)")
}

// 使用 Identifiable 数据
ForEach(items) { item in
    Text(item.name)
}

// 使用显式 ID
ForEach(items, id: \.self) {
    Text($0)
}

复杂数据结构

struct Product: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
    let isInStock: Bool
}

struct ProductListView: View {
    let products: [Product] = [
        Product(name: "iPhone", price: 5999, isInStock: true),
        Product(name: "iPad", price: 3999, isInStock: false),
        Product(name: "MacBook", price: 9999, isInStock: true)
    ]
    
    var body: some View {
        List {
            ForEach(products) { product in
                HStack {
                    VStack(alignment: .leading) {
                        Text(product.name)
                            .font(.headline)
                        Text(\(product.price)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    Spacer()
                    Text(product.isInStock ? "有货" : "缺货")
                        .foregroundStyle(product.isInStock ? .green : .red)
                        .font(.caption)
                        .padding(4)
                        .background(product.isInStock ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
                        .cornerRadius(4)
                }
            }
        }
        .navigationTitle("产品列表")
    }
}

性能优化

当处理大量数据时,使用 ForEach 的性能优化技巧:

  1. 使用稳定的 ID:避免使用 UUID() 作为临时 ID
  2. 使用 LazyVStack:对于非常长的列表,考虑使用 ScrollView + LazyVStack
  3. 避免在 ForEach 中进行复杂计算:将计算移到视图外部

6.4 ScrollView 滚动视图

ScrollView 介绍

ScrollView 是一个通用的滚动容器,可以包含任何视图内容,不局限于列表。

基本用法

struct SimpleScrollView: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(1..<20) {
                    Text("项目 \($0)")
                        .font(.title)
                        .frame(width: 300, height: 100)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(8)
                }
            }
            .padding()
        }
        .navigationTitle("简单滚动视图")
    }
}

水平滚动

struct HorizontalScrollView: View {
    let items = ["红色", "绿色", "蓝色", "黄色", "紫色", "橙色"]
    let colors: [Color] = [.red, .green, .blue, .yellow, .purple, .orange]
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 20) {
                ForEach(0..<items.count, id: \.self) {
                    VStack {
                        Rectangle()
                            .fill(colors[$0])
                            .frame(width: 150, height: 150)
                            .cornerRadius(8)
                        Text(items[$0])
                            .font(.headline)
                    }
                }
            }
            .padding()
        }
        .navigationTitle("水平滚动")
    }
}

双向滚动

struct BidirectionalScrollView: View {
    var body: some View {
        ScrollView([.horizontal, .vertical]) {
            VStack(spacing: 20) {
                ForEach(1..<10) { row in
                    HStack(spacing: 20) {
                        ForEach(1..<10) { col in
                            Text("\(row),\(col)")
                                .frame(width: 100, height: 100)
                                .background(Color.gray.opacity(0.1))
                                .cornerRadius(8)
                                .font(.headline)
                        }
                    }
                }
            }
            .padding()
        }
        .navigationTitle("双向滚动")
    }
}

刷新功能

struct RefreshableScrollView: View {
    @State private var items = ["项目 1", "项目 2", "项目 3"]
    @State private var isLoading = false
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(items, id: \.self) {
                    Text($0)
                        .font(.title)
                        .frame(maxWidth: .infinity, minHeight: 100)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(8)
                }
            }
            .padding()
        }
        .refreshable {
            // 模拟网络请求
            isLoading = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                items.append("项目 \(items.count + 1)")
                isLoading = false
            }
        }
        .navigationTitle("可刷新滚动视图")
    }
}

6.5 懒加载容器:LazyVStack、LazyHStack

懒加载容器介绍

LazyVStackLazyHStack 是懒加载的栈容器,只在需要时创建视图,非常适合处理大量数据。

LazyVStack 基本用法

struct LazyVStackExample: View {
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 20) {
                ForEach(1..<1000) {
                    Text("项目 \($0)")
                        .font(.title)
                        .frame(maxWidth: .infinity, minHeight: 100)
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(8)
                }
            }
            .padding()
        }
        .navigationTitle("LazyVStack 示例")
    }
}

LazyHStack 基本用法

struct LazyHStackExample: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 20) {
                ForEach(1..<100) {
                    VStack {
                        Text("项目 \($0)")
                            .font(.title)
                            .frame(width: 200, height: 200)
                            .background(Color.gray.opacity(0.1))
                            .cornerRadius(8)
                    }
                }
            }
            .padding()
        }
        .navigationTitle("LazyHStack 示例")
    }
}

性能对比

容器类型 优点 缺点 适用场景
VStack 简单,适合少量内容 一次性创建所有视图 内容较少的垂直布局
HStack 简单,适合少量内容 一次性创建所有视图 内容较少的水平布局
LazyVStack 懒加载,性能好 语法稍复杂 大量内容的垂直列表
LazyHStack 懒加载,性能好 语法稍复杂 大量内容的水平列表
List 功能丰富,自带滚动 样式固定 标准列表界面

实战:创建一个产品展示列表

需求分析

创建一个产品展示列表,包含以下功能:

  1. 产品图片、名称、价格、描述
  2. 分组显示(热门产品、新品)
  3. 下拉刷新
  4. 加载更多
  5. 产品状态(有货/缺货)

代码实现

import SwiftUI

// 产品模型
struct Product: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
    let description: String
    let imageName: String
    let isInStock: Bool
    let isPopular: Bool
}

struct ProductListView: View {
    // 产品数据
    @State private var products: [Product] = [
        Product(name: "iPhone 15 Pro", price: 7999, description: "最新款 iPhone,搭载 A17 Pro 芯片", imageName: "iphone", isInStock: true, isPopular: true),
        Product(name: "iPad Air", price: 4799, description: "轻薄便携的平板电脑", imageName: "ipad", isInStock: true, isPopular: true),
        Product(name: "MacBook Air", price: 8999, description: "轻薄便携的笔记本电脑", imageName: "macbook", isInStock: false, isPopular: true),
        Product(name: "Apple Watch", price: 2999, description: "智能手表,健康助手", imageName: "watch", isInStock: true, isPopular: false),
        Product(name: "AirPods Pro", price: 1899, description: "主动降噪耳机", imageName: "airpods", isInStock: true, isPopular: false)
    ]
    
    // 状态
    @State private var isRefreshing = false
    @State private var isLoadingMore = false
    
    var body: some View {
        List {
            // 热门产品分组
            Section("热门产品") {
                ForEach(products.filter { $0.isPopular }) { product in
                    ProductRow(product: product)
                }
            }
            
            // 新品分组
            Section("新品") {
                ForEach(products.filter { !$0.isPopular }) { product in
                    ProductRow(product: product)
                }
                
                // 加载更多
                if isLoadingMore {
                    HStack {
                        Spacer()
                        ProgressView()
                        Spacer()
                    }
                    .padding()
                } else {
                    Button("加载更多") {
                        loadMoreProducts()
                    }
                    .frame(maxWidth: .infinity)
                    .padding()
                }
            }
        }
        .navigationTitle("产品列表")
        .refreshable {
            refreshProducts()
        }
    }
    
    // 产品行视图
    struct ProductRow: View {
        let product: Product
        
        var body: some View {
            HStack(spacing: 16) {
                // 产品图片
                Image(systemName: product.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 80, height: 80)
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(8)
                
                // 产品信息
                VStack(alignment: .leading, spacing: 8) {
                    HStack {
                        Text(product.name)
                            .font(.headline)
                        Spacer()
                        Text(\(product.price)")
                            .font(.headline)
                            .foregroundStyle(.blue)
                    }
                    Text(product.description)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                    
                    // 库存状态
                    HStack {
                        Text(product.isInStock ? "有货" : "缺货")
                            .font(.caption)
                            .padding(4)
                            .background(product.isInStock ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
                            .foregroundStyle(product.isInStock ? .green : .red)
                            .cornerRadius(4)
                    }
                }
            }
            .padding(.vertical, 8)
        }
    }
    
    // 刷新产品
    private func refreshProducts() {
        isRefreshing = true
        // 模拟网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            // 刷新数据
            isRefreshing = false
        }
    }
    
    // 加载更多产品
    private func loadMoreProducts() {
        isLoadingMore = true
        // 模拟网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            // 添加更多产品
            let newProducts = [
                Product(name: "AirPods Max", price: 4399, description: "高端头戴式耳机", imageName: "headphones", isInStock: true, isPopular: false),
                Product(name: "HomePod", price: 2299, description: "智能音箱", imageName: "speaker", isInStock: false, isPopular: false)
            ]
            products.append(contentsOf: newProducts)
            isLoadingMore = false
        }
    }
}

#Preview {
    NavigationStack {
        ProductListView()
    }
}

代码解析

  1. Product 模型:包含产品的各种属性
  2. @State:用于管理产品数据和加载状态
  3. List 和 Section:用于分组显示产品
  4. ForEach:用于动态生成产品行
  5. ProductRow:自定义产品行视图
  6. refreshable:添加下拉刷新功能
  7. 加载更多:实现分页加载
  8. HStack 和 VStack:用于布局产品信息

技术点总结

List 组件

  • 核心功能:显示有序数据集合,自动处理滚动和复用
  • 数据要求:可以使用数组、Identifiable 对象或显式 ID
  • 样式选项:plain、grouped、insetGrouped、sidebar、automatic
  • 编辑功能:支持删除、移动操作
  • 性能特点:适合中等大小的列表,自动优化渲染

Section 分组

  • 作用:将列表内容逻辑分组
  • 组成:可以包含标题和页脚
  • 适用场景:需要逻辑分类的列表,如设置页面、联系人列表

ForEach 动态列表

  • 核心作用:根据数据动态生成视图
  • 使用方式:支持范围、Identifiable 对象、显式 ID
  • 性能考量:对于大量数据,建议使用 LazyVStack
  • 最佳实践:使用稳定的 ID,避免在闭包中进行复杂计算

ScrollView 滚动视图

  • 灵活性:可以包含任何类型的视图
  • 方向:支持垂直、水平、双向滚动
  • 刷新:通过 refreshable 添加下拉刷新
  • 滚动条:可以控制是否显示滚动指示器

懒加载容器

  • LazyVStack:垂直方向的懒加载容器
  • LazyHStack:水平方向的懒加载容器
  • 核心优势:只在需要时创建视图,显著提升性能
  • 适用场景:包含大量项目的列表或网格

性能优化建议

  1. 使用合适的容器:少量内容用 VStack/HStack,大量内容用 LazyVStack/LazyHStack
  2. 稳定的 ID:为 ForEach 提供稳定的标识符
  3. 避免复杂计算:将计算移到视图外部
  4. 合理使用 List:对于标准列表界面,List 提供了更好的用户体验
  5. 分页加载:对于非常长的列表,实现分页加载机制

参考资料


本内容为《SwiftUI 进阶》第六章,欢迎关注后续更新。

iOS RunLoop 原理深度解析与Swift高级用法

RunLoop是iOS开发的底层核心,贯穿应用全生命周期,支撑UI响应、定时器、网络回调、线程保活等所有异步操作,更是解决卡顿、死锁、内存泄漏的关键。本文以Swift视角,系统精简RunLoop的核心原理、组件机制、工作流程及高级实战,摒弃冗余,直击本质,助力开发者快速吃透底层逻辑并落地实践。

一、核心认知:RunLoop 的本质与关键误区

1.1 纠正常见误区

❌ 错误认知:RunLoop是用户态空转轮询(do-while死循环),持续占用CPU; ✅ 正确结论:RunLoop是苹果基于Mach内核封装的线程级事件调度管理器,核心靠内核阻塞调用实现“无事件休眠、有事件唤醒”,99%时间线程休眠,CPU占用接近0。 补充对比(精简版):普通死循环CPU占用接近100%,线程sleep无法响应即时事件,而RunLoop可在休眠时被即时事件唤醒,兼顾资源释放与响应速度。

1.2 核心定义与价值

本质:单线程事件调度中枢,核心职责3点:

  1. 统一接管线程所有事件(UI、定时器、网络回调等);
  2. 无事件时通过mach_msg阻塞休眠,释放CPU;
  3. 事件触发时唤醒线程,按优先级调度处理,通过Mode隔离事件避免干扰。

1.3 线程与RunLoop的绑定关系

  • 一一对应:一个线程对应一个RunLoop,生命周期完全绑定;
  • 懒加载:线程默认无RunLoop,调用RunLoop.current/CFRunLoopGetCurrent()时自动创建;
  • 主线程:系统自动创建并启动,贯穿APP生命周期;
  • 子线程:需手动管理(启动/停止),无事件源则启动后立即退出。

1.4 Swift常用的两套API体系

框架 API类型 线程安全 Swift用法 核心场景
Foundation RunLoop ❌ 非线程安全 RunLoop.current/main 上层业务开发(便捷)
Core Foundation CFRunLoopRef ✅ 线程安全 CFRunLoopGetCurrent() 底层开发(卡顿监控、线程保活)

二、底层拆解:RunLoop 核心组件(精简版)

核心结构:1个RunLoop + N个Mode + 3类组件(Source、Timer、Observer),核心规则:一次RunLoop仅运行在一个Mode下,切换Mode需退出并重新进入。

2.1 Mode:事件隔离容器(核心)

作用:隔离不同类型事件,避免干扰(如滑动与定时器不冲突),Swift常用Mode:

  • RunLoop.Mode.default:默认模式,APP空闲时运行(普通UI、默认定时器);
  • RunLoop.Mode.tracking:界面跟踪模式,滑动ScrollView/TableView时自动切换;
  • RunLoop.Mode.common:通用模式集合,事件可在多个Mode生效(推荐用于滑动时需触发的定时器)。

2.2 Source:事件输入源(唤醒RunLoop)

分两类,核心区别的是“是否具备内核唤醒能力”,补充Swift核心处理逻辑:

  • Source0:用户态事件(UI点击、手势、performSelector:onThread:),无内核唤醒能力,需手动调用CFRunLoopSourceSignal标记待处理,再调用CFRunLoopWakeUp唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调、跨线程Mach Port消息),基于Mach Port通信,内核检测到事件后自动唤醒RunLoop,无需手动操作。

补充:屏幕触摸完整流程(精简):手指触摸 → 内核包装为Mach消息 → Source1接收 → 唤醒RunLoop → 分发到Source0 → 处理手势/UI响应。

  • Source0:用户态事件(UI点击、手势),无内核唤醒能力,需手动标记待处理并唤醒RunLoop;
  • Source1:内核态事件(屏幕触摸、网络回调),基于Mach Port,可自动唤醒RunLoop。

2.3 Timer:定时触发源

依赖Mode机制,仅在绑定的Mode下触发,Swift实战选型(精简):

  • Timer:精度低(受RunLoop阻塞影响),适合普通定时(倒计时、轮播);
  • CADisplayLink:与屏幕刷新率同步(60fps),适合自定义动画;
  • GCD定时器:内核级精度最高,不依赖RunLoop,适合高精度场景(秒杀倒计时)。

2.4 Observer:状态监控者

监控RunLoop生命周期状态(entry/afterWaiting等),核心用于卡顿检测、性能监控,Swift中通过CFRunLoopObserver实现。

三、深度剖析:RunLoop 工作机制

核心流程:事件处理 → 阻塞休眠 → 唤醒处理 → 循环往复,核心依赖mach_msg函数实现阻塞与唤醒,结合CFRunLoop源码核心逻辑(精简伪代码):

// 核心循环逻辑(精简版)
void __CFRunLoopRun() {
    // 1. 通知进入RunLoop
    __CFRunLoopDoObservers(entry);
    while (1) {
        // 2. 处理Timer和Source0
        __CFRunLoopDoTimers();
        __CFRunLoopDoSources0();
        // 3. 检查Source1,有则处理,无则休眠
        if (!__CFRunLoopServiceMachPort()) {
            __CFRunLoopDoObservers(beforeWaiting);
            mach_msg(...);// 阻塞休眠
            __CFRunLoopDoObservers(afterWaiting);
        }
        // 4. 处理唤醒事件(Timer/Source1等)
        __CFRunLoopHandleMsg();
        // 5. 满足条件则退出
        if (shouldExit) break;
    }
    __CFRunLoopDoObservers(exit);
}

流程拆解:

  1. 进入RunLoop,通知Observer(entry状态);
  2. 处理当前Mode下到期的Timer、待处理的Source0;
  3. 检查Source1,有则直接处理,无则调用mach_msg阻塞休眠(释放CPU);
  4. 被事件(Source1/Timer/手动唤醒)唤醒,处理对应事件;
  5. 满足退出条件则终止,否则重复循环。

关键:mach_msg是内核级阻塞调用,无事件时线程挂起,有事件时内核自动唤醒,这是RunLoop与死循环的本质区别。

四、Swift 实操:基础用法

4.1 获取RunLoop实例

// 当前线程RunLoop(懒加载)
let currentRunloop = RunLoop.current
// 主线程RunLoop(系统自动创建)
let mainRunloop = RunLoop.main
// 线程安全的CFRunLoop
let cfRunloop = CFRunLoopGetCurrent()

4.2 子线程RunLoop启动(重点)

// 子线程保活示例
DispatchQueue.global().async {
    let runloop = RunLoop.current
    // 必须添加事件源(否则启动后立即退出)
    runloop.add(NSMachPort(), forMode: .default)
    // 无限运行(需手动停止)
    runloop.run()
}

// 停止RunLoop(需在对应线程调用)
DispatchQueue.global().async {
    CFRunLoopStop(CFRunLoopGetCurrent())
}

4.3 Timer避坑用法(推荐)

// 手动添加到common模式,滑动时仍触发
let timer = Timer(timeInterval: 1, repeats: true) { _ in
    print("定时执行,滑动不暂停")
}
RunLoop.current.add(timer, forMode: .common)
timer.fire() // 立即触发一次

五、高级实战:RunLoop 核心落地场景

5.1 主线程卡顿检测(核心应用)

原理:监控beforeSources和afterWaiting状态,计算耗时超过阈值(300ms)判定为卡顿,捕获堆栈用于排查。

import UIKit
import QuartzCore

class卡顿Monitor {
    static let shared = 卡顿Monitor()
    private let threshold: TimeInterval = 0.3 // 300ms阈值(可调整)
    private var startTimestamp: CFTimeInterval = 0
    private let lock = NSLock() // 保证线程安全
    private var observer: CFRunLoopObserver?
    
    private init() {} // 单例,禁止外部初始化
    
    func startMonitoring() {
        guard observer == nil else { return }
        let mainRunloop = CFRunLoopGetMain() // 监控主线程RunLoop
        // 上下文传递,将self绑定到Observer回调中
        let context = CFRunLoopObserverContext(
            version: 0,
            info: Unmanaged.passUnretained(self).toOpaque(),
            retain: nil,
            release: nil,
            copyDescription: nil
        )
        // 监控beforeSources(即将处理事件)和afterWaiting(唤醒后)状态
        observer = CFRunLoopObserverCreate(
            nil,
            CFRunLoopActivity.beforeSources.rawValue | CFRunLoopActivity.afterWaiting.rawValue,
            true, // 重复监控
            0, // 优先级(0最低)
            { _, activity, info in
                // 从上下文取出self
                guard let info = info else { return }
                let monitor = Unmanaged<卡顿Monitor>.fromOpaque(info).takeUnretainedValue()
                monitor.handleRunLoopActivity(activity)
            },
            &context
        )
        // 添加到common模式,确保滑动时也能监控
        if let observer = observer {
            CFRunLoopAddObserver(mainRunloop, observer, CFRunLoopMode.commonModes)
        }
    }
    
    func stopMonitoring() {
        guard let observer = observer else { return }
        CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
        self.observer = nil // 释放,避免内存泄漏
    }
    
    private func handleRunLoopActivity(_ activity: CFRunLoopActivity) {
        lock.lock()
        defer { lock.unlock() } // 确保锁一定会释放
        
        switch activity {
        case .beforeSources:
            // 记录事件处理开始时间戳
            startTimestamp = CACurrentMediaTime()
        case .afterWaiting:
            // 计算事件处理耗时
            let elapsed = CACurrentMediaTime() - startTimestamp
            if elapsed > threshold {
                print("⚠️ 主线程卡顿警告,耗时:(String(format: "%.2f", elapsed*1000))ms")
                let stack = getCurrentStack()
                print("卡顿堆栈信息:\n(stack)")
                // 实际开发中可在此处添加日志上报(友盟、Bugly等)
            }
        default:
            break
        }
    }
    
    // 优化版堆栈捕获:过滤系统堆栈,保留业务堆栈,更易排查
    private func getCurrentStack() -> String {
        var callStack = Thread.callStackSymbols
        // 过滤前2条(当前函数、Observer回调)和后3条(系统底层函数)
        callStack.removeFirst(2)
        if callStack.count > 8 {
            callStack = Array(callStack.prefix(8))
        }
        // 格式化堆栈,添加序号,更易阅读
        return callStack.enumerated().map { "($0.offset + 1). ($0.element)" }.joined(separator: "\n")
    }
}

// 使用方式(AppDelegate或SceneDelegate中)
// func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//     卡顿Monitor.shared.startMonitoring()
//     return true
// }

5.2 其他高频场景

  • 线程保活:通过子线程RunLoop+Source/Port,实现后台任务长期运行(如后台下载);
  • 延迟执行:利用RunLoop.perform(afterDelay:),比GCD更轻量且可取消;
  • 避免滑动卡顿:将耗时任务(如复杂计算)移出主线程,或切换到合适Mode。

5.3 常见坑点总结

  • 坑点1:Timer滑动失效 → 解决方案:将Timer添加到common模式,而非default模式;
  • 坑点2:子线程RunLoop启动后立即退出 → 解决方案:必须添加事件源(Source/Port/Timer),否则无事件可处理会直接退出;
  • 坑点3:手动停止RunLoop无效 → 解决方案:停止RunLoop必须在对应线程调用,不可跨线程停止;
  • 坑点4:Observer内存泄漏 → 解决方案:停止监控时,必须移除Observer并置为nil,避免循环引用;
  • 坑点5:混淆RunLoop与GCD定时器 → 解决方案:高精度定时用GCD定时器,普通定时用RunLoop的Timer(更轻量)。

六、核心总结

  1. RunLoop核心:线程的事件调度中枢,靠mach_msg实现“休眠-唤醒”,不占用多余CPU;

  2. 组件核心:Mode隔离事件,Source提供事件,Timer定时,Observer监控;

  3. Swift选型:上层用RunLoop(便捷),底层用CFRunLoopRef(线程安全);

  4. 实战价值:解决卡顿、线程保活、定时器失效等底层问题,是iOS高级开发必备技能;补充:吃透RunLoop,能快速定位APP性能瓶颈,避免因底层认知不足导致的隐蔽bug。

《swiftUI进阶 第9章SwiftUI 状态管理完全指南》

概述

状态管理是 SwiftUI 应用的核心。本章将系统介绍从 iOS 13 到 iOS 17+ 的所有状态管理技术,包括传统的 ObservableObject 系列和现代的 @Observable 宏,帮助你根据项目需求选择最合适的方案。


第一部分:基础状态管理(iOS 13+)

1. @State:本地视图状态

@State 用于管理视图内部的简单状态,当值改变时自动刷新 UI。

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
        }
    }
}

要点

  • 标记为 private,仅当前视图使用
  • 适合 IntStringBool 等简单类型
  • 当状态变化时,SwiftUI 重新计算 body

2. @Binding:父子视图双向绑定

@Binding 创建对现有状态的引用,允许子视图修改父视图的状态。

struct ParentView: View {
    @State private var isOn = false
    
    var body: some View {
        ToggleView(isOn: $isOn)  // 传递绑定
    }
}

struct ToggleView: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("开关", isOn: $isOn)
    }
}

第二部分:传统响应式状态管理(iOS 13+)

3. ObservableObject 协议与 @Published

ObservableObject 用于创建可观察的类,@Published 标记需要通知视图的属性。

import Combine

class UserViewModel: ObservableObject {
    @Published var name = "张三"
    @Published var age = 25
    
    func updateName(_ newName: String) {
        name = newName
    }
}

4. @StateObject vs @ObservedObject

特性 @StateObject @ObservedObject
生命周期 视图创建时初始化一次 随视图重建而重建
所有权 拥有对象 仅观察外部对象
适用场景 视图的主要数据源 从父视图传入的对象
struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()  // 拥有
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 传递
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: UserViewModel  // 观察
    
    var body: some View {
        Text(viewModel.name)
    }
}

5. @EnvironmentObject:全局共享状态

通过环境在任意层级共享对象,避免逐层传递。

class AppState: ObservableObject {
    @Published var isLoggedIn = false
}

@main
struct MyApp: App {
    @StateObject private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

struct ProfileView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        Text(appState.isLoggedIn ? "已登录" : "未登录")
    }
}

6. @Environment:系统环境值

访问系统提供的环境值,如颜色方案、尺寸类等。

struct ThemeAwareView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
    var body: some View {
        Text("当前模式: \(colorScheme == .dark ? "深色" : "浅色")")
    }
}

7. @AppStorage:持久化存储

使用 UserDefaults 自动持久化简单数据。

struct SettingsView: View {
    @AppStorage("username") var username = ""
    @AppStorage("isDarkMode") var isDarkMode = false
    
    var body: some View {
        TextField("用户名", text: $username)
        Toggle("深色模式", isOn: $isDarkMode)
    }
}

8. @SceneStorage:场景持久化

在场景(如多窗口)中保持状态,窗口关闭后自动清除。

struct DocumentView: View {
    @SceneStorage("scrollPosition") var scrollPosition: Double = 0
    
    var body: some View {
        ScrollView {
            // 内容
        }
    }
}

第三部分:现代状态管理(iOS 17+)

9. @Observable 宏

iOS 17 引入 @Observable 宏,简化了可观察对象的创建,无需 ObservableObject@Published

import SwiftUI

@Observable
class UserModel {
    var name = "张三"
    var age = 25
    var email = "zhangsan@example.com"
}

struct ContentView: View {
    @State private var userModel = UserModel()
    
    var body: some View {
        VStack {
            Text("姓名: \(userModel.name)")
            TextField("修改姓名", text: $userModel.name)  // 直接使用 $ 绑定
        }
    }
}

优势

  • 语法更简洁,无需协议和属性包装器
  • 所有属性默认可观察
  • 性能更优(直接访问)

10. @Bindable 双向绑定

当需要将 @Observable 对象的属性传递给需要绑定的子视图时,使用 @Bindable

@Observable
class Settings {
    var isDarkMode = false
}

struct ParentView: View {
    @State private var settings = Settings()
    
    var body: some View {
        ChildView(settings: settings)  // 直接传递
    }
}

struct ChildView: View {
    @Bindable var settings: Settings  // 添加 @Bindable
    
    var body: some View {
        Toggle("深色模式", isOn: $settings.isDarkMode)  // 可绑定
    }
}

11. 使用 @Environment 与 @Observable 结合

现代方式也可以将可观察对象放入环境。

@Observable
class AppState {
    var isLoggedIn = false
    var userName = ""
}

@main
struct MyApp: App {
    @State private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)  // 注入环境
        }
    }
}

struct ProfileView: View {
    @Environment(AppState.self) private var appState  // 读取环境
    
    var body: some View {
        Text(appState.isLoggedIn ? "欢迎 \(appState.userName)" : "未登录")
    }
}

第四部分:最佳实践与迁移指南

选择合适的状态管理工具

场景 推荐方式(iOS 13-16) 推荐方式(iOS 17+)
单个视图内部状态 @State @State
父子视图共享 @Binding @Binding
复杂业务逻辑 @StateObject + ObservableObject @State + @Observable
全局共享状态 @EnvironmentObject @Environment + @Observable
持久化简单数据 @AppStorage @AppStorage
场景临时状态 @SceneStorage @SceneStorage

从 ObservableObject 迁移到 @Observable

迁移步骤

  1. class SomeModel: ObservableObject 改为 @Observable class SomeModel
  2. 移除所有 @Published 包装器
  3. @StateObject 改为 @State(如果对象是视图拥有的)
  4. @ObservedObject 改为 @Bindable(如果需要双向绑定)
  5. @EnvironmentObject 改为 @Environment(SomeModel.self)

迁移示例

// 旧方式
class OldViewModel: ObservableObject {
    @Published var text = ""
}
struct OldView: View {
    @StateObject private var vm = OldViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

// 新方式
@Observable
class NewViewModel {
    var text = ""
}
struct NewView: View {
    @State private var vm = NewViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

第五部分:实战:完整的待办事项应用(双版本对比)

传统方式(ObservableObject)

import SwiftUI
import Combine

class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @StateObject private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

现代方式(@Observable)

import SwiftUI

@Observable
class TodoViewModel {
    var todos: [Todo] = []
    var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @State private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

总结

SwiftUI 提供了从基础到高级的完整状态管理方案:

  • 基础层@State@Binding – 适用于简单、局部的状态
  • 传统响应式层ObservableObject@Published@StateObject@ObservedObject@EnvironmentObject – 适用于 iOS 13-16 的复杂状态管理
  • 持久化层@AppStorage@SceneStorage – 适用于数据持久化
  • 现代层(iOS 17+)@Observable@Bindable – 更简洁、更高效,推荐新项目使用

选择建议:

  • 新项目且最低支持 iOS 17:优先使用 @Observable + @Environment
  • 需要兼容 iOS 16 及以下:继续使用 ObservableObject 系列
  • 两者可以在同一项目中共存,逐步迁移

掌握这些工具,你将能够构建出响应迅速、结构清晰的 SwiftUI 应用。


参考资料


本内容为《SwiftUI 进阶》第9章,涵盖从基础到现代的全部状态管理技术。欢迎关注后续更新。

《 SwiftUI 进阶第8章:表单与设置界面》

8.1 Form 组件

核心概念

Form 是 SwiftUI 中用于创建表单界面的专用组件,它提供了:

  • 自动的分组和分隔线
  • 自适应的布局
  • 与系统设置一致的外观
  • 支持多种表单控件

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    Text("姓名: 张三")
                    Text("年龄: 25")
                    Text("邮箱: zhangsan@example.com")
                }
            }
            .navigationTitle("个人资料")
        }
    }
}

动态表单

import SwiftUI

struct ContentView: View {
    @State private var name = "张三"
    @State private var age = 25
    @State private var email = "zhangsan@example.com"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    TextField("姓名", text: $name)
                    Stepper("年龄: \(age)", value: $age, in: 1...100)
                    TextField("邮箱", text: $email)
                }
            }
            .navigationTitle("编辑资料")
        }
    }
}

8.2 常见表单控件组合

基础控件

控件类型 用途 示例代码
TextField 文本输入 TextField("输入", text: $text)
SecureField 密码输入 SecureField("密码", text: $password)
Toggle 开关 Toggle("启用", isOn: $isEnabled)
Picker 选择器 Picker("选择", selection: $selection) { ... }
Stepper 步进器 Stepper("数量: \(count)", value: $count)
Slider 滑块 Slider(value: $value, in: 0...100)
DatePicker 日期选择 DatePicker("日期", selection: $date)

组合使用

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var theme = "浅色"
    @State private var brightness = 0.5
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                }
                
                Section {
                    Picker("主题", selection: $theme) {
                        Text("浅色").tag("浅色")
                        Text("深色").tag("深色")
                        Text("跟随系统").tag("跟随系统")
                    }
                }
                
                Section {
                    Text("亮度: \(Int(brightness * 100))%")
                    Slider(value: $brightness, in: 0...1)
                }
            }
            .navigationTitle("设置")
        }
    }
}

8.3 表单验证

基本验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var showError = false
    @State private var errorMessage = ""
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                    SecureField("密码", text: $password)
                }
                
                Section {
                    Button("登录") {
                        if !validateForm() {
                            showError = true
                        }
                    }
                }
            }
            .navigationTitle("登录")
            .alert("错误", isPresented: $showError) {
                Button("确定") {}
            } message: {
                Text(errorMessage)
            }
        }
    }
    
    func validateForm() -> Bool {
        if email.isEmpty {
            errorMessage = "请输入邮箱"
            return false
        }
        if !email.contains("@") {
            errorMessage = "请输入有效的邮箱"
            return false
        }
        if password.count < 6 {
            errorMessage = "密码至少需要6个字符"
            return false
        }
        return true
    }
}

实时验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    
    var emailIsValid: Bool {
        !email.isEmpty && email.contains("@")
    }
    
    var passwordIsValid: Bool {
        password.count >= 6
    }
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                        .foregroundColor(emailIsValid ? .primary : .red)
                    
                    SecureField("密码", text: $password)
                        .foregroundColor(passwordIsValid ? .primary : .red)
                    
                    if !emailIsValid && !email.isEmpty {
                        Text("请输入有效的邮箱")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                    
                    if !passwordIsValid && !password.isEmpty {
                        Text("密码至少需要6个字符")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
                
                Section {
                    Button("登录") {
                        // 登录逻辑
                    }
                    .disabled(!emailIsValid || !passwordIsValid)
                }
            }
            .navigationTitle("登录")
        }
    }
}

8.4 实战:用户设置页面

完整示例

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var haptic = true
    @State private var darkMode = false
    @State private var language = "简体中文"
    @State private var autoLock = 5 // 分钟
    
    var body: some View {
        NavigationStack {
            List {
                Section("通知设置") {
                    Toggle("推送通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                    Toggle("震动", isOn: $haptic)
                }
                
                Section("外观设置") {
                    Toggle("深色模式", isOn: $darkMode)
                }
                
                Section("语言设置") {
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
                
                Section("安全设置") {
                    Picker("自动锁定", selection: $autoLock) {
                        Text("30秒").tag(0)
                        Text("1分钟").tag(1)
                        Text("5分钟").tag(5)
                        Text("10分钟").tag(10)
                        Text("永不").tag(-1)
                    }
                }
                
                Section("关于") {
                    HStack {
                        Text("版本")
                        Spacer()
                        Text("1.0.0")
                            .foregroundColor(.gray)
                    }
                    
                    Button("检查更新") {
                        // 检查更新逻辑
                    }
                    
                    Button("隐私政策") {
                        // 打开隐私政策
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

分组样式

Form {
    // 表单内容
}
.formStyle(.grouped) // 分组样式

最佳实践

  1. 分组逻辑:按照功能将表单控件分组
  2. 标签清晰:为每个控件提供明确的标签
  3. 验证反馈:及时提供验证错误反馈
  4. 默认值:为控件设置合理的默认值
  5. 布局合理:使用合适的控件类型和布局

性能优化

  1. 避免复杂计算:不要在 body 中进行复杂计算
  2. 使用 @State 优化:合理使用 @State 管理表单状态
  3. 延迟加载:对于复杂表单,考虑使用延迟加载

与 iOS 专家博客对比

根据 SwiftUI by Example 的建议:

  • 使用 Section 组织表单内容
  • 为表单控件提供合适的键盘类型
  • 利用 Form 的自动布局特性
  • 结合 NavigationStack 构建设置页面层次

高级技巧

自定义表单样式

struct CustomFormStyle: FormStyle {
    func makeBody(configuration: Configuration) -> some View {
        VStack(spacing: 0) {
            ForEach(configuration.content) {
                $0
                    .padding()
                    .background(Color.white)
                    .border(Color.gray.opacity(0.2), edges: .bottom)
            }
        }
        .background(Color.gray.opacity(0.1))
    }
}

// 使用
Form {
    // 表单内容
}
.formStyle(CustomFormStyle())

表单数据持久化

import SwiftUI

struct ContentView: View {
    @AppStorage("notifications") private var notifications = true
    @AppStorage("darkMode") private var darkMode = false
    @AppStorage("language") private var language = "简体中文"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("深色模式", isOn: $darkMode)
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

总结

表单与设置界面是应用中常见的组成部分,SwiftUI 提供了强大的 Form 组件来简化开发:

  • Form:创建结构化的表单布局
  • 多种内置控件:满足各种输入需求
  • 实时验证:提供良好的用户反馈
  • 与系统风格一致:确保视觉一致性

通过合理组织表单内容、提供清晰的验证反馈、使用适当的控件类型,可以创建出既美观又实用的设置界面。


参考资料


本内容为《SwiftUI 进阶》第八章,欢迎关注后续更新。

《SwiftUI 进阶第7章:导航系统》

7.1 NavigationStack 基础导航

核心概念

NavigationStack 是 SwiftUI 中用于构建导航层次结构的核心组件,它替代了旧版的 NavigationView(在 iOS 16+ 中已被废弃)。

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("前往详情页", destination: DetailView())
            }
            .navigationTitle("主页面")
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情页")
    }
}

程序化导航

NavigationStack 支持使用路径进行程序化导航:

import SwiftUI

struct ContentView: View {
    @State private var path: [Int] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            List(1..<10) { number in
                NavigationLink(value: number) {
                    Text("项目 \(number)")
                }
            }
            .navigationTitle("主页面")
            .navigationDestination(for: Int.self) {
                DetailView(number: $0, path: $path)
            }
        }
    }
}

struct DetailView: View {
    let number: Int
    @Binding var path: [Int]
    
    var body: some View {
        VStack {
            Text("详情页 \(number)")
            Button("前往下一页") {
                path.append(number + 10)
            }
            Button("返回首页") {
                path.removeAll()
            }
        }
        .navigationTitle("详情页 \(number)")
    }
}

与官方文档对比

根据苹果官方文档,NavigationStack 提供了更灵活的导航控制,包括:

  • 路径管理:可以通过绑定的数组控制导航状态
  • 类型安全:使用 navigationDestination(for:) 提供类型安全的导航目标
  • 向后兼容:在 iOS 16+ 中推荐使用

7.2 NavigationLink 页面跳转

核心概念

NavigationLink 是用于创建导航链接的组件,它可以:

  • 直接指定目标视图
  • 使用值传递方式(配合 navigationDestination
  • 控制激活状态

直接目标方式

NavigationLink("前往详情页", destination: DetailView())

值传递方式

NavigationLink(value: item) {
    Text(item.name)
}

条件导航

NavigationLink(
    "登录", 
    destination: LoginView(),
    isActive: $isLoggedIn
)

7.3 navigationTitle 与 navigationBarTitleDisplayMode

navigationTitle

设置导航栏标题:

.navigationTitle("页面标题")

navigationBarTitleDisplayMode

控制标题显示模式:

模式 描述
.automatic 自动(默认)
.inline 内联模式(小字体)
.large 大标题模式
.navigationBarTitleDisplayMode(.large)

7.4 Sheet 模态视图

核心概念

Sheet 用于显示模态视图,通常用于:

  • 表单填写
  • 详情展示
  • 辅助操作

基本使用

import SwiftUI

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示 Sheet") {
            isSheetPresented = true
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView(isPresented: $isSheetPresented)
        }
    }
}

struct SheetView: View {
    @Binding var isPresented: Bool
    
    var body: some View {
        VStack {
            Text("这是一个 Sheet 视图")
            Button("关闭") {
                isPresented = false
            }
        }
        .padding()
    }
}

带值的 Sheet

.sheet(item: $selectedItem) {
    DetailView(item: $0)
}

7.5 TabView 标签页导航

核心概念

TabView 用于创建底部标签栏导航,是构建多标签应用的基础。

基本使用

import SwiftUI

struct ContentView: View {
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("首页", systemImage: "house")
                }
                .tag(0)
            
            ProfileView()
                .tabItem {
                    Label("个人", systemImage: "person")
                }
                .tag(1)
        }
    }
}

struct HomeView: View {
    var body: some View {
        Text("首页")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人中心")
    }
}

自定义样式

TabView {
    // 标签内容
}
.tabViewStyle(.automatic) // 自动样式

最佳实践

  1. 导航层次:保持导航层次清晰,避免过深的导航栈
  2. 标题设置:为每个页面设置合适的标题和显示模式
  3. 模态视图:合理使用 Sheet 展示临时内容
  4. 标签栏:控制标签数量(建议 3-5 个)
  5. 状态管理:使用 @State@Observable 管理导航状态

性能优化

  1. 延迟加载:使用 LazyView 包装目标视图
  2. 导航栈管理:及时清理不需要的导航路径
  3. 避免过度动画:减少导航过程中的复杂动画

建议:

  • 优先使用 NavigationStack 而非 NavigationView
  • 使用值类型传递而非对象引用
  • 结合 @ObservableObservableObject 管理复杂导航状态

实战:多页面应用

import SwiftUI

struct ContentView: View {
    @State private var path: [String] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            TabView {
                HomeView(path: $path)
                    .tabItem {
                        Label("首页", systemImage: "house")
                    }
                
                SettingsView()
                    .tabItem {
                        Label("设置", systemImage: "gear")
                    }
            }
        }
    }
}

struct HomeView: View {
    @Binding var path: [String]
    
    var body: some View {
        List {
            NavigationLink(value: "detail") {
                Text("详情页")
            }
            NavigationLink(value: "profile") {
                Text("个人资料")
            }
        }
        .navigationTitle("首页")
        .navigationDestination(for: String.self) {
            switch $0 {
            case "detail":
                DetailView()
            case "profile":
                ProfileView()
            default:
                Text("未知页面")
            }
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人资料")
            .navigationTitle("个人")
    }
}

struct SettingsView: View {
    var body: some View {
        Text("设置页面")
            .navigationTitle("设置")
    }
}

总结

导航系统是构建 iOS 应用的核心部分,SwiftUI 提供了现代化的导航组件:

  • NavigationStack:构建导航层次结构
  • NavigationLink:创建导航链接
  • Sheet:显示模态视图
  • TabView:实现标签页导航

掌握这些组件的使用,将帮助你构建结构清晰、用户体验良好的多页面应用。


参考资料


本内容为《SwiftUI 进阶》第七章,欢迎关注后续更新。

《SwiftUI 进阶第5章:数据处理与网络请求》

学习目标

  • 掌握 SwiftUI 中的数据处理基本方法
  • 了解如何进行网络请求
  • 学习如何处理网络请求的加载状态和错误
  • 掌握数据过滤和排序的方法
  • 了解如何使用 JSONDecoder 解析 JSON 数据

核心概念

数据模型

在 SwiftUI 中,数据模型通常使用结构体来定义,并且需要符合 Identifiable 协议以便在列表中使用。

示例代码:

struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

本地数据处理

本地数据处理包括数据的添加、删除、修改和查询等操作。

示例代码:

@State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
@State private var newData = ""

HStack {
    TextField("输入新数据", text: $newData)
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .padding()
    
    Button("添加") {
        if !newData.isEmpty {
            localData.append(newData)
            newData = ""
        }
    }
}

List(localData, id: \.self) { item in
    Text(item)
}

网络请求

在 SwiftUI 中,网络请求通常使用 URLSession 来实现,并且需要在后台线程中执行,然后在主线程中更新 UI。

示例代码:

@State private var posts: [Post] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil

func fetchPosts() {
    isLoading = true
    errorMessage = nil
    
    guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
        self.isLoading = false
        self.errorMessage = "无效的URL"
        return
    }
    
    URLSession.shared.dataTask(with: url) { data, response, error in
        DispatchQueue.main.async {
            self.isLoading = false
            
            if let error = error {
                self.errorMessage = error.localizedDescription
                return
            }
            
            guard let data = data else {
                self.errorMessage = "无数据返回"
                return
            }
            
            do {
                let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                self.posts = decodedPosts
            } catch {
                self.errorMessage = "解析数据失败"
            }
        }
    }.resume()
}

数据状态管理

在网络请求过程中,需要管理不同的状态:加载中、加载成功和加载失败。

示例代码:

if isLoading {
    ProgressView("加载中...")
        .padding()
} else if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
        .padding()
} else {
    List(posts) { post in
        VStack(alignment: .leading) {
            Text(post.title)
                .font(.headline)
            Text(post.body)
                .font(.body)
                .foregroundColor(.gray)
        }
    }
}

数据过滤

使用 filter 方法可以对数据进行过滤,只显示符合条件的数据。

示例代码:

List(localData.filter { $0.contains("1") }, id: \.self) {
    Text($0)
}

数据排序

使用 sorted 方法可以对数据进行排序,按照指定的规则排列数据。

示例代码:

List(localData.sorted(), id: \.self) {
    Text($0)
}

实践示例:完整数据处理与网络请求演示

以下是一个完整的数据处理与网络请求演示示例:

import SwiftUI

// 数据模型
struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

struct DataProcessingAndNetworkingDemo: View {
    // 状态管理
    @State private var posts: [Post] = []
    @State private var isLoading = false
    @State private var errorMessage: String? = nil
    @State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
    @State private var newData = ""
    @State private var filterKeyword = ""
    @State private var sortAscending = true
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("数据处理与网络请求")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 本地数据处理
                VStack(alignment: .leading, spacing: 12) {
                    Text("1. 本地数据处理")
                        .font(.headline)
                    
                    HStack {
                        TextField("输入新数据", text: $newData)
                            .textFieldStyle(.roundedBorder)
                        Button("添加") {
                            if !newData.isEmpty {
                                localData.append(newData)
                                newData = ""
                            }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                    
                    List(localData, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 150)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 网络请求
                VStack(alignment: .leading, spacing: 12) {
                    Text("2. 网络请求")
                        .font(.headline)
                    
                    Button("获取网络数据") {
                        fetchPosts()
                    }
                    .buttonStyle(.borderedProminent)
                    
                    if isLoading {
                        ProgressView("加载中...")
                            .padding()
                    } else if let errorMessage = errorMessage {
                        Text("错误: \(errorMessage)")
                            .foregroundColor(.red)
                            .padding()
                    } else if !posts.isEmpty {
                        List(posts) { post in
                            VStack(alignment: .leading) {
                                Text(post.title)
                                    .font(.headline)
                                Text(post.body)
                                    .font(.body)
                                    .foregroundColor(.gray)
                            }
                        }
                        .frame(height: 250)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 数据过滤
                VStack(alignment: .leading, spacing: 12) {
                    Text("3. 数据过滤")
                        .font(.headline)
                    
                    TextField("输入过滤关键词", text: $filterKeyword)
                        .textFieldStyle(.roundedBorder)
                    
                    List(localData.filter { 
                        filterKeyword.isEmpty ? true : $0.contains(filterKeyword) 
                    }, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 数据排序
                VStack(alignment: .leading, spacing: 12) {
                    Text("4. 数据排序")
                        .font(.headline)
                    
                    Toggle("升序排列", isOn: $sortAscending)
                    
                    List(sortAscending ? localData.sorted() : localData.sorted(by: >), id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 网络请求方法
    func fetchPosts() {
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
            self.isLoading = false
            self.errorMessage = "无效的URL"
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            DispatchQueue.main.async {
                self.isLoading = false
                
                if let error = error {
                    self.errorMessage = error.localizedDescription
                    return
                }
                
                guard let data = data else {
                    self.errorMessage = "无数据返回"
                    return
                }
                
                do {
                    let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                    self.posts = decodedPosts
                } catch {
                    self.errorMessage = "解析数据失败"
                }
            }
        }.resume()
    }
}

#Preview {
    DataProcessingAndNetworkingDemo()
}

常见问题与解决方案

1. 网络请求在主线程执行

问题:网络请求在主线程执行,导致 UI 卡顿。

解决方案:使用 DispatchQueue.global().async 将网络请求放在后台线程执行,然后在主线程中更新 UI。实际上 URLSession.dataTask 的回调默认就在后台线程,只需确保 UI 更新在 DispatchQueue.main.async 中。

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        // 更新 UI
    }
}.resume()

2. 数据解析失败

问题:JSON 数据解析失败。

解决方案

  • 确保数据模型与 JSON 数据结构完全匹配(字段名、类型)
  • 使用 CodingKeys 处理字段名不一致的情况
  • 使用 try?do-catch 捕获错误
struct Post: Decodable {
    let id: Int
    let title: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case title = "post_title"  // 如果 JSON 字段名不同
    }
}

3. 加载状态未正确显示

问题:网络请求过程中没有显示加载状态。

解决方案:使用 @State 变量管理加载状态,并在请求开始前设置为 true,完成后设置为 false

4. 错误处理不完善

问题:网络请求失败时没有显示错误信息。

解决方案:捕获并处理网络请求中的错误,将错误信息显示给用户。

if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
}

总结

本章介绍了 SwiftUI 中的数据处理与网络请求,包括:

  • 数据模型的定义:使用 IdentifiableDecodable 协议
  • 本地数据处理:增删改查、列表展示
  • 网络请求的实现:使用 URLSession 和异步回调
  • 数据状态管理:加载中、成功、失败三种状态
  • 数据过滤:使用 filter 方法按条件筛选
  • 数据排序:使用 sorted 方法自定义排序规则

通过这些技术,可以实现数据的获取、处理和展示,为应用提供丰富的数据源。在实际开发中,数据处理与网络请求是应用的核心功能之一,掌握这些技术对于开发高质量的 SwiftUI 应用至关重要。


参考资料


本内容为《SwiftUI 进阶》第五章,欢迎关注后续更新。

《SwiftUI 进阶第4章:响应式布局》

Snip20260418_7.png

学习目标

  • 掌握 SwiftUI 中的响应式布局概念
  • 了解如何根据屏幕尺寸调整布局
  • 学习使用环境变量获取设备信息
  • 掌握动态网格布局的实现方法
  • 了解几何读取器和安全区域的使用

核心概念

响应式布局基础

在 SwiftUI 中,响应式布局是通过环境变量、条件布局和自适应组件来实现的,它可以根据不同的屏幕尺寸和设备类型自动调整布局。


环境变量

尺寸类

SwiftUI 提供了尺寸类来描述设备的屏幕尺寸,主要有两种尺寸类:

  • horizontalSizeClass - 水平尺寸类,分为 .compact(紧凑)和 .regular(常规)
  • verticalSizeClass - 垂直尺寸类,同样分为 .compact.regular

示例代码:

@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass

Text("水平尺寸类: \(horizontalSizeClass == .compact ? "紧凑" : "常规")")
Text("垂直尺寸类: \(verticalSizeClass == .compact ? "紧凑" : "常规")")

自适应布局

根据尺寸类调整布局是响应式设计的核心。

示例代码:

// 根据水平尺寸类调整布局
if horizontalSizeClass == .compact {
    // 紧凑模式 - 垂直布局
    VStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
} else {
    // 常规模式 - 水平布局
    HStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
}

动态网格布局

使用 LazyVGridGridItem 可以创建动态网格布局,根据屏幕尺寸自动调整列数。

示例代码:

// 根据水平尺寸类调整网格列数
let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

LazyVGrid(columns: columns, spacing: 10) {
    ForEach(1..<9) { index in
        Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8)
            .frame(height: 100)
            .cornerRadius(10)
            .overlay(
                Text("\(index)")
                    .foregroundColor(.white)
                    .font(.headline)
            )
    }
}

几何读取器

GeometryReader 可以获取父视图的尺寸和位置信息,用于创建更加灵活的布局。

示例代码:

GeometryReader { geometry in
    VStack {
        Text("屏幕宽度: \(geometry.size.width, specifier: "%.0f")")
        Text("屏幕高度: \(geometry.size.height, specifier: "%.0f")")
        
        Rectangle()
            .fill(.purple)
            .frame(width: geometry.size.width * 0.8, height: 100)
            .cornerRadius(10)
    }
}
.frame(height: 200)

安全区域

安全区域是指屏幕上不会被系统 UI(如状态栏、导航栏、底部安全区域)遮挡的区域。

示例代码:

Color.blue
    .frame(height: 100)
    .ignoresSafeArea(edges: .top)
    .cornerRadius(10)

自适应文本

使用 .multilineTextAlignment() 可以创建自适应文本,根据屏幕宽度自动换行。

示例代码:

Text("这是一段自适应文本,会根据屏幕宽度自动换行")
    .font(.body)
    .multilineTextAlignment(.center)
    .padding()
    .background(.gray.opacity(0.1))
    .cornerRadius(10)

条件内容

根据尺寸类显示不同的内容,实现设备特定的布局。

示例代码:

if horizontalSizeClass == .compact {
    Text("当前是手机模式,显示手机专用内容")
        .font(.body)
        .padding()
        .background(.green)
        .foregroundColor(.white)
        .cornerRadius(10)
} else {
    Text("当前是平板模式,显示平板专用内容")
        .font(.body)
        .padding()
        .background(.blue)
        .foregroundColor(.white)
        .cornerRadius(10)
}

动态间距

根据屏幕尺寸调整组件之间的间距。

示例代码:

// 根据水平尺寸类调整间距
let spacing = horizontalSizeClass == .compact ? 10.0 : 20.0

VStack(spacing: spacing) {
    Color.red
        .frame(height: 50)
        .cornerRadius(10)
    Color.green
        .frame(height: 50)
        .cornerRadius(10)
    Color.blue
        .frame(height: 50)
        .cornerRadius(10)
}

实践示例:完整响应式布局演示

以下是一个完整的响应式布局演示示例,包含了各种响应式设计技术:

import SwiftUI

struct ResponsiveLayoutDemo: View {
    // 环境变量 - 用于获取屏幕尺寸
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("响应式布局")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 屏幕尺寸信息
                VStack {
                    Text("1. 屏幕尺寸信息")
                        .font(.headline)
                    HStack {
                        Text("水平尺寸类:")
                        Text(horizontalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                    HStack {
                        Text("垂直尺寸类:")
                        Text(verticalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 自适应布局(垂直/水平切换)
                VStack {
                    Text("2. 自适应布局")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        VStack(spacing: 10) {
                            Color.red.frame(height: 60).cornerRadius(8)
                            Color.green.frame(height: 60).cornerRadius(8)
                            Color.blue.frame(height: 60).cornerRadius(8)
                        }
                    } else {
                        HStack(spacing: 10) {
                            Color.red.frame(height: 80).cornerRadius(8)
                            Color.green.frame(height: 80).cornerRadius(8)
                            Color.blue.frame(height: 80).cornerRadius(8)
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "垂直堆叠" : "水平排列")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 动态网格布局
                VStack {
                    Text("3. 动态网格布局")
                        .font(.headline)
                    
                    let columns = horizontalSizeClass == .compact ? [
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ] : [
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ]
                    
                    LazyVGrid(columns: columns, spacing: 10) {
                        ForEach(1..<9) { index in
                            Color(hue: Double(index)/10, saturation: 0.7, brightness: 0.9)
                                .frame(height: 80)
                                .cornerRadius(8)
                                .overlay(
                                    Text("\(index)")
                                        .foregroundColor(.white)
                                        .font(.headline)
                                )
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "2列网格" : "4列网格")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 几何读取器
                VStack {
                    Text("4. 几何读取器")
                        .font(.headline)
                    
                    GeometryReader { geometry in
                        VStack {
                            Text("可用宽度: \(geometry.size.width, specifier: "%.0f")")
                                .font(.caption)
                            Rectangle()
                                .fill(.purple)
                                .frame(width: geometry.size.width * 0.7, height: 40)
                                .cornerRadius(8)
                                .overlay(
                                    Text("70% 宽度")
                                        .font(.caption)
                                        .foregroundColor(.white)
                                )
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .frame(height: 100)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 安全区域示例
                VStack {
                    Text("5. 安全区域")
                        .font(.headline)
                    
                    Color.blue
                        .frame(height: 60)
                        .cornerRadius(8)
                        .overlay(
                            Text("默认在安全区域内")
                                .foregroundColor(.white)
                        )
                    
                    Color.orange
                        .frame(height: 60)
                        .cornerRadius(8)
                        .ignoresSafeArea(edges: .horizontal)
                        .overlay(
                            Text("忽略水平安全区域")
                                .foregroundColor(.white)
                        )
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 自适应文本
                VStack {
                    Text("6. 自适应文本")
                        .font(.headline)
                    
                    Text("这是一段自适应文本,会根据屏幕宽度自动换行。当屏幕较窄时,文字会折行显示;屏幕较宽时,可以在一行内完整显示。")
                        .font(.body)
                        .multilineTextAlignment(.center)
                        .padding()
                        .background(Color.gray.opacity(0.2))
                        .cornerRadius(8)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 条件内容(设备专用)
                VStack {
                    Text("7. 条件内容")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        Text("📱 手机模式:显示紧凑型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.green)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    } else {
                        Text("🖥️ 平板模式:显示扩展型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.blue)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 8. 动态间距
                VStack {
                    Text("8. 动态间距")
                        .font(.headline)
                    
                    let dynamicSpacing = horizontalSizeClass == .compact ? 8.0 : 20.0
                    
                    VStack(spacing: dynamicSpacing) {
                        Color.red.frame(height: 40).cornerRadius(6)
                        Color.green.frame(height: 40).cornerRadius(6)
                        Color.blue.frame(height: 40).cornerRadius(6)
                    }
                    Text("当前间距: \(dynamicSpacing, specifier: "%.0f") pt")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    ResponsiveLayoutDemo()
}

常见问题与解决方案

1. 布局在不同设备上显示不一致

问题:布局在手机上显示正常,但在平板上显示异常。

解决方案:使用尺寸类和条件布局,为不同尺寸的设备提供不同的布局方案。

// 根据水平尺寸类选择不同的布局结构
if horizontalSizeClass == .compact {
    // 手机布局:垂直堆叠
    VStack { ... }
} else {
    // 平板布局:水平排列或更复杂的网格
    HStack { ... }
}

2. 内容被安全区域遮挡

问题:内容被状态栏或导航栏遮挡。

解决方案:使用 .ignoresSafeArea() 修饰符或确保内容在安全区域内。

// 方法一:忽略安全区域(适用于背景视图)
Color.blue.ignoresSafeArea()

// 方法二:使用 safeAreaInset 添加自定义内容
List {
    // 内容
}
.safeAreaInset(edge: .bottom) {
    Button("底部按钮") { }
        .padding()
}

3. 网格布局在小屏幕上显示拥挤

问题:网格布局在小屏幕上列数过多,导致内容拥挤。

解决方案:根据屏幕尺寸动态调整网格列数。

let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

4. GeometryReader 导致布局异常

问题:使用 GeometryReader 后,子视图大小不符合预期。

解决方案:注意 GeometryReader 会占据父视图提供的全部空间,可以在内部使用 frame(height:) 限制高度。

GeometryReader { geometry in
    // 内容
}
.frame(height: 200)  // 固定高度

总结

本章介绍了 SwiftUI 中的响应式布局技术,包括:

  • 环境变量:使用 @Environment 获取设备尺寸信息(horizontalSizeClassverticalSizeClass
  • 自适应布局:根据尺寸类调整布局结构(VStackHStack
  • 动态网格布局:使用 LazyVGridGridItem 创建响应式网格
  • 几何读取器:通过 GeometryReader 获取父视图尺寸,实现精确布局
  • 安全区域:处理状态栏、导航栏等系统 UI 遮挡问题
  • 自适应文本:使用 .multilineTextAlignment() 实现文本自动换行
  • 条件内容:为不同设备类型显示不同的 UI 组件
  • 动态间距:根据屏幕尺寸调整组件之间的间距

通过这些技术,可以创建在不同设备上都能良好显示的布局,提升用户体验。在实际开发中,响应式布局是确保应用在各种设备上都能正常显示的重要手段。


参考资料


本内容为《SwiftUI 进阶》第四章,欢迎关注后续更新。

《SwiftUI 进阶学习第3章:手势与交互》

手势基础

在 SwiftUI 中,手势是通过各种手势类型和修饰符来实现的,它们可以附加到任何视图上,以响应用户的交互。


常用手势类型

1. 点击手势

点击手势通过 onTapGesture 修饰符实现,用于检测用户的点击操作。

示例代码:

@State private var isTapped = false

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onTapGesture {
        isTapped.toggle()
    }

点击次数:

可以通过 count 参数指定点击次数,例如双击:

Rectangle()
    .onTapGesture(count: 2) {
        tapCount += 1
    }

2. 长按手势

长按手势通过 onLongPressGesture 修饰符实现,用于检测用户的长按操作。

示例代码:

@State private var isLongPressed = false

Rectangle()
    .fill(.green)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onLongPressGesture {
        isLongPressed.toggle()
    }

3. 拖拽手势

拖拽手势通过 DragGesture 实现,用于检测用户的拖拽操作。

示例代码:

@State private var dragOffset = CGSize.zero

Circle()
    .fill(.red)
    .frame(width: 50, height: 50)
    .offset(dragOffset)
    .gesture(
        DragGesture()
            .onChanged { value in
                dragOffset = value.translation
            }
            .onEnded { value in
                // 可以在这里添加结束拖动的逻辑
            }
    )

4. 缩放手势

缩放手势通过 MagnificationGesture 实现,用于检测用户的缩放操作。

示例代码:

@State private var scale = 1.0

Rectangle()
    .fill(.purple)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .scaleEffect(scale)
    .gesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )

5. 旋转手势

旋转手势通过 RotationGesture 实现,用于检测用户的旋转操作。

示例代码:

@State private var rotation = 0.0

Rectangle()
    .fill(.orange)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .rotationEffect(.degrees(rotation))
    .gesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

组合手势

组合手势是将多种手势效果结合在一起,可以使用 .simultaneousGesture() 修饰符。

示例代码:

@State private var offset = CGSize.zero
@State private var scale = 1.0
@State private var rotation = 0.0

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .offset(offset)
    .scaleEffect(scale)
    .rotationEffect(.degrees(rotation))
    .gesture(
        DragGesture()
            .onChanged { value in
                offset = value.translation
            }
    )
    .simultaneousGesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )
    .simultaneousGesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

手势状态管理

手势通常与状态管理结合使用,以追踪手势的状态和数据。

示例代码:

@State private var isDragging = false

Rectangle()
    .fill(.purple)
    .frame(width: 100, height: 100)
    .cornerRadius(10)
    .gesture(
        DragGesture()
            .onChanged { _ in
                isDragging = true
            }
            .onEnded { _ in
                isDragging = false
            }
    )

实践示例:完整手势演示

以下是一个完整的手势演示示例,包含了各种手势类型和组合:

import SwiftUI

struct GestureAndInteractionDemo: View {
    // 状态管理
    @State private var isTapped = false
    @State private var isLongPressed = false
    @State private var offset = CGSize.zero
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var dragOffset = CGSize.zero
    @State private var isDragging = false
    @State private var tapCount = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("手势与交互")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 点击手势
                VStack {
                    Text("1. 点击手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isTapped ? Color.green : Color.blue)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture {
                            withAnimation {
                                isTapped.toggle()
                            }
                        }
                    Text("状态: \(isTapped ? "已点击" : "未点击")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 长按手势
                VStack {
                    Text("2. 长按手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isLongPressed ? Color.red : Color.green)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onLongPressGesture {
                            withAnimation {
                                isLongPressed.toggle()
                            }
                        }
                    Text("状态: \(isLongPressed ? "长按中" : "未长按")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 拖拽手势
                VStack {
                    Text("3. 拖拽手势")
                        .font(.headline)
                    Circle()
                        .fill(.red)
                        .frame(width: 60, height: 60)
                        .offset(dragOffset)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    dragOffset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        dragOffset = .zero
                                    }
                                }
                        )
                    Text("拖拽小球后自动归位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 缩放手势
                VStack {
                    Text("4. 缩放手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.purple)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .scaleEffect(scale)
                        .gesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                    Text("双指缩放,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 旋转手势
                VStack {
                    Text("5. 旋转手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.orange)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("双指旋转,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 组合手势
                VStack {
                    Text("6. 组合手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .offset(offset)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    offset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        offset = .zero
                                    }
                                }
                        )
                        .simultaneousGesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                        .simultaneousGesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("支持拖动、缩放、旋转")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 双击计数
                VStack {
                    Text("7. 双击计数")
                        .font(.headline)
                    Rectangle()
                        .fill(.teal)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture(count: 2) {
                            tapCount += 1
                        }
                    Text("双击次数: \(tapCount)")
                    Button("重置") {
                        tapCount = 0
                    }
                    .buttonStyle(.bordered)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    GestureAndInteractionDemo()
}

常见问题与解决方案

1. 手势不响应

问题:视图添加了手势,但没有响应。

解决方案

  • 确保视图有足够的大小(例如,不要将手势添加到 frame(width: 0, height: 0) 的视图上)
  • 检查视图是否被其他视图遮挡(使用 .contentShape(Rectangle()) 扩大可点击区域)
  • 确保没有其他手势冲突
// 扩大点击区域
Rectangle()
    .fill(.clear)
    .contentShape(Rectangle())  // 使透明区域也能响应手势
    .onTapGesture { }

2. 组合手势冲突

问题:多个手势同时应用时出现冲突。

解决方案

  • 使用 .simultaneousGesture() 修饰符来允许同时处理多个手势
  • 使用 .highPriorityGesture() 让某个手势优先
  • 使用 .gesture()including 参数控制手势识别行为
// 高优先级手势(会阻断其他手势)
view.highPriorityGesture(
    TapGesture().onEnded { }
)

// 同时识别多个手势
view.simultaneousGesture(dragGesture)
    .simultaneousGesture(rotationGesture)

3. 手势状态管理

问题:手势结束后状态没有正确更新。

解决方案

  • .onEnded 回调中正确更新状态
  • 对于需要持久化的状态,使用 @State@StateObject
DragGesture()
    .onChanged { value in
        // 实时更新
        offset = value.translation
    }
    .onEnded { value in
        // 结束时的处理
        withAnimation {
            offset = .zero
        }
    }

总结

本章介绍了 SwiftUI 中的手势与交互,包括:

  • 基本手势类型:点击、长按、拖拽、缩放、旋转
  • 手势的状态管理和数据处理
  • 组合手势的实现方法(.simultaneousGesture
  • 手势的高级应用技巧(优先级控制、自定义识别)

通过这些手势,可以使应用界面更加交互友好,提升用户体验。在实际开发中,合理使用手势可以为应用增添交互性,使界面操作更加直观自然。


参考资料


本内容为《SwiftUI 进阶》第三章,欢迎关注后续更新。

《SwiftUI 进阶学习第2章:动画与过渡》

学习目标

  • 掌握 SwiftUI 中的基本动画实现
  • 了解不同类型的动画效果
  • 学习如何创建组合动画
  • 掌握过渡效果的使用方法
  • 了解不同动画曲线的特点

核心概念

动画基础

在 SwiftUI 中,动画是通过 withAnimation 函数来实现的,它可以将状态变化包装在动画中,使 UI 变化更加平滑自然。

withAnimation {
    // 状态变化
}

动画类型

1. 淡入淡出动画

淡入淡出动画通过改变视图的不透明度来实现,可以使用 .transition(.opacity) 修饰符。

struct FadeAnimationDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("显示/隐藏") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("Hello, Animation!")
                    .transition(.opacity)
            }
        }
    }
}

2. 缩放动画

缩放动画通过改变视图的缩放比例来实现,可以使用 .scaleEffect() 修饰符。

struct ScaleAnimationDemo: View {
    @State private var scale = 1.0
    
    var body: some View {
        VStack {
            Button("缩放") {
                withAnimation(.spring()) {
                    scale = scale == 1.0 ? 1.5 : 1.0
                }
            }
            
            Circle()
                .fill(.red)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
        }
    }
}

3. 旋转动画

旋转动画通过改变视图的旋转角度来实现,可以使用 .rotationEffect() 修饰符。

struct RotationAnimationDemo: View {
    @State private var rotation = 0.0
    
    var body: some View {
        VStack {
            Button("旋转") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    rotation += 360
                }
            }
            
            Rectangle()
                .fill(.yellow)
                .frame(width: 100, height: 100)
                .rotationEffect(.degrees(rotation))
        }
    }
}

4. 位移动画

位移动画通过改变视图的位置来实现,可以使用 .offset() 修饰符。

struct OffsetAnimationDemo: View {
    @State private var offset = CGSize.zero
    
    var body: some View {
        VStack {
            Button("移动") {
                withAnimation(.interactiveSpring()) {
                    offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                }
            }
            
            Rectangle()
                .fill(.blue)
                .frame(width: 100, height: 100)
                .offset(offset)
        }
    }
}

5. 颜色动画

颜色动画通过改变视图的颜色来实现,可以直接动画化颜色属性。

struct ColorAnimationDemo: View {
    @State private var color = Color.blue
    
    var body: some View {
        VStack {
            Button("变色") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    color = color == .blue ? .red : .blue
                }
            }
            
            Rectangle()
                .fill(color)
                .frame(width: 200, height: 100)
                .cornerRadius(10)
        }
    }
}

6. 组合动画

组合动画是将多种动画效果结合在一起,可以同时应用多个动画修饰符。

struct CombinedAnimationDemo: View {
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    
    var body: some View {
        VStack {
            Button("组合动画") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    scale = scale == 1.0 ? 1.2 : 1.0
                    rotation = rotation == 0 ? 45 : 0
                    opacity = opacity == 1.0 ? 0.5 : 1.0
                }
            }
            
            Rectangle()
                .fill(.green)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
                .rotationEffect(.degrees(rotation))
                .opacity(opacity)
        }
    }
}

过渡效果

过渡效果是在视图出现或消失时应用的动画,可以使用 .transition() 修饰符。

struct TransitionDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("切换视图") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("滑入视图")
                    .transition(.slide)
            }
        }
    }
}

SwiftUI 提供了多种内置过渡效果:

过渡效果 描述
.opacity 淡入淡出
.slide 从边缘滑入/滑出
.scale 缩放出现/消失
.move(edge:) 从指定方向移动
.asymmetric(insertion:removal:) 不对称过渡(出现和消失用不同效果)

动画曲线

动画曲线定义了动画的速度变化,可以使用不同的动画曲线来实现不同的视觉效果。

常用动画曲线

曲线 描述
.linear 线性动画,速度保持不变
.easeIn 缓入动画,开始慢,逐渐加快
.easeOut 缓出动画,开始快,逐渐减慢
.easeInOut 缓入缓出动画,开始慢,中间快,结束慢
.spring() 弹簧动画,有弹性效果
.interactiveSpring() 交互式弹簧动画,响应更灵敏

示例代码

// 线性动画
withAnimation(.linear(duration: 1.0)) {
    // 动画代码
}

// 弹簧动画
withAnimation(.spring(response: 0.6, dampingFraction: 0.8, blendDuration: 0)) {
    // 动画代码
}

// 可重复动画
withAnimation(.easeInOut(duration: 1.0).repeatCount(3, autoreverses: true)) {
    // 动画代码
}

实践示例:完整动画演示

以下是一个完整的动画演示示例,包含了各种动画类型和过渡效果:

import SwiftUI

struct AnimationAndTransitionDemo: View {
    // 状态管理
    @State private var isVisible = false
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    @State private var offset = CGSize.zero
    @State private var color = Color.blue
    @State private var selectedCurve = "linear"
    
    let curveOptions = ["linear", "easeIn", "easeOut", "easeInOut", "spring"]
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("动画与过渡")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 动画曲线选择
                VStack {
                    Text("动画曲线选择")
                        .font(.headline)
                    Picker("曲线", selection: $selectedCurve) {
                        ForEach(curveOptions, id: \.self) { option in
                            Text(option).tag(option)
                        }
                    }
                    .pickerStyle(.segmented)
                }
                
                // 淡入淡出动画
                VStack {
                    Text("1. 淡入淡出")
                        .font(.headline)
                    Button("显示/隐藏") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("Hello, Animation!")
                            .padding()
                            .background(Color.orange)
                            .cornerRadius(8)
                            .transition(.opacity)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 缩放动画
                VStack {
                    Text("2. 缩放动画")
                        .font(.headline)
                    Button("缩放") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.5 : 1.0
                        }
                    }
                    Circle()
                        .fill(.red)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 旋转动画
                VStack {
                    Text("3. 旋转动画")
                        .font(.headline)
                    Button("旋转") {
                        withAnimation(getAnimation()) {
                            rotation += 360
                        }
                    }
                    Rectangle()
                        .fill(.yellow)
                        .frame(width: 80, height: 80)
                        .rotationEffect(.degrees(rotation))
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 位移动画
                VStack {
                    Text("4. 位移动画")
                        .font(.headline)
                    Button("移动") {
                        withAnimation(getAnimation()) {
                            offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                        }
                    }
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 80, height: 80)
                        .offset(offset)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 颜色动画
                VStack {
                    Text("5. 颜色动画")
                        .font(.headline)
                    Button("变色") {
                        withAnimation(getAnimation()) {
                            color = color == .blue ? .red : .blue
                        }
                    }
                    Rectangle()
                        .fill(color)
                        .frame(width: 150, height: 80)
                        .cornerRadius(10)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 组合动画
                VStack {
                    Text("6. 组合动画")
                        .font(.headline)
                    Button("组合动画") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.2 : 1.0
                            rotation = rotation == 0 ? 45 : 0
                            opacity = opacity == 1.0 ? 0.5 : 1.0
                        }
                    }
                    Rectangle()
                        .fill(.green)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .opacity(opacity)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 过渡效果
                VStack {
                    Text("7. 过渡效果(Slide)")
                        .font(.headline)
                    Button("切换视图") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("滑入视图")
                            .padding()
                            .background(Color.purple)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                            .transition(.slide)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 根据选择的曲线返回对应的动画
    private func getAnimation() -> Animation {
        switch selectedCurve {
        case "linear":
            return .linear(duration: 0.8)
        case "easeIn":
            return .easeIn(duration: 0.8)
        case "easeOut":
            return .easeOut(duration: 0.8)
        case "easeInOut":
            return .easeInOut(duration: 0.8)
        case "spring":
            return .spring(response: 0.6, dampingFraction: 0.7)
        default:
            return .easeInOut(duration: 0.8)
        }
    }
}

#Preview {
    AnimationAndTransitionDemo()
}

常见问题与解决方案

1. 动画不生效

问题:状态变化了,但没有动画效果。

解决方案:确保状态变化被包裹在 withAnimation 函数中。

// 错误 ❌
isVisible.toggle()

// 正确 ✅
withAnimation {
    isVisible.toggle()
}

2. 动画效果不符合预期

问题:动画效果不够流畅或不符合预期。

解决方案:尝试使用不同的动画曲线,如 .spring().easeInOut(),并调整动画时长。

// 使用弹簧动画获得更自然的弹性效果
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) {
    // 状态变化
}

3. 过渡效果不显示

问题:视图出现或消失时没有过渡效果。

解决方案:确保为视图添加了 .transition() 修饰符,并且状态变化在 withAnimation 中。

if isVisible {
    Text("Hello")
        .transition(.slide)  // 必须添加 transition
}

4. 动画卡顿或掉帧

问题:动画执行时界面卡顿。

解决方案

  • 避免在动画中同时改变过多属性
  • 对于复杂视图,考虑使用 .drawingGroup() 优化渲染
  • 确保动画中不执行耗时操作

总结

本章介绍了 SwiftUI 中的动画与过渡效果,包括:

  • 基本动画类型:淡入淡出、缩放、旋转、位移、颜色动画
  • 组合动画:同时应用多种动画效果
  • 过渡效果:视图出现/消失时的动画(.transition
  • 动画曲线:线性、缓入、缓出、弹簧等不同速度曲线
  • 实践示例:完整的动画演示应用

通过这些动画效果,可以使应用界面更加生动有趣,提升用户体验。在实际开发中,合理使用动画可以为应用增添活力,使界面交互更加自然流畅。


参考资料


本内容为《SwiftUI 高级教程》第二章,欢迎关注后续更新。

《SwiftUI 进阶学习第1章:高级视图组件》

Snip20260418_5.png

概述

本章介绍 SwiftUI 中的高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组等。这些组件可以帮助您构建更加丰富和交互性更强的用户界面。

学习目标

  • 掌握各种高级视图组件的使用方法
  • 了解如何配置和自定义这些组件
  • 能够在实际项目中应用这些组件

核心组件

1. 日期选择器 (DatePicker)

功能说明

  • 可以只显示日期部分
  • 可以同时显示日期和时间
  • 支持多种显示样式

代码示例

DatePicker(
    "选择日期",
    selection: $selectedDate,
    displayedComponents: .date
)
.padding()
.background(.gray.opacity(0.1))
.cornerRadius(10)

2. 时间选择器 (DatePicker)

功能说明

  • 可以只显示小时和分钟
  • 支持24小时和12小时制

代码示例

DatePicker(
    "选择时间",
    selection: $selectedTime,
    displayedComponents: .hourAndMinute
)
.padding()
.background(.blue.opacity(0.1))
.cornerRadius(10)

3. 分段控件 (Picker)

功能说明

  • 使用 .segmented 样式
  • 支持多个选项
  • 可以绑定到状态变量

代码示例

Picker("选择选项", selection: .constant(0)) {
    Text("选项1").tag(0)
    Text("选项2").tag(1)
    Text("选项3").tag(2)
}
.pickerStyle(.segmented)
.padding()
.background(.green.opacity(0.1))
.cornerRadius(10)

4. 滑块 (Slider)

功能说明

  • 可以设置最小值和最大值
  • 支持步长
  • 可以显示当前值

代码示例

HStack {
    Text("音量: \(Int(progress * 100))%")
    Slider(value: $progress, in: 0...1)
}
.padding()
.background(.yellow.opacity(0.1))
.cornerRadius(10)

5. 步进器 (Stepper)

功能说明

  • 可以设置最小值、最大值和步长
  • 可以显示当前值
  • 支持自定义标签

代码示例

Stepper(
    "数量: \(Int(progress * 10))",
    value: $progress,
    in: 0...1,
    step: 0.1
)
.padding()
.background(.purple.opacity(0.1))
.cornerRadius(10)

6. 活动指示器 (ProgressView)

功能说明

  • 可以显示文本
  • 可以设置样式
  • 适合在异步操作时使用

代码示例

if isPlaying {
    ProgressView("加载中...")
        .padding()
}

7. 进度视图 (ProgressView)

功能说明

  • 可以设置当前值和总值
  • 支持动画效果
  • 适合显示下载、上传等进度

代码示例

ProgressView(value: progress)
    .padding()

Button("更新进度") {
    withAnimation {
        progress = progress < 1.0 ? progress + 0.1 : 0.0
    }
}

8. 列表分组 (List)

功能说明

  • 支持多个分组
  • 可以添加分组标题
  • 适合显示分类数据

代码示例

List {
    Section(header: Text("水果")) {
        Text("苹果")
        Text("香蕉")
        Text("橙子")
    }
    
    Section(header: Text("蔬菜")) {
        Text("西红柿")
        Text("黄瓜")
        Text("土豆")
    }
}
.frame(height: 200)

综合示例

以下是一个完整的高级视图组件演示:

struct AdvancedViewsDemo: View {
    // 状态管理
    @State private var selectedDate = Date()
    @State private var selectedTime = Date()
    @State private var isPlaying = false
    @State private var progress = 0.0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // 标题
                Text("高级视图组件")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 日期选择器
                VStack {
                    Text("1. 日期选择器")
                        .font(.headline)
                        .fontWeight(.semibold)
                    
                    DatePicker(
                        "选择日期",
                        selection: $selectedDate,
                        displayedComponents: .date
                    )
                    .padding()
                    .background(.gray.opacity(0.1))
                    .cornerRadius(10)
                }
                
                // 其他组件...
            }
            .padding()
        }
    }
}

最佳实践

  1. 响应式设计:确保组件在不同屏幕尺寸上都能正常显示
  2. 用户体验:为组件添加适当的标签和提示
  3. 性能优化:对于复杂列表,考虑使用 ListForEach 的性能优化技巧
  4. 可访问性:确保组件支持 VoiceOver 等辅助功能
  5. 自定义样式:根据应用的设计风格自定义组件的外观

总结

本章介绍了 SwiftUI 中的各种高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组。这些组件是构建现代 iOS 应用界面的重要工具,掌握它们的使用方法对于开发高质量的 SwiftUI 应用至关重要。

通过本章的学习,您应该能够:

  • 熟练使用各种高级视图组件
  • 根据实际需求配置和自定义组件
  • 构建具有良好用户体验的界面
  • 应用最佳实践来提高应用质量

参考资料


本内容为《SwiftUI 进阶学习》第一章,欢迎关注后续更新。

iOS 线程常驻(RunLoop 保活)实战:原理、优劣、避坑与双语言实现

作为 iOS 资深开发,线程常驻是底层线程开发的高阶技能,核心用于高频轻量任务、音视频数据流、长连接等极致性能场景。它的本质是通过 RunLoop 保活子线程,让线程执行完任务后不销毁,一直等待新任务。

本文将从核心原理、优劣分析、生产级高级写法、避免方案四个维度深度拆解,并提供 Objective-C + Swift 双语言完整示例。


一、核心原理:线程常驻的底层逻辑

1. 默认线程生命周期

iOS 普通子线程(NSThread/pthread)执行流程:创建线程 → 执行任务 → 任务完成 → 线程自动销毁缺点:频繁创建 / 销毁线程会产生巨大性能开销。

2. 线程常驻核心机制

RunLoop 保活:给子线程绑定一个无限循环的 RunLoop,添加空输入源防止 RunLoop 立即退出,让线程进入休眠状态(不消耗 CPU),实现永久存活。

  • 关键 API:CFRunLoopAddSource(添加保活源)、CFRunLoopRun(启动循环)、CFRunLoopStop(停止循环)
  • 核心:RunLoop 不退出 → 线程不销毁

3. 适用边界

仅用于高频、轻量、低延迟任务(日志上报、埋点、音视频编解码、长连接心跳);普通业务绝对禁止使用。


二、线程常驻的 优势 VS 劣势(资深视角)

✅ 核心优势

  1. 极致性能:避免线程频繁创建 / 销毁(线程是操作系统重量级资源,创建耗时≈100ms)
  2. 低延迟响应:任务直达常驻线程,无线程创建耗时
  3. 资源可控:专用线程处理特定任务,不与业务线程竞争
  4. 长连接保活:网络长连接、音视频流必须用常驻线程保证链路不中断

❌ 致命劣势

  1. 内存泄漏风险:忘记停止 RunLoop → 线程永久驻留内存,无法释放
  2. 系统资源浪费:常驻线程会占用系统线程池配额,过多会导致 APP 卡顿
  3. 维护成本极高:手动管理 RunLoop、线程安全、生命周期,极易出现死锁 / 野指针
  4. 违背系统设计:GCD/NSOperation 已自动实现线程复用,手动常驻是兜底方案

三、线程常驻 高级写法(生产级封装)

基础版仅用于理解原理,工程中必须用高级封装版:单例复用、线程安全任务队列、优雅退出、无内存泄漏。线程常驻仅支持 NSThread(pthread),GCD 无法手动实现常驻(系统自动管理线程)。

方案 1:Objective-C 高级常驻线程

objectivec

#import <Foundation/Foundation.h>

@interface ResidentThread : NSObject
/// 单例全局常驻线程
+ (instancetype)sharedThread;
/// 异步执行任务
- (void)executeTask:(dispatch_block_t)task;
/// 优雅退出线程(必须调用,防止内存泄漏)
- (void)stopThread;
@end

// ====================== 实现 ======================
#import "ResidentThread.h"

@interface ResidentThread ()
@property (nonatomic, strong) NSThread *residentThread; // 常驻线程
@property (nonatomic, assign) BOOL isStopped;            // 退出标记
@property (nonatomic, strong) NSLock *lock;               // 线程安全锁
@property (nonatomic, strong) NSMutableArray *taskArray; // 任务队列
@end

@implementation ResidentThread

+ (instancetype)sharedThread {
    static ResidentThread *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _isStopped = NO;
        _lock = [[NSLock alloc] init];
        _taskArray = [NSMutableArray array];
        // 创建常驻线程
        __weak typeof(self) weakSelf = self;
        self.residentThread = [[NSThread alloc] initWithTarget:weakSelf selector:@selector(runLoopAction) object:nil];
        self.residentThread.name = @"com.app.resident.thread";
        [self.residentThread start];
    }
    return self;
}

/// RunLoop 保活核心方法
- (void)runLoopAction {
    @autoreleasepool {
        // 1. 添加空输入源,防止RunLoop立即退出
        CFRunLoopSourceContext context = {0};
        CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
        CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        CFRelease(source);
        
        // 2. 启动RunLoop循环(休眠状态,不消耗CPU)
        while (!self.isStopped) {
            // 执行队列中的任务
            [self.lock lock];
            if (self.taskArray.count > 0) {
                dispatch_block_t task = self.taskArray.firstObject;
                [self.taskArray removeObjectAtIndex:0];
                task();
            }
            [self.lock unlock];
            
            // RunLoop 运行1秒,循环检测
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, NO);
        }
        
        // 3. 停止RunLoop,线程销毁
        CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
        NSLog(@"常驻线程已销毁");
    }
}

/// 异步添加任务
- (void)executeTask:(dispatch_block_t)task {
    if (!task || self.isStopped) return;
    [self.lock lock];
    [self.taskArray addObject:task];
    [self.lock unlock];
}

/// 优雅退出
- (void)stopThread {
    if (self.isStopped) return;
    self.isStopped = YES;
    // 停止RunLoop
    CFRunLoopStop(CFRunLoopGetCurrent());
    self.residentThread = nil;
}

@end

方案 2:Swift 高级常驻线程

swift

import Foundation

final class ResidentThread {
    // 单例
    static let shared = ResidentThread()
    private init() {
        self.setupThread()
    }
    
    // MARK: - 私有属性
    private var thread: Thread!
    private var isStopped = false
    private let lock = NSLock()
    private var taskArray = [() -> Void]()
    
    // MARK: - 初始化常驻线程
    private func setupThread() {
        thread = Thread(target: self, selector: #selector(runLoopAction), object: nil)
        thread.name = "com.app.resident.thread.swift"
        thread.start()
    }
    
    // MARK: - RunLoop 保活核心
    @objc private func runLoopAction() {
        autoreleasepool {
            // 1. 添加空源,防止RunLoop退出
            let context = CFRunLoopSourceContext()
            let source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, context)
            CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
            
            // 2. 循环执行任务
            while !isStopped {
                lock.lock()
                if !taskArray.isEmpty {
                    let task = taskArray.removeFirst()
                    task()
                }
                lock.unlock()
                
                // RunLoop 休眠1秒,低功耗
                CFRunLoopRunInMode(.defaultMode, 1.0, false)
            }
            
            // 3. 清理资源
            CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .defaultMode)
            print("Swift 常驻线程已销毁")
        }
    }
    
    // MARK: - 公开API
    /// 执行任务
    func execute(task: @escaping () -> Void) {
        guard !isStopped else { return }
        lock.lock()
        taskArray.append(task)
        lock.unlock()
    }
    
    /// 优雅退出
    func stop() {
        guard !isStopped else { return }
        isStopped = true
        CFRunLoopStop(CFRunLoopGetCurrent())
    }
}

双语言使用示例

objectivec

// OC 使用
- (void)testResidentThread {
    // 执行任务
    [[ResidentThread sharedThread] executeTask:^{
        NSLog(@"OC 常驻线程执行任务:%@", [NSThread currentThread]);
    }];
    
    // 页面销毁/模块销毁时,必须调用退出!
    // [[ResidentThread sharedThread] stopThread];
}

swift

// Swift 使用
func testResidentThread() {
    // 执行任务
    ResidentThread.shared.execute {
        print("Swift 常驻线程执行任务:Thread.current)")
    }
    
    // 必须在合适时机退出
    // ResidentThread.shared.stop()
}

四、如何避免线程常驻?(最优工程实践)

99% 的业务场景,完全不需要手动实现线程常驻!苹果的 GCD / NSOperation 已经内置了线程池复用机制,系统自动管理线程生命周期,比手动常驻更安全、更高效。

替代方案 1:GCD 串行队列(系统自动复用线程)

GCD 会复用空闲线程,不会频繁创建 / 销毁,完美替代手动常驻线程。

objectivec

// OC:GCD 复用线程(推荐)
dispatch_queue_t serialQueue = dispatch_queue_create("com.app.gcd.serial", DISPATCH_QUEUE_SERIAL);
- (void)gcdTask {
    dispatch_async(serialQueue, ^{
        NSLog(@"GCD 复用线程:%@", [NSThread currentThread]);
    });
}

swift

// Swift:GCD 复用线程
private let serialQueue = DispatchQueue(label: "com.app.gcd.serial.swift")
func gcdTask() {
    serialQueue.async {
        print("GCD 复用线程:Thread.current)")
    }
}

替代方案 2:NSOperationQueue(可控并发)

swift

// Swift 操作队列
private let operationQueue = OperationQueue()
init() {
    operationQueue.maxConcurrentOperationCount = 1 // 串行复用
}
func operationTask() {
    let op = BlockOperation {
        print("NSOperation 复用线程")
    }
    operationQueue.addOperation(op)
}

避免线程常驻的核心原则

  1. 普通业务 → 用 GCD:系统自动线程复用,零维护成本
  2. 复杂任务 → 用 NSOperation:支持依赖 / 取消,自动管理线程
  3. 绝对禁止:无理由创建手动常驻线程
  4. 必须用常驻:仅音视频、长连接、低延迟心跳等极致场景

五、关键避坑指南

  1. 必须优雅退出:页面 / 模块销毁时,一定要调用 stopThread 停止 RunLoop,否则内存泄漏
  2. 禁止多开:整个 APP 最多创建 1~2 个 常驻线程,过多会耗尽系统线程资源
  3. 线程安全:任务队列必须加锁,防止多线程读写崩溃
  4. 禁止 UI 操作:常驻线程是子线程,绝对不能更新 UI
  5. 低功耗设计:RunLoop 使用 RunInMode 定时休眠,不要无限循环消耗 CPU

总结

  1. 核心原理:线程常驻 = RunLoop 保活,是底层性能优化方案
  2. 高级写法:生产级必须封装单例 + 线程安全队列 + 优雅退出
  3. 优劣:性能极致但风险极高,仅用于特殊场景
  4. 最优解优先用 GCD/NSOperation,系统自动线程复用,避免手动常驻
  5. 生命周期:常驻线程必须手动退出,否则永久泄漏

自定义导航栏的深度实践:从视觉需求到架构设计

引言:当标准组件无法满足设计灵魂

在iOS开发中,UINavigationController及其配套的导航栏(UINavigationBar)为应用提供了基础的页面栈管理和统一的头部导航体验。然而,当产品设计追求沉浸感、个性化视觉或复杂的滚动交互时,这套标准组件往往会成为束缚。开发者们常常面临一个抉择:是费力地扭曲系统导航栏的默认样式,还是彻底抛弃它,从头开始构建一个自定义的导航视图?

回顾一段真实的开发对话,需求非常具体:在一个内容详情页,导航栏初始时需要完全透明,与背景图融为一体;随着用户向下滚动,导航栏背景应逐渐显现,最终形成一个固定的、不透明的头部,营造出类似系统导航栏的滚动效果。这个需求看似只是一个UI效果,实则触及了iOS界面架构中关于视图层级管理、滚动交互协调、视觉连续性以及代码组织的深层课题。本文将深入剖析这一案例,探讨如何从简单的视觉需求出发,设计出既满足效果又具备良好架构的自定义导航方案。

一、需求拆解:透明、渐变与固顶——效果背后的技术要点

首先,我们必须清晰理解这个滚动效果的技术本质。它并非一个简单的“显示/隐藏”切换,而是一个基于滚动偏移量的连续动画过程。这要求我们至少解决以下几个问题:

  1. 视觉叠加与透明:初始状态下,导航栏区域的按钮和标题必须可见,但其背景视图必须是透明的,以便其下方的背景图(或内容)能够透出。
  2. 滚动监听:需要精确监听UIScrollView(或UITableView、UICollectionView)的contentOffset.y变化,并将其映射到导航栏背景的透明度(alpha)上。
  3. 视图层级(Z-Index)‍:导航栏背景、内容滚动视图、导航栏上的按钮和标题,这三者必须有正确的叠加顺序。按钮和标题必须始终位于最上层,确保可交互性;背景视图位于它们之下、内容视图之上。
  4. 布局与安全区:自定义导航栏需要正确适配刘海屏、灵动岛等设备的安全区域,避免内容被遮挡。

最初的实现方案是在viewDidLoad中直接隐藏了系统导航栏
navigationController?.setNavigationBarHidden(true, animated: true)),这标志着我们选择了完全自定义的道路。随后,代码构建了一个独立的navBarBackground视图(一个UIView)作为背景,并通过scrollViewDidScroll代理方法,根据滚动偏移量offsetY与一个阈值(threshold)计算alpha值,实现渐变效果。核心逻辑如下:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let offsetY = scrollView.contentOffset.y
    let threshold: CGFloat = 100 // 滚动多少开始完全显示
    let alpha = min(1, max(0, offsetY / threshold))
    navBarBackground.alpha = alpha
}

这段代码简洁地实现了核心交互逻辑,但它仅仅是故事的开始。当我们把这段代码嵌入一个真实的、复杂的视图控制器时,大量细节问题会浮现出来。

二、方案演化:从“能用的代码”到“健壮的实现”

初始实现常将导航栏的所有元素(背景、按钮、标题)的创建和布局,都堆砌在控制器的setupUI()方法里。这种方式虽然直接,却为维护和复用埋下隐患。

更优雅的做法是进行组件化封装。创建一个CustomNavigationBar类,它内部管理着自己的子视图,并对外提供清晰的配置接口。

class GradientNavigationBar: UIView {
    private let backgroundView = UIView()
    let backButton = UIButton(type: .system)
    let titleLabel = UILabel()
    
    var backgroundAlpha: CGFloat = 0 {
        didSet {
            backgroundView.alpha = backgroundAlpha
            // 可根据alpha同步调整标题颜色,确保可读性
            titleLabel.textColor = backgroundAlpha > 0.5 ? .black : .white
        }
    }
    
    // ... 初始化、布局方法
}

在视图控制器中,我们只需初始化并添加这个组件,然后在滚动回调中更新其backgroundAlpha属性。这种封装将变化隔离在组件内部,控制器代码变得清晰,也便于在其他页面复用。

三、核心挑战与解决方案:布局、安全区与性能

1. 视图层级与布局策略

正确的视图层级是效果的基础。下图展示了推荐的自定义导航栏与内容视图的层级及布局关系:

image.png 关键点在于:
- 导航栏定位:CustomNavigationBar的顶部应与安全区顶部对齐,确保控件不被遮挡。
- 内容视图定位:UIScrollView的顶部应与self.view的顶部对齐(可忽略安全区),以实现内容从屏幕最顶部开始的沉浸效果。
- 内容插入:通过设置scrollView.contentInset.top = navBar.height,为滚动内容预留出导航栏控件所占的空间,避免初始状态下的文字被遮挡。

2. 安全区处理的陷阱

这是自定义导航栏最容易出错的地方。我们需要让导航栏的交互控件位于安全区内,但让它的背景视觉能够向上延伸到状态栏区域。这通常通过将背景视图的顶部约束设置为superview.top(而非安全区顶部),同时确保背景视图位于控件层之下来实现。

3. 滚动协调的精度与性能

scrollViewDidScroll调用非常频繁,计算必须高效。透明度计算公式 alpha = min(1, max(0, offsetY / threshold)) 的映射关系如下图所示:

image.png阈值(Threshold)‍ 的选择至关重要。它通常与设计意图相关,例如,可以设置为背景图的高度减去导航栏高度,这样导航栏背景恰好在地图完全滚出屏幕时变为不透明。

四、架构升华:从直接操作到状态驱动

当交互逻辑变得复杂(例如,滚动到不同区域还需改变标题颜色、右侧按钮样式),在scrollViewDidScroll中直接操作各个UI属性会使代码迅速变得难以维护。此时,应引入状态驱动的思维。

我们可以定义一个描述导航栏视觉状态的数据模型,并将滚动偏移量等原始输入,转化为状态的变化。

struct NavigationBarState {
    var backgroundColorAlpha: CGFloat
    var titleColor: UIColor
    var barStyle: UIBarStyle // 用于影响状态栏样式
}

// 在视图模型中
func handleScrollOffset(_ offsetY: CGFloat) {
    let newAlpha = min(1, max(0, offsetY / threshold))
    let newState = NavigationBarState(
        backgroundColorAlpha: newAlpha,
        titleColor: newAlpha > 0.5 ? .black : .white,
        barStyle: newAlpha > 0.5 ? .default : .black
    )
    currentState = newState // 触发UI更新
}

视图或组件则订阅此状态,并做出响应。这种模式将状态计算逻辑与UI渲染逻辑分离,带来了显著优势:
- 可测试性:状态计算逻辑是纯函数,易于单元测试。
- 可维护性:添加新的视觉规则(如滚动到一半时改变按钮图标)只需修改状态计算逻辑,UI渲染代码保持稳定。
- 一致性:状态是唯一真相源,避免了多个UI属性在复杂交互下可能出现的状态不一致。

下图描绘了这种状态驱动的单向数据流架构:

image.png

## 五、总结:在规范与自由之间寻找工程平衡 实现一个“滚动渐变显示背景”的自定义导航栏,是一个绝佳的微观样本。它迫使我们在系统规范带来的便利与自定义需求带来的自由之间,做出工程化的权衡。

我们的技术决策路径通常是:
1. 评估:首先尝试用UINavigationBarAppearance等系统API进行深度定制,看是否能满足需求。
2. 抉择:当系统API无法实现时,果断选择完全自定义。
3. 设计:以组件化思维构建自定义导航栏,明确其接口和职责。
4. 加固:细致处理安全区、约束、滚动协调等细节,确保鲁棒性。
5. 升华:在复杂度上升时,引入状态驱动等架构思想,提升代码的可维护性和可扩展性。

最终目标始终如一:在实现惊艳视觉体验的同时,构建出干净、坚固、易于理解的代码结构。这不仅是满足一个需求,更是在塑造我们作为开发者的工程素养。在接下来的文章中,我们将把这种对“架构”和“边界”的思考,带入第三方SDK的集成领域,探讨如何在享受便利的同时,牢牢掌控自己应用的命运。

超越Toast:构建优雅的UI反馈与异步协调机制

引言:从一个“小需求”引发的架构思考

在iOS应用开发中,我们似乎总在追逐宏大的架构模式与炫酷的技术框架,却常常忽略了那些日复一日、看似微不足道的代码细节。正是这些细节,如同精密仪器中的齿轮,其啮合的好坏直接决定了整个应用运行的流畅度与用户体验的细腻感。一次真实的开发对话记录,将我引向了对其中一个“齿轮”的深度审视:一个为Toast.showSuccess()方法添加completion回调的需求。

这个需求的背景简单而普遍:用户修改信息后,界面需要显示“操作成功”的提示,并在提示完全消失后,自动刷新页面数据。最初的实现却存在一个隐蔽的缺陷——Toast的消失动画与数据刷新操作是并发的,这可能导致视觉上的割裂甚至潜在的逻辑错误。这个看似只需几行代码就能解决的“小问题”,实则像一面棱镜,折射出iOS开发中关于异步操作时序协调、UI反馈生命周期管理以及业务逻辑与副作用分离等一系列核心课题。本文将以此为起点,层层深入,探讨如何从简单的功能实现,演进到构建一套优雅、健壮的应用状态协调机制。

一、微观剖析:Toast回调需求背后的时序陷阱让我们首先重现问题场景。

一个典型的网络请求与UI反馈流程如下:

APIClient.shared.changeUserInfo(username: newName) { userInfo in
    // 网络请求成功回调
    Toast.showSuccess() // 显示成功提示,1.5秒后自动消失
    self.loadUserInfo() // 立即执行数据刷新
}

对应的Toast工具类可能基于SVProgressHUD封装:

static func showSuccess(_ status: String = “操作成功“.localized) {
    DispatchQueue.main.async {
        SVProgressHUD.setDefaultStyle(.dark)
        SVProgressHUD.showSuccess(withStatus: status)
        SVProgressHUD.dismiss(withDelay: 1.5) // 异步延迟消失
    }
}

隐患分析:

1. 视觉竞态: Toast.showSuccess()内部的dismiss(withDelay:)启动了一个为期1.5秒的异步动画。而self.loadUserInfo()会立即执行,可能包含复杂的UI渲染(如tableView.reloadData())。这导致提示尚在淡出,下方内容已骤然变化,用户体验不连贯。

2. 逻辑耦合: 业务逻辑(刷新数据)与UI表现(显示Toast)被紧耦合地顺序书写,但二者在时间维度上缺乏明确的依赖关系声明。代码的“字面顺序”无法准确表达开发者“逻辑顺序”的意图。

3. 可维护性风险: 如果未来需要在Toast消失后执行更多操作(如跳转页面、发送分析事件),我们将不得不深入这个网络请求的成功回调块内进行修改,违反了开闭原则。

问题的本质是:我们将一个本应串行化的、具有明确前后依赖关系的流程(显示Toast → Toast消失 → 执行后续操作),用并发的、无协调的方式实现了。 对话中给出的解决方案直接而有效——为showSuccessshowError方法增加completion闭包参数,并在HUD的dismiss回调中触发它。

static func showSuccess(_ status: String = “操作成功“.localized, completion: (() -> Void)? = nil) {
    DispatchQueue.main.async {
        SVProgressHUD.setDefaultStyle(.dark)
        SVProgressHUD.showSuccess(withStatus: status)
        // 关键:将completion传递给dismiss的回调
        SVProgressHUD.dismiss(withDelay: 1.5, completion: completion)
    }
}

改进后的调用方式清晰且可靠:

Toast.showSuccess {
    self.loadUserInfo() // ✅ 确保在Toast完全消失后执行
}

这一改进虽小,却意义重大:它赋予了UI组件明确的“生命周期”概念。 Toast不再只是一个“显示然后不管”的静态视图,而是一个能通知外部其“任务何时真正完成”的主动参与者。这标志着我们的代码从“命令式”思维(一步步执行指令)开始向“响应式”或“声明式”思维(描述状态与副作用的关系)过渡。

二、中观演进:从离散回调到统一状态管理

为单个Toast添加回调解决了眼前的问题,但在复杂的业务场景中,我们会发现自身陷入了“回调地狱”的泥潭。考虑一个电商应用的订单提交流程:

  1. 提交订单,显示“提交中”Loading。
  2. 提交成功,显示“提交成功”Toast。
  3. Toast消失后,开始倒计时跳转到订单详情页。
  4. 若用户在此期间点击了Toast区域的某个按钮,则取消跳转,执行其他操作。

如果只用嵌套回调来写,代码将难以阅读和维护。此时,我们需要一个更高维度的抽象来管理整个流程——状态(State)‍

我们可以为这个提交场景定义一个状态枚举:

enum OrderSubmissionState {
    case idle
    case submitting
    case success(message: String)
    case failure(error: Error)
    case navigating(countdown: Int)
    case cancelled
}

这个状态机清晰地描述了整个流程可能处于的所有阶段。接下来,我们可以创建一个状态管理器(如一个ViewModel),其内部持有当前状态,并允许外部订阅状态变化:

class OrderSubmissionViewModel {
    private(set) var currentState: OrderSubmissionState = .idle {
        didSet { stateDidChange?(currentState) }
    }
    var stateDidChange: ((OrderSubmissionState) -> Void)?
    
    func submitOrder() {
        currentState = .submitting
        APIClient.shared.submitOrder { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success:
                    self?.currentState = .success(message: “订单提交成功“)
                    // 状态变为success后,触发Toast显示,并在其completion中触发下一步状态变迁
                case .failure(let error):
                    self?.currentState = .failure(error: error)
                }
            }
        }
    }
    
    func cancelNavigation() {
        if case .navigating = currentState {
            currentState = .cancelled
        }
    }
}

在视图控制器中,我们不再直接指挥每一个UI动作,而是响应状态的变化:

viewModel.stateDidChange = { [weak self] state in
    self?.render(for: state)
}

private func render(for state: OrderSubmissionState) {
    switch state {
    case .submitting:
        Toast.showLoading(“提交中...“)
    case .success(let message):
        Toast.showSuccess(message) { [weak self] in
            // Toast消失后,驱动状态进入下一个阶段
            self?.viewModel.startCountdown()
        }
    case .navigating(let countdown):
        updateCountdownLabel(countdown)
    case .failure(let error):
        Toast.showError(error.localizedDescription)
    case .idle, .cancelled:
        // 重置UI
        break
    }
}

通过引入状态机,我们获得了以下优势:

- 逻辑清晰: 业务规则(状态如何转换)集中管理,一目了然。
- UI与逻辑解耦: 视图层只负责根据状态渲染,不关心状态如何变化。
- 可测试性增强: 可以轻松模拟各种状态,测试UI渲染是否正确。
- 易于扩展: 新增一个状态(如“部分成功需确认”)或状态转换路径,对现有代码影响最小。

下图展示了从“离散回调”模式到“状态驱动”模式的架构演变:

image.png

三、宏观视野:将状态管理思维融入应用架构

Toast与状态机的故事并未结束。当我们把目光从单个页面移开,审视整个应用时,会发现类似的“状态同步”问题无处不在。在另一段关于“关注/取消关注”功能实现的对话中,就深刻体现了这一点iOS开发†12。

最初,点击关注按钮后,需要手动刷新整个列表才能看到状态(如变为“互相关注”)更新iOS开发†12。这显然体验不佳。优化方案是,在网络请求成功后,立即在本地更新对应的数据模型,并刷新该特定单元格的UIiOS开发†12。这本质上就是一次局部状态的同步。更进一步的方案是,在请求成功后,直接重新拉取整个列表数据以确保绝对一致性iOS开发†12。这几种策略的取舍,正是不同维度状态管理思维的体现: 1. 乐观更新(Optimistic Update)‍: 先更新本地UI状态,再发送请求。请求失败则回滚。体验最快,但需要处理失败回滚的复杂逻辑。
2. 悲观更新(Pessimistic Update)‍: 等待请求成功后再更新本地状态。体验有延迟,但逻辑简单一致。
3. 强制同步(Force Sync)‍: 关键操作后,强制从服务器拉取最新数据。保证一致性,但增加网络开销。
一个成熟的架构需要为开发者提供选择这些策略的能力。例如,在一个基于Redux或类似单向数据流的架构中,一个“关注用户”的Action被派发后,可以通过中间件(Middleware)‍ 来灵活实现上述策略:
- 乐观更新中间件: 先派发一个UserFollowStatusUpdated的Action来立即更新UI,然后发起网络请求,根据结果派发成功或回滚的Action。
- 强制同步中间件: 在关注请求成功后,自动派发一个FetchLatestFollowList的Action。

此时,我们的Toast组件也可以被整合进这个状态流中。它可以作为一个状态监听器或副作用执行器。例如,我们可以创建一个ToastMiddleware,它监听特定的状态变化(如state.ui.toastMessage),当检测到变化时,自动执行显示Toast的操作,并在Toast消失后,派发一个ToastDidHide的Action来触发后续流程。

四、实战深化:复杂场景下的时序挑战与解决方案

让我们将理论应用于更复杂的实战场景。设想一个发布动态的功能:

  1. 用户点击发布,按钮置灰,显示“发布中”全屏遮罩。
  2. 并行执行:a) 上传图片至云存储; b) 发布文本内容至服务器。
  3. 两者都成功后,隐藏遮罩,显示“发布成功”Toast。
  4. Toast消失后,自动跳转到动态列表,并滚动到最新项。
  5. 过程中任何一步失败,都要隐藏遮罩,显示错误Toast,并允许用户重试。

这里的挑战在于管理多个并行异步任务的完成状态,并协调它们与一系列串行UI动画(遮罩、Toast、跳转)之间的关系。简单的回调嵌套将使代码成为噩梦。

解决方案一:使用DispatchGroup

let dispatchGroup = DispatchGroup()
var uploadError: Error?
var postError: Error?

dispatchGroup.enter()
uploadImage { error in
    uploadError = error
    dispatchGroup.leave()
}

dispatchGroup.enter()
postContent { error in
    postError = error
    dispatchGroup.leave()
}

dispatchGroup.notify(queue: .main) {
    if uploadError == nil && postError == nil {
        hideFullscreenMask()
        Toast.showSuccess {
            navigateToFeedListAndScrollToTop()
        }
    } else {
        hideFullscreenMask()
        Toast.showError(“发布失败“)
    }
}

解决方案二:使用Combine或RxSwift等响应式框架

// 伪代码,展示Combine思路
let imageUploadPublisher = uploadImagePublisher()
let contentPostPublisher = postContentPublisher()

Publishers.Zip(imageUploadPublisher, contentPostPublisher)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        // 处理错误
    }, receiveValue: { _ in
        hideFullscreenMask()
        Toast.showSuccess {
            navigateToFeedListAndScrollToTop()
        }
    })
    .store(in: &cancellables)

解决方案三:定义更精细的状态机

enum PublishingState {
    case idle
    case publishing
    case uploadingImage(progress: Double)
    case postingContent
    case success
    case failure(error: PublishingError) // 可细分错误类型
}

响应式框架和状态机结合的方案最具表现力和可维护性。它清晰地描绘了数据流:多个异步任务被抽象为数据流(Publisher),通过操作符(如Zip)进行组合,最终产出结果流,并驱动UI状态变迁。所有的时序关系都通过操作符的语义来声明,而非通过回调的执行顺序来隐含。

下图描绘了复杂发布场景下的状态流转与副作用协调:

image.png

五、总结:细节处的架构哲学

回顾全文,我们从“为Toast添加一个completion回调”这个极其具体的需求出发,逐步探讨了异步时序陷阱、状态机抽象、应用级状态管理架构,以及复杂并行任务的协调方案。这个思考过程本身,揭示了一个重要的方法论:优秀的架构往往源于对简单问题的深刻追问和不懈优化

那个小小的completion闭包,不仅仅是一个语法糖。它是一个信号,标志着我们的代码开始关注以下原则:
1. 生命周期的完整性: UI组件应有明确的开始、进行中、结束的声明点。
2. 副作用的可控性: 将数据逻辑(网络请求)与界面副作用(显示、动画)分离,并明确其触发条件和顺序。
3. 状态的唯一性: 以状态为中心驱动UI变化,避免多源头修改导致的界面不一致。

在后续的文章中,我们将把这种状态驱动与关注生命周期的思维,应用到自定义导航栏的交互设计、第三方SDK的集成封装、以及网络层的健壮性设计等更多场景中。你会发现,很多复杂的架构决策,其内核都与今天我们讨论的“如何优雅地等待一个Toast消失”一脉相承——那就是如何在异步、事件驱动的世界里,构建出同步、可预测、易于理解的应用逻辑。这,或许就是移动开发在细节之处所蕴含的架构哲学。

NativeScript 的 SwiftUI 入门指南

本文中,我们将演示如何在 NativeScript 中使用 SwiftUI,共同探索构建精彩用户界面的新可能性。

前提条件

  • macOS Catalina 或更高版本
  • Xcode 11 或更高版本
  • 运行 iOS 13 或更高版本的 iOS 设备/模拟器

SwiftUI 概念

现代 iOS 开发主要使用 Swift 编程语言。SwiftUI 采用了声明式的语法——你只需说明用户界面应当做什么。

我建议你先浏览一下官方的 SwiftUI 教程,以便熟悉基本概念。

创建 NativeScript 应用

我们可以使用标准的 TypeScript 模板来创建一个应用:

ns create swiftui --ts
cd swiftui

这会搭建一个通常被称为“原生风味”(vanilla)的 NativeScript 应用。换句话说,它提供了基本的数据绑定能力以及相当简单的设置。不过,我们在此介绍的内容适用于任何框架(Angular、React、Svelte、Vue 等)。

SwiftUI 插件

安装 SwiftUI 插件

npm install @nativescript/swift-ui

注意: 你的最低 iOS 部署目标应至少为 13。

你可以在 App_Resources/iOS/build.xcconfig 文件中添加这一行:

IPHONEOS_DEPLOYMENT_TARGET = 13.0;

使用 SwiftUI

A. 创建你的 SwiftUI 视图

创建 App_Resources/iOS/src/SampleView.swift

import SwiftUI

struct SampleView: View {

  var body: some View {
    VStack {
      Text("Hello World")
        .padding()
    }
  }
}

B. 创建你的 SwiftUI 提供者 (Provider)

这将为你的 SwiftUI 准备好与 NativeScript 的双向数据绑定。

创建 App_Resources/iOS/src/SampleViewProvider.swift

import SwiftUI

@objc
class SampleViewProvider: UIViewController, SwiftUIProvider {

  // MARK: 初始化

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

  required public init() {
    super.init(nibName: nil, bundle: nil)
  }

  public override func viewDidLoad() {
    super.viewDidLoad()
    setupSwiftUIView(content: swiftUIView)
  }

  // MARK: 私有部分

  private var swiftUIView = SampleView()

  /// 从 NativeScript 接收数据
  func updateData(data: NSDictionary) {
      // 可以留空
  }

  /// 允许向 NativeScript 发送数据
  var onEvent: ((NSDictionary) -> ())?
}

C. 插入到任意 NativeScript 布局中

  • app/main-page.xml
<Page
  xmlns="http://schemas.nativescript.org/tns.xsd" 
  xmlns:sw="@nativescript/swift-ui" 
  class="page"
>
  <StackLayout>
    <sw:SwiftUI swiftId="sampleView" height="100" />
  </StackLayout>
</Page>

D. 通过 swiftId 注册你的 SwiftUI 视图

这一步可以在 NativeScript 应用的启动引导文件中完成(通常是 app.tsmain.ts)。

  • app.ts
import { 
  registerSwiftUI, 
  UIDataDriver
} from "@nativescript/swift-ui";

// A. 你可以使用 'ns typings ios' 为你自己的 Swift Provider 生成类型定义
// B. 否则,可以通过声明你知道已提供的类名来忽略此步骤
declare const SampleViewProvider: any;

registerSwiftUI("sampleView", (view) =>
  new UIDataDriver(SampleViewProvider.alloc().init(), view)
);

现在你可以使用 ns debug ios 来运行应用了。

使用 Xcode 开发你的 SwiftUI

运行项目一次后,你就可以在 Xcode 中打开它,利用 Xcode 强大的智能感知辅助,进一步开发你的 SwiftUI。例如,在项目的根目录下执行:

open platforms/ios/swiftui.xcworkspace

你会发现你的 .swift 代码位于 TNSNativeSource 文件夹下,如下图所示

在这里插入图片描述

基础视图应用截图

在这里插入图片描述

高级 SwiftUI 集成

让我们深入一点,建立 SwiftUI 和 NativeScript 之间的数据绑定和事件交互。

创建 SwiftUI 组件

这可以是你想在 NativeScript 中使用的任何 SwiftUI 视图。

创建 App_Resources/iOS/src/SampleView.swift

import SwiftUI

class ButtonProps: ObservableObject {
  @Published var count: Int = 0
  var incrementCount: (() -> Void)?
}

struct SampleView: View {

  @ObservedObject var props = ButtonProps()

  var body: some View {
      VStack(alignment: .center, spacing: 0) {
          HStack(alignment:.center) {
              Text("Count $props.count)")
                  .padding()
                  .scaledToFill()
                  .minimumScaleFactor(0.5)
          }
          HStack(alignment: .center) {
              Button(action: {
                  self.props.incrementCount?()
              }) {
                  Image(systemName: "plus.circle.fill")
                      .foregroundColor(.white)
                      .padding()
                      .background(LinearGradient(
                          gradient: Gradient(
                              colors: [Color.purple, Color.pink]), startPoint: .top, endPoint: .bottom
                      ))
                      .clipShape(Circle())
              }
          }
      }
      .padding()
      .clipShape(Circle())
  }
}

创建 App_Resources/iOS/src/SampleViewProvider.swift

import SwiftUI

@objc
class SampleViewProvider: UIViewController, SwiftUIProvider {

  // MARK: 初始化

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

  required public init() {
    super.init(nibName: nil, bundle: nil)
  }

  public override func viewDidLoad() {
    super.viewDidLoad()
    setupSwiftUIView(content: swiftUIView)
    registerObservers()
  }

  // MARK: 私有部分

  private var swiftUIView = SampleView()

  private func registerObservers() {
    swiftUIView.props.incrementCount = {
      let count = self.swiftUIView.props.count + 1
      // 更新 swiftUI 视图
      self.swiftUIView.props.count = count
      // 通知 nativescript
      self.onEvent?(["count": count])
    }
  }

  // MARK: API

  /// 从 NativeScript 接收数据
  func updateData(data: NSDictionary) {
    if let count = data.value(forKey: "count") as? Int {
      // 更新 swiftUI 视图
      swiftUIView.props.count = count
      // 通知 nativescript
      self.onEvent?(["count": count])
    }
  }

  /// 向 NativeScript 发送数据
  var onEvent: ((NSDictionary) -> Void)?
}

在 NativeScript 布局中使用你的 SwiftUI

  • app/main-page.xml:
<Page
  xmlns="http://schemas.nativescript.org/tns.xsd"
  xmlns:sw="@nativescript/swift-ui"
  navigatingTo="navigatingTo"
>
  <StackLayout>
    <sw:SwiftUI swiftId="sampleView" data="{{ nativeCount }}" swiftUIEvent="{{ onEvent }}" loaded="{{ loadedSwiftUI }}" />
    <Label text="{{ 'NativeScript Label: ' + nativeCount.count }}" class="h2" />
    <Button text="NativeScript 数据绑定: 减少" tap="{{ updateNativeScriptData }}" class="btn btn-primary" />
    <Button text="SwiftUI 数据绑定: 减少" tap="{{ updateSwiftData }}" class="btn btn-primary" />
  </StackLayout>
</Page>
  • app/main-page.ts:
import {
  registerSwiftUI,
  UIDataDriver,
  SwiftUI,
  SwiftUIEventData,
} from "@nativescript/swift-ui";
import { 
  EventData,
  Observable,
  Page
} from "@nativescript/core";

// A. 你可以使用 'ns typings ios' 为你自己的 Swift Provider 生成类型定义
// B. 否则,可以通过声明你知道已提供的类名来忽略此步骤
declare const SampleViewProvider: any;

registerSwiftUI("sampleView", (view) =>
  new UIDataDriver(SampleViewProvider.alloc().init(), view)
);

interface CountData {
  count: number;
}

export function navigatingTo(args: EventData) {
  const page = <Page>args.object;
  page.bindingContext = new DemoModel();
}

export class DemoModel extends Observable {
  swiftui: SwiftUI;
  nativeCount = {
    count: 0,
  };

  loadedSwiftUI(args) {
    this.swiftui = args.object;
  }

  onEvent(evt: SwiftUIEventData<CountData>) {
    this.set("nativeCount", { count: evt.data.count });
  }

  updateNativeScriptData() {
    this.set('nativeCount', { count: this.nativeCount.count - 1 });
  }

  updateSwiftData() {
    this.swiftui.updateData({ count: this.nativeCount.count - 1 });
  }
}

dev.to/valorsoftwa…

第5章:基础状态管理

Snip20260416_2.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

5.1 @State:本地视图状态

@State 介绍

@State 是 SwiftUI 中最基本的状态管理工具,用于管理视图的本地状态。它是一个属性包装器,允许我们在结构体中创建可变状态。

基本用法

import SwiftUI

struct CounterView: View {
    // 使用 @State 声明本地状态
    @State private var count = 0
    
    var body: some View {
        VStack(spacing: 20) {
            // 显示状态值
            Text("Count: \(count)")
                .font(.largeTitle)
                .fontWeight(.bold)
            
            // 修改状态
            Button("Increment") {
                count += 1  // 状态改变,UI 自动更新
            }
            .buttonStyle(.borderedProminent)
            
            Button("Reset") {
                count = 0  // 状态重置
            }
            .buttonStyle(.bordered)
        }
        .padding()
    }
}

#Preview {
    CounterView()
}

工作原理

@State 的工作原理:

  1. 当你使用 @State 标记一个属性时,SwiftUI 会在底层为这个属性创建一个独立的存储
  2. 这个存储不受结构体值类型特性的影响,即使结构体被重新创建,状态也会保持
  3. 当状态值改变时,SwiftUI 会自动重新计算视图的 body 属性
  4. 系统会对比新旧视图树,只更新发生变化的部分

最佳实践

  1. 标记为 private@State 应该只在当前视图内部使用,所以应该标记为 private
  2. 初始值:必须为 @State 属性提供初始值
  3. 避免在 body 中修改:不要在 body 计算属性中直接修改 @State
  4. 简单类型@State 适合存储简单类型(如 Bool、Int、String 等)

5.2 @Binding:父子视图双向绑定

@Binding 介绍

@Binding 用于在父子视图之间创建双向绑定,允许子视图修改父视图的状态。

基本用法

import SwiftUI

// 父视图
struct ParentView: View {
    // 父视图的状态
    @State private var isPlaying = false
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Parent View")
                .font(.headline)
            
            Text("Is Playing: \(isPlaying ? "Yes" : "No")")
            
            // 使用 $ 符号创建绑定并传递给子视图
            PlayButton(isPlaying: $isPlaying)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

// 子视图
struct PlayButton: View {
    // 使用 @Binding 接收父视图的状态引用
    @Binding var isPlaying: Bool
    
    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            // 修改绑定值,会同步更新父视图的状态
            isPlaying.toggle()
        }
        .buttonStyle(.borderedProminent)
        .tint(isPlaying ? .red : .green)
        .padding()
        .background(Color.gray.opacity(0.05))
        .cornerRadius(8)
    }
}

#Preview {
    ParentView()
}

工作原理

@Binding 的工作原理:

  1. 它不是存储状态,而是创建一个对现有状态的引用
  2. 当子视图修改绑定值时,实际上是修改了原始的 @State 状态
  3. 状态的所有权仍然在父视图中
  4. 这种机制确保了单一数据源(SSOT)原则

实际应用

// 表单输入示例
struct FormView: View {
    @State private var username = ""
    @State private var email = ""
    
    var body: some View {
        VStack(spacing: 16) {
            Text("User Form")
                .font(.headline)
            
            TextFieldView(
                title: "Username",
                text: $username,
                placeholder: "Enter your username"
            )
            
            TextFieldView(
                title: "Email",
                text: $email,
                placeholder: "Enter your email"
            )
            
            Text("Username: \(username)")
            Text("Email: \(email)")
        }
        .padding()
    }
}

struct TextFieldView: View {
    let title: String
    @Binding var text: String
    let placeholder: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.subheadline)
                .fontWeight(.medium)
            
            TextField(
                placeholder,
                text: $text
            )
            .textFieldStyle(.roundedBorder)
        }
    }
}

5.3 @StateObject:可观察对象状态

@StateObject 介绍

@StateObject 用于管理符合 ObservableObject 协议的对象,适用于需要在多个视图之间共享的复杂状态。

基本用法

import SwiftUI
import Combine

// 可观察对象模型
class UserViewModel: ObservableObject {
    // 使用 @Published 标记需要发布的属性
    @Published var username = ""
    @Published var email = ""
    @Published var isLoggedIn = false
    
    func login() {
        // 模拟登录操作
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.isLoggedIn = true
        }
    }
    
    func logout() {
        username = ""
        email = ""
        isLoggedIn = false
    }
}

struct UserView: View {
    // 使用 @StateObject 管理可观察对象
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("User Profile")
                .font(.headline)
            
            if viewModel.isLoggedIn {
                Text("Welcome, \(viewModel.username)!")
                    .font(.title)
                Text("Email: \(viewModel.email)")
                
                Button("Logout") {
                    viewModel.logout()
                }
                .buttonStyle(.borderedProminent)
                .tint(.red)
            } else {
                TextField("Username", text: $viewModel.username)
                    .textFieldStyle(.roundedBorder)
                TextField("Email", text: $viewModel.email)
                    .textFieldStyle(.roundedBorder)
                
                Button("Login") {
                    viewModel.login()
                }
                .buttonStyle(.borderedProminent)
                .disabled(viewModel.username.isEmpty || viewModel.email.isEmpty)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    UserView()
}

工作原理

@StateObject 的工作原理:

  1. 它会创建并拥有一个符合 ObservableObject 协议的对象
  2. 当对象的 @Published 属性改变时,所有使用该对象的视图都会自动更新
  3. 即使视图被重新创建,@StateObject 也会保持对象的生命周期
  4. 适用于需要在多个视图之间共享的复杂状态

最佳实践

  1. 用于复杂状态:适用于包含多个相关属性的复杂状态
  2. 单一数据源:作为状态的唯一来源
  3. 生命周期管理:由 SwiftUI 管理对象的生命周期
  4. 性能考虑:对于大型对象,考虑使用更细粒度的状态管理

5.4 @ObservedObject:观察外部对象

@ObservedObject 介绍

@ObservedObject 用于观察外部传入的符合 ObservableObject 协议的对象,适用于从父视图传递的可观察对象。

基本用法

import SwiftUI

// 父视图
struct ParentWithObservedObject: View {
    // 父视图拥有状态对象
    @StateObject private var userViewModel = UserViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Parent View")
                .font(.headline)
            
            // 传递给子视图
            ChildView(viewModel: userViewModel)
        }
        .padding()
    }
}

// 子视图
struct ChildView: View {
    // 使用 @ObservedObject 观察外部对象
    @ObservedObject var viewModel: UserViewModel
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Child View")
                .font(.subheadline)
            
            TextField("Username", text: $viewModel.username)
                .textFieldStyle(.roundedBorder)
            
            TextField("Email", text: $viewModel.email)
                .textFieldStyle(.roundedBorder)
            
            Button("Login") {
                viewModel.login()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    ParentWithObservedObject()
}

工作原理

@ObservedObject 的工作原理:

  1. 它不拥有对象,只是观察外部传入的对象
  2. 当对象的 @Published 属性改变时,视图会自动更新
  3. 对象的生命周期由其拥有者管理
  4. 适用于从父视图传递的可观察对象

与 @StateObject 的区别

特性 @StateObject @ObservedObject
所有权 拥有对象,管理生命周期 观察对象,不管理生命周期
初始化 在视图中直接初始化 从外部传入
适用场景 作为状态的唯一来源 观察父视图传递的对象
性能 更高效,避免重复创建 可能会因父视图重建而重复创建

5.5 @EnvironmentObject:全局环境对象

@EnvironmentObject 介绍

@EnvironmentObject 用于访问通过环境传递的全局可观察对象,适用于跨多个视图层级共享的状态。

基本用法

import SwiftUI

// 全局状态模型
class AppState: ObservableObject {
    @Published var isDarkMode = false
    @Published var userLanguage = "zh"
    
    func toggleDarkMode() {
        isDarkMode.toggle()
    }
    
    func changeLanguage(to language: String) {
        userLanguage = language
    }
}

// 主视图 - 设置环境对象
struct MainView: View {
    @StateObject private var appState = AppState()
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Main View")
                    .font(.headline)
                
                NavigationLink("Settings", destination: SettingsView())
                NavigationLink("Profile", destination: ProfileView())
            }
            .padding()
        }
        // 通过环境传递对象
        .environmentObject(appState)
    }
}

// 设置视图 - 访问环境对象
struct SettingsView: View {
    // 通过 @EnvironmentObject 访问全局对象
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Settings")
                .font(.headline)
            
            Toggle("Dark Mode", isOn: $appState.isDarkMode)
            
            Picker("Language", selection: $appState.userLanguage) {
                Text("English").tag("en")
                Text("中文").tag("zh")
                Text("日本語").tag("ja")
            }
            .pickerStyle(.segmented)
        }
        .padding()
        .background(appState.isDarkMode ? Color.black : Color.white)
        .foregroundColor(appState.isDarkMode ? Color.white : Color.black)
    }
}

// 个人资料视图 - 访问环境对象
struct ProfileView: View {
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Profile")
                .font(.headline)
            
            Text("Current Language: \(appState.userLanguage)")
            Text("Dark Mode: \(appState.isDarkMode ? "On" : "Off")")
        }
        .padding()
        .background(appState.isDarkMode ? Color.black : Color.white)
        .foregroundColor(appState.isDarkMode ? Color.white : Color.black)
    }
}

#Preview {
    MainView()
}

工作原理

@EnvironmentObject 的工作原理:

  1. 它从环境中查找指定类型的可观察对象
  2. 当对象的 @Published 属性改变时,所有使用该对象的视图都会自动更新
  3. 不需要手动传递对象,通过环境自动注入
  4. 适用于跨多个视图层级共享的全局状态

最佳实践

  1. 全局状态:用于应用级别的全局状态
  2. 依赖注入:通过环境进行依赖注入,避免层层传递
  3. 类型安全:基于类型查找,确保类型正确
  4. 错误处理:确保在使用前在环境中设置了对象

5.6 @Environment:环境值

@Environment 介绍

@Environment 用于访问 SwiftUI 环境中的系统值,如布局方向、颜色方案、字体大小等。

基本用法

import SwiftUI

struct EnvironmentValuesView: View {
    // 访问环境值
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.layoutDirection) private var layoutDirection
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Environment Values")
                .font(.headline)
            
            Text("Color Scheme: \(colorScheme == .dark ? "Dark" : "Light")")
            Text("Layout Direction: \(layoutDirection == .leftToRight ? "LTR" : "RTL")")
            Text("Dynamic Type Size: \(dynamicTypeSize.description)")
            Text("Horizontal Size Class: \(horizontalSizeClass == .regular ? "Regular" : "Compact")")
            
            // 根据环境值调整布局
            if horizontalSizeClass == .regular {
                HStack {
                    Text("Wide Layout")
                    Spacer()
                    Text("More Content")
                }
            } else {
                VStack {
                    Text("Narrow Layout")
                    Text("Content Below")
                }
            }
        }
        .padding()
        .background(colorScheme == .dark ? Color.black : Color.white)
        .foregroundColor(colorScheme == .dark ? Color.white : Color.black)
    }
}

#Preview {
    EnvironmentValuesView()
}

常用环境值

环境值 类型 描述
\.colorScheme ColorScheme 当前颜色方案(浅色/深色)
\.layoutDirection LayoutDirection 布局方向(LTR/RTL)
\.dynamicTypeSize DynamicTypeSize 动态字体大小
\.horizontalSizeClass UserInterfaceSizeClass? 水平尺寸类
\.verticalSizeClass UserInterfaceSizeClass? 垂直尺寸类
\.locale Locale 当前区域设置
\.calendar Calendar 当前日历
\.timeZone TimeZone 当前时区
\.accessibilityEnabled Bool 是否启用辅助功能
\.scenePhase ScenePhase 场景阶段(活跃/非活跃/背景)

工作原理

@Environment 的工作原理:

  1. 它从 SwiftUI 环境中读取指定的环境值
  2. 当环境值改变时,视图会自动更新
  3. 环境值由系统或父视图设置
  4. 适用于响应系统设置和环境变化

5.7 @SceneStorage:场景存储

@SceneStorage 介绍

@SceneStorage 用于在场景级别持久化存储简单数据,适用于保存用户偏好设置和状态。

基本用法

import SwiftUI

struct SceneStorageView: View {
    // 使用 @SceneStorage 存储数据
    @SceneStorage("username") private var username = ""
    @SceneStorage("isDarkMode") private var isDarkMode = false
    @SceneStorage("counter") private var counter = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Scene Storage")
                .font(.headline)
            
            TextField("Username", text: $username)
                .textFieldStyle(.roundedBorder)
            
            Toggle("Dark Mode", isOn: $isDarkMode)
            
            VStack {
                Text("Counter: \(counter)")
                HStack {
                    Button("Increment") {
                        counter += 1
                    }
                    .buttonStyle(.bordered)
                    
                    Button("Reset") {
                        counter = 0
                    }
                    .buttonStyle(.bordered)
                }
            }
            
            Text("Note: Data persists across app restarts")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(isDarkMode ? Color.black : Color.white)
        .foregroundColor(isDarkMode ? Color.white : Color.black)
    }
}

#Preview {
    SceneStorageView()
}

工作原理

@SceneStorage 的工作原理:

  1. 它将数据存储在场景的 UserDefaults 中
  2. 数据会在场景重启后保持
  3. 每个场景有自己的存储,不同场景之间数据隔离
  4. 适用于存储用户偏好设置和临时状态

最佳实践

  1. 简单数据:适合存储简单类型(String、Int、Bool 等)
  2. 场景隔离:每个场景有独立的存储
  3. 自动持久化:数据自动保存,无需手动管理
  4. 键名唯一性:使用唯一的键名避免冲突

5.8 @AppStorage:应用存储

@AppStorage 介绍

@AppStorage 用于在应用级别持久化存储简单数据,适用于保存全局用户偏好设置。

基本用法

import SwiftUI

struct AppStorageView: View {
    // 使用 @AppStorage 存储数据
    @AppStorage("userName") private var userName = "Guest"
    @AppStorage("appTheme") private var appTheme = "light"
    @AppStorage("notificationsEnabled") private var notificationsEnabled = true
    
    var body: some View {
        VStack(spacing: 20) {
            Text("App Storage")
                .font(.headline)
            
            TextField("User Name", text: $userName)
                .textFieldStyle(.roundedBorder)
            
            Picker("Theme", selection: $appTheme) {
                Text("Light").tag("light")
                Text("Dark").tag("dark")
                Text("Auto").tag("auto")
            }
            .pickerStyle(.segmented)
            
            Toggle("Enable Notifications", isOn: $notificationsEnabled)
            
            Text("Note: Data persists across app reinstalls")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(getThemeColor())
        .foregroundColor(appTheme == "dark" ? Color.white : Color.black)
    }
    
    private func getThemeColor() -> Color {
        switch appTheme {
        case "dark":
            return Color.black
        case "light":
            return Color.white
        default:
            return Color.white
        }
    }
}

#Preview {
    AppStorageView()
}

工作原理

@AppStorage 的工作原理:

  1. 它将数据存储在应用的 UserDefaults 中
  2. 数据会在应用重启后保持
  3. 所有场景共享相同的存储
  4. 适用于存储全局用户偏好设置

与 @SceneStorage 的区别

特性 @AppStorage @SceneStorage
存储范围 应用级别,所有场景共享 场景级别,每个场景独立
持久化 持久化到 UserDefaults 持久化到场景的 UserDefaults
适用场景 全局偏好设置 场景特定状态
数据共享 跨场景共享 场景隔离

5.9 @FocusedValue:聚焦值

@FocusedValue 介绍

@FocusedValue 用于在视图层次结构中传递聚焦状态相关的值,适用于处理键盘焦点和上下文相关操作。

基本用法

import SwiftUI

// 定义聚焦值键
struct EditModeKey: FocusedValueKey {
    typealias Value = Bool
}

// 扩展 FocusedValues
extension FocusedValues {
    var isEditMode: EditModeKey.Value? {
        get { self[EditModeKey.self] }
        set { self[EditModeKey.self] = newValue }
    }
}

struct FocusedValueView: View {
    @State private var isEditMode = false
    @State private var text = "Hello, SwiftUI"
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Focused Value")
                .font(.headline)
            
            // 设置聚焦值
            TextField("Enter text", text: $text)
                .textFieldStyle(.roundedBorder)
                .focusedValue(\.isEditMode, true)
            
            Button("Toggle Edit Mode") {
                isEditMode.toggle()
            }
            .buttonStyle(.borderedProminent)
            
            // 子视图访问聚焦值
            FocusedChildView()
        }
        .padding()
        .environment(\.isEditMode, isEditMode)
    }
}

struct FocusedChildView: View {
    // 访问聚焦值
    @FocusedValue(\.isEditMode) private var isEditMode
    
    var body: some View {
        VStack {
            Text("Child View")
                .font(.subheadline)
            
            Text("Edit Mode: \(isEditMode ?? false ? "On" : "Off")")
            
            if isEditMode ?? false {
                Text("Editing is enabled!")
                    .foregroundColor(.green)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

#Preview {
    FocusedValueView()
}

工作原理

@FocusedValue 的工作原理:

  1. 它通过 FocusedValues 字典传递值
  2. 当焦点变化时,聚焦值会自动更新
  3. 适用于与焦点相关的上下文信息
  4. 可以自定义聚焦值键

适用场景

  1. 键盘焦点:跟踪当前聚焦的视图
  2. 上下文操作:根据聚焦状态显示不同的操作
  3. 编辑模式:在编辑模式下显示额外的控件
  4. 工具栏配置:根据当前聚焦的内容配置工具栏

5.10 状态驱动 UI 更新原理

核心原理

SwiftUI 的核心设计哲学是状态驱动

UI = f(State)

这意味着:

  1. UI 是状态的函数
  2. 当状态改变时,UI 会自动更新
  3. 给定相同的状态,总是渲染相同的 UI

更新流程

当状态改变时,SwiftUI 的更新流程如下:

  1. 状态改变:用户操作或其他因素导致状态值发生变化
  2. 检测变化:SwiftUI 检测到状态变化
  3. 重新计算 body:重新调用受影响视图的 body 计算属性
  4. 构建新视图树:生成新的视图层次结构
  5. 对比差异:对比新旧视图树,找出变化的部分
  6. 更新 UI:只更新发生变化的部分,保持其他部分不变

性能优化

SwiftUI 的状态驱动机制本身就很高效,因为:

  1. 增量更新:只更新变化的部分
  2. 值类型:视图是轻量级的值类型,创建成本低
  3. 智能对比:使用高效的差异算法
  4. 批处理:合并多个状态更新,减少渲染次数
  5. 懒加载:只渲染可见的部分

5.11 状态管理最佳实践

1. 选择合适的状态管理工具

状态类型 推荐工具 适用场景
本地简单状态 @State 单个视图的内部状态
父子视图共享 @Binding 子视图需要修改父视图状态
复杂对象状态 @StateObject 多个属性的复杂状态
外部对象引用 @ObservedObject 观察父视图传递的对象
全局共享状态 @EnvironmentObject 跨多个视图的全局状态
系统环境值 @Environment 访问系统设置和环境
场景级持久化 @SceneStorage 场景特定的持久状态
应用级持久化 @AppStorage 全局用户偏好设置
聚焦相关状态 @FocusedValue 与焦点相关的上下文信息

2. 状态管理原则

  1. 单一数据源:每个状态应该有唯一的来源
  2. 状态提升:将状态提升到需要访问它的所有视图的共同父视图
  3. 最小化状态:只存储必要的状态,避免冗余
  4. 状态隔离:将相关状态组织在一起,避免混乱
  5. 可测试性:状态管理应该易于测试
  6. 性能考虑:对于大型状态,考虑使用更细粒度的更新

3. 性能优化技巧

  1. 使用 Equatable:为模型实现 Equatable 协议,避免不必要的更新
  2. 视图分离:将复杂视图拆分为更小的子视图
  3. @State 用于简单类型@State 适合存储简单类型,复杂类型使用 @StateObject
  4. 避免在 body 中创建对象:不要在 body 计算属性中创建新对象
  5. 使用 .id() 强制更新:当需要强制视图更新时使用
  6. 考虑使用 Combine:对于复杂的异步操作,使用 Combine 框架

实战:创建一个完整的状态管理示例

需求分析

创建一个包含多种状态管理技术的应用,包括:

  1. 本地状态管理
  2. 父子视图绑定
  3. 可观察对象
  4. 环境对象
  5. 持久化存储

代码实现

import SwiftUI
import Combine

// 全局应用状态
class AppState: ObservableObject {
    @Published var isDarkMode = false
    @Published var currentUser: User? = nil
    
    func toggleTheme() {
        isDarkMode.toggle()
    }
    
    func login(user: User) {
        currentUser = user
    }
    
    func logout() {
        currentUser = nil
    }
}

// 用户模型
struct User: Identifiable, Equatable {
    let id = UUID()
    let name: String
    let email: String
}

// 主应用视图
struct StateManagementDemo: View {
    @StateObject private var appState = AppState()
    @AppStorage("lastLoggedInUser") private var lastLoggedInUser = ""
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("状态管理演示")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                
                // 主题切换
                Toggle("深色模式", isOn: $appState.isDarkMode)
                
                // 用户登录状态
                if appState.currentUser != nil {
                    Text("欢迎, \(appState.currentUser?.name ?? "")!")
                    Button("退出登录") {
                        appState.logout()
                        lastLoggedInUser = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .tint(.red)
                } else {
                    LoginView()
                }
                
                // 导航链接
                NavigationLink("计数器示例", destination: CounterView())
                NavigationLink("待办事项示例", destination: TodoApp())
                NavigationLink("环境对象示例", destination: EnvironmentObjectDemo())
            }
            .padding()
        }
        .environmentObject(appState)
        .preferredColorScheme(appState.isDarkMode ? .dark : .light)
    }
}

// 登录视图
struct LoginView: View {
    @State private var name = ""
    @State private var email = ""
    @EnvironmentObject private var appState: AppState
    @AppStorage("lastLoggedInUser") private var lastLoggedInUser = ""
    
    var body: some View {
        VStack(spacing: 16) {
            TextField("姓名", text: $name)
                .textFieldStyle(.roundedBorder)
            TextField("邮箱", text: $email)
                .textFieldStyle(.roundedBorder)
            Button("登录") {
                let user = User(name: name, email: email)
                appState.login(user: user)
                lastLoggedInUser = name
            }
            .buttonStyle(.borderedProminent)
            .disabled(name.isEmpty || email.isEmpty)
            
            if !lastLoggedInUser.isEmpty {
                Text("上次登录: \(lastLoggedInUser)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

// 计数器视图
struct CounterView: View {
    @State private var count = 0
    @SceneStorage("counterValue") private var storedCount = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("计数器")
                .font(.headline)
            Text("Count: \(count)")
                .font(.largeTitle)
                .fontWeight(.bold)
            HStack(spacing: 16) {
                Button("减1") {
                    count -= 1
                    storedCount = count
                }
                .buttonStyle(.bordered)
                Button("重置") {
                    count = 0
                    storedCount = 0
                }
                .buttonStyle(.bordered)
                Button("加1") {
                    count += 1
                    storedCount = count
                }
                .buttonStyle(.borderedProminent)
            }
            Text("场景存储值: \(storedCount)")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
        .onAppear {
            // 从场景存储恢复
            count = storedCount
        }
    }
}

// 待办事项应用
struct TodoApp: View {
    @State private var todos: [TodoItem] = [
        TodoItem(title: "学习 SwiftUI 状态管理"),
        TodoItem(title: "完成本章练习"),
        TodoItem(title: "构建示例应用")
    ]
    @State private var newTodoTitle = ""
    
    var body: some View {
        VStack {
            HStack(spacing: 8) {
                TextField("输入新的待办事项", text: $newTodoTitle)
                    .textFieldStyle(.roundedBorder)
                Button("添加") {
                    addTodo()
                }
                .buttonStyle(.borderedProminent)
                .disabled(newTodoTitle.isEmpty)
            }
            .padding()
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Button(action: {
                            todo.isCompleted.toggle()
                        }) {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .foregroundColor(todo.isCompleted ? .green : .gray)
                        }
                        .buttonStyle(.plain)
                        Text(todo.title)
                            .strikethrough(todo.isCompleted, color: .gray)
                            .foregroundColor(todo.isCompleted ? .secondary : .primary)
                        Spacer()
                        Button(action: {
                            deleteTodo(todo)
                        }) {
                            Image(systemName: "trash.fill")
                                .foregroundColor(.red)
                        }
                        .buttonStyle(.plain)
                    }
                }
            }
        }
        .navigationTitle("待办事项")
    }
    
    private func addTodo() {
        guard !newTodoTitle.isEmpty else { return }
        todos.append(TodoItem(title: newTodoTitle))
        newTodoTitle = ""
    }
    
    private func deleteTodo(_ todo: TodoItem) {
        if let index = todos.firstIndex(where: { $0.id == todo.id }) {
            todos.remove(at: index)
        }
    }
}

// 待办事项模型
struct TodoItem: Identifiable, Equatable {
    let id = UUID()
    var title: String
    var isCompleted = false
}

// 环境对象演示
struct EnvironmentObjectDemo: View {
    @EnvironmentObject private var appState: AppState
    
    var body: some View {
        VStack(spacing: 20) {
            Text("环境对象演示")
                .font(.headline)
            Text("当前主题: \(appState.isDarkMode ? "深色" : "浅色")")
            Text("登录状态: \(appState.currentUser != nil ? "已登录" : "未登录")")
            if let user = appState.currentUser {
                Text("用户: \(user.name)")
                Text("邮箱: \(user.email)")
            }
            Button("切换主题") {
                appState.toggleTheme()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

#Preview {
    StateManagementDemo()
}

代码解析

  1. AppState:使用 @StateObject 管理全局应用状态
  2. User:符合 IdentifiableEquatable 协议的用户模型
  3. StateManagementDemo:主应用视图,设置环境对象
  4. LoginView:使用 @State@AppStorage 管理登录状态
  5. CounterView:使用 @State@SceneStorage 管理计数器
  6. TodoApp:使用 @State 管理待办事项列表
  7. EnvironmentObjectDemo:使用 @EnvironmentObject 访问全局状态

小结

本章详细介绍了 SwiftUI 中的状态管理系统,包括:

  • @State:用于管理视图的本地状态
  • @Binding:用于父子视图之间的双向绑定
  • @StateObject:用于管理可观察对象的状态
  • @ObservedObject:用于观察外部传入的可观察对象
  • @EnvironmentObject:用于访问全局环境对象
  • @Environment:用于访问系统环境值
  • @SceneStorage:用于场景级别的持久化存储
  • @AppStorage:用于应用级别的持久化存储
  • @FocusedValue:用于传递聚焦相关的值
  • 状态驱动 UI 更新的原理
  • 状态管理的最佳实践
  • 一个完整的状态管理示例应用

通过本章的学习,你已经掌握了 SwiftUI 中所有的状态管理技术,能够根据不同的场景选择合适的状态管理工具,创建具有复杂交互功能的应用。


参考资料


本内容为《SwiftUI 基础教程》第五章,欢迎关注后续更新。

第4章:基础布局系统

Snip20260416_1.png

Snip20260416_2.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

第4章:基础布局系统

4.1 垂直布局:VStack

VStack 介绍

VStack 是 SwiftUI 中用于垂直堆叠视图的容器,它会将子视图按垂直方向排列。VStack 是构建垂直布局的基础组件,适用于需要从上到下排列的界面元素。

基本用法


// 基本垂直栈

VStack {

    Text("第一行")

    Text("第二行")

    Text("第三行")

}

  


// 带间距和对齐的垂直栈

VStack(alignment: .leading, spacing: 20) {

    Text("左对齐")

    Text("第二行")

    Text("第三行")

}

.padding()

对齐方式

VStack 提供了三种主要的对齐方式:

  • .leading:左对齐

  • .center:居中对齐(默认)

  • .trailing:右对齐

  • .top.bottom:在嵌套布局中使用


// 不同对齐方式

VStack(alignment: .leading) {

    Text("左对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

  


VStack(alignment: .center) {

    Text("居中对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

  


VStack(alignment: .trailing) {

    Text("右对齐")

    Text("这是一行更长的文本")

    Text("短文本")

}

.padding()

嵌套 VStack

VStack 可以嵌套使用,创建更复杂的布局结构。


// 嵌套垂直栈

VStack(spacing: 10) {

    Text("标题")

        .font(.headline)

    

    VStack(alignment: .leading, spacing: 8) {

        Text("项目 1")

        Text("项目 2")

        Text("项目 3")

    }

    .padding()

    .background(Color.gray.opacity(0.1))

    .cornerRadius(8)

    

    Button("确认") {}

}

.padding()

适用场景

  • 表单布局:从上到下排列的输入字段

  • 列表项:垂直排列的内容块

  • 页面结构:标题、内容、按钮的垂直布局

  • 卡片式布局:垂直堆叠的信息卡片

性能考虑

  • VStack 会根据子视图的大小自动调整高度

  • 对于大量子视图,考虑使用 LazyVStack 来提高性能

  • 避免过深的嵌套,可能会影响渲染性能

4.2 水平布局:HStack

HStack 介绍

HStack 是 SwiftUI 中用于水平堆叠视图的容器,它会将子视图按水平方向排列。HStack 是构建水平布局的基础组件,适用于需要从左到右排列的界面元素。

基本用法


// 基本水平栈

HStack {

    Text("左侧")

    Text("中间")

    Text("右侧")

}

  


// 带间距和对齐的水平栈

HStack(alignment: .top, spacing: 20) {

    Text("顶部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

对齐方式

HStack 提供了三种主要的对齐方式:

  • .top:顶部对齐

  • .center:居中对齐(默认)

  • .bottom:底部对齐

  • .leading.trailing:在嵌套布局中使用


// 不同对齐方式

HStack(alignment: .top) {

    Text("顶部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

  


HStack(alignment: .center) {

    Text("居中对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

  


HStack(alignment: .bottom) {

    Text("底部对齐")

    Text("这是一行\n多行文本")

    Text("短文本")

}

.padding()

空间分配

HStack 可以使用 Spacer 来分配空间,实现更灵活的布局。


// 空间分配

HStack {

    Text("左侧")

    Spacer()  // 占据剩余空间

    Text("右侧")

}

.padding()

  


// 带比例的空间分配

HStack {

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

    Spacer()

    Text("1/4 宽度")

        .frame(maxWidth: .infinity)

}

.padding()

适用场景

  • 工具栏:水平排列的操作按钮

  • 列表项内容:左侧图标、中间文本、右侧箭头

  • 表单行:标签和输入框的水平排列

  • 导航栏:左侧返回按钮、中间标题、右侧操作按钮

性能考虑

  • HStack 会根据子视图的大小自动调整宽度

  • 对于大量子视图,考虑使用 LazyHStack 来提高性能

  • 注意水平空间不足时的布局行为,可能需要使用 ScrollView

4.3 层叠布局:ZStack

ZStack 介绍

ZStack 是 SwiftUI 中用于层叠视图的容器,它会将子视图按层叠方式排列,后面的视图会覆盖前面的视图。ZStack 是构建叠加效果的基础组件,适用于需要层级关系的界面元素。

基本用法


// 基本层叠

ZStack {

    Color.blue  // 背景

    Text("前景文本")

        .foregroundStyle(.white)

        .font(.largeTitle)

}

.frame(height: 200)

  


// 多层叠

ZStack {

    // 底层

    Rectangle()

        .fill(Color.yellow)

        .frame(width: 200, height: 200)

    

    // 中层

    Circle()

        .fill(Color.green)

        .frame(width: 150, height: 150)

    

    // 顶层

    Text("ZStack")

        .font(.headline)

}

对齐方式

ZStack 提供了多种对齐方式,可以精确控制子视图的位置:

  • .topLeading.top.topTrailing

  • .leading.center.trailing

  • .bottomLeading.bottom.bottomTrailing


// 不同对齐方式

ZStack(alignment: .topLeading) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("左上角")

        .padding(10)

}

  


ZStack(alignment: .center) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("居中")

}

  


ZStack(alignment: .bottomTrailing) {

    Rectangle()

        .fill(Color.gray.opacity(0.2))

        .frame(width: 300, height: 200)

    

    Text("右下角")

        .padding(10)

}

实际应用


// 带徽章的图标

ZStack(alignment: .topTrailing) {

    Image(systemName: "bell")

        .font(.system(size: 24))

    

    Circle()

        .fill(Color.red)

        .frame(width: 16, height: 16)

        .overlay {

            Text("3")

                .font(.system(size: 10))

                .foregroundStyle(.white)

        }

        .offset(x: 4, y: -4)

}

  


// 卡片覆盖效果

ZStack {

    RoundedRectangle(cornerRadius: 12)

        .fill(Color.white)

        .shadow(radius: 4)

        .frame(width: 300, height: 200)

    

    VStack {

        Text("卡片标题")

            .font(.headline)

        Text("卡片内容")

            .foregroundStyle(.secondary)

    }

    .padding()

    

    // 右上角标签

    Text("NEW")

        .font(.caption)

        .foregroundStyle(.white)

        .padding(4)

        .background(Color.blue)

        .cornerRadius(4)

        .offset(x: 45, y: -10)

}

适用场景

  • 带背景的文本:文本叠加在背景之上

  • 徽章效果:通知图标上的数字徽章

  • 卡片布局:带有覆盖元素的信息卡片

  • 复杂 UI 组件:需要多层叠加的自定义控件

  • 模态视图:半透明覆盖层

性能考虑

  • ZStack 会按照添加顺序渲染视图,后面的视图会覆盖前面的

  • 对于复杂的叠加效果,注意渲染性能

  • 考虑使用 offset 修饰符来微调子视图位置

4.4 间距与对齐

间距设置

间距是布局中的重要因素,它决定了视图之间的关系和视觉舒适度。


// VStack 间距

VStack(spacing: 16) {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

  


// HStack 间距

HStack(spacing: 20) {

    Text("左")

    Text("中")

    Text("右")

}

  


// 嵌套栈的间距

VStack(spacing: 20) {

    Text("标题")

    

    HStack(spacing: 10) {

        Button("按钮 1") {}

        Button("按钮 2") {}

    }

    

    Text("底部文本")

}

对齐设置

对齐决定了视图在容器中的位置,影响整体布局的一致性。


// 垂直对齐

VStack(alignment: .leading) {

    Text("左对齐")

    Text("这是一行更长的文本")

}

  


// 水平对齐

HStack(alignment: .center) {

    Text("顶部")

        .font(.largeTitle)

    Text("底部")

        .font(.footnote)

}

  


// 层叠对齐

ZStack(alignment: .bottom) {

    Image(systemName: "photo")

        .resizable()

        .aspectRatio(contentMode: .fit)

        .frame(height: 200)

    

    Text("图片标题")

        .padding()

        .background(Color.black.opacity(0.5))

        .foregroundStyle(.white)

        .frame(maxWidth: .infinity, alignment: .center)

}

内边距与外边距

内边距(padding)和外边距是控制视图与其他元素之间空间的重要工具。


// 内边距

VStack {

    Text("带内边距的文本")

        .padding()  // 四周内边距

    

    Text("自定义内边距")

        .padding(.horizontal, 20// 水平内边距

        .padding(.vertical, 10)    // 垂直内边距

}

  


// 外边距

VStack {

    Text("带外边距的文本")

}

.padding()  // 给整个 VStack 添加内边距

  


// 组合使用

Text("文本")

    .padding(10// 内边距

    .background(Color.yellow)

    .padding(10// 外边距(看起来像内边距)

    .background(Color.blue)

适用场景

  • 表单设计:通过间距和对齐创建整齐的表单

  • 卡片布局:使用内边距和外边距创建视觉层次感

  • 响应式设计:根据不同屏幕尺寸调整间距

  • 可访问性:适当的间距提高内容的可读性

最佳实践

  • 保持一致的间距系统:使用统一的间距值(如 8、16、24 等)

  • 考虑内容的重要性:重要内容之间应有更大的间距

  • 响应式调整:在不同屏幕尺寸上调整间距

  • 测试不同设备:确保在各种设备上布局都美观

4.5 垫片:Spacer

Spacer 介绍

Spacer 是 SwiftUI 中用于占据剩余空间的视图,它会自动扩展以填充可用空间。Spacer 是实现灵活布局的重要工具,特别适用于需要将元素推到容器边缘的场景。

基本用法


// 水平布局中的 Spacer

HStack {

    Text("左侧")

    Spacer()  // 占据中间的所有空间

    Text("右侧")

}

.padding()

  


// 垂直布局中的 Spacer

VStack {

    Text("顶部")

    Spacer()  // 占据中间的所有空间

    Text("底部")

}

.frame(height: 200)

.padding()

灵活使用


// 顶部对齐

VStack {

    Text("标题")

    Spacer()

}

.frame(height: 200)

.padding()

  


// 底部对齐

VStack {

    Spacer()

    Text("底部文本")

}

.frame(height: 200)

.padding()

  


// 两端对齐

HStack {

    Text("左侧")

    Spacer()

    Text("中间")

    Spacer()

    Text("右侧")

}

.padding()

实际应用


// 工具栏布局

HStack {

    Button("返回") {

        print("返回")

    }

    

    Spacer()

    

    Text("页面标题")

    

    Spacer()

    

    Button("更多") {

        print("更多")

    }

}

.padding()

.background(Color(.systemBackground))

  


// 表单底部按钮

VStack {

    // 表单内容

    ForEach(0..<3) {

        Text("表单项 \($0 + 1)")

            .padding()

            .background(Color.gray.opacity(0.1))

            .cornerRadius(8)

            .padding(.horizontal)

    }

    

    Spacer()

    

    // 底部按钮

    Button("提交") {

        print("提交")

    }

    .buttonStyle(.borderedProminent)

    .padding()

}

适用场景

  • 工具栏:将标题居中,按钮放在两侧

  • 表单:将提交按钮固定在底部

  • 卡片:将内容推到顶部,操作按钮放在底部

  • 导航栏:创建平衡的布局

性能考虑

  • Spacer 是轻量级视图,对性能影响很小

  • 合理使用 Spacer 可以减少不必要的几何计算

  • 避免在不需要的地方使用 Spacer,可能会导致意外的布局行为

4.6 布局修饰符

框架修饰符

frame 修饰符用于控制视图的大小和对齐方式。


// 设置固定大小

Text("固定大小")

    .frame(width: 200, height: 100)

    .background(Color.yellow)

  


// 设置最大和最小大小

Text("灵活大小")

    .frame(minWidth: 100, maxWidth: 300, minHeight: 50, maxHeight: 150)

    .background(Color.blue)

  


// 填充父容器

Text("填充")

    .frame(maxWidth: .infinity, maxHeight: .infinity)

    .background(Color.green)

  


// 带对齐的框架

Text("右对齐")

    .frame(width: 200, alignment: .trailing)

    .background(Color.gray.opacity(0.2))

位置修饰符

positionoffset 修饰符用于调整视图的位置。


// 绝对位置

Text("绝对位置")

    .position(x: 100, y: 100)

  


// 相对偏移

Text("相对偏移")

    .offset(x: 50, y: 20)

  


// 组合使用

ZStack {

    Text("基础位置")

        .background(Color.yellow)

    

    Text("偏移位置")

        .offset(x: 50, y: 30)

        .background(Color.red)

}

布局优先级

layoutPriority 修饰符用于设置视图的布局优先级。


HStack {

    Text("短文本")

        .layoutPriority(1// 高优先级

        .background(Color.yellow)

    

    Text("这是一段非常长的文本,会被截断")

        .background(Color.blue)

}

.frame(width: 200)

适用场景

  • 响应式设计:根据屏幕尺寸调整视图大小

  • 自定义布局:精确控制视图位置

  • 复杂界面:处理不同优先级的内容

  • 动态布局:根据内容自动调整

4.7 网格布局:LazyVGrid 和 LazyHGrid

网格布局介绍

LazyVGridLazyHGrid 是 SwiftUI 中用于创建网格布局的容器,它们支持延迟加载,适用于大量数据。网格布局使用 GridItem 来定义列或行的大小和间距。

基本用法


// 定义网格列

let columns = [

    GridItem(.flexible()),

    GridItem(.flexible()),

    GridItem(.flexible())

]

  


// 垂直网格

ScrollView {

    LazyVGrid(columns: columns, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(height: 80)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                        .font(.headline)

                )

        }

    }

    .padding()

}

  


// 水平网格

let rows = [

    GridItem(.flexible()),

    GridItem(.flexible())

]

  


ScrollView(.horizontal) {

    LazyHGrid(rows: rows, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(width: 100)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                        .font(.headline)

                )

        }

    }

    .padding()

}

GridItem 配置

GridItem 支持多种大小配置:

  • .fixed:固定大小

  • .flexible:灵活大小(默认)

  • .adaptive:自适应大小,尽可能多的列


// 固定大小的列

let fixedColumns = [

    GridItem(.fixed(100)),

    GridItem(.fixed(100)),

    GridItem(.fixed(100))

]

  


// 自适应列数

let adaptiveColumns = [

    GridItem(.adaptive(minimum: 80, maximum: 120))

]

  


// 混合配置

let mixedColumns = [

    GridItem(.fixed(80)),

    GridItem(.flexible()),

    GridItem(.fixed(80))

]

适用场景

  • 图片网格:相册、产品展示

  • 图标网格:应用图标、功能入口

  • 数据网格:表格数据展示

  • 响应式布局:自适应不同屏幕尺寸

4.8 列表和表单:List 和 Form

List 列表

List 是 SwiftUI 中用于显示滚动列表的容器,自动处理单元格布局和数据展示。


// 基本列表

List {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

  


// 带分组的列表

List {

    Section(header: Text("分组 1")) {

        Text("项目 1")

        Text("项目 2")

    }

    

    Section(header: Text("分组 2")) {

        Text("项目 3")

        Text("项目 4")

    }

}

  


// 动态列表

let items = ["苹果", "香蕉", "橙子", "葡萄"]

  


List(items, id: \.self) { item in

    HStack {

        Image(systemName: "fruit")

        Text(item)

    }

}

Form 表单

Form 是专门用于表单布局的容器,提供了预设的样式和间距。


// 基本表单

Form {

    TextField("用户名", text: .constant(""))

    SecureField("密码", text: .constant(""))

    Toggle("记住密码", isOn: .constant(true))

    Button("登录") {}

}

  


// 带分组的表单

Form {

    Section(header: Text("个人信息")) {

        TextField("姓名", text: .constant(""))

        TextField("邮箱", text: .constant(""))

    }

    

    Section(header: Text("偏好设置")) {

        Toggle("接收通知", isOn: .constant(true))

        Picker("主题", selection: .constant("浅色")) {

            Text("浅色").tag("浅色")

            Text("深色").tag("深色")

        }

    }

    

    Section {

        Button("保存设置") {}

    }

}

适用场景

  • List:显示结构化数据列表、设置项、联系人

  • Form:创建用户输入表单、设置页面、注册登录页面

4.9 几何读取器:GeometryReader

GeometryReader 介绍

GeometryReader 是一个特殊的容器,它可以读取父视图的几何信息(尺寸和位置),并根据这些信息来布局子视图。


// 基本用法

GeometryReader { geometry in

    VStack {

        Text("宽度: \(geometry.size.width)")

        Text("高度: \(geometry.size.height)")

        Text("安全区域: \(geometry.safeAreaInsets.top)")

    }

    .frame(width: geometry.size.width, height: geometry.size.height)

    .background(Color.gray.opacity(0.1))

}

  


// 根据父视图大小调整子视图

GeometryReader { geometry in

    HStack(spacing: 0) {

        Color.red

            .frame(width: geometry.size.width * 0.3)

        Color.green

            .frame(width: geometry.size.width * 0.3)

        Color.blue

            .frame(width: geometry.size.width * 0.4)

    }

}

.frame(height: 100)

  


// 自适应网格

GeometryReader { geometry in

    let columns = Int(geometry.size.width / 100)

    let gridItems = Array(repeating: GridItem(.flexible()), count: max(columns, 1))

    

    LazyVGrid(columns: gridItems, spacing: 10) {

        ForEach(1..<10) { index in

            Rectangle()

                .fill(Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8))

                .frame(height: 80)

                .overlay(

                    Text("\(index)")

                        .foregroundColor(.white)

                )

        }

    }

    .padding()

}

适用场景

  • 响应式布局:根据屏幕尺寸调整布局

  • 自定义布局:需要精确控制尺寸的场景

  • 动画效果:基于几何信息创建动画

  • 复杂布局:需要基于父视图尺寸的布局

4.10 其他重要布局组件

Divider 分隔线

Divider 用于在视图之间创建水平或垂直的分隔线。


// 水平分隔线

VStack {

    Text("顶部")

    Divider()

    Text("底部")

}

  


// 垂直分隔线

HStack {

    Text("左侧")

    Divider()

    Text("右侧")

}

.frame(height: 50)

Group 视图分组

Group 用于将多个视图组合在一起,作为一个整体应用修饰符。


// 分组应用修饰符

Group {

    Text("项目 1")

    Text("项目 2")

    Text("项目 3")

}

.foregroundColor(.blue)

.font(.headline)

  


// 条件渲染

Group {

    if true {

        Text("显示这个")

    } else {

        Text("显示那个")

    }

}

自定义布局(iOS 16+)

iOS 16 引入了 Layout 协议,允许创建完全自定义的布局。


// 简单的自定义布局

struct SimpleLayout: Layout {

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

        // 计算布局尺寸

        return CGSize(width: proposal.width ?? 300, height: proposal.height ?? 200)

    }

    

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

        // 放置子视图

        for (index, subview) in subviews.enumerated() {

            let x = bounds.minX + CGFloat(index) * 50

            let y = bounds.midY

            subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)

        }

    }

}

  


// 使用自定义布局

SimpleLayout {

    Text("1")

    Text("2")

    Text("3")

    Text("4")

}

.frame(height: 100)

.background(Color.gray.opacity(0.1))

实战:创建一个登录页面

需求分析

创建一个包含以下元素的登录页面:

  1. 应用图标和标题

  2. 用户名输入框

  3. 密码输入框(带可见性切换)

  4. 登录按钮

  5. 忘记密码链接

  6. 注册链接

代码实现


import SwiftUI

  


struct LoginView: View {

    // 状态变量

    @State private var username = ""

    @State private var password = ""

    @State private var showPassword = false

    

    var body: some View {

        ZStack {

            // 背景

            LinearGradient(

                colors: [.blue.opacity(0.1), .purple.opacity(0.1)],

                startPoint: .top,

                endPoint: .bottom

            )

            .ignoresSafeArea()

            

            VStack(spacing: 24) {

                // 应用图标和标题

                VStack(spacing: 12) {

                    Image(systemName: "lock.fill")

                        .resizable()

                        .aspectRatio(contentMode: .fit)

                        .frame(width: 80, height: 80)

                        .foregroundStyle(.blue)

                    

                    Text("欢迎回来")

                        .font(.largeTitle)

                        .fontWeight(.bold)

                    

                    Text("请登录以继续")

                        .foregroundStyle(.secondary)

                }

                

                // 输入区域

                VStack(spacing: 16) {

                    // 用户名输入框

                    TextField(

                        "用户名",

                        text: $username,

                        prompt: Text("请输入用户名")

                    )

                    .textFieldStyle(.roundedBorder)

                    .padding(.horizontal)

                    

                    // 密码输入框

                    ZStack(alignment: .trailing) {

                        if showPassword {

                            TextField(

                                "密码",

                                text: $password,

                                prompt: Text("请输入密码")

                            )

                        } else {

                            SecureField(

                                "密码",

                                text: $password,

                                prompt: Text("请输入密码")

                            )

                        }

                        

                        Button(action: {

                            showPassword.toggle()

                        }) {

                            Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")

                                .foregroundStyle(.secondary)

                                .padding(.trailing, 16)

                        }

                    }

                    .textFieldStyle(.roundedBorder)

                    .padding(.horizontal)

                    

                    // 忘记密码

                    HStack {

                        Spacer()

                        Button("忘记密码?") {

                            print("忘记密码")

                        }

                        .foregroundStyle(.blue)

                        .padding(.trailing)

                    }

                }

                

                // 登录按钮

                Button("登录") {

                    print("登录")

                }

                .buttonStyle(.borderedProminent)

                .tint(.blue)

                .padding(.horizontal)

                .frame(maxWidth: .infinity)

                

                // 注册链接

                HStack {

                    Text("还没有账号?")

                    Button("立即注册") {

                        print("注册")

                    }

                    .foregroundStyle(.blue)

                }

                

                Spacer()

            }

            .padding(.top, 60)

        }

    }

}

  


#Preview {

    LoginView()

}

代码解析

  1. ZStack:用于层叠背景和内容,创建深度感

  2. VStack:用于垂直排列各个部分,保持页面结构清晰

  3. HStack:用于水平排列忘记密码链接和注册链接

  4. Spacer:用于底部填充空间,将内容推到顶部

  5. TextField 和 SecureField:用于用户输入

  6. Button:用于操作按钮

  7. LinearGradient:用于创建美观的背景渐变

  8. ** @State**:用于管理视图状态

实战:创建一个产品详情页

需求分析

创建一个产品详情页,包含以下元素:

  1. 产品图片

  2. 产品标题和价格

  3. 产品描述

  4. 规格选择

  5. 购买按钮

代码实现


import SwiftUI

  


struct ProductDetailView: View {

    // 状态变量

    @State private var selectedColor = "红色"

    @State private var selectedSize = "M"

    @State private var quantity = 1

    

    // 产品数据

    let productName = "SwiftUI 高级教程"

    let productPrice = "¥99.00"

    let productDescription = "本教程涵盖了 SwiftUI 的高级特性,包括动画、手势、布局和性能优化等内容。通过实际项目案例,帮助你掌握 SwiftUI 的核心概念和最佳实践。"

    let colors = ["红色", "蓝色", "黑色"]

    let sizes = ["S", "M", "L", "XL"]

    

    var body: some View {

        ScrollView {

            VStack(spacing: 20) {

                // 产品图片

                ZStack {

                    Color.gray.opacity(0.1)

                        .frame(height: 300)

                    

                    Image(systemName: "book.fill")

                        .resizable()

                        .aspectRatio(contentMode: .fit)

                        .frame(width: 150, height: 150)

                        .foregroundStyle(.blue)

                }

                

                // 产品信息

                VStack(alignment: .leading, spacing: 12) {

                    HStack {

                        Text(productName)

                            .font(.title)

                            .fontWeight(.bold)

                        Spacer()

                        Text(productPrice)

                            .font(.title)

                            .fontWeight(.bold)

                            .foregroundStyle(.red)

                    }

                    

                    // 产品描述

                    Text(productDescription)

                        .foregroundStyle(.secondary)

                        .lineLimit(nil)

                    

                    // 颜色选择

                    Text("颜色")

                        .font(.headline)

                    HStack(spacing: 10) {

                        ForEach(colors, id: \.self) {

                            color in

                            Button(action: {

                                selectedColor = color

                            }) {

                                Text(color)

                                    .padding(8)

                                    .background(selectedColor == color ? Color.blue : Color.gray.opacity(0.1))

                                    .foregroundStyle(selectedColor == color ? .white : .primary)

                                    .cornerRadius(4)

                            }

                        }

                    }

                    

                    // 尺寸选择

                    Text("尺寸")

                        .font(.headline)

                    HStack(spacing: 10) {

                        ForEach(sizes, id: \.self) {

                            size in

                            Button(action: {

                                selectedSize = size

                            }) {

                                Text(size)

                                    .padding(8)

                                    .background(selectedSize == size ? Color.blue : Color.gray.opacity(0.1))

                                    .foregroundStyle(selectedSize == size ? .white : .primary)

                                    .cornerRadius(4)

                            }

                        }

                    }

                    

                    // 数量选择

                    Text("数量")

                        .font(.headline)

                    HStack {

                        Button(action: {

                            if quantity > 1 {

                                quantity -= 1

                            }

                        }) {

                            Image(systemName: "minus.circle")

                                .font(.system(size: 24))

                        }

                        

                        Text("\(quantity)")

                            .font(.headline)

                            .padding(.horizontal, 20)

                        

                        Button(action: {

                            quantity += 1

                        }) {

                            Image(systemName: "plus.circle")

                                .font(.system(size: 24))

                        }

                    }

                }

                .padding()

                

                // 购买按钮

                Button("加入购物车") {

                    print("加入购物车")

                }

                .buttonStyle(.borderedProminent)

                .tint(.blue)

                .padding(.horizontal)

                .frame(maxWidth: .infinity)

                .padding(.bottom, 30)

            }

        }

        .navigationTitle("产品详情")

        .navigationBarTitleDisplayMode(.inline)

    }

}

  


#Preview {

    ProductDetailView()

}

代码解析

  1. ScrollView:用于滚动显示产品详情

  2. ZStack:用于显示产品图片和背景

  3. VStack:用于垂直排列产品信息

  4. HStack:用于水平排列价格、颜色选择、尺寸选择和数量控制

  5. Button:用于选择颜色、尺寸和调整数量

  6. ** @State**:用于管理用户选择的状态

小结

本章详细介绍了 SwiftUI 中的基础布局系统,包括:

  • VStack:垂直堆叠视图,适用于从上到下的布局

  • HStack:水平堆叠视图,适用于从左到右的布局

  • ZStack:层叠视图,适用于需要层级关系的布局

  • 间距与对齐:控制视图之间的空间和位置关系

  • Spacer:占据剩余空间,实现灵活布局

  • 布局修饰符:控制视图的大小、位置和优先级

  • 网格布局:LazyVGrid、LazyHGrid,用于创建网格

  • 列表和表单:List、Form,用于显示列表和表单

  • 几何读取器:GeometryReader,用于获取父视图尺寸

  • 其他布局组件:Divider、Group、自定义布局

  • 实战案例:登录页面和产品详情页的完整实现

布局最佳实践

  1. 保持简洁:使用最少的容器实现所需布局

  2. 嵌套合理:避免过深的布局嵌套

  3. 响应式设计:考虑不同屏幕尺寸的布局适配

  4. 性能优化:对于大量数据使用 Lazy 容器

  5. 一致性:保持间距和对齐的一致性

  6. 可访问性:确保布局对所有用户都友好

通过本章的学习,你已经掌握了 SwiftUI 中最基本的布局技巧,能够创建各种常见的布局结构。在实际开发中,你可以根据具体需求选择合适的布局容器和技术,创建美观、响应式的用户界面。

参考资料

本内容为《SwiftUI 基础教程》第四章,欢迎关注后续更新。

第3章:基础视图组件

Snip20260416_1.png

示例代码都放这里啦,有需要的可以下载学习。swiftUIDemo

3.1 文本显示:Text

Text 组件介绍

Text 是 SwiftUI 中最基本的视图组件,用于显示文本内容。它支持富文本、字体样式、颜色等多种属性。

基本用法

// 基本文本
Text("Hello, SwiftUI!")

// 带样式的文本
Text("Hello, SwiftUI!")
    .font(.largeTitle)         // 设置字体大小
    .fontWeight(.bold)         // 设置字重
    .foregroundStyle(.blue)    // 设置文本颜色
    .italic()                  // 斜体
    .underline()               // 下划线
    .strikethrough()           // 删除线

富文本

// 富文本
Text("Hello, \(Text("SwiftUI").foregroundStyle(.blue).bold())!")

// 多行文本
Text("这是一段多行文本,\n可以通过反斜杠 n 来换行,\n或者直接在字符串中换行。")
    .multilineTextAlignment(.center)  // 多行文本对齐方式
    .lineLimit(3)                     // 限制行数
    .truncationMode(.tail)            // 截断方式

本地化

// 本地化文本
Text("welcome_message")  // 从 Localizable.strings 文件中读取

// 带参数的本地化
Text("greeting", comment: "欢迎语")

// 格式化文本
let name = "张三"
Text("欢迎 %@", name)

日期和数字格式化

// 日期格式化
let date = Date()
Text(date, style: .date)           // 仅日期
Text(date, style: .time)           // 仅时间
Text(date, style: .relative)       // 相对时间
Text(date, style: .offset)         // 时间偏移
Text(date, style: .timer)          // 计时器

// 数字格式化
let number = 123456.789
Text(number, format: .number)
Text(number, format: .currency(code: "CNY"))
Text(number, format: .percent)

3.2 图片显示:Image

Image 组件介绍

Image 用于显示图片,可以从系统图标、资源文件或网络加载图片。

基本用法

// 系统图标
Image(systemName: "star.fill")

// 资源文件图片
Image("avatar")

// 网络图片 (iOS 15+)
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { phase in
    switch phase {
    case .empty:
        ProgressView()  // 加载中
    case .success(let image):
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
    case .failure:
        Image(systemName: "photo")  // 加载失败
    @unknown default:
        EmptyView()
    }
}

图片修饰符

Image("avatar")
    .resizable()                 // 可调整大小
    .aspectRatio(contentMode: .fit)  // 内容模式
    .frame(width: 100, height: 100)  // 设置大小
    .clipShape(Circle())         // 裁剪形状
    .overlay(                    // 叠加内容
        Circle()
            .stroke(Color.blue, lineWidth: 2)
    )
    .shadow(radius: 5)           // 阴影
    .opacity(0.8)                // 透明度

系统图标

// 系统图标
Image(systemName: "heart.fill")
    .foregroundStyle(.red)
    .font(.system(size: 24))

// 多色图标 (iOS 15+)
Image(systemName: "person.fill.badge.plus")
    .symbolRenderingMode(.multicolor)

// 可变颜色图标
Image(systemName: "star")
    .foregroundStyle(.yellow)

3.3 按钮交互:Button

Button 组件介绍

Button 用于创建可点击的按钮,支持多种样式和交互方式。

基本用法

// 基本按钮
Button("点击我") {
    print("按钮被点击了")
}

// 带图标的按钮
Button {
    print("按钮被点击了")
} label: {
    HStack {
        Image(systemName: "star.fill")
        Text("喜欢")
    }
}

// 带角色的按钮
Button("删除", role: .destructive) {
    print("删除操作")
}

按钮样式

// 边框按钮
Button("边框按钮") {
    // 操作
}
.buttonStyle(.bordered)

// 突出显示的按钮
Button("突出按钮") {
    // 操作
}
.buttonStyle(.borderedProminent)
.tint(.blue)  // 按钮颜色

// 胶囊按钮
Button("胶囊按钮") {
    // 操作
}
.buttonStyle(.borderedProminent)
.tint(.green)
.cornerRadius(20)

// 文本按钮
Button("文本按钮") {
    // 操作
}
.buttonStyle(.plain)

禁用状态

@State private var isEnabled = false

Button("禁用按钮") {
    // 操作
}
.disabled(!isEnabled)
.opacity(isEnabled ? 1.0 : 0.5)

3.4 输入控件:TextField、SecureField、TextEditor

TextField 文本输入框

@State private var text = ""

TextField("请输入文本", text: $text)
    .textFieldStyle(.roundedBorder)  // 边框样式
    .padding()                      // 内边距
    .keyboardType(.default)         // 键盘类型
    .autocapitalization(.sentences) // 自动大写
    .autocorrectionDisabled(true)   // 禁用自动纠正

// 带提示的 TextField
TextField(
    "请输入用户名",
    text: $text,
    prompt: Text("用户名不能为空")
        .foregroundStyle(.secondary)
)
.textFieldStyle(.roundedBorder)

SecureField 安全输入框

@State private var password = ""

SecureField("请输入密码", text: $password)
    .textFieldStyle(.roundedBorder)
    .padding()

// 带可见性切换的密码输入
@State private var showPassword = false

ZStack(alignment: .trailing) {
    if showPassword {
        TextField("请输入密码", text: $password)
    } else {
        SecureField("请输入密码", text: $password)
    }
    Button(action: {
        showPassword.toggle()
    }) {
        Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
            .foregroundStyle(.secondary)
            .padding(.trailing, 8)
    }
}
.textFieldStyle(.roundedBorder)
.padding()

TextEditor 多行文本编辑器

@State private var message = ""

TextEditor(text: $message)
    .frame(height: 150)           // 设置高度
    .border(Color.gray.opacity(0.3), width: 1)  // 边框
    .cornerRadius(8)              // 圆角
    .padding()
    .foregroundStyle(.primary)    // 文本颜色

// 带占位符的 TextEditor
ZStack(alignment: .topLeading) {
    TextEditor(text: $message)
        .frame(height: 150)
        .padding(8)
    
    if message.isEmpty {
        Text("请输入消息...")
            .foregroundStyle(.secondary)
            .padding(10)
            .allowsHitTesting(false)  // 允许点击穿透
    }
}
.border(Color.gray.opacity(0.3), width: 1)
.cornerRadius(8)
.padding()

3.5 开关与选择:Toggle、Picker、Slider、Stepper

Toggle 开关

@State private var isEnabled = false

Toggle("启用功能", isOn: $isEnabled)
    .toggleStyle(.switch)  // 开关样式
    .padding()

// 带图标的 Toggle
Toggle(isOn: $isEnabled) {
    HStack {
        Image(systemName: "bell.fill")
        Text("接收通知")
    }
}
.toggleStyle(.switch)
.padding()

Picker 选择器

@State private var selectedOption = "选项1"
let options = ["选项1", "选项2", "选项3"]

// 分段控件样式
Picker("选择", selection: $selectedOption) {
    ForEach(options, id: \.self) {
        Text($0)
    }
}
.pickerStyle(.segmented)
.padding()

// 菜单样式
Picker("选择", selection: $selectedOption) {
    ForEach(options, id: \.self) {
        Text($0)
    }
}
.pickerStyle(.menu)
.padding()

// 轮盘样式(iOS 14+)
@State private var selectedColor = Color.red
let colors: [Color] = [.red, .green, .blue, .yellow]

Picker("颜色", selection: $selectedColor) {
    ForEach(colors, id: \.self) {
        ColorPickerView(color: $0)
    }
}
.pickerStyle(.wheel)
.frame(height: 200)
.padding()

// 辅助视图
struct ColorPickerView: View {
    let color: Color
    var body: some View {
        HStack {
            Rectangle()
                .fill(color)
                .frame(width: 20, height: 20)
                .cornerRadius(4)
            Text(String(describing: color))
        }
    }
}

Slider 滑块

@State private var value = 0.5

Slider(value: $value, in: 0...1)
    .padding()
    .tint(.blue)  // 滑块颜色

// 带标签的滑块
Slider(
    value: $value,
    in: 0...1,
    label: { Text("亮度") },
    minimumValueLabel: { Text("暗") },
    maximumValueLabel: { Text("亮") }
)
.padding()

// 整数滑块
@State private var intValue = 5

Slider(value: Binding(
    get: { Double(intValue) },
    set: { intValue = Int($0) }
), in: 0...10, step: 1)
.padding()
Text("值:\(intValue)")

Stepper 步进器

@State private var count = 0

Stepper("数量:\(count)", value: $count)
    .padding()

// 带范围的步进器
Stepper(
    "数量:\(count)",
    value: $count,
    in: 0...10,
    step: 2
)
.padding()

// 带标签的步进器
Stepper {
    Text("数量:\(count)")
} onIncrement: {
    count += 1
    print("增加到:\(count)")
} onDecrement: {
    count -= 1
    print("减少到:\(count)")
}
.padding()

3.6 进度指示:ProgressView

不确定进度

// 基本进度指示器
ProgressView()

// 带标签的进度指示器
ProgressView("加载中...")

// 带样式的进度指示器
ProgressView("处理中...")
    .progressViewStyle(.circular)
    .tint(.blue)
    .padding()

确定进度

@State private var progress = 0.0

ProgressView("下载进度", value: progress, total: 1.0)
    .padding()

// 带百分比的进度条
ProgressView(
    value: progress,
    total: 1.0,
    label: { Text("下载进度") },
    currentValueLabel: { Text("\(Int(progress * 100))%") }
)
.padding()

// 水平进度条样式
ProgressView(value: progress, total: 1.0)
    .progressViewStyle(.linear)
    .tint(.green)
    .frame(height: 10)
    .padding()

实战:创建一个用户设置页面

需求分析

创建一个包含以下元素的用户设置页面:

  1. 个人信息区域
  2. 通知设置(开关)
  3. 主题选择(选择器)
  4. 字体大小(滑块)
  5. 清除缓存按钮
  6. 退出登录按钮

代码实现

import SwiftUI

struct SettingsView: View {
    // 状态变量
    @State private var notificationsEnabled = true
    @State private var selectedTheme = "浅色"
    @State private var fontSize = 16.0
    @State private var cacheSize = "128 MB"
    
    // 主题选项
    let themes = ["浅色", "深色", "跟随系统"]
    
    var body: some View {
        NavigationStack {
            List {
                // 个人信息区域
                Section {
                    HStack {
                        Image(systemName: "person.circle.fill")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 60, height: 60)
                            .foregroundStyle(.blue)
                        
                        VStack(alignment: .leading, spacing: 4) {
                            Text("张三")
                                .font(.headline)
                            Text("zhangsan@example.com")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                        Spacer()
                        Image(systemName: "chevron.right")
                            .foregroundStyle(.secondary)
                    }
                    .padding(.vertical, 8)
                }
                
                // 通知设置
                Section("通知设置") {
                    Toggle("接收推送通知", isOn: $notificationsEnabled)
                    Toggle("声音提醒", isOn: $notificationsEnabled)
                    Toggle("振动提醒", isOn: $notificationsEnabled)
                }
                
                // 外观设置
                Section("外观设置") {
                    Picker("主题", selection: $selectedTheme) {
                        ForEach(themes, id: \.self) {
                            Text($0)
                        }
                    }
                    .pickerStyle(.menu)
                    
                    VStack(alignment: .leading, spacing: 8) {
                        Text("字体大小:\(Int(fontSize))")
                        Slider(value: $fontSize, in: 12...24, step: 1)
                            .tint(.blue)
                    }
                }
                
                // 存储设置
                Section("存储设置") {
                    HStack {
                        Text("缓存大小")
                        Spacer()
                        Text(cacheSize)
                            .foregroundStyle(.secondary)
                    }
                    Button("清除缓存") {
                        // 清除缓存逻辑
                        print("清除缓存")
                    }
                    .foregroundStyle(.blue)
                }
                
                // 账户设置
                Section {
                    Button("关于我们") {
                        // 关于我们逻辑
                    }
                    Button("隐私政策") {
                        // 隐私政策逻辑
                    }
                    Button("退出登录", role: .destructive) {
                        // 退出登录逻辑
                    }
                }
            }
            .navigationTitle("设置")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

#Preview {
    SettingsView()
}

代码解析

  • List 和 Section:使用列表和分组组织设置项
  • NavigationStack:提供导航功能
  • Toggle:用于开关设置
  • Picker:用于主题选择
  • Slider:用于调整字体大小
  • Button:用于操作按钮
  • HStack 和 VStack:用于布局
  • @State:用于管理视图状态

小结

本章介绍了 SwiftUI 中的基础视图组件,包括:

  • Text:文本显示,支持富文本、本地化和格式化
  • Image:图片显示,支持系统图标、资源文件和网络图片
  • Button:按钮交互,支持多种样式和角色
  • 输入控件:TextFieldSecureFieldTextEditor
  • 选择控件:TogglePickerSliderStepper
  • 进度指示:ProgressView
  • 一个完整的用户设置页面实战

通过本章的学习,你已经掌握了 SwiftUI 中最常用的基础组件,能够创建各种常见的用户界面元素。


参考资料


本内容为《SwiftUI 基础教程》第三章,欢迎关注后续更新。

❌