SWIFT:构建一个异步SwiftUI的教程

704 阅读5分钟

在构建现代应用程序时,想要响应UI事件而触发某种形式的异步操作是非常普遍的。例如,在下面这个基于SwiftUI的PhotoView ,我们使用一个Task ,每当用户点击该视图的按钮时,就会触发一个异步的onLike 动作。

struct PhotoView: View {
    var photo: Photo
    var onLike: () async -> Void

    var body: some View {
        VStack {
            Image(uiImage: photo.image)
            Text(photo.description)
            
            Button(action: {
                Task {
                    await onLike()
                }
            }, label: {
                Image(systemName: "hand.thumbsup.fill")
            })
            .disabled(photo.isLiked)
        }
    }
}

上面的实现无疑是一个好的起点。然而,如果我们的Photo 模型的isLiked 属性在我们的异步调用完成才更新,那么如果用户连续多次点击按钮,我们可能会出现重复的onLike 调用--因为我们目前只在该属性被设置为true 后才禁用我们的按钮。

现在,我们可以选择在调用onLike 之前执行一次本地模型更新来解决这个问题。然而,这样做会给我们的数据模型引入多个真相来源,这通常是需要避免的事情。因此,在理想情况下,我们希望让我们的PhotoView ,简单地渲染它从其父级视图得到的Photo ,而不需要做任何本地拷贝或修改。

所以,让我们来探讨一下如何在执行动作的时候让我们的按钮自己失效。因为这将涉及到引入额外的状态,而这些状态只与我们的按钮本身有关--让我们把所有的代码封装在一个新的AsyncButton 视图中,在等待其异步操作完成时,也会显示一个加载旋钮。

struct AsyncButton<Label: View>: View {
    var action: () async -> Void
    @ViewBuilder var label: () -> Label

    @State private var isPerformingTask = false

    var body: some View {
        Button(
            action: {
                isPerformingTask = true
            
                Task {
                    await action()
                    isPerformingTask = false
                }
            },
            label: {
                ZStack {
                    // We hide the label by setting its opacity
                    // to zero, since we don't want the button's
                    // size to change while its task is performed:
                    label().opacity(isPerformingTask ? 0 : 1)

                    if isPerformingTask {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isPerformingTask)
    }
}

如果你对上述按钮的label 闭包所注解的@ViewBuilder 属性感到好奇,那么请查看"用结果生成器属性注解属性"。

由于我们新的AsyncButton 的 API 与 SwiftUI 内置的Button 类型完全匹配,我们将能够通过简单地改变它所创建的按钮的类型来更新我们的PhotoView ,并删除其action 闭包中的Task (因为我们现在可以在该闭包中直接使用await ,因为它用async 关键字标记)。

struct PhotoView: View {
    var photo: Photo
    var onLike: () async -> Void

    var body: some View {
        VStack {
            Image(uiImage: photo.image)
            Text(photo.description)
            
            AsyncButton(action: {
                await onLike()
            }, label: {
                Image(systemName: "hand.thumbsup.fill")
            })
            .disabled(photo.isLiked)
        }
    }
}

非常好!现在,如果这是我们应用程序中唯一需要执行上述异步操作的地方,那么我们可以在这里把事情结束。但是,假设我们的代码库还包含许多其他类似的async-功能调用的按钮,而且我们也想在这些地方重用我们的新AsyncButton

为了使事情更有趣,我们还可以说,在我们代码库的某些部分,我们不希望在执行我们的异步操作时显示一个加载旋钮,而且我们还希望有一个选项可以同时执行多个操作。

为了支持这些选项,让我们引入一个ActionOption 枚举,这将使我们代码库的每个部分能够调整它希望我们的AsyncButton 在执行其动作时的行为:

extension AsyncButton {
    enum ActionOption: CaseIterable {
        case disableButton
        case showProgressView
    }
}

我们之所以让新的枚举符合 CaseIterable是因为这样做可以让我们使用该协议自动生成的allCases 属性轻松地默认启用所有选项(从而使这是一个向后兼容的变化)。然后,在激活这些行为之前,我们将检查指定的选项是否包含disableButtonshowProgressView - 就像这样:

struct AsyncButton<Label: View>: View {
    var action: () async -> Void
    var actionOptions = Set(ActionOption.allCases)
    @ViewBuilder var label: () -> Label

    @State private var isDisabled = false
    @State private var showProgressView = false

    var body: some View {
        Button(
            action: {
                if actionOptions.contains(.disableButton) {
                    isDisabled = true
                }

                if actionOptions.contains(.showProgressView) {
                    showProgressView = true
                }
            
                Task {
                    await action()
                    isDisabled = false
                    showProgressView = false
                }
            },
            label: {
                ZStack {
                    label().opacity(showProgressView ? 0 : 1)

                    if showProgressView {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isDisabled)
    }
}

有了这些变化,我们的AsyncButton ,现在已经足够灵活,可以适应许多不同的使用情况,但在称其为 "完成 "之前,仍有一些修饰的余地,使其API和内部行为更加完美。

首先,让我们使用一个延迟任务来显示我们的按钮ProgressView ,如果它的任务最终需要超过150毫秒的时间来完成。这样,我们就可以避免在执行快速操作时快速显示和隐藏那个加载旋钮,这是异步UI代码中非常常见的一种故障。

struct AsyncButton<Label: View>: View {
    var action: () async -> Void
    var actionOptions = Set(ActionOption.allCases)
    @ViewBuilder var label: () -> Label

    @State private var isDisabled = false
    @State private var showProgressView = false

    var body: some View {
        Button(
            action: {
                if actionOptions.contains(.disableButton) {
                    isDisabled = true
                }
            
                Task {
                    var progressViewTask: Task<Void, Error>?

                    if actionOptions.contains(.showProgressView) {
                        progressViewTask = Task {
                            try await Task.sleep(nanoseconds: 150_000_000)
                            showProgressView = true
                        }
                    }

                    await action()
                    progressViewTask?.cancel()

                    isDisabled = false
                    showProgressView = false
                }
            },
            label: {
                ZStack {
                    label().opacity(showProgressView ? 0 : 1)

                    if showProgressView {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isDisabled)
    }
}

请注意,根据应用程序和我们希望让我们的AsyncButton 执行的操作类型,我们可能要向上或向下调整150毫秒的值。我们可能还想引入逻辑,在给定的时间内始终显示加载旋钮,以便给用户适当的反馈,说明确实执行了一个动作。

最后,让我们也引入两个方便的API--一个是当我们想渲染一个AsyncButton ,显示一个Text 作为它的标签,另一个是当我们想使用一个系统图标显示的Image 。这可以通过使用通用类型约束来扩展我们的按钮类型--像这样:

extension AsyncButton where Label == Text {
    init(_ label: String,
         actionOptions: Set<ActionOption> = Set(ActionOption.allCases),
         action: @escaping () async -> Void) {
        self.init(action: action) {
            Text(label)
        }
    }
}

extension AsyncButton where Label == Image {
    init(systemImageName: String,
         actionOptions: Set<ActionOption> = Set(ActionOption.allCases),
         action: @escaping () async -> Void) {
        self.init(action: action) {
            Image(systemName: systemImageName)
        }
    }
}

有了以上这些,我们现在可以使用我们新的基于Image 的初始化器,以一种非常轻量级的方式在我们的PhotoView 中构建AsyncButton

struct PhotoView: View {
    var photo: Photo
    var onLike: () async -> Void

    var body: some View {
        VStack {
            Image(uiImage: photo.image)
            Text(photo.description)
            
            AsyncButton(
                systemImageName: "hand.thumbsup.fill",
                action: onLike
            )
            .disabled(photo.isLiked)
        }
    }
}

非常好!当然,我们可以通过多种方式继续迭代我们的AsyncButton 类型,使其更加灵活,或者采用不同类型的设计(例如在其标签旁边显示其加载旋钮),但我希望这篇文章能给你一些灵感,告诉你如何将同步动作集成到SwiftUI视图中,以及如何将这种视图通用化,使其更加通用和可重用。

谢谢你的阅读!