SVVSを理解する：単方向データフローで効率的なSwiftUI開発

## はじめに

　SwiftUIでアプリを作っていく際にアーキテクチャは何が良いかいつも悩むのですが、色々調べていくうちに「SVVS」が良いのではと思いました。今回はSVVSについて紹介します。

　SVVSは『Store』『View』『ViewState』の略で、Chatworkの福井さん、中山さんが[iOSDC 2023で紹介したアーキテクチャ](https://fortee.jp/iosdc-japan-2023/proposal/edcec751-4f7f-46aa-9c17-92249a1a1771)です。本記事ではSVVSの概要と簡単な実装例、swift-testingを使ったテストを紹介します。

## SVVSの概要

### レイヤー構造

![SVVS-ページ5.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/8a910765-66d5-e40e-87cf-652ae298a6ef.png)

* SVVSでは、ViewのアクションがViewStateを通じてStoreに伝達されます。StoreはAPIやDBと通信し、データを保持します。
* Storeのデータ変更はViewStateを介してViewに反映され、単方向データフローを実現します。

### SVVSのメリット・デメリット

* メリット
    * SwiftUIの特徴を活かした実装ができる
    * ライブラリに依存しない
    * TCA、Clean Architecture等に比べて学習コストが低い
* デメリット
    * 採用している方があまり居ないため情報が少ない
    * Combineの知識が必要

## 簡単なケースで実装を紹介

* ログインした際に取得したユーザ情報をログイン後の画面で表示するパターンを考えてみます。

![SVVS-ページ4.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/d8cdb995-bb69-88cc-5464-b7ee073e0205.png)

このようにLoginViewでログインした場合にUserStoreでAPI通信を行い、UserStoreに保持させた状態で、HomeViewへ遷移した時にUserStoreからユーザ情報を取得して表示します。

### コードについて

上記の実装を実際のコードで紹介します。

　ChatworkさんのSVVSのサンプルを元にしていますが、Swift6やswift-testingを考慮して少し変わっています。これから参考にしようと思っている方がいらっしゃればまずは[Chatworkさんのサンプル](https://github.com/chatwork/svvs-sample)をご確認いただくことをお勧めします。

`※ビルド環境はXcode 16.1, Swift 6 modeです。`

#### LoginView
  * ID/PASS入力とログインボタン押下で状態を変更します。

```swift
import SwiftUI

struct LoginView<State: LoginViewStateProtocol & ObservableObject>: View {
    @StateObject private var state: State

    init(state: State = LoginViewState()) {
        self._state = StateObject(wrappedValue: state)
    }

    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    Text("ID:")
                        .frame(width: 100)
                    TextField("IDを入力してください", text: self.$state.userId)
                        .keyboardType(.emailAddress)
                }
                HStack {
                    Text("Password:")
                        .frame(width: 100)
                    SecureField("パスワードを入力してください", text: self.$state.password)
                        .keyboardType(.asciiCapable)
                }
                Spacer()
                    .frame(height: 40)
                if self.state.loginState == .notLoggedIn {
                    Button(action: {
                        Task {
                            await self.state.didTapLoginButton()
                        }
                    }, label: {
                        Text("ログイン")
                    })
                } else if self.state.loginState == .loggingIn {
                    ProgressView("ログイン中...")
                } else if self.state.loginState == .loggedIn {
                    EmptyView()
                }
            }
            .padding()
            .navigationDestination(isPresented: self.$state.shouldNavigateHome) {
                HomeView()
            }
            .task {
                Task {
                    await self.state.didAppear()
                }
            }
        }
    }
}
```

#### LoginViewState
  * ログインアクションを契機にログイン状態を変更し、Storeにログインを依頼します。
  * ログイン結果で自身のプロパティを更新してViewを更新させます。

```swift
import Combine
import Foundation

@MainActor
protocol LoginViewStateProtocol: ObservableObject {
    var loginState: LoginState { get set }
    var userId: String { get set }
    var password: String { get set }
    var shouldNavigateHome: Bool { get set }

    func didTapLoginButton() async
    func didAppear() async
}

@MainActor
class LoginViewState: LoginViewStateProtocol {
    private let store: any UserStoreProtocol
    /// ログインステータス
    @Published var loginState: LoginState = .notLoggedIn
    /// 入力されたユーザID
    @Published var userId: String = ""
    /// 入力されたパスワード
    @Published var password: String = ""
    /// ホーム画面へ遷移する場合true
    @Published var shouldNavigateHome: Bool = false
    private var cancellables = Set<AnyCancellable>()

    init(store: any UserStoreProtocol = UserStore.shared) {
        self.store = store
        self.setupStoreBindings()
    }

    /// Storeプロパティ購読を設定する
    func setupStoreBindings() {
        self.store.userPublisher
            .sink { [weak self] user in
                if user != nil {
                    self?.loginState = .loggedIn
                    self?.shouldNavigateHome = true
                }
            }
            .store(in: &cancellables)
        self.store.errorPublisher
            .sink { [weak self] _ in
                // 実際はエラーハンドリングを行う
                self?.loginState = .notLoggedIn
            }
            .store(in: &cancellables)
    }

    /// ログインボタン押下された
    func didTapLoginButton() async {
        Task { @MainActor in
            self.loginState = .loggingIn
        }
        await self.store.login(self.userId, self.password)
    }

    /// 画面が表示された
    func didAppear() async {
        self.loginState = .notLoggedIn
        self.shouldNavigateHome = false
    }
}
```

#### UserStore
  * LoginRepositoryを利用してログインAPIを実行します。
  * 成功した場合自身のuserプロパティを更新し、ViewStateを更新させます。
    * ちなみにこのサンプルのUserは`User(id: "12345", name: "太郎")`という非常にシンプルなものになっています。

```swift
import Combine

@MainActor
protocol UserStoreProtocol: ObservableObject {
    var userPublisher: Published<User?>.Publisher { get }
    var errorPublisher: Published<Error?>.Publisher { get }
    func login(_ userId: String, _ password: String) async
}

@MainActor
class UserStore: ObservableObject, UserStoreProtocol {
    static var shared = UserStore()

    let loginRepository: LoginRepositoryProtocol

    @Published var user: User?
    @Published var error: Error?
    // プロトコルに準拠するためのPublisher
    var userPublisher: Published<User?>.Publisher { $user }
    var errorPublisher: Published<Error?>.Publisher { $error }

    init(_ repository: LoginRepositoryProtocol = LoginRepository()) {
        self.loginRepository = repository
    }

    func login(_ userId: String, _ password: String) async {
        self.error = nil
        do {
            self.user = try await self.loginRepository.login(userId, password)
        } catch {
            await MainActor.run {
                self.user = nil
                self.error = error
            }
        }
    }
}
```

#### HomeView
  * Storeからデータを取得し、ユーザー名を表示するだけの単純な処理です

```swift
import SwiftUI

struct HomeView: View {
    @StateObject private var state: HomeViewState

    init(state: HomeViewState = HomeViewState()) {
        self._state = .init(wrappedValue: state)
    }

    var body: some View {
        VStack {
            Text(self.state.userGreeting)
        }
    }
}
```

#### HomeViewState

```swift
import Combine
import Foundation

@MainActor
class HomeViewState: ObservableObject {
    private let store: any UserStoreProtocol
    @Published var user: User?

    private var cancellables = Set<AnyCancellable>()

    init(store: any UserStoreProtocol = UserStore.shared) {
        self.store = store
        self.setupStoreBindings()
    }

    /// Storeプロパティ購読を設定する
    func setupStoreBindings() {
        self.store.userPublisher
            .sink { [weak self] user in
                self?.user = user
            }
            .store(in: &cancellables)
    }

    /// ユーザ名付きの挨拶文字列
    var userGreeting: String {
        guard let user else { return "名前が取得できません" }
        return "こんにちは \(user.name)さん"
    }
}
```

簡単ではありますが、Store, View, ViewStateの雰囲気は伝わるのではないかと思います。

また、ここにさらにユーザが作成したノート一覧を表示する画面を追加するとなる場合、UserStoreを使いつつ、NoteStoreを使ってノート一覧を取得、保持して表示することができます。

![SVVS-ページ6.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/dc2afc43-46d0-6d2d-8249-542134322277.png)

## swift-testing実装

swift-testingの実装もしてみたので載せておきます。まだ完全に理解できていないですがテストしたいことはできています。

#### LoginViewStateTests

```swift
import Testing
import Combine

@testable import SVVS_Example

@MainActor
struct LoginViewStateTests {

    @Test func didTapLoginButtonでloginが呼ばれる() async {
        var cancellables: Set<AnyCancellable> = []
        let store = MockUserStore()
        let state = LoginViewState(store: store)
        await confirmation { confirmed in
            await state.didTapLoginButton()
            await withCheckedContinuation { continuation in
                state.$loginState
                    .sink { state in
                        if state == .loggingIn {
                            confirmed()
                            continuation.resume()
                        }
                    }
                    .store(in: &cancellables)
            }
        }
        #expect(store.isLoginCalled == true)
        #expect(state.loginState == .loggingIn)
    }

    @Test func didAppearで値が更新される() async {
        let store = MockUserStore()
        let state = LoginViewState(store: store)
        await state.didAppear()
        #expect(state.loginState == .notLoggedIn)
        #expect(state.shouldNavigateHome == false)
    }
}
```

#### UserStoreTests

```swift
import Testing

@testable import SVVS_Example

@MainActor
final class MockLoginRepository: LoginRepositoryProtocol {
    var isCalledLogin = false
    func login(_ userId: String, _ password: String) async throws -> User {
        isCalledLogin = true
        if userId == "error" {
            throw NetworkError.invalidRequest
        } else {
            return User(id: "test", name: "Test User")
        }
    }
}

@MainActor
struct UserStoreTests {

    @Test func loginに成功した場合userに値が入る() async {
        let repository = MockLoginRepository()
        let store = UserStore(repository)
        await store.login("user_id", "password")
        #expect(repository.isCalledLogin == true)
        #expect(store.user != nil)
        #expect(store.error == nil)
    }

    @Test func loginに失敗した場合errorに値が入る() async {
        let repository = MockLoginRepository()
        let store = UserStore(repository)
        await store.login("error", "password")
        #expect(repository.isCalledLogin == true)
        #expect(store.user == nil)
        #expect(store.error != nil)
    }
}
```

#### HomeViewStateTests

```swift
import Testing
import Combine

@testable import SVVS_Example

@MainActor
struct HomeViewStateTests {

    @Test("userGreetingのパラメータテスト", arguments:
            [
                (user: nil, expected: "名前が取得できません"),
                (user: User(id: "12345", name: "太郎"), expected: "こんにちは 太郎さん")
            ]
    )
    func userGreetingのテスト(user: User?, expected: String) {
        let mockUserStore = MockUserStore()
        mockUserStore.user = user
        let state = HomeViewState(store: mockUserStore)
        #expect(state.userGreeting == expected)
    }
}
```

## まとめ

SVVSについては最近触り始めたばかりですが、直感的に実装できるので気に入ってます。
もしよろしければ触れてみてください。

今回紹介したソースコードはこちらにあります。
https://github.com/y-hirakaw/swiftui_study/tree/main/SVVS_Example

他にもこのリポジトリではGitHub APIでユーザ検索するサンプルコードもあるのでご興味あればご覧ください。