Search code examples
swiftasync-awaitconcurrency

Reusable async await network call to return different groups of data


I am letting users create "booklists" and I'm storing the Google Books ID for the specific book they added in firestore. When they go to their profile to see what books they've added, I run a fetch request to recreate all of those books in their list.

Previously, I was using completion handlers to run multiple network requests but after some feedback, I'm trying to restructure the function to use async/await to make sure everything is done before completing. I think I have successfully built the reusable function in my book service, but now I'm struggling to actually use it. I have four groups of books being returned - current, next, finished, and sharing. I've tried adding them all as tasks on the view but that seemed inefficient to me. I've also tried using my old structure to call the functions again from the ListsViewModel but I get tons of errors (which is the way it is in the following code). Thank you!

BookService

class BookService {
    
    //MARK: REUSABLE FETCH BOOK FUNCTION
    
    func fetchBooks(uid: String, document: String) async throws -> [Book] {
        
        let bookIds = try await fetchBookIdsFromFirebase(uid: uid, document: document)
        
        print("got bookIds in to fetchbooks func")
        
        let books = try await withThrowingTaskGroup(of: Book.self, returning: [Book].self) { group in
            
            for bookId in bookIds {
                
                group.addTask {
                    try await self.decodeBook(bookId: bookId)
                }
                
            }
            
            var books = [Book]()
            
            for try await result in group {
                books.append(result)
            }
            
            return books
        }
        
        return books
    }
    
    func decodeBook(bookId: String) async throws -> Book {
        
        guard let url = try await createListURLs(for: bookId) else { throw SearchError.badUrl }
        
        print("got to create url in fetchbooks func")
        
        let (data, response) = try await URLSession.shared.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw SearchError.badResponse  }
        
        return try JSONDecoder().decode(Book.self, from: data)
    }
    
    func fetchBookIdsFromFirebase(uid: String, document: String) async throws -> [String] {
        
        let snapshot = try await COLLECTION_BOOKLIST.document(uid).collection(document)
            .getDocuments()
        
        let documents = snapshot.documents
        
        let bookIds = documents.compactMap({ try? $0.data(as: String.self) })
        
        print("got bookIds from firebase : \(bookIds)")
        
        return bookIds
    }
    
    func createListURLs(for bookId: String) async throws -> URL? {
        let baseURL = "https://www.googleapis.com/books/v1/volumes/\(bookId)"
        
        let components = URLComponents(string: baseURL)
        return components?.url
    }
}

ListsViewModel

class ListsViewModel: ObservableObject {
    var user: User
    
    @Published var users = [User]()
    @Published var currentBooks: [Book] = []
    @Published var nextBooks: [Book] = []
    @Published var doneBooks: [Book] = []
    @Published var sharingBooks: [Book] = []
    @Published var book: Book?

    let bookservice = BookService()
    let service = UserService()
        
    init(user: User) {
        self.user = user
        fetchCurrent()
        fetchNextUp()
        fetchDone()
        fetchSharing()
    }
    
    func lists(forFilter filter: Lists) -> [Book] {
        switch filter {
        case .current:
            return currentBooks
        case .next:
            return nextBooks
        case .done:
            return doneBooks
        case .sharing:
            return sharingBooks
        }
    }
    
    func fetchLists(forFilter filter: Lists) -> () {
        switch filter {
        case .current:
            return self.fetchCurrent()
        case .next:
            return self.fetchNextUp()
        case .done:
            return self.fetchDone()
        case .sharing:
            return self.fetchSharing()
        }
    }
    
    func fetchCurrent() {
        guard let uid = user.id else { return }
        do {
            currentBooks = try await bookservice.fetchBooks(uid: uid, document: "current")
        } catch {
            return currentBooks
        }
    }
    
    func fetchNextUp() {
        guard let uid = user.id else { return }
        do {
            nextBooks = try await bookservice.fetchBooks(uid: uid, document: "next-up")
        } catch {
            return nextBooks
        }
    }
    
    func fetchDone() {        
        guard let uid = user.id else { return }
        do {
            doneBooks = try await bookservice.fetchBooks(uid: uid, document: "done")
        } catch {
            return doneBooks
        }

    }
    
