The user's defaults database acts as the store of the weather application we are building. That is fine, but most objects shouldn't be aware of that implementation detail. The LocationsViewModel and AddLocationViewModel classes are tightly coupled to the UserDefaults class, but that isn't necessary. In this episode, we decouple the LocationsViewModel and AddLocationViewModel classes from the UserDefaults class.
Why?
Why is this necessary? That's a very good question. There are several reasons, but three stand out. First, by decoupling the UserDefaults class from the LocationsViewModel and AddLocationViewModel classes, it becomes much easier to move to another persistence solution, such as Core Data. Second, we want to be in control of the data the previews display. While that is possible using the UserDefaults class, it is tedious and we can easily avoid that. Third, by decoupling the LocationsViewModel and AddLocationViewModel classes from the UserDefaults class, it becomes much easier to unit test the view models.
Declaring the Store Protocol
The pattern we use in this episode should feel familiar. Create a group with name Store. Add a Swift file to the Store group and name it Store.swift. Replace the import statement for Foundation with an import statement for the Combine framework. Declare a protocol with name Store.
import Combine
protocol Store {
}
We already know what the interface of the Store protocol should look like. The Store protocol declares one property and one method. Declare a property with name locationsPublisher. As the name suggests, the locationsPublisher property is a publisher with Output type [Location] and Failure type Never.
import Combine
protocol Store {
// MARK: - Properties
var locationsPublisher: AnyPublisher<[Location], Never> { get }
}
Declare a method with name addLocation(_:). The method defines one parameter, location of type Location, and is throwing.
import Combine
protocol Store {
// MARK: - Properties
var locationsPublisher: AnyPublisher<[Location], Never> { get }
// MARK: - Methods
func addLocation(_ location: Location) throws
}
I want to make it clear that we still plan to store the array of locations in the user's defaults database. We merely use the Store protocol to hide that implementation detail from the LocationsViewModel and AddLocationViewModel classes. The next step is conforming the UserDefaults class to the Store protocol.
Add a Swift file to the Store group and name it UserDefaults+Store.swift. Add an import statement for the Combine framework at the top and declare an extension for the UserDefaults class. We use the extension to conform the UserDefaults class to the Store protocol.
import Combine
import Foundation
extension UserDefaults: Store {
}
The compiler throws an error because the UserDefaults class doesn't conform to the Store protocol. To conform to the Store protocol, the UserDefaults class needs to implement the locationsPublisher property.
import Combine
import Foundation
extension UserDefaults: Store {
// MARK: - Properties
var locationsPublisher: AnyPublisher<[Location], Never> {
}
}
Open LocationsViewModel.swift in the assistant editor on the right. We can copy most of the implementation of the view model's start() method. Don't forget to wrap the publisher the replaceError operator returns with a type eraser by invoking the eraseToAnyPublisher() method.
import Foundation
extension UserDefaults: Store {
// MARK: - Properties
var locationsPublisher: AnyPublisher<[Location], Never> {
publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
.replaceError(with: [])
.eraseToAnyPublisher()
}
}
We also need to create a store for previews. Add a Swift file to the Store group and name it PreviewStore.swift. Add an import statement for the Combine framework and declare a final class with name PreviewStore that conforms to the Store protocol.
import Combine
final class PreviewStore: Store {
}
The compiler complains that the PreviewStore class doesn't conform to the Store protocol. Let's fix that by adding stubs for the property and the method the Store protocol defines.
import Combine
final class PreviewStore: Store {
// MARK: - Properties
var locationsPublisher: AnyPublisher<[Location], Never> {
}
// MARK: - Methods
func addLocation(_ location: Location) throws {
}
}
Declare a Published, private, variable property with name locations. We set the property's initial value to the array of locations the static previews property returns.
import Combine
final class PreviewStore: Store {
// MARK: - Properties
@Published private var locations = Location.previews
// MARK: -
var locationsPublisher: AnyPublisher<[Location], Never> {
}
// MARK: - Methods
func addLocation(_ location: Location) throws {
}
}
In the body of the computed locationsPublisher property, we wrap the locations publisher with a type eraser and return the result.
import Combine
final class PreviewStore: Store {
// MARK: - Properties
@Published private var locations = Location.previews
// MARK: -
var locationsPublisher: AnyPublisher<[Location], Never> {
$locations
.eraseToAnyPublisher()
}
// MARK: - Methods
func addLocation(_ location: Location) throws {
}
}
In the addLocation(_:) method, we append the value of the location parameter to the array of locations.
import Combine
final class PreviewStore: Store {
// MARK: - Properties
@Published private var locations = Location.previews
// MARK: -
var locationsPublisher: AnyPublisher<[Location], Never> {
$locations
.eraseToAnyPublisher()
}
// MARK: - Methods
func addLocation(_ location: Location) throws {
locations.append(location)
}
}
Injecting the Store into the View Model
The view models should no longer access the UserDefaults singleton. They should instead interact with the store through a Store object. Open AddLocationViewModel.swift and declare a private, constant property with name store of type Store.
private let store: Store
We inject the Store object through initializer injection. We add a parameter to the initializer with name store of type Store. In the body of the initializer, we store a reference to the Store object in the store property.
// MARK: - Initialization
init(
store: Store,
geocodingService: GeocodingService
) {
self.store = store
self.geocodingService = geocodingService
setupBindings()
}
Now that the view model has access to a store, we can replace the reference to the UserDefaults singleton with a reference to the store in the addLocation(with:) method.
// MARK: - Public API
func addLocation(with id: String) {
guard let location = locations.first(where: { $0.id == id }) else {
return
}
do {
try store.addLocation(location)
} catch {
print("Unable to Add Location \(error)")
}
}
Open AddLocationView.swift and navigate to the AddLocationView_Previews struct. In the body of the static previews property, we inject a PreviewStore instance into the view model.
struct AddLocationView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = AddLocationViewModel(
store: PreviewStore(),
geocodingService: GeocodingPreviewClient()
)
return AddLocationView(
viewModel: viewModel,
showsAddLocationView: .constant(true)
)
}
}
We repeat these steps for the LocationsViewModel class. Open LocationsViewModel.swift and declare a private, constant property with name store of type Store.
private let store: Store
We inject the Store object through initializer injection. Define an initializer that accepts an object of type Store. In the body of the initializer, we store a reference to the Store object in the store property.
// MARK: - Initialization
init(store: Store) {
self.store = store
}
We also need to update the computed addLocationViewModel property. In the body of the computed property, we pass a reference to the store to the initializer of the AddLocationViewModel class.
var addLocationViewModel: AddLocationViewModel {
AddLocationViewModel(
store: store,
geocodingService: GeocodingClient()
)
}
Now that the view model has access to a store, we can replace the reference to the UserDefaults singleton with a reference to the store in the start() method.
// MARK: - Public APi
func start() {
store.locationsPublisher
.map { $0.map(LocationCellViewModel.init(location:)) }
.eraseToAnyPublisher()
.assign(to: &$locationCellViewModels)
}
Open LocationsView.swift and navigate to the LocationsView_Previews struct. In the body of the static previews property, we inject a PreviewStore instance into the view model.
struct LocationsView_Previews: PreviewProvider {
static var previews: some View {
LocationsView(
viewModel: .init(store: PreviewStore())
)
}
}
Open ThunderstormApp.swift and navigate to the computed body property. We inject the UserDefaults singleton into the LocationsViewModel instance.
import SwiftUI
@main
struct ThunderstormApp: App {
// MARK: - App
var body: some Scene {
WindowGroup {
LocationsView(
viewModel: .init(store: UserDefaults.standard)
)
}
}
}
Build and run the application. The behavior of the application hasn't changed, but we successfully decoupled the LocationsViewModel and AddLocationViewModel classes from the UserDefaults class.
What's Next?
Even though we added a layer of complexity in this episode, I hope you can appreciate the benefits of the solution we implemented. Later in this series, we take advantage of this change when we write unit tests for the view models.
Replacing the store is now trivial. Let's say the application grows in complexity and we need a store that is more flexible and powerful. That would require two steps, creating a type that conforms to the Store protocol and injecting the store in ThunderstormApp. That's it.