SwiftUI下拉Sheet时卡顿并导致CPU过高的问题
SwiftUI下拉Sheet时卡顿并导致CPU过高的问题

SwiftUI下拉Sheet时卡顿并导致CPU过高的问题

问题描述

在使用SwiftUI开发iOS应用《汇率仓库》的过程中,发现每次下拉关闭Sheet视图时,都会出现明显的卡顿。

上面的Gif图演示了这两个关闭方式,当点击调用dismiss的按钮时,Sheet视图是立即关闭的。但是,当逐渐下拉Sheet视图时,就会出现明显的卡顿。

通过Xcode调试应用,也可以在监控资源的过程中发现,CPU在手动下拉关闭Sheet的过程中明显升高至 160% 左右。

排查原因

为了寻找导致CPU过高的代码,使用排除法,将主视图的代码依次隐藏。

问题代码1

计算变量导致卡顿。

HStack(spacing:0){
    Text(currencySymbols[appStorage.localCurrency] ?? "USD")
    Text("  ")
    Text("\(totalAmount.0)")    // 问题代码
     // 显示小数部分(格式化为两位小数)
    Text(String(format: "%.2f", totalAmount.1).dropFirst(1)) // 去掉小数点符号,问题代码
        .foregroundColor(.gray)  // 小数部分使用灰色字体
}

当隐藏大部分代码,显示totalAmount计算变量时,手动关闭Sheet就会出现卡顿,因此初步定位的问题代码是,计算变量导致。

// 计算总金额的计算属性
var totalAmount: (Int,Double) {
    // 获取最新的汇率数据
    let latestRates = fetchLatestRates()
    rateDict = Dictionary(uniqueKeysWithValues: latestRates.map { ($0.symbol ?? "", $0.rate) })
    
    // 计算所有外币的金额
    var total = 0.0
    for userCurrency in userForeignCurrencies {
        if let symbol = userCurrency.symbol, let rate = rateDict[symbol],let localCurrency = rateDict[appStorage.localCurrency] ,rate > 0, localCurrency > 0 {
            total += userCurrency.amount / rate * localCurrency
        }
    }
    
    currencyCount = total
    // 将 totalAmount 拆分为整数部分和小数部分
    if total.isFinite {
        let integerPart = Int(total)
        let decimalPart = total - Double(integerPart)
        return (integerPart,decimalPart)
    } else {
        return (0,0.0)
    }
}

问题原因1

因为totalAmount是一个计算属性,它会在每次视图渲染时重新执行,这里面又调用了Core Data的fetch操作。

var totalAmount: (Int, Double) {
    let latestRates = fetchLatestRates()   // 每次访问 totalAmount 都会执行 Core Data 读取!
    ...
    return (integerPart, decimalPart)
}

在SwiftUI中又调用了这个计算属性:

Text("\(totalAmount.0)")
Text(String(format: "%.2f", totalAmount.1))

每次SwiftUI渲染这两个Text时,都会重新出发totalAmount,从而调用:

1) fetchLatestRates()(磁盘读操作)

2)计算汇率

3)更新 rateDict 和 currencyCount 等变量。

更具体的讲,是当我下拉Sheet关闭时,SwiftUI会快速重绘主视图以实现平滑动画,这里的totalAmount被反复触发调用,就导致:

1)多次 Core Data 访问

2)汇率计算重复执行

3)视图结构重新构建

4)CPU 飙升,性能下降

解决方案1

缓存计算结果,而不是在 var 里每次重新计算

可以把 totalAmount 从计算属性改成普通变量或 @State / @Published 存储值,只在需要的时候更新一次,比如在数据加载或 sheet 关闭时:

@State private var totalAmount: (Int, Double) = (0, 0.0)

