📱

Observationを使ったVIPERアーキテクチャの実装

2024/09/21に公開

更新履歴

2025.01.26

Swift6に対応

はじめに

VIPERとは

https://qiita.com/hicka04/items/09534b5daffec33b2bec

Observationtとは

https://qiita.com/usamik26/items/6544879e3aa8343b709f

Observationを使ったVIPER

Combineを使ったVIPERアーキテクチャについてはちらほら見かけたのですがObservationとなると見かけなかったので試してみました。

UIKitでObservationを簡単に使う

こちらの記事にもありますがUIKitでObservationを使おうとすると監視が継続されないため再起的に呼ぶ必要があります。
https://qiita.com/usamik26/items/6544879e3aa8343b709f#監視を継続する

またSwift6で、Strict Concurrencyの対応が必要になっています。

それらを踏まえた上で毎回実装を用意するのは面倒なので次のようなextensionを用意します。

UIKit+Observation.swift
import UIKit

public protocol ObservableUIKit: AnyObject, Sendable {}
public extension ObservableUIKit {

    // 監視対象のパラメータがnilになったら停止する
    @MainActor
    @discardableResult
    func tracking<T>(
        useInitialValue: Bool = true,
        sendOptional: Bool = false,
        shouldStop: @escaping (@Sendable () -> Bool) = { false },
        _ apply: @escaping @Sendable @MainActor () -> T?,
        onChange: @escaping (@Sendable @MainActor (Self, T) -> Void)
    ) -> Self {

        if useInitialValue, let value = apply() {
            onChange(self, value)
        }

        _ = withObservationTracking(apply, onChange: {[weak self] in

            Task { @MainActor in
                guard let self, let value = apply() else { return }

                onChange(self, value)

                if shouldStop() {
                    return
                }

                self.tracking(
                    useInitialValue: useInitialValue,
                    shouldStop: shouldStop,
                    apply,
                    onChange: onChange
                )
            }
        })
        
        return self
    }

    // 監視対象のパラメータがnilになっても監視し続ける
    @MainActor
    @discardableResult
    func trackingOptional<T>(
        useInitialValue: Bool = true,
        shouldStop: @escaping (@Sendable () -> Bool) = { false },
        _ apply: @escaping @Sendable @MainActor () -> T?,
        onChange: @escaping (@Sendable @MainActor (Self, T?) -> Void)
    ) -> Self {

        if useInitialValue {
            onChange(self, apply())
        }

        _ = withObservationTracking(apply, onChange: {[weak self] in

            Task { @MainActor in
                guard let self else { return }

                onChange(self, apply())

                if shouldStop() {
                    return
                }

                self.tracking(
                    useInitialValue: useInitialValue,
                    shouldStop: shouldStop,
                    apply,
                    onChange: onChange
                )
            }
        })
        return self
    }
}

extension UIView: @retroactive Sendable {}
extension UIView: ObservableUIKit {}
extension UIViewController: @retroactive Sendable {}
extension UIViewController: ObservableUIKit {}

これにより、おそらくUIKitのほとんど全てのクラスで値の監視が簡単にできます。

例えばUICollectionViewの場合は次のように書けます。

UIKit+Observation.swift
let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .plain))
let collectionView: UICollectionView = .init(frame: .zero, collectionViewLayout: layout)

collectionView
  .tracking { [weak self] in
      // 監視したいパラメータを記述
      self?.presenter.initilalLoading
  } onChange: { collectionView, loading in
      // 監視してるパラメータが変更された時にやることを記述
      collectionView.isHidden = loading
  }.tracking { [weak self] in
      // メソッドチェインにより監視したいパラメータをさらに記述
      self?.presenter.refreshLoading
  } onChange: { collectionView, refreshLoading in
      collectionView.refreshControl?.endRefreshing()
  }.tracking { [weak self] in
      self?.presenter.users
  } onChange: { [weak self] _, users in
      var snapshot = NSDiffableDataSourceSnapshot<Int, User>()
      snapshot.appendSections([0])
      snapshot.appendItems(users)
      self!.diffableDataSource.apply(snapshot, animatingDifferences: false)
  }

監視したいパラメータは次のように@Observationを付けるだけで良いです。

