A few years ago, Apple added support for detecting potential issues at runtime. Runtime issues show up as purple issues in Xcode's Issues Navigator. They are easy to miss or ignore, but they are just as important as errors at compile time.
The only difference with compiler errors is that runtime issues aren't detected at compile time. As the name suggests, runtime issues are detected at runtime and aren't necessarily fatal. The consequences of a runtime issue can be severe and that is why you should treat a runtime issue as an error at compile time.
In this video, we take a look at the runtime issue breakpoint, a breakpoint that helps you stay on top of runtime issues.
Errors, Warnings, and Runtime Issues
Xcode's Issues Navigator supports three types of issues, errors, warnings, and runtime issues. An error is detected at compile time and prevents your app from running. A warning is a signal from the compiler that something may be wrong and deserves your attention, but it doesn't prevent your app from running by default. A runtime issue is similar to a warning in that it doesn't prevent your app from running.
Due to limitations of the compiler, runtime issues can only be detected at runtime. That may change as the compiler evolves and becomes more capable. That is one of the superpowers of compiled languages, detecting issues before they cause problems. The compiler you use every day becomes better and better over time, resulting in fewer bugs making it into production.
An Example
Open the starter project of this video in Xcode. The project isn't complicated. We have an app that fetches a list of posts from an API and displays it in a LazyVGrid
. The business logic is handled by a view model, the PostsViewModel
class. Check out Mastering MVVM With Swift and Mastering MVVM With SwiftUI if you want to learn more about the Model-View-ViewModel pattern.
PostsView.swift
var body: some View {
VStack {
switch viewModel.state {
case .loading:
ProgressView()
case .posts(let cellViewModels):
ScrollView {
LazyVGrid(columns: layout, spacing: 20.0) {
ForEach(cellViewModels) { cellViewModel in
PostCell(viewModel: cellViewModel)
}
}
.padding()
}
case .error(let error):
Spacer()
Text(error)
Spacer()
}
}
.task { await viewModel.start() }
}
In the fetchPosts()
method, the view model uses the URLSession
API to fetch an array of posts. It decodes the response, maps the array of Post
objects to an array of PostCellViewModel
objects, and updates the view model's state
property. If an error is thrown in the do
clause, the view displays an error to the user through the state
property.
PostsViewModel.swift
private func fetchPosts() async {
do {
try await Task.sleep(nanoseconds: 2_000_000_000)
let (data, _) = try await URLSession.shared.data(from: url)
let cellViewModels = try JSONDecoder()
.decode([Post].self, from: data)
.map(PostCellViewModel.init(post:))
state = .posts(cellViewModels)
} catch {
print("Failed to Fetch Posts \(error)")
state = .error("The app failed to fetch the posts from the server.")
}
}
The state
property is of type State
, an enum. It defines three cases, loading
, posts
, and error
. I like using this pattern as it clearly communicates the possible states of the PostsView
. We use associated values to pass the view the data it needs to display.
PostsViewModel.swift
enum State {
// MARK: - Cases
case loading
case posts([PostCellViewModel])
case error(String)
}
Run the app in the simulator and open the Issues Navigator on the left. You should see a runtime issue pop up in the Issues Navigator the moment the app displays the list of posts.
Xcode shows a warning, a runtime issue, that the view model publishes changes from a background thread. What is happening? Revisit PostsViewModel.swift. In the fetchPosts()
method, the view model fetches the array of posts from a remote API. That operation occurs asynchronously on a background thread to make sure the user interface remains responsive.
The problem is that the state
property is updated on that same background thread. Because the state
property defines what the PostsView
displays, updating the value of the state
property should take place on the main thread. Remember that updating the user interface should always happen on the main thread. We take a look at the solution to this problem later.
Adding a Runtime Issue Breakpoint
It is easy to miss or ignore runtime issues in the Issues Navigator, but Xcode can help you stay on top of the runtime issues in your project. Open the Breakpoints Navigator on the left, click the + button in the lower left, and choose Runtime Issue Breakpoint.
The breakpoint has two options, the number of times to ignore the breakpoint when a runtime issue is detected and, more interesting, the type of runtime issue to detect. For this example, we set Type to All, the default.
If you notice that the debugger doesn't suspend the process when a runtime issue is hit, then make sure the corresponding diagnostic is enabled. Click the small arrow below the Type field to open the Diagnostics tab.
This opens the Posts scheme. The Diagnostics tab shows us which information the compiler and debugger collect. The Main Thread Checker and Thread Performance Checker are enabled by default. You can optionally check the Address Sanitizer or the Thread Sanitizer.
Debugging a Runtime Issue
With the runtime issue breakpoint set, run the app one more time. The debugger makes the runtime issue more explicit by suspending the process of the app. We can easily find the root cause by opening the Debug Navigator on the left.
The stack trace takes us to the root of the problem. As I explained earlier, the state
property of the view model is updated from a background thread, the same thread on which the list of posts is fetched.
It is trivial to resolve the issue thanks to Swift Concurrency. We apply the MainActor
attribute to the declaration of the PostsViewModel
class. This ensures the view model's state
property is accessed from the main thread. Swift Concurrency takes care of the rest under the hood.
PostsViewModel.swift
import Foundation
@MainActor
final class PostsViewModel: ObservableObject {
...
}
Run the app one more time. You should no longer run into a runtime issue.
What's Next?
Runtime issues don't stop you from running your app because they can't. The debugger detects a runtime issue at runtime hence the name. But that doesn't make runtime issues harmless. I strongly recommend treating runtime issues as compile time errors. A runtime issue can have severe consequences, so give them the attention they deserve.