LoginSignup
79
53

More than 3 years have passed since last update.

[Xcode 11] MVVM with Combine in SwiftUI

Last updated at Posted at 2019-06-16

@akifumifukayaです。
先日、発表した内容を記事として残しておこうと思います。
※ 2019/10/21 にXcode 11に対応した内容に更新しました。

スライドは以下にアップロードしています。
https://speakerdeck.com/akifumifukaya/mvvm-with-combine-in-swiftui

2019年8月5日に発表した資料はこちらです。
https://speakerdeck.com/akifumifukaya/mvvm-with-combine-ef82b6ce-8fae-4bb1-9643-ea613334c8f8

What is Combine?

Combineとは、AppleがWWDC19で発表した新しいフレームワークのことです。

iOS 13.0, macOS 10.15, UIKit for Mac 13.0+, tvOS 13.0, watchOS 6.0+で使用することができます。
イベントを処理するオペレータを組み合わせることで、非同期イベントの処理を行うことができます。

フレームワークの詳細は、以下のドキュメントで見ることができます。
https://developer.apple.com/documentation/combine

Combine Features

Combineの特徴として、以下の4つが挙げられています。

  • Generic
  • Type safe
  • Composition first
  • Request driven

Generic, Type safe はSwiftの強みを引き継いでいます。
Composition firstは、チェーンのように処理をつなげて・組み立てながら宣言することができるので、コードが読みやすかったり、処理の流れがわかりやすくなっています。
Request drivenは、必要なタイミングで処理が行われるため、メモリ使用やパフォーマンスにも良い影響を与えます。

※ こちらの内容は、 Introducing Combine から引用しています。

Key Concepts

主要な概念として、以下の3つがあります。

  • Publishers
  • Subscribers
  • Operators

※ こちらの内容は、 Introducing Combine から引用しています。

Publishers

  • Publishersは時系列に連続した値を伝えることができる型を宣言できます
  • 値やエラーがどのように生み出されるを定義します
  • Value型、つまりStructで定義されています
  • Subscriberに登録することができます

※ こちらの内容は、 Introducing Combine から引用しています。

Subscribers

  • SubscribersはPublisherからの入力を受け取ることができる型を宣言できます
  • Publisherから値やcompletionを受け取ります
  • Reference型、つまりClassで定義されています

※ こちらの内容は、 Introducing Combine から引用しています。

Operator

  • オペレータはPublisherです
  • 値が変化したときの振る舞いを表します
  • Publisherをサブスクライブすることをupstreamと呼びます
  • Subscriberへ値を送ることをdownstreamと呼びます
  • Value型、つまりStructで定義されています

※ こちらの内容は、 Introducing Combine から引用しています。

How to actualize MVVM with Combine

では、どのようにCombineを使用してMVVMを実現するかを説明したいと思います。
https://github.com/akifumi/mvvm-with-combine-in-swiftui/blob/master/CombineSample/ContentView.swift

What are requirements?

今回、実現したいことは以下です。

  • TextFieldを追加し、テキストを入力できるようにする
  • 入力されたテキストをバリデーションする
  • ロジック部分をViewModelとして分け、viewmodelを分離する
  • viewViewModelをつなぎ合わせる
  • TextFieldのコンテンツをtwo-wayバインディングする

ContentViewにTextFieldを追加する

コードは以下のようになります。

struct ContentView : View {
    @State private var username: String = ""

    var body: some View {
        VStack {
            TextField("Placeholder", text: $username, onEditingChanged: { (changed) in
                print("onEditingChanged: \(changed)")
            }, onCommit: {
                print("onCommit")
            })
        }
        .padding(.horizontal)
    }
}

VStackで縦方向のStackViewを作成し、その中にTextFieldを配置しています。
変数に@Stateを付けることによって、Viewと変数をtwo-wayバインディングで紐付けることができます。
また$変数のように$プレフィックスを付けることで、Bindingオブジェクトを取得することができます。
ここからViewModelに分割していきます。

ViewModelを作成

次にViewModelを作成します。

final class ContentViewModel : ObservableObject, Identifiable {
    @Published
    var username: String = ""
}

ViewModelを作成し、ObservableObjectを適応します。
ObservableObjectvar objectWillChange: Self.ObjectWillChangePublisher { get }を宣言することを要求しています。

swift
var objectWillChange = PassthroughSubject<Void, Never>()

今回は単純な変更通知を送るためにPassthroughSubjectを用いて宣言しました。
またusernameという変数を定義し、willSetobjectWillChange.send(())を呼びます。

willSetが呼ばれると、SwiftUI側へViewの更新通知が行うようにしています。