ObservationModel.swift
@Observation
class Presenter {
  var initilalLoading: Bool = false
  var refreshLoading: Bool = false
  var users: [User] = []
}

実装

一気に主要箇所を書きます。
こちらのソースではextensionやAPIなどはありませんが、ちゃんと完成した動くコードはgithubにあげました。

https://github.com/sakiyamaK/ObservationVIPER

VIPERはProtocol HogeUsecaseと抽象化して、
class HogeInteractor: HogeUsecaseと実装を書いたりしますが、
自分が最初それらの名前の対応を覚えるのが大変だったので
Protocol HogeInteractorclass HogeInteractorImpl: HogeInteractor
protocolclassの名前を揃えてます。

余計読みにくかったらごめんなさい。

Interactor

UserListInteractor.swift
import Foundation

@MainActor
public protocol UserListInteractor {
    var loading: Bool { get }
    var users: [User]? { get }
    var initilalLoading: Bool { get }
    var refreshLoading: Bool { get }
    func fetch() async
}

@Observable
public final class UserListInteractorImpl: UserListInteractor {
    
    public private(set) var loading: Bool = true
    public private(set) var users: [User]?
    public var initilalLoading: Bool {
        users == nil && loading
    }
    public var refreshLoading: Bool {
        users != nil && loading
    }

    public func fetch() async {
        defer {
            loading = false
        }
        
        do {
            loading = true
            // 0.5秒遅らせる
            let delayInNanoseconds = 500_000_000 // 500ミリ秒をナノ秒に変換
            try await Task.sleep(nanoseconds: UInt64(delayInNanoseconds))
            users = try await API.shared.getUsers()
        } catch let e {
            users = []
            print(e)
        }
    }

}

Presenter

UserListPresenter.swift
import Foundation

@MainActor
public protocol UserListPresenter {
    var initilalLoading: Bool { get }
    var refreshLoading: Bool { get }
    var users: [User] { get }
    
    func viewDidLoad()
    func select(indexPath: IndexPath)
    func changeValueRefreshControl()
}

@Observable
public final class UserListPresenterImpl: UserListPresenter {
    
    deinit { print("\(Self.self) deinit") }

    public var initilalLoading: Bool {
        interactor.initilalLoading
    }
    public var refreshLoading: Bool {
        interactor.refreshLoading
    }
    public var users: [User] {
        interactor.users ?? []
    }
    
    private let interactor: UserListInteractor
    private let router: UserListRouter
        
    init(
        interactor: UserListInteractor,
        router: UserListRouter
    ) {
        self.interactor = interactor
        self.router = router
    }
    
    public func viewDidLoad() {
        Task {
            await self.interactor.fetch()
        }
    }

    public func changeValueRefreshControl() {
        Task {
            await self.interactor.fetch()
        }
    }

    public func select(indexPath: IndexPath) {
        router.show(user: users[indexPath.item])
    }    
}

View

UserListView.swift
import UIKit
import Kingfisher

public protocol UserListView: UIViewController {
}

public final class UserListViewImpl: UIViewController, UserListView {
    
    private var presenter: UserListPresenter!
    func inject(presenter: UserListPresenter) {
        self.presenter = presenter
    }

    private var diffableDataSource: UICollectionViewDiffableDataSource<Int, User>!
    
