In the previous episode, we solved some of the problems we discussed earlier in this series. At the same time, we introduced a few code smells. We address those code smells in this episode.

Delegating Network Requests

The notes view model uses the URLSession API to fetch the array of notes the application displays. While that may seem like a reasonable solution, it has a few problems. A side effect is that the preview also performs a network request to fetch the array of notes.

The solution I have in mind isn't complex. We create a dedicated object for fetching the notes and inject that object into the view model. We start by creating a protocol that defines the interface for fetching the notes. Create a group with name Networking and add a Swift file with name APIService.swift. Declare a protocol with name APIService.

import Foundation

protocol APIService {
	
}

The APIService protocol defines one method, fetchNotes(). The fetchNotes() method is asynchronous and throwing. It returns an array of Note objects.

import Foundation

protocol APIService {

    // MARK: - Methods

    func fetchNotes() async throws -> [Note]

}

Add another Swift file to the Networking group and name it APIClient.swift. Declare a final class, APIClient, that conforms to the APIService protocol.

import Foundation

final class APIClient: APIService {
	
}

Open NotesViewModel.swift in the assistant editor on the right and move the fetchNotes() method of the NotesViewModel class to the APIClient class. We need to make two adjustments to the fetchNotes() method. First, the return type needs to match that of the APIService protocol, that is, an array of Note objects. Second, the fetchNotes() method should return the result of the decoding operation. It shouldn't assign it to a notes property.

import Foundation

final class APIClient: APIService {

    func fetchNotes() async throws -> [Note] {
        let url = URL(string: "https://cdn.cocoacasts.com/2354d51028d53fcc00ceb0c66f25475d5c79bff0/notes.json")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Note].self, from: data)
    }

}

Open NotesViewModel.swift and declare a private, constant property apiService of type APIService.

import Foundation

@MainActor final class NotesViewModel: ObservableObject {

    // MARK: - Properties

    private let apiService: APIService
	
	...

}

We also declare an initializer that accepts a parameter apiService of type APIServcice. The initializer assigns the value of the apiService parameter to the apiService property.

// MARK: - Initialization

init(apiService: APIService) {
    self.apiService = apiService
}

The notes view should no longer invoke the fetchNotes() method. That's an implementation detail of its view model. We add a start() method to the NotesViewModel class in which we invoke the fetchNotes() method of the APIService object. The result of the fetchNotes() method is assigned to the notes property. We print the error to the console if something goes wrong. Because the fetchNotes() method is an asynchronous method, we use a Task to invoke it.

func start() {
    Task {
        do {
            notes = try await apiService.fetchNotes()
        } catch {
            print("Unable to Fetch Notes \(error)")
        }
    }
}

The start() method is a synchronous method so we no longer need to use the task modifier in the NotesView struct. We make use of the onAppear modifier instead.

var body: some View {
    NavigationView {
        ...
    }
    .onAppear {
        viewModel.start()
    }
}

We create an APIClient instance and pass it to the initializer of the NotesViewModel in the NotesView struct.

import SwiftUI

struct NotesView: View {

    // MARK: - Properties

    @ObservedObject var viewModel = NotesViewModel(apiService: APIClient())
	
	...

}

Build and run the application to make sure everything still works as expected.

Injecting the View Model

Creating APIService and APIClient are only part of the solution. The solution is flawed as long as the NotesView struct creates its own view model. In fact, that is a code smell. A view shouldn't create its own view model. The view model should be injected into the view. We need to make a few changes. First, the notes view no longer creates a NotesViewModel instance.

import SwiftUI

struct NotesView: View {

    // MARK: - Properties

    @ObservedObject var viewModel: NotesViewModel
	
	...

}

Second, the view model is created by the object that creates the notes view. In this example, the NotesApp object creates the notes view. Open NotesApp.swift and update the initializer of the NotesView struct. It creates a NotesViewModel instance and passes it to the initializer of the NotesView struct.

import SwiftUI

@main
struct NotesApp: App {
    var body: some Scene {
        WindowGroup {
            NotesView(
                viewModel: .init(
                    apiService: APIClient()
                )
            )
        }
    }
}

Third, we need to inject a view model into the NotesView object that is used to generate the preview. Before we do, we need to create another type that conforms to the APIService protocol. The APIClient class performs a network request and that is what we want to avoid. The solution isn't complex thanks to the groundwork we laid earlier. Add a Swift file to the Networking group and name it APIPreviewClient.swift. Declare a struct with name APIPreviewClient that conforms to the APIService protocol.

import Foundation

struct APIPreviewClient: APIService {

    func fetchNotes() async throws -> [Note] {
		
    }

}

Before we implement the fetchNotes() method, we add stub data to the Preview Content group. The stub data is a JSON file that contains a handful of notes.

[
    {
        "id": 1,
        "title": "Lorem Ipsum",
        "contents": "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
    },
    {
        "id": 2,
        "title": "Etiam Commodo",
        "contents": "Etiam commodo nibh gravida, scelerisque risus convallis, consectetur dui."
    },
    {
        "id": 3,
        "title": "Duis Molestie",
        "contents": "Duis molestie orci ac nunc posuere, in euismod quam mollis."
    }
]

In the fetchNotes() method, we obtain the URL of the JSON file in the Preview Content group and use it to create a Data object. The remainder of the implementation is identical to that of the fetchNotes() method of the APIClient class. We use a JSONDecoder instance to create an array of Note objects from the Data object.

func fetchNotes() async throws -> [Note] {
    guard let url = Bundle.main.url(forResource: "notes", withExtension: "json") else {
        fatalError("Unable to Find notes.json")
    }

    let data = try Data(contentsOf: url)
    return try JSONDecoder().decode([Note].self, from: data)
}

With the APIPreviewClient struct in place, we can update the static previews property of the NotesView_Previews struct in NotesView.swift. We create an APIPreviewClient object and pass it to the initializer of the NotesViewModel class. The view model is in turn passed to the initializer of the NotesView struct.

struct NotesView_Previews: PreviewProvider {
    static var previews: some View {
        NotesView(
            viewModel: .init(
                apiService: APIPreviewClient()
            )
        )
    }
}

This change ensures that the preview no longer performs a network request to obtain the array of notes.

What's Next?

Even though the project is simple, the changes we made in the past episodes have made a significant impact. The notes view is no longer making a network request to fetch notes. We moved that responsibility to its view model and the APIClient class. The preview no longer makes a network request, making it more reliable and more responsive.

There is one more code smell we need to resolve. Even though the notes view no longer has a property that stores an array of Note objects, it can still access the array of notes through its view model. A view shouldn't have direct access to model objects. We fix that in the next episode.