Lab 12.1 — Refactor to MVVM
Goal: take a single 400-line UIViewController doing everything (network, parsing, state, presentation) and refactor it cleanly to MVVM with @Observable while preserving behavior.
Time: ~3 hours.
Prereqs: Xcode 16+, iOS 17+ simulator, comfort with async/await.
Setup
Create a new iOS App project, “RefactorMVVM”, SwiftUI lifecycle but with a UIKit screen via UIViewControllerRepresentable. Drop in the following starter ArticlesViewController:
final class ArticlesViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
private let tableView = UITableView()
private var articles: [[String: Any]] = []
private var isLoading = false
private var errorMessage: String?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Articles"
tableView.dataSource = self
tableView.delegate = self
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
load()
}
private func load() {
isLoading = true
let url = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")!
URLSession.shared.dataTask(with: url) { data, _, error in
DispatchQueue.main.async {
self.isLoading = false
if let error = error {
self.errorMessage = error.localizedDescription
self.tableView.reloadData()
return
}
guard let data = data,
let ids = try? JSONSerialization.jsonObject(with: data) as? [Int] else {
self.errorMessage = "Decode failed"
self.tableView.reloadData()
return
}
self.fetchDetails(for: Array(ids.prefix(20)))
}
}.resume()
}
private func fetchDetails(for ids: [Int]) {
let group = DispatchGroup()
var fetched: [[String: Any]] = []
for id in ids {
group.enter()
let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
URLSession.shared.dataTask(with: url) { data, _, _ in
defer { group.leave() }
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
fetched.append(json)
}.resume()
}
group.notify(queue: .main) {
self.articles = fetched
self.tableView.reloadData()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
articles.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = articles[indexPath.row]["title"] as? String ?? "(no title)"
return cell
}
}
(Yes, this is a horror show on purpose — most legacy code looks like this.)
Build
Run it, confirm the table populates with 20 Hacker News stories.
Tasks
Task 1 — Model (30 min)
Replace [String: Any] with a real struct Article: Identifiable, Codable, Equatable:
struct Article: Identifiable, Codable, Equatable {
let id: Int
let title: String
let url: String?
let by: String
let score: Int
}
Update parsing to use JSONDecoder not JSONSerialization.
Task 2 — Service (30 min)
Extract networking into:
protocol HackerNewsService {
func topStoryIDs() async throws -> [Int]
func article(id: Int) async throws -> Article
}
final class LiveHackerNewsService: HackerNewsService { /* … */ }
Use async/await. Use withThrowingTaskGroup to fetch article details in parallel.
Task 3 — ViewModel (45 min)
@Observable @MainActor
final class ArticlesViewModel {
enum State { case idle, loading, loaded([Article]), error(String) }
private(set) var state: State = .idle
private let service: HackerNewsService
init(service: HackerNewsService) { self.service = service }
func load() async { /* set loading, fetch IDs, fetch details, set loaded or error */ }
}
Task 4 — View (30 min)
Replace UITableView with a SwiftUI List:
struct ArticlesView: View {
@State private var viewModel: ArticlesViewModel
init(service: HackerNewsService = LiveHackerNewsService()) {
_viewModel = State(initialValue: ArticlesViewModel(service: service))
}
var body: some View {
NavigationStack {
content
.navigationTitle("Articles")
.task { await viewModel.load() }
.refreshable { await viewModel.load() }
}
}
@ViewBuilder var content: some View {
switch viewModel.state {
case .idle, .loading: ProgressView()
case .loaded(let articles): List(articles) { article in /* row */ }
case .error(let msg): VStack { Text(msg); Button("Retry") { Task { await viewModel.load() } } }
}
}
}
Task 5 — Tests (45 min)
Create ArticlesViewModelTests:
struct MockService: HackerNewsService {
let result: Result<[Article], any Error>
func topStoryIDs() async throws -> [Int] { [1] }
func article(id: Int) async throws -> Article {
switch result { case .success(let a): a.first!; case .failure(let e): throw e }
}
}
Write tests for: load() → .loaded; failure → .error; pre-load state is .idle.
Stretch
- Add pull-to-refresh state distinct from initial load.
- Add per-article detail screen via
NavigationLink. - Persist last-fetched articles via SwiftData; show cached on launch before network completes.
- Add a Combine-based variant for comparison; note where async/await is cleaner.
Notes
The point isn’t to memorize MVVM syntax — it’s to feel the difference between the starter mess and the refactored version. After this lab, you should be able to spot Massive View Controller smell in a code review within seconds.