    private lazy var collectionView: UICollectionView = {
        
        let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .plain))
        let collectionView: UICollectionView = .init(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.refreshControl = UIRefreshControl()
        
        collectionView.refreshControl?.addAction(.init(handler: {[weak self] _ in
            self!.presenter.changeValueRefreshControl()
        }), for: .valueChanged)
        
        return collectionView
    }()
    
    private var activityIndicatorView: UIActivityIndicatorView = .init(style: .large)
    
    public override func viewDidLoad() {
        super.viewDidLoad()
                
        self.setupUI()
        self.setupDataSource()
        self.setupObservation()

        presenter.viewDidLoad()
    }
    
    private func setupUI() {
        self.view.backgroundColor = .systemBackground
        self.view.addSubview(activityIndicatorView)
        activityIndicatorView.applyArroundConstraint(equalTo: self.view)

        self.view.addSubview(collectionView)
        collectionView.applyArroundConstraint(equalTo: self.view)
    }
    
    private func setupObservation() {
        collectionView.tracking {[weak self] in
            self?.presenter.initilalLoading
        } onChange: { collectionView, loading in
            collectionView.isHidden = loading
        }.tracking {[weak self] in
            self?.presenter.refreshLoading
        } onChange: { collectionView, refreshLoading in
            collectionView.refreshControl?.endRefreshing()
        }.tracking {[weak self] in
            self?.presenter.users
        } onChange: {[weak self] _, users in
            var snapshot = NSDiffableDataSourceSnapshot<Int, User>()
            snapshot.appendSections([0])
            snapshot.appendItems(users)
            self!.diffableDataSource.apply(snapshot, animatingDifferences: false)
        }

        activityIndicatorView.tracking {[weak self] in
            self?.presenter.initilalLoading
        } onChange: { activityIndicatorView, loading in
            if loading {
                activityIndicatorView.startAnimating()
            } else {
                activityIndicatorView.stopAnimating()
            }
        }
    }
    
    private func setupDataSource() {
        
        let imageSize = CGSize(width: 40, height: 40)
        let dummyImage = UIImage.createImage(with: imageSize, color: .clear)
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, User> { cell, indexPath, item in
            
            var config = UIListContentConfiguration.cell()
            
            config.text = item.id.displayName
            config.imageProperties.maximumSize = imageSize
            config.imageProperties.reservedLayoutSize = imageSize
            config.image = dummyImage
            cell.contentConfiguration = config
            
            Task {
                guard let urlStr = item.picture.thumbnail, let url = URL(string: urlStr) else { return }
                guard let image = try? await KingfisherManager.shared.asyncRetrieveImage(with: url)
                else { return }
                config.image = image
                cell.contentConfiguration = config
            }
        }
        
        diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: itemIdentifier
            )
        }
    }
}

extension UserListViewImpl: UICollectionViewDelegate {
    public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        presenter.select(indexPath: indexPath)
    }
}

Router

UserListRouter.swift
import Foundation

@MainActor
public protocol UserListRouter {
    func show(user: User)
}

public final class UserListRouterImpl: UserListRouter {
    
    deinit { print("\(Self.self) deinit") }

    private unowned var view: UserListView!
    init(view: UserListView!) {
        self.view = view
    }

    public static func assembleModules() -> UserListView {
        let view = UserListViewImpl()
        let interactor = UserListInteractorImpl()
        let router = UserListRouterImpl(view: view)
        let presenter = UserListPresenterImpl(
            interactor: interactor,
            router: router
        )
        view.inject(presenter: presenter)
        return view
    }
    
    public func show(user: User) {
        
    }
}


Entity

実際はAPI通信したjsonから変換させるためにCodableであったり、
UICollectionViewDiffableDataSourceに対応させるためにHashableであったりしますが、
それらに必要な記述はVIPERやObservationと関係ないため省略しています。

UserListEntity.swift
import Foundation

public struct User: Codable, Hashable {
        
    public struct Id: Codable {
        let name: String?
        let value: String?
        var displayName: String {
            guard let name, let value else {
                return "no name"
            }
            return name + ", " + value
        }
    }
    public struct Picture: Codable {
        let thumbnail: String?
    }
    
    let id: Id
    let picture: Picture

    // テストのためにinitも用意
    public init(id: Id, picture: Picture) {
        self.id = id
        self.picture = picture
        self.uuid = UUID()
    }
}

テストコード

普段テストコードを書いてないので、これでいいのかよく分かってませんw
Presenterのテストを書いてみました。
View,Intearctor,Routerも同様に書けるはずです。

PresenterTests

UserListPresenterTests.swift
import XCTest
@testable import ObservationVIPER

@MainActor
extension User {
    static var testList: [User] = [
        .init(
            id: .init(name: "test taro", value: "123"),
            picture: .init(thumbnail: nil)
        ),
        .init(
            id: .init(name: "test hanako", value: "456"),
            picture: .init(thumbnail: nil)
        )
    ]
}

final class UserListPresenterTests: XCTestCase {
    
