众所周知,SwiftUI 是一个呼应式结构,这意味着,当数据源发生改变时,结构会自动更新视图。相同,当咱们想调整视图显现时,应直接对状况进行修正。可是,SwiftUI 中的一些体系控件并没有完全遵从呼应式的规划准则,由此在某些状况下会呈现严重的错误,影响用户体会,并使开发者无所适从。

本文将解析 SwiftUI 中两个因为未能贯彻呼应式编程准则而导致的严重错误,并供给相应的解决方案。这两个错误包含:经过手势撤销 Sheet 后,快速右滑导航容器导致运用锁死;以及在翻滚中回来上层视图时导致运用溃散。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】

视图改变在前、状况改变在后

在 SwiftUI 中,某些可编程控件在履行必定的操作时,会先更新视图,待视图改变完成后再修正与其对应的状况。这些控件基本上都是对 UIkit(AppKit)的二次包装。

Sheet

履行下面的代码,你能够清楚地看到,在经过手势撤销 Sheet 时,与其关联的状况是在 Sheet 完成撤销动画后才发生了改变。而经过调用环境值或直接修正绑定状况,SwiftUI 则遵从了呼应式编程准则,进行了的先调整状况,后更新视图的操作。

struct SheetDemo: View {
    @StateObject var store = SheetStore()
    var body: some View {
        Button("Show") {
            store.show.toggle()
        }
        .sheet(isPresented: $store.show) {
            SheetView()
                .environmentObject(store)
        }
    }
}
struct SheetView: View {
    @Environment(\.dismiss) var dismiss
    @EnvironmentObject var store: SheetStore
    var body: some View {
        VStack {
            Button("Dismiss by ENV") {
                print("Dismiss by ENV")
                dismiss()
            }
            Button("Dismiss by Store") {
                print("Dismiss by Store")
                store.show = false
            }
        }
    }
}
class SheetStore: ObservableObject {
    @Published var show = false {
        didSet {
            print("show \(show ? "T" : "F")")
        }
    }
}

请留意观察,在操作后命令行界面的输出状况。

解析 SwiftUI 中两处由状态更新滞后引发的严重 Bug

NavigationStack

NavigationStack 相同也存在类似的状况。运行下面的代码,点击左上方的回来按钮,与 NavigationStack 绑定的 path,直到视图回来上一层后,才会发生改变。经过环境值回来上层视图也相同需要等候视图回来后,才会修正状况。只有直接修正 path,SwiftUI 才干体现的像一个真实的呼应式编程结构。

struct NavigationStackDemo: View {
    @StateObject var store = StackStore()
    var body: some View {
        NavigationStack(path: $store.path) {
            List(0 ..< 20) { i in
                NavigationLink(value: i) { Text("\(i)") }
            }
            .navigationDestination(for: Int.self) { n in
                Row(n: n)
                    .environmentObject(store)
            }
        }
    }
}
struct Row: View {
    @Environment(\.dismiss) var dismiss
    @EnvironmentObject var store: StackStore
    let n: Int
    var body: some View {
        List {
            Button("Dismiss By ENV") {
                print("Dismiss By Env")
                dismiss()
            }
            Button("Dismiss By Store") {
                print("Dismiss by Store")
                store.path.removeLast()
            }
        }
        .navigationTitle("\(n)")
    }
}
class StackStore: ObservableObject {
    @Published var path = [Int]() {
        didSet {
            print("set path \(path)")
        }
    }
}

解析 SwiftUI 中两处由状态更新滞后引发的严重 Bug

这有什么问题吗?

假如仅从上述两个例子考虑,无论状况调整是否及时,都不会呈现什么错误的结果。可是,当运用程序处于某些特殊状况或用户进行某些特定操作时,状况更新的滞后会导致不可接受的后果。

经过手势撤销 Sheet 后,快速右滑导航容器会导致运用锁死

这是一个在 SwiftUI 所有版别中存在的错误,你能够在众多的论坛或谈天室里看到不少的开发者都在寻找解决方法。它的复现条件十分简略:

  • 在真机上测验( 模拟器上不简单复现 )
  • 点击 “GO” 按钮进入下一层视图
  • 点击 “Show Sheet” 按钮弹出 Sheet
  • 经过下滑手势撤销 Sheet
  • 在 Sheet 撤销后(动画结束时),立即在屏幕上由左至右滑动,回来上一层视图
  • 在滑动回来到上一层视图后,运用会锁死。
struct SheetDismissDemo: View {
    @State var showSheet = false
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("GO") {
                    VStack {
                        Button("Show Sheet") {
                            showSheet.toggle()
                        }
                        .sheet(isPresented: $showSheet) {
                            SheetDetailView()
                        }
                    }
                }
            }
        }
    }
}
struct SheetDetailView: View {
    var body: some View {
        Text("Sheet")
    }
}

留意观察,在测验运用手势回来上层视图后,左上角的 Back 按钮将消失,但视图并没有回来根视图

假如我告知你,上述状况正是由前文提到的状况更新滞后所导致,那么你该怎么防止这个问题呢?

咱们首要做一个测验:

struct SheetDetailView: View {
    @Binding var isPresented: Bool
    var body: some View {
        Button("Dismiss") {
            isPresented = false
        }
    }
}

在修正了 SheetDetailView 的代码后,咱们不再运用手势来撤销 Sheet,而是经过点击 “Dismiss” 按钮来实现这一操作。再次履行上述进程,您会发现在回来上层视图后,运用并不会锁死,一切都恢复了正常。

然而,明显地,逼迫用户点击 “Dismiss” 按钮并不是一个好的挑选,特别是在没有屏蔽手势撤销 Sheet 的状况下。

经过下面的代码,咱们能够让用户运用下滑手势来撤销 Sheet,一起又不会导致运用锁死。

struct SheetDismissDemo: View {
    @State var showSheet = false
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("GO") {
                    VStack {
                        Button("Show Sheet") {
                            showSheet.toggle()
                        }
                        .sheet(isPresented: $showSheet) {
                            SheetDetailView()
                        }
                    }
                }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(
            Group {
                // disable NavigationStack gesture when showSheet is true
                if showSheet {
                    Color.white.opacity(0.01)
                        .highPriorityGesture(DragGesture(minimumDistance: 0))
                }
            }
        )
    }
}
struct SheetDetailView: View {
    var body: some View {
        Text("Sheet")
    }
}

原理如下:当 showSheet 为真时,为 NavigationStack 增加一个屏蔽手势的远景视图,以确保用户只能在 showSheet 为否时经过滑动回来到上一层视图。

当视图正在翻滚时回来上一层视图会导致运用溃散

这是一个由 xiaogd 在我的 Discord 论坛中提出的 问题。它的复现条件如下:

  • iOS 16 体系,在真机或模拟器上测验
  • 点击视图列表中的按钮,能够进入下一级视图。请至少进入第三级视图
  • 翻滚当时视图
  • 当视图处于翻滚状况时,点击 NavigationStack 左上角的 “Back” 按钮。
  • 在回来上层视图后,持续点击 “Back” 按钮
  • 运用大概率会呈现溃散状况
