Combine을 사용한 VIPER 아키텍처 구현
개시하다
이른바 VIPER 구조
아래의 보도를 총결하였으니, 상세한 상황은 이쪽을 보십시오.
Combine 소개
아래의 보도는 매우 참고할 만한 가치가 있으니, 상세한 상황은 이쪽을 보십시오.
Combine × VIPER 아키텍처
생각한 일
완성
샘플 코드
Presenter
Combine 도입 시 가장 큰 변화
주안점
ObservableObjectObservableObjectPassthroughSubject 활용PassthroughSubject에서 재생@Published된 속성@Published를 공개하는 게 귀찮아서 포기했습니다.샘플 코드
ArticleSearchPresenter.swift
ArticleSearchPresenter.swift
import Foundation
import Combine
import CombineSchedulers
enum ArticleSearchViewEvent {
case viewDidLoad
case refreshControlValueChanged
case didSelect(article: ArticleModel)
}
final class ArticleSearchPresenter: Presentation {
private var cancellables: Set<AnyCancellable> = []
let viewEventSubject = PassthroughSubject<ArticleSearchViewEvent, Never>()
@Published var articles: [ArticleModel] = []
@Published var articleSearchError: ArticleSearchError?
init<
Router: ArticleSearchWireframe,
ArticleSearchInteractor: ArticleSearchUsecase
>(
mainScheduler: AnySchedulerOf<DispatchQueue> = .main,
router: Router,
articleSearchInteractor: ArticleSearchInteractor
) {
let searchKeywordSubject = PassthroughSubject<String, Never>()
// 受け取ったイベントをもとに処理の依頼を各所に投げる
viewEventSubject
.sink { event in
switch event {
case .viewDidLoad, .refreshControlValueChanged:
searchKeywordSubject.send("Swift")
case .didSelect(let article):
router.navigationSubject.send(.articleDetail(article))
}
}.store(in: &cancellables)
// キーワードが変更されたら記事を検索して結果を`@Published`のプロパティにセット
searchKeywordSubject
.flatMap { searchKeyword in
articleSearchInteractor
.execute(searchKeyword)
.convertToResultPublisher()
}.receive(on: mainScheduler)
.sink { [weak self] result in
switch result {
case .success(let articles):
self?.articles = articles
case .failure(let error):
self?.articleSearchError = error
}
}.store(in: &cancellables)
}
}
View
주안점
@Published 변수의 변경을 감시하고 화면 업데이트를 실시한다.샘플 코드
UIKit
ArticleSearchViewController.swift
ArticleSearchViewController.swift
import UIKit
import Combine
import CombineCocoa
class ArticleSearchViewController: UICollectionViewController {
private let presenter: ArticleSearchPresenter
// ...
override func viewDidLoad() {
super.viewDidLoad()
// ...
// 記事の一覧が更新されたらリストを更新
presenter.$articles
.sink { [weak self] articles in
var snapshot = NSDiffableDataSourceSnapshot<Int, ArticleModel>()
snapshot.appendSections([0])
snapshot.appendItems(articles, toSection: 0)
self?.dataSource.apply(snapshot, animatingDifferences: true) {
self?.collectionView.refreshControl?.endRefreshing()
}
}.store(in: &cancellables)
// エラーが発生したらアラートを出す
presenter.$articleSearchError
.compactMap { $0 }
.sink { [weak self] error in
let alert = UIAlertController(
title: error.errorDescription,
message: error.recoverySuggestion,
preferredStyle: .alert
)
alert.addAction(.init(title: "OK", style: .default, handler: nil))
self?.present(alert, animated: true) {
self?.collectionView.refreshControl?.endRefreshing()
}
}.store(in: &cancellables)
// Viewで発生したイベントをPresenterに通知
presenter.viewEventSubject.send(.viewDidLoad)
}
}
SwiftUIArticleSearchView.swift
ArticleSearchView.swift
import SwiftUI
struct ArticleSearchView: View {
@ObservedObject var presenter: ArticleSearchPresenter
var body: some View {
ArticleListView(
articles: presenter.articles, // 記事の一覧が更新されたらリストを更新
onTapArticle: { article in
presenter.viewEventSubject.send(.didSelect(article: article))
}
)
.alert(item: $presenter.articleSearchError) { error in // エラーが発生したらアラートを出す
Alert(
title: .init(error.errorDescription),
message: error.recoverySuggestion.map { .init($0) },
dismissButton: nil
)
}.navigationBarTitle(Text("Articles"), displayMode: .large)
.onAppear {
// Viewで発生したイベントをPresenterに通知
presenter.viewEventSubject.send(.viewDidLoad)
}
}
}
extension ArticleSearchView {
struct ArticleListView: View {
let articles: [ArticleModel]
let onTapArticle: (ArticleModel) -> Void
var body: some View {
List(articles) { article in
HStack {
Text(article.title)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
onTapArticle(article)
}
}.listStyle(.plain)
}
}
}
Router
주안점
PassthroughSubjectPassthroughSubject샘플 코드
ArticleSearchRouter.swift
ArticleSearchRouter.swift
import UIKit
import SwiftUI
import Combine
enum ArticleSearchDestination: Equatable {
case articleDetail(_ article: ArticleModel)
}
protocol ArticleSearchWireframe: Wireframe where Destination == ArticleSearchDestination {}
final class ArticleSearchRouter: ArticleSearchWireframe {
private var cancellables: Set<AnyCancellable> = []
fileprivate weak var viewController: UIViewController?
let navigationSubject = PassthroughSubject<ArticleSearchDestination, Never>()
private init() {
// 画面遷移
navigationSubject
.sink { destination in
switch destination {
case .articleDetail(let article):
let articleDetailView = ArticleDetailRouter.assembleModules(article: article)
self.viewController?.navigationController?.pushViewController(articleDetailView, animated: true)
}
}.store(in: &cancellables)
}
// DI
static func assembleModules() -> UIViewController {
let router = ArticleSearchRouter()
let qiitaDataStore = QiitaDataStore()
let articleSearchInteractor = ArticleSearchInteractor(qiitaRepository: qiitaDataStore)
let presenter = ArticleSearchPresenter(router: router, articleSearchInteractor: articleSearchInteractor)
let view = ArticleSearchViewController(presenter: presenter)
router.viewController = view
return view
}
}
Interactor
주안점
샘플 코드
ArticleSearchInteractor.swift
ArticleSearchInteractor.swift
import Foundation
import Combine
struct ArticleSearchError: UsecaseError, Identifiable {
private let error: QiitaRepositoryError
var errorDescription: String {
// ...
}
var recoverySuggestion: String? {
// ...
}
var id: String {
error.localizedDescription
}
init(error: QiitaRepositoryError) {
self.error = error
}
}
protocol ArticleSearchUsecase: Usecase
where Input == String,
Output == [ArticleModel],
Failure == ArticleSearchError {}
final class ArticleSearchInteractor {
private let qiitaRepository: QiitaRepository
init(qiitaRepository: QiitaRepository) {
self.qiitaRepository = qiitaRepository
}
}
extension ArticleSearchInteractor: ArticleSearchUsecase {
func execute(_ input: String) -> AnyPublisher<[ArticleModel], ArticleSearchError> {
qiitaRepository
.searchArticles(keyword: input)
.mapError { .init(error: $0) }
.eraseToAnyPublisher()
}
}
테스트 코드
combine-schedulers라는 프로그램 라이브러리를 사용하여 실현
PresenterTests
ArticleSearchPresenterTests.swift
ArticleSearchPresenterTests.swift
@testable import ViperCombineSample
import Quick
import Nimble
import Combine
import CombineSchedulers
import OrderedCollections
final class ArticleSearchPresenterTests: QuickSpec {
override func spec() {
var cancellables: Set<AnyCancellable> = []
var testScheduler: TestSchedulerOf<DispatchQueue>!
var presenter: ArticleSearchPresenter!
var router: MockArticleSearchRouter!
var articleSearchInteractor: MockArticleSearchInteractor!
var articlesOutputs: [[ArticleModel]] = []
var articleSearchErrorOutputs: [ArticleSearchError?] = []
var navigationOutputs: [ArticleSearchDestination] = []
beforeEach {
testScheduler = DispatchQueue.test
router = .init()
articleSearchInteractor = .init()
presenter = .init(
mainScheduler: testScheduler.eraseToAnyScheduler(),
router: router,
articleSearchInteractor: articleSearchInteractor
)
presenter.$articles.sink { articlesOutputs.append($0) }.store(in: &cancellables)
presenter.$articleSearchError.sink { articleSearchErrorOutputs.append($0) }.store(in: &cancellables)
router.navigationSubject.sink { navigationOutputs.append($0) }.store(in: &cancellables)
}
afterEach {
cancellables = []
articlesOutputs = []
articleSearchErrorOutputs = []
navigationOutputs = []
}
describe("viewDidLoad") {
beforeEach {
testScheduler.schedule {
presenter.viewEventSubject.send(.viewDidLoad)
}
}
context("articleSearchInteractorの返却値がエラーのとき") {
let error = ArticleSearchError(error: .connectionError(NSError(domain: "hoge", code: -1, userInfo: nil)))
beforeEach {
testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
articleSearchInteractor.executeResult.send(completion: .failure(error))
}
testScheduler.advance(by: 10)
}
it("articleSearchErrorが更新される") {
expect(articleSearchErrorOutputs) == [
nil,
error
]
}
}
context("articleSearchInteractorの返却値が成功のとき") {
let articles = [
ArticleModel(id: .init(rawValue: "article_id"), title: "article_title", body: "article_body")
]
beforeEach {
testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
articleSearchInteractor.executeResult.send(articles)
}
testScheduler.advance(by: 10)
}
it("articlesが更新される") {
expect(articlesOutputs) == [
[],
.init(articles)
]
}
}
}
// ...
}
}
InteractorTests
ArticleSearchInteractorTests.swift
ArticleSearchInteractorTests.swift
@testable import ViperCombineSample
import Foundation
import Quick
import Nimble
import Combine
import CombineSchedulers
final class ArticleSearchInteractorTests: QuickSpec {
override func spec() {
var cancellables: Set<AnyCancellable> = []
var testScheduler: TestSchedulerOf<DispatchQueue>!
var executeOutputs: [Result<ArticleSearchInteractor.Output, ArticleSearchInteractor.Failure>] = []
var interactor: ArticleSearchInteractor!
var qiitaDataStore: MockQiitaDataStore!
let input: ArticleSearchInteractor.Input = "Swift"
let error = NSError(domain: "hoge", code: -1, userInfo: nil)
beforeEach {
testScheduler = DispatchQueue.test
qiitaDataStore = .init()
interactor = .init(qiitaRepository: qiitaDataStore)
}
afterEach {
cancellables = []
executeOutputs = []
}
describe("execute") {
beforeEach {
testScheduler.schedule {
interactor.execute(input)
.convertToResultPublisher()
.sink { executeOutputs.append($0) }
.store(in: &cancellables)
}
}
context("dataStoreの返却値がconnectionErrorのとき") {
let connectionError: QiitaRepositoryError = .connectionError(error)
beforeEach {
testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
qiitaDataStore.searchArticlesResult.send(completion: .failure(connectionError))
}
testScheduler.advance(by: 10)
}
it("エラーが返却される") {
expect(executeOutputs) == [
.failure(.init(error: connectionError))
]
}
}
// ...
context("dataStoreの返却値が成功のとき") {
let response = [
ArticleModel(id: .init(rawValue: "article_id"), title: "article_title", body: "article_body")
]
beforeEach {
testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
qiitaDataStore.searchArticlesResult.send(response)
}
testScheduler.advance(by: 10)
}
it("[ArticleModel]が返却される") {
expect(executeOutputs) == [
.success(response)
]
}
}
}
}
}
향후의 전망
전체 Swift UI화
TBD
생각
NavigationLink으로 인해 Router는 현재처럼 View를 참조하여 외부에서 화면 마이그레이션을 수행하기 어렵습니다여기 기사.Presenter에서 생성
NavigationLink하는 방법UI 구성 요소
NavigationLink를 사용하여Presenter에게 데이터 변경 사항을 알릴 때Presenter에게 이벤트 알림 방법@Binding등TextField했는데 프레젠테이션에 이벤트 알림을 일체화시켰는데 여기도 정리가 되나요?Swift Conceurrency와 공존/교환 등
TBD
생각
여기 기사. 참조가 될 수 있음
총결산
Reference
이 문제에 관하여(Combine을 사용한 VIPER 아키텍처 구현), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://zenn.dev/hicka04/articles/viper-combine텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념
(Collection and Share based on the CC Protocol.)