Observationを使ったVIPERアーキテクチャの実装
更新履歴
2025.01.26
Swift6に対応
はじめに
VIPERとは
Observationtとは
Observationを使ったVIPER
Combine
を使ったVIPERアーキテクチャについてはちらほら見かけたのですがObservation
となると見かけなかったので試してみました。
UIKitでObservationを簡単に使う
こちらの記事にもありますがUIKitでObservation
を使おうとすると監視が継続されないため再起的に呼ぶ必要があります。
またSwift6で、Strict Concurrencyの対応が必要になっています。
それらを踏まえた上で毎回実装を用意するのは面倒なので次のようなextensionを用意します。
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
の場合は次のように書けます。
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
を付けるだけで良いです。
@Observation
class Presenter {
var initilalLoading: Bool = false
var refreshLoading: Bool = false
var users: [User] = []
}
実装
一気に主要箇所を書きます。
こちらのソースではextensionやAPIなどはありませんが、ちゃんと完成した動くコードはgithubにあげました。
VIPERはProtocol HogeUsecase
と抽象化して、
class HogeInteractor: HogeUsecase
と実装を書いたりしますが、
自分が最初それらの名前の対応を覚えるのが大変だったので
Protocol HogeInteractor
、class HogeInteractorImpl: HogeInteractor
とprotocol
とclass
の名前を揃えてます。
余計読みにくかったらごめんなさい。
Interactor
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
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
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
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と関係ないため省略しています。
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
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による疎結合が保てました。
View側をSwiftUIに変えることもできそうです。
SwiftUIは全然知らないので誰か試してくださいw
あらためてですが、こちらにその他の実行を含めたソースコードをあげました。
Discussion