SwiftUI 代码调试之都是“变心”惹的祸

0. 概览

这是一段非常简单的 SwiftUI 代码,我们将 Item 数组传递到子视图并在子视图中对其进行修改,修改的结果会立即在主视图中反映出来。

不幸的是,当我们修改 Item 名称时却发现不能连续输入:每次敲一个字符键盘都会立即收起并且原输入焦点会马上丢失,这是怎么回事呢?

在本篇博文中您将学到以下内容

  • [0. 概览](#0. 概览)
  • [1. 不该发生的错误](#1. 不该发生的错误)
  • [2. 无效的尝试:用子视图包装](#2. 无效的尝试:用子视图包装)
  • [3. 寻根究底](#3. 寻根究底)
  • [4. 解决之道](#4. 解决之道)
  • 总结

该问题这是初学者在 SwiftUI 开发中常常会犯的一个错误,不过看完本篇之后相信大家都会对此自胸有成竹!

废话不再,Let's fix it!!!😉


1. 不该发生的错误

照例我们先看一下源代码。

例子中我们创建了 Item 结构用来作为 Model 中的"真相之源"。


想要了解更多 SwiftUI 编程和"真相之源"奥秘的小伙伴们,请观赏我专题专栏中的如下文章:


注意,我们让 Item 遵守了 Identifiable 协议,这样可以更好的适配 SwiftUI 列表中的显示:

swift 复制代码
struct Item: Identifiable {
    
    var id: String {
        name
    }
    
    var name: String
    var count: Int
}

let g_items: [Item] = [
    .init(name: "宇宙魔方", count: 11),
    .init(name: "宝石手套", count: 1),
    .init(name: "大黄蜂", count: 1)
]

接下来是主视图 ItemListView,可以看到我们将 items 状态传递到子视图的 ForEach 循环中去了:

swift 复制代码
struct ItemListView: View {
    @State var items = g_items
    
    private var total: Int {
        items.reduce(0) { $0 + $1.count}
    }
    
    private var desc: [String] {
        items.reduce([String]()) { $0 + [$1.name]}
    }
    
    var body: some View {
        NavigationStack {
            // 子视图 ForEach 循环...
            ForEach($items) { $item in
				// 代码马上来...
			}
            
            VStack {
                Text(desc.joined(separator: ","))
                    .font(.title3)
                    .foregroundStyle(.pink)
                HStack {
                    Text("宝贝总数量:\(total)")
                        .font(.headline)
                    
                    Spacer().frame(width: 20)
                    
                    Button("所有 +1"){
                        for idx in items.indices {
                            guard items[idx].count < 100 else { continue}
                            
                            items[idx].count += 1
                        }
                    }
                    .font(.headline)
                    .buttonStyle(.borderedProminent)
                }
            }.offset(y: 200)
        }
    }
}

最后是 ForEach 循环中的内容,如下所示我们用单个 item 的值绑定来实现修改其内容的目的:

swift 复制代码
ForEach($items) { $item in
    HStack {
        
        TextField("输入项目名称", text: $item.name)
            .font(.title2.weight(.heavy))
        
        
        Text("数量:\(item.count)")
            .foregroundStyle(.gray)
        
        Slider(value: .init(get: {
            Double(item.count)
        }, set: {
            item.count = Int($0)
        }), in: 0.0...100.0)
    }
}
.padding()

这样一段看起来"天衣无缝"的代码为什么会出现在更改 Item 名称时键盘反复关闭、输入焦点丢失的问题呢?

2. 无效的尝试:用子视图包装

我们首先猜测是子视图中 Item 名称的更改导致了父视图的"冗余"刷新,从而引起键盘不正确被重置。


更多 SwiftUI 和 Swift 代码调试的例子,请观赏我专题专栏中的博文:


因为键盘所属的视图发生重建所以键盘本身也会被重置,那么如何验证我们的猜测呢?一种方式是使用如下的调试技术:

在这里我们假设病根果真如此。那么一种常用的解决办法立即浮现于脑海:我们可以将引起刷新的子视图片段包装在新的 View 结构中,这样做到原因是 SwiftUI 渲染器足够智能可以只刷新子视图而不是父视图中大段内容的更改。


更详细的原理请参考如下链接:


So,让我撸起袖子开动起来!

首先,将 ForEach 循环中编辑单个 Item 的 View 包装为一个新的视图 ItemEditView:

swift 复制代码
struct ItemEditView: View {
    @Binding var item: Item
    
    var body: some View {
        HStack {
            
            TextField("输入项目名称", text: $item.name)
                .font(.title2.weight(.heavy))
            
            
            Text("数量:\(item.count)")
                .foregroundStyle(.gray)
            
            Slider(value: .init(get: {
                Double(item.count)
            }, set: {
                item.count = Int($0)
            }), in: 0.0...100.0)
        }
    }
}

接着,我们将 ForEach 循环本身用一个新视图取代:

swift 复制代码
struct EditView: View {
    
    @Binding var items: [Item]
    
    var body: some View {
        ForEach($items) { $item in
            ItemEditView(item: $item)
        }
        .padding()
    }
}

最后,我们所要做的就是将父视图 ItemListView 中的 ForEach 循环变为 EditView 视图:

swift 复制代码
NavigationStack {
    EditView(items: $items)

    // 其它代码不变...
}

再次运行代码...不幸的是问题依旧:

看来这并不是简单父视图"过度"刷新的问题,一定是有什么不应有的行为触发了父视图的刷新,到底是什么呢?

3. 寻根究底

问题一定出在 ForEach 循环里!

回顾之前 Item 的定义,我们用 Identifiable 协议满足 ForEach 对子项目唯一性的挑剔,我们用 Item.name 构建了 id 属性。

当 Model 元素遵守 Identifiable 协议时,应该确保在任意时刻所有 Item 的 id 属性值都是唯一的!从目前来看,上述代码在修改 Item 名称时并没有发生重名的情况(虽然可能发生),所以对于唯一性是没有问题的。


当然在实际代码中用户很可能会输入重复的 Item 名称,所以还是不可接收的。

不过,这段代码在这里只是作为例子来向大家展示解决问题的推理过程,所以不必深究 😉


但是 id 还有另一个重要的特征:稳定性

一般的,当 Identifiable 实体对象的 id 属性改变时,SwiftUI 会认为其不再是同一个对象,而立即刷新其所对应的视图界面。

所以,正如大家所看到的那样:每次用户输入 name 中的新字符时,键盘会被立即关闭焦点也随即丢失!

4. 解决之道

知道了问题原因,解决起来就很容易了。

我们只需要在 Item 生命周期中保证 id 的稳定性就可以了,这意味着不能再用 name 值作为 id 的"关联"值:

swift 复制代码
struct Item: Identifiable {
    let id = UUID()
    
    var name: String
    var count: Int
}

如上代码所示,我们在 Item 创建时为 id 生成一个唯一的 UUID 对象,这可以保证两点:

  • 任意时刻 Item 的唯一性;
  • 任意 Item 在其生命周期中的稳定性;

有了如上修改之后,我们再来运行代码看看结果:

可以看到,现在我们可以毫无问题的连续输入 Item 的名字了,焦点不会再丢失,一切回归正常,棒棒哒!!!💯

总结

在本篇博文中,我们讨论了 SwiftUI 开发中一个非常常见的问题,并借助一步步溯本回原的推理找到症结根本之所在,最后一发入魂将其完美解决!相信小伙伴们都能由此受益匪浅。

感谢观赏,再会!😎

相关推荐
大熊猫侯佩14 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)
swiftui·swift·apple watch
大熊猫侯佩14 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(四)
数据库·swiftui·apple watch
MaoJiu1 天前
Flutter造轮子系列:flutter_permission_kit
flutter·swiftui
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)
数据库·swiftui·swift
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)
数据库·swiftui·swift
大熊猫侯佩2 天前
用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化
数据库·swiftui·swift
大熊猫侯佩2 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)
数据库·swiftui·swift
大熊猫侯佩2 天前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩3 天前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩3 天前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple