如何将协议定义为@ObservedObject属性的类型?

72

我有一个SwiftUI视图,它依赖于一个ViewModel,该ViewModel具有一些已发布的属性。我想定义一个协议和ViewModel层次结构的默认实现,并使视图依赖于协议而不是具体类?

我想要能够编写以下内容:

protocol ItemViewModel: ObservableObject {
    @Published var title: String

    func save()
    func delete()
}

extension ItemViewModel {
    @Published var title = "Some default Title"

    func save() {
        // some default behaviour
    }

    func delete() {
        // some default behaviour
    }
}


struct ItemView: View {
    @ObservedObject var viewModel: ItemViewModel

    var body: some View {
        TextField($viewModel.title, text: "Item Title")
        Button("Save") { self.viewModel.save() }  
    }
}

// What I have now is this:

class AbstractItemViewModel: ObservableObject {
    @Published var title = "Some default Title"

    func save() {
        // some default behaviour
    }

    func delete() {
        // some default behaviour
    }
}

class TestItemViewModel: AbstractItemViewModel {
    func delete() {
        // some custom behaviour
    }
}

struct ItemView: View {
    @ObservedObject var viewModel: AbstractItemViewModel

    var body: some View {
        TextField($viewModel.title, text: "Item Title")
        Button("Save") { self.viewModel.save() } 
    }
}
6个回答

94

目前在Swift协议和扩展中不允许使用包装器和存储属性。因此,我会采用以下方法,将协议、泛型和类进行混合使用...(所有内容都可以编译,并已在Xcode 11.2 / iOS 13.2上进行了测试)

// base model protocol
protocol ItemViewModel: ObservableObject {
    var title: String { get set }

    func save()
    func delete()
}

// generic view based on protocol
struct ItemView<Model>: View where Model: ItemViewModel {
    @ObservedObject var viewModel: Model

    var body: some View {
        VStack {
            TextField("Item Title", text: $viewModel.title)
            Button("Save") { self.viewModel.save() }
        }
    }
}

// extension with default implementations
extension ItemViewModel {
    
    var title: String {
        get { "Some default Title" }
        set { }
    }
    
    func save() {
        // some default behaviour
    }

    func delete() {
        // some default behaviour
    }
}

// concrete implementor
class SomeItemModel: ItemViewModel {
    @Published var title: String
    
    init(_ title: String) {
        self.title = title
    }
}

// testing view
struct TestItemView: View {
    var body: some View {
        ItemView(viewModel: SomeItemModel("test"))
    }
}

备份


这是否意味着我必须在每个视图模型具体实现中重复所有我想用@Published包装的属性?到目前为止还没有办法将其作为默认协议实现吗? - M.Serag
1
我认为不可能,因为@Published var some会生成私有存储属性_some,而协议是禁止使用的。 - Asperi
1
另一种方法是不使用 @Published,而是通过 objectWillChange 发布器手动发布更改。这样做的好处是我们可以摆脱默认的协议实现。更多信息请参见:https://www.hackingwithswift.com/books/ios-swiftui/manually-publishing-observableobject-changes - Bartosz Olszanowski
1
没有办法让这个适用于环境变量,对吧? - Nicolas Degen
2
@NicolasDegen 我发现这个也适用于 @EnvironmentObject,只是需要手动指定泛型的具体类。例如:NavigationLink(destination: ItemView<SomeItemModel>()) { ... } - Jeremy
没错,我已经弄清楚了 :) - Nicolas Degen

14

这篇文章与其他一些文章相似,但它只是针对已发布变量所需的模板,没有干扰。

protocol MyViewModel: ObservableObject {
    var lastEntry: String { get }
}

class ActualViewModel: MyViewModel {
    @Published private(set) var lastEntry: String = ""
}

struct MyView<ViewModel>: View where ViewModel: MyViewModel {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        Text(viewModel.lastEntry)
    }
}

View 的通用 ViewModel: MyViewModel 约束让编译器知道它需要为任何使用 MyViewModel 协议的类型构建逻辑。


10

我们在我们的小型库中编写了一个自定义属性包装器,找到了解决方案。您可以查看XUI

实际上有两个问题:

  1. ObservableObject 中的关联类型要求
  2. ObservedObject 的泛型约束

通过创建一个类似于 ObservableObject 的协议(不带关联类型)和一个类似于 ObservedObject 的协议封装(不带泛型约束),我们可以解决这个问题!

让我先给您展示一下这个协议:

protocol AnyObservableObject: AnyObject {
    var objectWillChange: ObservableObjectPublisher { get }
}

这基本上是 ObservableObject 的默认表现形式,这使得新的和现有的组件很容易遵守该协议。

其次,属性包装器-它稍微复杂一些,这就是为什么我只会添加 链接。它具有没有约束的通用属性,这意味着我们也可以将其与协议一起使用(目前只是语言限制)。但是,您需要确保仅将此类型与符合AnyObservableObject的对象一起使用。我们称这个属性包装器为@Store

好的,现在让我们来看一下创建和使用视图模型协议的过程:

  1. 创建视图模型协议
protocol ItemViewModel: AnyObservableObject {
    var title: String { get set }

    func save()
    func delete()
}
  1. 创建视图模型实现
class MyItemViewModel: ItemViewModel, ObservableObject {

    @Published var title = ""

    func save() {}
    func delete() {}

}
  1. 在您的视图中使用@Store属性包装器:
struct ListItemView: View {
    @Store var viewModel: ListItemViewModel

    var body: some View {
        // ...
    }

}

9
我认为类型擦除是最好的答案。
因此,您的协议保持不变。 你有:
protocol ItemViewModel: ObservableObject {
    var title: String { get set }

    func save()
    func delete()
}

因此,我们需要一个具体类型,以便视图始终可以依赖它(如果太多的视图在视图模型上变成通用的,则可能会变得混乱)。因此,我们将创建一种类型擦除实现。

class AnyItemViewModel: ItemViewModel {
    var title: title: String { titleGetter() }
    private let titleGetter: () -> String

    private let saver: () -> Void
    private let deleter: () -> Void

    let objectWillChange: AnyPublisher<Void, Never>

    init<ViewModel: ItemViewModel>(wrapping viewModel: ViewModel) {
        self.objectWillChange = viewModel
            .objectWillChange
            .map { _ in () }
            .eraseToAnyPublisher()
        self.titleGetter = { viewModel.title }
        self.saver = viewModel.save
        self.deleter = viewModel.delete
    }

    func save() { saver() }
    func delete() { deleter() }
}

为了方便起见,我们还可以添加一个扩展来擦除ItemViewModel并具有良好的尾部语法:

extension ItemViewModel {
   func eraseToAnyItemViewModel() -> AnyItemViewModel {
        AnyItemViewModel(wrapping: self)
   }
}

此时您的观点可能是:

struct ItemView: View {
    @ObservedObject var viewModel: AnyItemViewModel

    var body: some View {
        TextField($viewModel.title, text: "Item Title")
        Button("Save") { self.viewModel.save() }  
    }
}

你可以像这样创建它(非常适用于预览):
ItemView(viewModel: DummyItemViewModel().eraseToAnyItemViewModel())

在技术上,您可以在视图初始化器中执行类型擦除,但这样您实际上必须编写该初始化器,这种方法有些不妥。


我喜欢你的解决方案Christopher!你能否解释一下当你对self.objectWillChange执行objectWillChange.map({_ in}).eraseToAnyPublisher时发生了什么?以及为什么我们需要那个objectWillChange? 谢谢! - bodich
ObservableObject协议要求一个objectWillChange发布者,但将细节留给相关类型。 (这意味着实现具体类型决定细节。)因此,需要map,因为我们需要将包装类型可能提供的任何(未知/不同)类型的发布者转换为我们的类型擦除实现承诺的特定具体类型。 - Christopher Thiebaut
1
@ettore 我的意思是,如果泛型嵌套足够复杂,你的嵌套类型会变得相当复杂。如果所有类型都被强制保持泛型,仅仅是为了实现细节原因,那么它会变得不太符合人体工程学,并且视觉上更加嘈杂。因此,通常我更喜欢使用类型擦除来限制这种情况。当然,鉴于 Swift 现在具有用于类型擦除的 any 关键字,这个答案有些过时了。 - Christopher Thiebaut
1
这怎么过时了,@ChristopherThiebaut?我正在使用any,但在具体情况下,我仍然需要擦除类型:@ViewBuilder func f(_ t: any P) { SomeView(observed: t.eraseToAnyP) }。如果我声明SomeView { @ObservedObject var observed: any P },我仍然会得到Type 'any P' cannot conform to 'ObservableObject'的错误。 - Tae
@Tae,你是对的。我写下它过时了,可能有点太快了,在类型擦除引入后,我在那个时候并不足够了解其限制。 - Christopher Thiebaut
显示剩余2条评论

7

好的,我花了一些时间弄清楚这些内容,但一旦我搞对了,一切都很清晰明了。

目前不可能在协议中使用PropertyWrappers。但您可以在视图中使用泛型,并期望您的ViewModel符合您的协议。这非常适合测试或者如果您需要为预览设置轻量级的东西。

我有一些示例样例,以便您可以正确配置您的代码

协议:

protocol UploadStoreProtocol:ObservableObject {

    var uploads:[UploadModel] {get set}

}

视图模型: 您需要确保您的视图模型是ObservableObject,并在可以更改的变量上添加@Published

// For Preview
class SamplePreviewStore:UploadStoreProtocol {

    @Published  var uploads:[UploadModel] = []

    init() {
        uploads.append( UploadModel(id: "1", fileName: "Image 1", progress: 0, started: true, errorMessage: nil))
        uploads.append( UploadModel(id: "2", fileName: "Image 2", progress: 47, started: true, errorMessage: nil))
        uploads.append( UploadModel(id: "3", fileName: "Image 3", progress: 0, started: false, errorMessage: nil))
    }
}

// Real Storage
class UploadStorage:UploadStoreProtocol {

    @Published var uploads:[UploadModel] = []

    init() {
        uploads.append( UploadModel(id: "1", fileName: "Image 1", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "2", fileName: "Image 2", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "3", fileName: "Image 3", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "4", fileName: "Image 4", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "5", fileName: "Image 5", progress: 0, started: false, errorMessage: nil))
    }
    func addItem(){
        uploads.append( UploadModel(id: "\(Int.random(in: 100 ... 100000))", fileName: "Image XX", progress: 0, started: false, errorMessage: nil))
    }
    func removeItemAt(index:Int){
        uploads.remove(at: index)
    }
}

对于 UI 视图,您可以使用泛型:

struct UploadView<ViewModel>: View where ViewModel:UploadStoreProtocol {

    @ObservedObject var store:ViewModel
    
    var body: some View {
        List(store.uploads.indices){ item in
            ImageRow(item: $store.uploads[item])
        }.padding()
    }
}

struct ImageRow: View {

    @Binding var item:UploadModel

    var body: some View {
        HStack{
            Image(item.id ?? "")
                .resizable()
                .frame(width: 50.0, height: 50.0)
            VStack (alignment: .leading, spacing: nil, content: {
                Text(item.fileName ?? "-")
                Text(item.errorMessage ?? "")
                    .font(.caption)
                    .foregroundColor(.red)
            })
            Spacer()
            VStack {
                if (item.started){
                    Text("\(item.progress)").foregroundColor(.purple)
                }
                UploadButton(is_started: $item.started)
            }
        }
    }
}

现在您的视图已准备好获取ViewModel,您可以在外部设置您的存储库:

@main
struct SampleApp: App {

    @StateObject var uploadStore = UploadStorage()

    var body: some Scene {
        WindowGroup {
            UploadView(store: uploadStore)
        }
    }
}

同时,您可以预览以下内容:

struct ContentView_Previews: PreviewProvider {

    @StateObject static var uploadStore = SamplePreviewStore()

    static var previews: some View {
        UploadView(store: uploadStore)
        
    }
}

如果使用@EnvironmentObject而不是@ObservedObject呢? 在这种情况下,每次使用UploadView时,你的应用代码中都需要有UploadView< UploadStorage >(store: uploadStore),这并不方便。 - onthemoon

0

我不确定如何在协议中使用@property包装器。除此之外,普通的Swift规则适用。

        protocol ItemViewModel: ObservableObject {
            var title: String{get set}

            func save()
            func delete()
        }

        extension ItemViewModel {
            //var title = "Some default Title"

            func save() {
                // some default behaviour
                title = "save in protocol"
                print("save in protocol")
            }

            func delete() {
                // some default behaviour
                 print("delete in protocol")
            }
        }

        // What I have now is this:

        class AbstractItemViewModel:  ItemViewModel{
            @Published var title = "Some default Title"

        //    func save() {
        //          print("save in class")
        //        // some default behaviour
        //    }
        //
        //    func delete() {
        //         print("delete in class")
        //        // some default behaviour
        //    }
        }

        class TestItemViewModel: AbstractItemViewModel {
             func delete() {
                // some custom behaviour
                title = "delete in"
                  print("delete in ")
            }
        }

        struct ItemView: View {
            @ObservedObject var viewModel: TestItemViewModel

            var body: some View {
                VStack{
                Button(action: { self.viewModel.save()}){
                    Text("protocol save")
                }
                    Button(action: { self.viewModel.delete()}){
                               Text("class delete")
                           }
                    TextField.init ("Item Title", text:  $viewModel.title)}
            }
        }

有没有一种方法可以使视图不依赖于具体类型,以便传递具有相同接口但不同实现的不同具体类? - M.Serag
当使用 @ObservedObject var viewModel: AbstractItemViewModel 时,可以通过 ItemView(viewModel: TestItemViewModel()) 进行调用。 - E.Coms

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接