    @Observable
    final class MockSomeUserListInteractor: UserListInteractor {
        deinit { print("\(Self.self) deinit") }

        public private(set) var loading: Bool = false
        public private(set) var users: [User]?
        public var initilalLoading: Bool {
            users == nil && loading
        }
        public var refreshLoading: Bool {
            users != nil && loading
        }

        public func fetch() async {
            defer {
                loading = false
            }
            
            do {
                loading = true
                // 0.5秒遅らせる
                let delayInNanoseconds = 500_000_000 // 500ミリ秒をナノ秒に変換
                try await Task.sleep(nanoseconds: UInt64(delayInNanoseconds))
                users = User.testList
            } catch _ {
                users = []
            }
        }
        
        public func set(users: [User]) {
            self.users = users
        }
    }
    
    @Observable
    @MainActor
    final class MockRouter: UserListRouter {
        var user: User?
        func show(user: User) {
            self.user = user
        }
    }
    
    func test_ユーザ情報の取得() async throws {
        let view = await UserListViewImpl()
        let mockIntearctor = await MockSomeUserListInteractor()
        let router = await MockRouter()
        let presenter = await UserListPresenterImpl(interactor: mockIntearctor, router: router)
        await view.inject(presenter: presenter)

        Task { @MainActor in
            XCTAssertTrue(presenter.users.isEmpty)
            XCTAssertFalse(presenter.initilalLoading)
            XCTAssertFalse(presenter.refreshLoading)
        }

        await presenter.viewDidLoad()

        try await Task.sleep(nanoseconds: 000_000_100)

        Task { @MainActor in
            XCTAssertTrue(presenter.initilalLoading)
            XCTAssertFalse(presenter.refreshLoading)
        }

        try await Task.sleep(nanoseconds: 550_000_000)

        Task { @MainActor in
            XCTAssertFalse(presenter.initilalLoading)
            XCTAssertFalse(presenter.refreshLoading)

            XCTAssertTrue(!presenter.users.isEmpty)
        }
    }

    func test_ひっぱりリロード() async throws {
        let view = await UserListViewImpl()
        let mockIntearctor = await MockSomeUserListInteractor()
        let router = await UserListRouterImpl(view: view)
        let presenter = await UserListPresenterImpl(interactor: mockIntearctor, router: router)
        await view.inject(presenter: presenter)

        await mockIntearctor.set(users: User.testList)

        Task { @MainActor in
            XCTAssertFalse(presenter.initilalLoading)
            XCTAssertFalse(presenter.refreshLoading)
        }

        await presenter.changeValueRefreshControl()

        try await Task.sleep(nanoseconds: 000_000_100)

        Task { @MainActor in
            XCTAssertFalse(presenter.initilalLoading)
            XCTAssertTrue(presenter.refreshLoading)
        }
        try await Task.sleep(nanoseconds: 550_000_000)

        Task { @MainActor in
            XCTAssertFalse(presenter.initilalLoading)
            XCTAssertFalse(presenter.refreshLoading)

            XCTAssertTrue(!presenter.users.isEmpty)
        }
    }
    
    func test_ユーザの選択() async throws {
        let view = await UserListViewImpl()
        let mockIntearctor = await MockSomeUserListInteractor()
        let router = await MockRouter()
        let presenter = await UserListPresenterImpl(interactor: mockIntearctor, router: router)
        await view.inject(presenter: presenter)

        await mockIntearctor.set(users: User.testList)

        await presenter.select(indexPath: .init(item: 0, section: 0))

        Task { @MainActor in
            XCTAssertTrue(router.user == User.testList.first)
        }

        await presenter.select(indexPath: .init(item: 1, section: 0))

        Task { @MainActor in
            XCTAssertTrue(router.user == User.testList[1])
        }
    }
}

終わりに

こちらの記事によるCombineを使ったVIPERと比べてObservationだとProtocolによる疎結合が保てました。

https://zenn.dev/hicka04/articles/viper-combine

View側をSwiftUIに変えることもできそうです。
SwiftUIは全然知らないので誰か試してくださいw

あらためてですが、こちらにその他の実行を含めたソースコードをあげました。
https://github.com/sakiyamaK/ObservationVIPER

Discussion