iOS Clean Architecture with SwiftUI, Swift Concurrency with MVVM pattern.
The whole design architecture is separated into 5 parts in horizontal axis:
- View: UI layer, doesn't contain any business logic
- ViewModel: UI Logic layer.
- Service: Middle layer between ViewModel and Data Layer.
- Data Layer: Database, Networking, User Preferences, Analytics, ... or any third party services
- Model: Model is shared layer. Model can be accessed from any where.
Model are implemented as Swift struct
struct Article: Decodable {
var author: String
var title: String
var description: String
var url: String
var urlToImage: String
var publishedAt: String
var content: String
}
Each Service includes a protocol and a default implementation.
protocol ArticleService {
func searchArticlesByKeyword(_ keyword: String, page: Int) async throws -> [Article]
}
actor DefaultArticleService: ArticleService {
func searchArticlesByKeyword(_ keyword: String, page: Int) async throws -> [Article] {
return try await ArticleAPI
.searchArticles(keyword: keyword, page: page)
.call([Article].self)
}
}
The ViewModel performs pure transformation of a user Input to the Output
final class ArticleListViewModel: ObservableObject {
@Injected var articleService: ArticleService
@Published private(set) var articles: [Article] = []
@Published private(set) var isFetching = false
@MainActor
func fetchArticles() async throws {
isFetching = true
defer { isFetching = false }
articles = try await articleService.searchArticlesByKeyword("Tesla", page: 1)
}
}
As you can see, articleService
is injected to ViewModel by @Injected
annotation. Thanks to Resolver library to make dependency injection easier.
The View only sends input and observe the output state to update UI
struct ArticleListView: View {
@ObservedObject var viewModel: ArticleListViewModel
var body: some View {
NavigationView {
VStack {
List(viewModel.articles) { article in
ArticleListRow(article: article)
}
if viewModel.isFetching {
ProgressView()
}
Button("Load Articles") {
Task {
try? await viewModel.fetchArticles()
}
}
}
}
}
}
In order to write fast, reliable unit test, we need to mock the Service (to avoid ViewModel interacting with Data Layer).
struct MockArticleService: ArticleService {
func searchArticlesByKeyword(_ keyword: String, page: Int) async throws -> [Article] {
MockLoader.load("searchArticles.json", ofType: SearchArticleResult.self)!.articles
}
}
Then inject mock service into ViewModel for testing the view model.
class ArticleListViewModelTests: XCTestCase {
private typealias ViewModel = ArticleListViewModel
private var viewModel: ViewModel = .init()
override func setUpWithError() throws {
viewModel.articleService = MockArticleService()
}
override func tearDownWithError() throws {}
func testFetchArticles_whenSuccess() async {
try? await viewModel.fetchArticles()
XCTAssertFalse(viewModel.isFetching)
XCTAssertEqual(viewModel.articles.count, 20)
}
}
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 for code generator.