iOS Clean Architecture with UIKit, MVVM, RxSwift
The whole design architecture is separated into 4 rings:
- Entities: Enterprise business rules
- UseCases: Application business rules
- Data: Network & Data persistent
- Application: UI & Devices
The most important rule is that the inner ring knows nothing about outer ring. Which means the variables, functions and classes (any entities) that exist in the outer layers can not be mentioned in the more inward levels.
Entities are implemented as Swift struct
struct Article: Decodable {
@Default.Empty var author: String
@Default.Empty var title: String
@Default.Empty var description: String
@Default.Empty var url: String
@Default.Empty var urlToImage: String
@Default.Empty var publishedAt: String
@Default.Empty var content: String
}
UseCases are protocols
protocol ArticleUseCase {
func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]>
}
Domain layer doesn't depend on UIKit or any 3rd party framework.
Repositories are concrete implementation of UseCases
struct SearchArticleResult: Decodable {
@Default.EmptyList var articles: [Article]
@Default.Zero var totalResults: Int
}
struct ArticleRepository: ArticleUseCase {
func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> {
return ArticleService
.searchArticlesByKeyword(q: keyword, pageSize: pageSize, page: page)
.request(returnType: SearchArticleResult.self)
.map { $0.articles }
}
}
Application is implemented with the MVVM pattern. The ViewModel performs pure transformation of a user Input to the Output
protocol ViewModelProtocol {
associatedtype Input
associatedtype Output
func transform(input: Input) -> Output
}
struct ArticleListViewModel: ViewModelProtocol {
struct Input {
let search: Observable<String>
let loadMore: Observable<Void>
}
struct Output {
let tableData: Driver<[SectionModel]>
let fetching: Driver<Bool>
let error: Driver<Error>
}
@Injected var articleUseCase: ArticleUseCase
func transform(input: Input) -> Output {
.....
Observable.merge(search, loadMore)
.flatMapLatest { keyword in
return articleUseCase
.findArticlesByKeyword(keyword, pageSize: pageSize, page: currentPage.value)
.trackActivity(activityTracker)
.trackError(errorTracker)
.asDriver(onErrorJustReturn: [])
}
.subscribe(onNext: { articles in
.....
}
}
As you can see, articleUseCase
is injected to ViewModel by @Injected
annotation. Thanks to Resolver library to make dependency injection easier.
The ViewModel is injected to ViewController via Navigator
struct ArticleNavigator {
let navigationController: UINavigationController
func showArticles() {
let articleListViewController = Storyboard.load(.article, type: ArticleListViewController.self)
articleListViewController.viewModel = ArticleListViewModel()
articleListViewController.navigator = self
navigationController.pushViewController(articleListViewController, animated: false)
}
.....
final class ArticleListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var searchBar: UISearchBar!
private let bag = DisposeBag()
var viewModel: ArticleListViewModel!
var navigator: ArticleNavigator!
.....
In this architecture ViewModels, UseCases and Entities (if they contains business logic) can be tested.
To test the ViewModel you should have the RepositoryMock
struct ArticleRepositoryMock: ArticleUseCase {
func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> {
return MockLoader
.load(returnType: SearchArticleResult.self, file: "searchArticles.json")
.map { $0.articles }
}
}
typealias ViewModel = ArticleListViewModel
class ArticleListViewModelTests: XCTestCase {
var testScheduler: TestScheduler!
var viewModel: ViewModel!
let bag = DisposeBag()
override func setUpWithError() throws {
testScheduler = TestScheduler(initialClock: 0)
viewModel = ArticleListViewModel()
viewModel.articleUseCase = ArticleRepositoryMock()
}
override func tearDownWithError() throws {}
func test_searchWithKeyword() throws {
// Given
let search = testScheduler
.createHotObservable([
.next(0, "Tesla")
])
.asObservable()
let input = ViewModel.Input(search: search, loadMore: .never())
let output = viewModel.transform(input: input)
// When
testScheduler.start()
let articles = try! output.tableData.articles.toBlocking().first()!
// Then
XCTAssertEqual(articles.count, 20)
XCTAssertEqual(articles[1].author, "Mike Murphy")
}
func test_loadMore() throws {
// Given
let search = testScheduler
.createHotObservable([
.next(0, "Tesla")
])
.asObservable()
let loadMore = testScheduler
.createHotObservable([
.next(2, ())
])
.asObservable()
let input = ViewModel.Input(search: search, loadMore: loadMore)
let output = viewModel.transform(input: input)
// When
testScheduler.start()
let articles = try! output.tableData.articles.toBlocking().first()!
// Then
XCTAssertEqual(articles.count, 40)
}
The clean architecture, MVVM or VIPER will create a lot of files when you start a new module. So using a code generator is the smart way to save time.
codegen is a great tool to do it.