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 开发中一个非常常见的问题,并借助一步步溯本回原的推理找到症结根本之所在,最后一发入魂将其完美解决!相信小伙伴们都能由此受益匪浅。

感谢观赏,再会!😎

相关推荐
今天也想MK代码2 天前
在Swift开发中简化应用程序发布与权限管理的解决方案——SparkleEasy
前端·javascript·chrome·macos·electron·swiftui
CYRUS STUDIO4 天前
adb 远程调试,手动修改 adb 调试授权信息
android·adb·调试
云中双月6 天前
如何使用Ida Pro和Core Dump文件定位崩溃位置(Linux下无调试符号的进程专享)
linux·嵌入式·gdb·调试·gcc·崩溃·ida pro·ulimit·core dump·cross compile
Just_Paranoid7 天前
开源库 FloatingActionButton
android·view·floatingbutton
東三城7 天前
【ios】---SwiftUI开发从入门到放弃
ios·swiftui·swift·1024程序员节
今天也想MK代码10 天前
基于swiftui 实现3D loading 动画效果
ios·swiftui·swift
胖虎111 天前
SwiftUI(五)- ForEach循环创建视图&尺寸类&安全区域
ios·swiftui·swift·foreach·安全区域
skylin1984010115 天前
iOS调试真机出现的 “__llvm_profile_initialize“ 错误
ios·objective-c·调试·1024程序员节
Projectsauron15 天前
STM32 调试之栈回溯和 CmBacktrace 的使用
stm32·嵌入式·调试·1024程序员节
zyosasa17 天前
SwiftUI 精通之路 11: 栅格布局
前端·swiftui·swift