func updateTotalAmount() {
    let latestRates = fetchLatestRates()
    rateDict = Dictionary(uniqueKeysWithValues: latestRates.map { ($0.symbol ?? "", $0.rate) })
    
    var total = 0.0
    for userCurrency in userForeignCurrencies {
        if let symbol = userCurrency.symbol,
           let rate = rateDict[symbol],
           let localCurrency = rateDict[appStorage.localCurrency],
           rate > 0, localCurrency > 0 {
            total += userCurrency.amount / rate * localCurrency
        }
    }
    
    currencyCount = total
    if total.isFinite {
        let integerPart = Int(total)
        let decimalPart = total - Double(integerPart)
        self.totalAmount = (integerPart, decimalPart)
    } else {
        self.totalAmount = (0, 0.0)
    }
}

在视图打开时调用该方法:

.onAppear {
    updateTotalAmount()
}

除“管理外币”视图外,其他的视图在手动下拉关闭Sheet并返回主视图时,不再出现卡顿并导致CPU过高的问题。

这里有人可能认为可以给计算属性加上@State,这样只有数据修改时SwiftUI才会重新渲染,但事实上会存在语法错误。

这是因为Swift不允许@State用于计算属性。

现在还剩下“管理外币”视图在关闭的过程中,仍然存在这一卡顿的问题,下面是关于“管理外币”视图的代码分析。

问题代码2

在”管理外币“视图中,涉及到41种外币,所以绑定了41个TextField比较多。

TextField("0.0", text: Binding(get: {
    inputAmounts[currency.symbol ?? ""] ?? ""
}, set: { newValue in
    inputAmounts[currency.symbol ?? ""] = newValue
}))
.keyboardType(.decimalPad) // 数字小数点键盘
.focused($focusedField, equals: .symbol(currency.symbol ?? "")) // 添加这一行
.multilineTextAlignment(.trailing)
.padding(.leading,10)
.onChange(of: focusedField) { newFocus in
    // 当失去焦点,处理文本框关于 CoreData 方法
    if newFocus != .symbol(currency.symbol ?? "") {
        handleInputChange(for: currency.symbol ?? "", newValue: inputAmounts[currency.symbol ?? ""] ?? "")
    }
}

未隐藏TextField时,手动关闭Sheet视图并返回到主视图时,CPU达到100%并且卡顿明显。

当隐藏全部TextField后,虽然 CPU占比仍然高达90%,但是手动关闭Sheet视图时卡顿消除。

问题原因2

当只显示3个TextField并且不添加修饰符时,SwiftUI显示就存在明显卡顿。

TextField("0.0", text: Binding(get: {
    inputAmounts[currency.symbol ?? ""] ?? ""
}, set: { newValue in
    inputAmounts[currency.symbol ?? ""] = newValue
}))

因此,初步判断卡顿问题的原因,是TextField + Binding + Sheet的组合导致性能卡顿。

虽然只是3个TextField,但是只要绑定的状态是复杂或动态key的字典型Binding,就会导致SwiftUI在Sheet关闭时:

1)尝试强制释放 TextField 的所有绑定状态

2)SwiftUI 内部执行大量 diff + 绑定更新检查(尤其是绑定在 ForEach 中)

3)与 Sheet 的关闭动画同时触发,造成 UI 卡顿

首先是现在TextField显示,使用LazyVStack替代VStack,这样 SwiftUI 可以懒加载只渲染当前屏幕内可见的输入框,极大减少开销。

替代后,手动下拉Sheet的CPU从100%降到了80%左右。

ScrollView(showsIndicators: false) {
    LazyVStack {    // 懒加载垂直堆叠视图
        // 其他代码
    }
}

但是卡顿仍然存在。

经过一系列测试发现,这个问题确实比较难规避,因为TextField 是控件中最容易触发 View diff 和 layout 的,因为它:

1)使用 UIKit 的 UITextField 做桥接

2)键盘出现/隐藏时,伴随多个 GeometryReader、SafeAreaInset 等系统层更新

3)会持续读取绑定值,导致每次 diff 都需“拉取最新状态”

所以,当我用 Dictionary 做 Binding,如:

TextField("0.0", text: Binding(get: {
    inputAmounts[currency.symbol ?? ""] ?? ""
}, set: { newValue in
    inputAmounts[currency.symbol ?? ""] = newValue
}))

SwiftUI 无法精准追踪是哪个 key 改了,它会导致整个 sheet 的状态树做 diff。即便只改动一个 key,SwiftUI 仍然倾向于刷新大块内容。

接着,我逐渐注释代码,最后发现,当TextField的Binding设置为空时,Sheet手动关闭不再卡顿。

TextField("0.0", text: Binding(get: {
    return ""
    // inputAmounts[currency] ?? ""
}, set: { newValue in
    // inputAmounts[currency] = newValue
}))
.keyboardType(.decimalPad) // 数字小数点键盘
.focused($focusedField, equals: .symbol(currency)) // 添加这一行
.multilineTextAlignment(.trailing)
.padding(.leading,10)
.onChange(of: focusedField) { newFocus in
    // 当失去焦点,处理文本框关于 CoreData 方法
    if newFocus != .symbol(currency) {
        handleInputChange(for: currency, newValue: inputAmounts[currency] ?? "")
    }
}

因此,判断“管理外币”视图手动下拉关闭时,卡顿的原因就是“TextField 绑定到字典(如 inputAmounts[currency])”导致的,特别是在Sheet的场景下,手动下拉关闭更容易卡顿、动画掉帧或退出延迟。

可能的原因为:字典绑定不是 SwiftUI 的理想数据结构,SwiftUI 的状态系统(@State, @Binding)期望的是结构化、稳定、可跟踪的值变化。

inputAmounts[currency] // 每次都会返回一个新的 Optional 值

SwiftUI 无法精确追踪每个绑定值的变化路径,因此:

1)每次更新都可能让整个 View 或 Sheet 重建。

2)尤其是关闭 Sheet 时的 diff+layout 阶段,会遇到额外重计算和潜在冲突。

解决方案2

针对这个TextField +. Binding + 字典目前还没有比较好的解决方案,如果是iOS16+,可以考虑禁止Sheet下拉:

.sheet(isPresented: $showingSheet) {
    YourSheetView()
        .interactiveDismissDisabled(true)
}

或者使用FullScreen

我有尝试改用@State列表数组,而非字典绑定,因为考虑到字典绑定可能导致性能差,所以替代为数组。

创建一个存储货币和金额的结构:

struct CurrencyInput: Identifiable {
    let id: String   // symbol
    var value: String
}

在视图中创建@State列表数组:

@State private var inputs: [CurrencyInput] = []

通过下标id去定位数据:

TextField("0.0", text: Binding(
    get: {
        inputs.first(where: { $0.id == currency.symbol })?.value ?? ""
    },
    set: { newValue in
        if let index = inputs.firstIndex(where: { $0.id == currency.symbol }) {
            inputs[index].value = newValue
        } else {
            inputs.append(CurrencyInput(id: currency.symbol, value: newValue))
        }
    }
))

总的来说,及时将绑定字典改为@State列表数组,仍然存在下拉Sheet视图卡顿,虽然CPU在这里只有70%左右的占比。

以上就是全部的内容分析。

相关文章

1、SwiftUI的diff系统(Diffing System):https://fangjunyu.com/2025/05/03/swiftui%e7%9a%84diff%e7%b3%bb%e7%bb%9f%ef%bc%88diffing-system%ef%bc%89/

2、SwiftUI布局系统(layout system)之三步布局:https://fangjunyu.com/2024/12/20/swiftui%e4%b8%89%e6%ad%a5%e5%b8%83%e5%b1%80/

3、SwiftUI全屏视图fullScreenCover:https://fangjunyu.com/2025/01/02/swiftui%e5%85%a8%e5%b1%8f%e8%a7%86%e5%9b%befullscreencover/

   

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开发者。

欢迎加入我们的 微信交流群QQ交流群,交流更多精彩内容!
微信交流群二维码 QQ交流群二维码

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注