struct NavigationStackBackDemo: View {
    @StateObject var pathHolder = PathHolder()
    var body: some View {
        NavigationStack(path: $pathHolder.path) {
            DetailView()
                .navigationDestination(for: Int.self) { _ in
                    DetailView()
                }
        }
        .environmentObject(pathHolder)
    }
}
struct DetailView: View {
    @EnvironmentObject var holder: PathHolder
    var body: some View {
        ScrollView {
            ForEach(0 ..< 100) { i in
                NavigationLink(value: i) {
                    Text("\(i)")
                        .font(.title)
                        .foregroundStyle(.yellow)
                        .frame(maxWidth: .infinity)
                        .frame(height:150).padding(.vertical,5)
                        .background(.blue)
                }
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .navigationTitle(!holder.path.isEmpty ? "\(holder.path.count)" : "Root")
    }
}
class PathHolder: ObservableObject {
    @Published var path = [Int](){
        didSet{
            print("set path \(path)")
        }
    }
}

解析 SwiftUI 中两处由状态更新滞后引发的严重 Bug

依据前文所述,咱们知道直接点击 NavigationStack 供给的 Back 按钮,状况只会在视图现已回来到上一层时才会更新。假如咱们以为问题出在这儿,就需要运用编程式导航的方法来调整代码。

为了不影响用户的运用习气,咱们禁用了 NavigationStack 自带的 Back 按钮。经过自定义回来按钮以及扩展 UINavigationController 的方法,实现了在禁用 Back 按钮后仍支持手势回来,并先修正状况后再进行视图呼应。

ScrollView {
  ....
}
// start
.navigationBarBackButtonHidden(true)
.toolbar {
    if !holder.path.isEmpty {
        ToolbarItem(placement: .topBarLeading) {
            Button {
                holder.path.removeLast()
            } label: {
                Image(systemName: "chevron.backward")
            }
        }
    }
}
// end
.navigationBarTitleDisplayMode(.inline)

扩展 UINavigationController:

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }
    // Allows swipe back gesture after hiding standard navigation bar with .navigationBarHidden(true).
    public func gestureRecognizerShouldBegin(_: UIGestureRecognizer) -> Bool {
        viewControllers.count > 1
    }
    // Allows interactivePopGestureRecognizer to work simultaneously with other gestures.
    public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
        viewControllers.count > 1
    }
    // Blocks other gestures when interactivePopGestureRecognizer begins (my TabView scrolled together with screen swiping back)
    public func gestureRecognizer(_: UIGestureRecognizer, shouldBeRequiredToFailBy _: UIGestureRecognizer) -> Bool {
        viewControllers.count > 1
    }
}

解析 SwiftUI 中两处由状态更新滞后引发的严重 Bug

这个问题现已在 iOS 17 中得以修复,不知道是否和咱们在 Discord 中讨论后给苹果提交的 Feedback 有关。

为什么状况更新滞后会导致严重错误

因为 SwiftUI 的 不透明性,想要分析这些问题的成因并不简单。幸运的是,我从 @KyleSwifter 的 解密 SwiftUI 背后的 AttributeGraph 一文中找到了头绪。

AttributeGraph 是 SwiftUI 用于保护众多数据源与视图之间依赖联系的工具。为了改善 AttributeGraph 的效率并减少其占用空间,SwiftUI 会在一些特定状况下对其进行整理和保护(例如经过 CFRunLoopObserverCreate 监听 Runtime 的闲暇机遇)。

在咱们遇到问题的两个场景中,运用程序都恰好运用了导航容器,而且经过特定的操作,使 RunLoop 处于了适合 AG 打包更新的状况。因为在回来上层视图时,状况尚未更新,因而在整理 AG 时(回来动画运行中),会损坏运用程序的 AttributeGraph 完整性,然后导致运用程序死锁或溃散。

因而,当咱们首要更新状况,然后 SwiftUI 再呼应该状况的改变(回来上层视图),即便此刻对 AG 进行整理,仍将能够确保 AttributeGraph 的完整性,运用自然不会呈现问题。

状况更新滞后不仅存在于本文介绍的两个案例中,当开发者遇到类似状况时,能够测验采用状况更新优先的开发策略进行修正。

总结

今年 SwiftUI 现已进入了第五个年初。随着版别的进步,SwiftUI 的功用也确实得到了相当程度的增加。不过,即便在最新的版别中,在一些对 UIKit(AppKit)进行二次包装的控件中,仍有不少细节处理不到位的问题。希望 SwiftUI 开发组能尽早注重这些问题。

欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

订阅下方的 邮件列表,能够及时获得每周最新文章。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的大众号:【肘子的Swift记事本】