    func fetchSharing() {
        guard let uid = user.id else { return }
        // old way of using function
        bookservice.fetch2(forUid: uid, document: "sharing") { books in
            DispatchQueue.main.async {
                self.sharingBooks = books
            }
        }
    }

BookListView

struct BookListView: View {
    @Binding var selectedList: Lists
    @StateObject var viewModel: ListsViewModel
    
    var body: some View {
        
        ForEach(viewModel.lists(forFilter: self.selectedList)) { book in
            
            NavigationLink {
                LazyView(BookPageView(viewModel: BookPageViewModel(book: book)))
            } label: {
                ListItemBookWithButton(book: book, selectedList: $selectedList, viewModel: viewModel)
                    .padding(.horizontal)
            }
        }
    }
}

Book Model

struct ApiResponse: Codable {
    var items: [Book]
    let error: ApiError?
    let timestamp: Timestamp?
}

struct Book: Codable, Identifiable {
    var id: String?
    let selfLink: String?
    var volumeInfo: VolumeInfo
    var timestamp: Timestamp?
}

struct VolumeInfo: Codable {
    var title: String
    let authors: [String]?
    let categories: [String]?
    let description: String?
    let industryIdentifier: [IndustryIdentifier]?
    let imageLinks: ImageLinks?
}

struct ImageLinks: Codable {
    let thumbnail: URL?
}

struct IndustryIdentifier: Codable {
    let type: String
    let identifier: String
}

struct ErrorMessage: Codable {
    let domain: String
    let message: String
    let reason: String
    let location: String
    let locationType: String
}

struct ApiError: Error, Codable {
    let code: Int
    let message: String
    let errors: [ErrorMessage]
}

MainListView

struct MainListView: View {
    @StateObject var viewModel: ListsViewModel
    
    private let twoItems = [GridItem(spacing: 8), GridItem(spacing: 8)]
    private let height = UIScreen.main.bounds.width / 2
    
    @State var image = Image("book-empty-1")
    @State var selectedList: Lists = .current
    @State var rotate: [Int] = [-6, 0, 6]
    @State var offset: [Int] = [2, 8, 16]
    @State var offsettwo: [Int] = [-4, 2, 10]
    
    var body: some View {
        LazyVGrid(columns: twoItems) {
            ForEach(viewModel.isSharingListUser ? Lists.allCases : Lists.filteredList, id: \.rawValue) { item in
                NavigationLink {
                    LazyView(BookListView(viewModel: viewModel))
                } label: {
                    VStack (alignment: .center) {
                        
                        Text(item.title)
                            .foregroundColor(Color(.label))
                            .font(Font.body)
                            .padding(.top, 8)
                        
                        if viewModel.lists(forFilter: item).isEmpty {
                            EmptyImages
                        } else {
                            
                            HStack (spacing: -15) {
                                ForEach(Array(zip(viewModel.lists(forFilter: item).prefix(3))), id: \.0) { index, book in
                                    AsyncImage(url: book.volumeInfo.imageLinks?.thumbnail) { phase in
                                        if let image = phase.image {
                                            image
                                                .resizable()
                                                .scaledToFit()
                                                .frame(width: 64, height: 93)
                                                .cornerRadius(6)
                                                .rotationEffect(Angle(degrees: Double(rotate[index])))
                                                .offset(y: CGFloat(offset[index]))
                                        } else {
                                            Image("empty-book")
                                                .resizable()
                                                .scaledToFit()
                                                .frame(width: 64, height: 93)
                                                .cornerRadius(6)
                                                .rotationEffect(Angle(degrees: Double(rotate[index])))
                                                .offset(y: CGFloat(offset[index]))
                                        }
                                    }
                                }
                            }
                        }
                    }
                    .frame(maxWidth: 175, maxHeight: height)
                    .clipped()
                    .overlay(
                        RoundedRectangle(cornerRadius: 6)
                            .stroke(Color(.systemGray5), lineWidth: 1)
                    )
                    .padding(.top)
                    .padding(1)
                }
                .simultaneousGesture(TapGesture().onEnded{
                    self.selectedList = item
                })
            }
        }
        .padding(.horizontal)
    }
}

extension MainListView {
    var EmptyImages: some View {
        HStack (spacing: -15) {
            ForEach(0...2, id: \.self) { index in
                image
                    .resizable()
                    .scaledToFit()
                    .frame(width: 64, height: 93)
                    .cornerRadius(6)
                    .rotationEffect(Angle(degrees: Double(rotate[index])))
                    .offset(y: CGFloat(offsettwo[index]))
            }
        }
    }
}

EDIT

code for linking :

@MainActor
class ListsViewModel: ObservableObject {
    var user: User
    