@Publishedを使用するとobjectWillChangeのパブリッシャーはwillSetで自動的に呼ばれるようになりました。

Publisherを作成し、usernameをバリデーションする

final class ContentViewModel : ObservableObject, Identifiable {
    
    @Published
    var username: String = ""
    private var validatedUsername: AnyPublisher<String?, Never> {
        return $username
            .debounce(for: 0.1, scheduler: RunLoop.main)
            .removeDuplicates()
            .flatMap { (username) -> AnyPublisher<String?, Never> in
               Future<String?, Never> { (promise) in
                    // FIXME: API request
                    if 1...10 ~= username.count {
                        promise(.success(username))
                    } else {
                        promise(.success(nil))
                    }
                }
                .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

$usernameusernameの変更を受け取ることができます。
次にusernameにバリデーションを行うために、var validatedUsername: AnyPublisher<String?, Never>を定義しています。
今回はAPIの実装は行いませんでしたが、Futureを用いることで非同期処理も行うことができます。
今回はusernameが有効であれば、usernameを返し、無効であればnilを返すように実装しました。

ContentViewModel.StatusText を作成

final class ContentViewModel : ObservableObject, Identifiable {
    
    struct StatusText {
        let content: String
        let color: Color
    }
    @Published
    var status: StatusText = StatusText(content: "NG", color: .red)
}

バリデーションの結果を見やすいように、簡単なラベルを追加します。
StatusTextはテキストとカラーも持つオブジェクトにしました。

ViewModelにonApprear()を追加

ContentViewが表示された際の処理を記述していきます。

final class ContentViewModel : ObservableObject, Identifiable {
    
    private var cancellables: [AnyCancellable] = []
    private(set) lazy var onAppear: () -> Void = { [weak self] in
        guard let self = self else { return }
        self.validatedUsername
            .sink(receiveValue: { [weak self] (value) in
                if let value = value {
                    self?.username = value
                } else {
                    print("validatedUsername.receiveValue: Invalid username")
                }
            })
            .store(in: &self.cancellables)

        // Update StatusText
        self.validatedUsername
            .map { (value) -> StatusText in
                if let _ = value {
                    return StatusText(content: "OK", color: .green)
                } else {
                    return StatusText(content: "NG", color: .red)
                }
            }
            .sink(receiveValue: { [weak self] (value) in
                self?.status = value
            })
            .store(in: &self.cancellables)
    }
}

usernameが有効の場合、usernameを有効な値で書き換えています。

        self.validatedUsername
            .sink(receiveValue: { [weak self] (value) in
                if let value = value {
                    self?.username = value
                } else {
                    print("validatedUsername.receiveValue: Invalid username")
                }
            })
            .store(in: &self.cancellables)

usernameが有効の場合はOK、無効な場合はNGと表示するStatusTextを作成し、Viewを更新するようにしています。

        // Update StatusText
        self.validatedUsername
            .map { (value) -> StatusText in
                if let _ = value {
                    return StatusText(content: "OK", color: .green)
                } else {
                    return StatusText(content: "NG", color: .red)
                }
            }
            .sink(receiveValue: { [weak self] (value) in
                self?.status = value
            })
            .store(in: &self.cancellables)

sink()関数でAnyCancellableが返ってくるので、store関数で開放されないように保持することができます。
これをしないと更新通知を受け取ることができません。

ViewModelにonDisapprear()を追加

ContentViewが非表示になった際の処理を記述していきます。

final class ContentViewModel : ObservableObject, Identifiable {
    
    private var cancellables: [AnyCancellable] = []
    
    private(set) lazy var onDisappear: () -> Void = { [weak self] in
        guard let self = self else { return }
        self.cancellables.forEach { $0.cancel() }
        self.cancellables = []
    }
}

onApprearcancellablesに保持していたAnyCancellableの配列をViewの非表示時に開放してあげます。

ContentViewとContentViewModelを紐付ける

struct ContentView : View {
    @ObservedObject var viewModel: ContentViewModel

    var body: some View {
        VStack {
            HStack {
                Text($viewModel.status.wrappedValue.content)
                    .foregroundColor($viewModel.status.wrappedValue.color)
                Spacer()
            }
            TextField("Placeholder", text: $viewModel.username, onEditingChanged: { (changed) in
                print("onEditingChanged: \(changed)")
            }, onCommit: {
                print("onCommit")
            })
        }
        .padding(.horizontal)
        .onAppear(perform: viewModel.onAppear)
        .onDisappear(perform: viewModel.onDisappear)
    }
}

このように記述することでContentViewContentViewModelをバインディングしました。
ViewとModelをきれいに分離することができ、とても良いですね!!

まとめ

参考

サンプルコード

79
53
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
79
53