📱

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

2024/09/21に公開

はじめに

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#監視を継続する

毎回これを書くのは面倒なので次のようなextensionを用意します。

NSObect+.swift
import Foundation

public protocol Observablable: NSObject {}
public extension Observablable {
    @discardableResult
    func observation<T>(
        tracking: @escaping (() -> T),
        onChange: @escaping ((Self, T) -> Void),
        shouldStop: (() -> Bool)? = nil,
        useInitialValue: Bool = true,
        mainThread: Bool = true
    ) -> Self {
        
        @Sendable func process() {
            onChange(self, tracking())
            
            if let shouldStop, shouldStop() {
                return
            }
            
            self.observation(
                tracking: tracking,
                onChange: onChange,
                shouldStop: shouldStop,
                useInitialValue: useInitialValue,
                mainThread: mainThread
            )
        }
        
        if useInitialValue {
            onChange(self, tracking())
        }
        
        _ = withObservationTracking({
            tracking()
        }, onChange: {
            if mainThread {
                Task { @MainActor in
                    process()
                }
            } else {
                process()
            }
        })
        return self
    }
}

extension NSObject: Observablable { }

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

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

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

collectionView
  .observation(
      tracking: {[weak self] in
          // 監視したいパラメータを記述
          self!.presenter.initilalLoading
      },
      onChange: { collectionView, loading in
          // 監視してるパラメータが変更された時にやることを記述
          collectionView.isHidden = loading
      }
  ).observation(
      tracking: {[weak self] in
          // メソッドチェインにより監視したいパラメータをさらに記述
          self!.presenter.refreshLoading
      }, onChange: { collectionView, refreshLoading in
          collectionView.refreshControl?.endRefreshing()
      }
  ).observation(
      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

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
            users = try await API.shared.getUsers()
        } catch let e {
            users = []
            print(e)
        }
    }
}

Presenter

UserListPresenter.swift
import Foundation

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 {
    
    public var initilalLoading: Bool {
        interactor.initilalLoading
    }
    public var refreshLoading: Bool {
        interactor.refreshLoading
    }
    public var users: [User] {
        interactor.users ?? []
    }
    
    private weak var view: UserListView?
    private let interactor: UserListInteractor
    private let router: UserListRouter
        
    init(
        view: UserListView,
        interactor: UserListInteractor,
        router: UserListRouter
    ) {
        self.view = view
        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 = .white
        self.view.addSubview(activityIndicatorView)
        activityIndicatorView.applyArroundConstraint(equalTo: self.view)

        self.view.addSubview(collectionView)
        collectionView.applyArroundConstraint(equalTo: self.view)
    }
    
    private func setupObservation() {
        collectionView
            .observation(
                tracking: {[weak self] in
                    self!.presenter.initilalLoading
                },
                onChange: { collectionView, loading in
                    collectionView.isHidden = loading
                }
            ).observation(
                tracking: {[weak self] in
                    self!.presenter.refreshLoading
                }, onChange: { collectionView, refreshLoading in
                    collectionView.refreshControl?.endRefreshing()
                }
            ).observation(
                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
            .observation(
                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

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

public final class UserListRouterImpl: UserListRouter {
    
    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(
            view: view,
            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

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 {

        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
    final class MockRouter: UserListRouter {
        var user: User?
        func show(user: User) {
            self.user = user
        }
    }
    
    func test_ユーザ情報の取得() async throws {
        let view = await UserListViewImpl()
        let mockIntearctor = MockSomeUserListInteractor()
        let router = MockRouter()
        let presenter = UserListPresenterImpl(view: view, interactor: mockIntearctor, router: router)
        await view.inject(presenter: presenter)
        

        XCTAssertTrue(presenter.users.isEmpty)
        XCTAssertFalse(presenter.initilalLoading)
        XCTAssertFalse(presenter.refreshLoading)

        presenter.viewDidLoad()

        try await Task.sleep(nanoseconds: 000_000_100)

        XCTAssertTrue(presenter.initilalLoading)
        XCTAssertFalse(presenter.refreshLoading)

        try await Task.sleep(nanoseconds: 550_000_000)

        XCTAssertFalse(presenter.initilalLoading)
        XCTAssertFalse(presenter.refreshLoading)

        XCTAssertTrue(!presenter.users.isEmpty)
    }
    
    func test_ひっぱりリロード() async throws {
        let view = await UserListViewImpl()
        let mockIntearctor = MockSomeUserListInteractor()
        let router = UserListRouterImpl(view: view)
        let presenter = UserListPresenterImpl(view: view, interactor: mockIntearctor, router: router)
        await view.inject(presenter: presenter)
        
        mockIntearctor.set(users: User.testList)

        XCTAssertFalse(presenter.initilalLoading)
        XCTAssertFalse(presenter.refreshLoading)

        presenter.changeValueRefreshControl()
        
        try await Task.sleep(nanoseconds: 000_000_100)

        XCTAssertFalse(presenter.initilalLoading)
        XCTAssertTrue(presenter.refreshLoading)

        try await Task.sleep(nanoseconds: 550_000_000)

        XCTAssertFalse(presenter.initilalLoading)
        XCTAssertFalse(presenter.refreshLoading)

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

        presenter.select(indexPath: .init(item: 0, section: 0))
        
        XCTAssertTrue(router.user == User.testList.first)

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

        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