    @Published var selectedBooks: [Book] = []
    
    @Published var selectedList: Lists = .current
    
    @Published var users = [User]()
    @Published var sharedUsers = [User]()
    @Published var shareListCount: Int = 0
    @Published var currentBooks: [Book] = []
    @Published var nextBooks: [Book] = []
    @Published var doneBooks: [Book] = []
    @Published var sharingBooks: [Book] = []
    @Published var book: Book?
    
    @Published var state: ListsStates = .loading
    
    @Published var isListsCompleted = false
    @Published var isShareCompleted = false
    @Published var isSharedUsersCompleted = false
    
    private var currentFetchTask: Task<Void, Error>?
    
    let bookservice = BookService()
    let service = UserService()
    
    enum ListsStates {
        case loading
        case loaded
        case empty
    }
    
    var isSharingListUser: Bool { return user.isCurrentUser == true || user.isSharedWithMe == true }
    
    init(user: User) {
        self.user = user
        identifySelected()
    }
    
    
    func fetch(forFilter filter: Lists) {
        guard let uid = user.id else { return }
        
        self.selectedBooks = []
        currentFetchTask?.cancel()
        
        currentFetchTask = Task {
            self.selectedBooks = try await bookservice.fetchBooks(uid: uid,
                                                          document: filter.document)
        }
    }
    
    func identifySelected() {
        if selectedList == .current {
            selectedBooks = currentBooks
        } else if selectedList == .next {
            selectedBooks = nextBooks
        } else if selectedList == .done {
            selectedBooks = doneBooks
        } else if selectedList == .sharing {
            selectedBooks = sharingBooks
        }
    }
}

Solution

  • This is mostly fine as a starting point. An important change you should make, however, is to drive the View directly from the ViewModel. What I mean is that there should be a single @Published property that always represents the books to display. A simple implementation might look like this:

    enum Lists: String {
        case current, next = "next-up", done, sharing
    }
    
    @MainActor
    class ListsViewModel: ObservableObject {
        var user: User
        
        @Published var books: [Book] = []
    
        let bookservice = BookService()
    
        private var currentFetchTask: Task<Void, Error>?
        
        init(user: User) {
            self.user = user
        }
    
        func fetch(forFilter filter: Lists) {
            guard let uid = user.id else { return }
            self.books = [] 
            currentFetchTask?.cancel()
            currentFetchTask = Task {
                self.books = try await bookservice.fetchBooks(uid: uid, 
                                                              document: filter.rawValue)
            }
    }
    

    Then the View would always just display the current value of books, no matter what it is:

    ForEach(viewModel.books) { book in
    

    This isn't a complete answer. You need to think about what happens when there are errors, or the fetch is interrupted. Currently this just clears the list while it's being changed, but you might want something else. And you may want to move selectedList into the view model, so that it's easier to synchronize. But this should be a good starting point.


    Your latest code is quite close IMO. It just can benefit from some minor cleanup:

    Don't make selectedList @Published. This is something other objects modify, not something other objects watch. And put a didSet on it to update selectedBooks:

    var selectedList: Lists = .current {
        didSet {
            selectedBooks = selectedBooks(for: selectedList)
        }
    }
    

    And then create a method to get the right books. This uses the new "switch is a value" feature from the latest Swift. If you are using an older Swift, you can just add return to each leg.

    func selectedBooks(for list: Lists) -> [Book] {
        return switch list {
        case .current: currentBooks
        case .next: nextBooks
        case .sharing: sharingBooks
        case .done: doneBooks
        }
    }