はじめに
SwiftUIでアプリを作っていく際にアーキテクチャは何が良いかいつも悩むのですが、色々調べていくうちに「SVVS」が良いのではと思いました。今回はSVVSについて紹介します。
SVVSは『Store』『View』『ViewState』の略で、Chatworkの福井さん、中山さんがiOSDC 2023で紹介したアーキテクチャです。本記事ではSVVSの概要と簡単な実装例、swift-testingを使ったテストを紹介します。
SVVSの概要
レイヤー構造
- SVVSでは、ViewのアクションがViewStateを通じてStoreに伝達されます。StoreはAPIやDBと通信し、データを保持します。
- Storeのデータ変更はViewStateを介してViewに反映され、単方向データフローを実現します。
SVVSのメリット・デメリット
- メリット
- SwiftUIの特徴を活かした実装ができる
- ライブラリに依存しない
- TCA、Clean Architecture等に比べて学習コストが低い
- デメリット
- 採用している方があまり居ないため情報が少ない
- Combineの知識が必要
簡単なケースで実装を紹介
- ログインした際に取得したユーザ情報をログイン後の画面で表示するパターンを考えてみます。
このようにLoginViewでログインした場合にUserStoreでAPI通信を行い、UserStoreに保持させた状態で、HomeViewへ遷移した時にUserStoreからユーザ情報を取得して表示します。
コードについて
上記の実装を実際のコードで紹介します。
ChatworkさんのSVVSのサンプルを元にしていますが、Swift6やswift-testingを考慮して少し変わっています。これから参考にしようと思っている方がいらっしゃればまずはChatworkさんのサンプルをご確認いただくことをお勧めします。
※ビルド環境はXcode 16.1, Swift 6 modeです。
LoginView
- ID/PASS入力とログインボタン押下で状態を変更します。
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を更新させます。
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: "太郎")
という非常にシンプルなものになっています。
- ちなみにこのサンプルのUserは
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からデータを取得し、ユーザー名を表示するだけの単純な処理です
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
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を使ってノート一覧を取得、保持して表示することができます。
swift-testing実装
swift-testingの実装もしてみたので載せておきます。まだ完全に理解できていないですがテストしたいことはできています。
LoginViewStateTests
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
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
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でユーザ検索するサンプルコードもあるのでご興味あればご覧ください。