Observationを使ったVIPERアーキテクチャの実装
はじめに
VIPERとは
Observationtとは
Observationを使ったVIPER
Combine
を使ったVIPERアーキテクチャについてはちらほら見かけたのですがObservation
となると見かけなかったので試してみました。
UIKitでObservationを簡単に使う
こちらの記事にもありますがUIKitでObservation
を使おうとすると監視が継続されないため再起的に呼ぶ必要があります。
毎回これを書くのは面倒なので次のようなextensionを用意します。
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
の場合は次のように書けます。
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
を付けるだけで良いです。
@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
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
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
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
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と関係ないため省略しています。
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
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による疎結合が保てました。
View側をSwiftUIに変えることもできそうです。
SwiftUIは全然知らないので誰か試してくださいw
あらためてですが、こちらにその他の実行を含めたソースコードをあげました。
Discussion