0

I am trying to build a chat app. I request the messages through API. Since the chat might be an image(a URL for that image in this case), and every image has different height / width ratio, I’m trying to fetch their ratios before reloading the table view. Rather than GCD, I’d like to make use of the new Swift Concurrency.


This is the method that requests the messages. After receiving the response, it fetches the ratios(if that message is an image), and updates the UI.
    private func requestMessages() {
        APIManager.requestMessages { [weak self] result in
            guard let self = self else { return }
            switch result {
            case .success(let result):
                guard let messageSections = result.messageSections else {
                    alert(message: result.description)
                    return
                }
                    
                self.messageSections = messageSections

                // fetches the ratios, and then update the UI
                Task { [weak self] in
                    try await self?.fetchImageRatios()

                    DispatchQueue.main.async { [weak self] in   // breakpoint 1
                        guard let self = self else { return }
                        self.tableView.reloadData()             // breakpoint 2
                    }
                }
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }

In the Task { } above, it calls the async method fetchImageRatios() which looks like this:

    private func fetchImageRatios() async throws {
        try await withThrowingTaskGroup(of: CGFloat?.self) { group in
            for messageSection in messageSections {
                for message in messageSection {
                    guard let imageURLString = message.imageURL,
                          let imageURL = URL(string: imageURLString) else {
                        continue
                    }
                    
                    group.addTask { [weak self] in
                        try await self?.fetchRatio(from: imageURL)
                    }
                    
                    for try await ratio in group {
                        message.ratio = ratio
                    }
                }
            }
            
            print("messageSections 1 - inside withThrowingTaskGroup closure")
            dump(messageSections)
        }
        
        print("messageSections 2 - outside withThrowingTaskGroup closure")
        dump(messageSections)
    }

In this method fetchImageRatios(), it iterates through messageSections(the type is [[Message]], please check the code block below) in for loops, and fetches the ratio of each image from its image URL. Since fetching the ratio does not need to be serial, I wanted to implement it as concurrent, so I put the each fetching process in a TaskGroup, as child tasks. (Also wanted to try structured concurrency.)

    struct Message: Codable {
        var username: String?
        var message: String?
        var time: String?
        var read: Bool?
        var imageURL: String?
        var ratio: CGFloat?
    }

Struct Message has ratio property, and I give that the value of the ratio I fetch. (Actually, here, it doesn’t have to wait for all the fetchings to be finished before updating the ratio property, but I didn’t know how else I can do it without waiting, especially because I thought it’s better to access each message in the for loops anyways. If there’s a better idea, please let me know.)



Now my main question is here:

When the method fetchRatio(from imageURL: URL) is like #1, with Data(contentsOf: URL), it does work. It shows each image perfectly with their own ratio. It also prints "messageSections 1 - inside withThrowingTaskGroup closure" and "messageSections 2 - outside withThrowingTaskGroup closure”, and dumps messageSections in fetchImageRatios() without a problem. But, it does give the purple warning saying “Synchronous URL loading of https://www.notion.so/front-static/meta/default.png should not occur on this application's main thread as it may lead to UI unresponsiveness. Please switch to an asynchronous networking API such as URLSession.”

#1

    private func fetchRatio(from imageURL: URL) async throws -> CGFloat? {
        // Data(contentsOf:)
        guard let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) else { return nil }
        let width = image.size.width
        let height = image.size.height
        let ratio = height / width

        return ratio
    }

But, when the method fetchRatio(from imageURL: URL) is like #2, which uses URLSession.shared.data(from: URL), I don’t see anything in my table view. It’s just empty. It doesn’t even print "messageSections 1 - inside withThrowingTaskGroup closure" or "messageSections 2 - outside withThrowingTaskGroup closure”, and no dumping messageSections. I guessed maybe it doesn’t reload the table view so I put breakpoints at // breakpoint 1 and // breakpoint 2 in requestMessages() but the app doesn’t even stop there which is so confusing. It does iterate through for try await ratio in group though.

#2

    private func fetchRatio(from imageURL: URL) async throws -> CGFloat? {
        // URLSession - data(from:)
        let (data, _) = try await URLSession.shared.data(from: imageURL)
        guard let image = UIImage(data: data) else { return nil }
        let width = image.size.width
        let height = image.size.height
        let ratio = height / width
        
        return ratio
    }

I’m guessing it’s because URLSession.shared.data(from: imageURL) is being called with try await, but I feel like it should still work as expected.. What am I missing?

Why does it not even print, dump, or go into the DispatchQueue.main.async block when it’s the second case(#2)?

Any ideas to improve my codes are welcome. I really appreciate your help in advance.


Cupertino
  • 21
  • 3
  • 1# You should use `Data(contentsOf:)` only for file URLs. It's not suitable to load data from a remote URL. It's a synchronous function and will block the calling thread until it is completed. – CouchDeveloper Aug 25 '23 at 08:01
  • 1
    2# I can't answer this question since you did not provide code which shows how you render this. Tip: you may refactor all your async functions such, that they have parameters providing _all_ required input and _return the result_. Use async/await across. Specifically, they do not read or write from `self` or any other "global". Even better make them `static`. That way, you can test the functions easily in a Unit test. Ensure your data is what you expect. Next step will be to ensure the data will be _rendered_ as expected. – CouchDeveloper Aug 25 '23 at 08:07
  • 1
    I hate to say it, but there is so much wrong here that it’s almost hard to say where to start. The whole premise of fetching the images (but not save them) to get their dimensions before you load the table is deeply flawed. – Rob Aug 25 '23 at 08:09
  • Furthermore, you don't want to compute the ratios of the images, when you need the image for computing this anyway. Just load the image and return them in your result. Then render the data (image). You probably need to redesign a couple things. Actually, the first thing you need to do is getting a better understanding of the problem :) – CouchDeveloper Aug 25 '23 at 08:14
  • You should convert old completion handlers into async/await compatible functions. Watch “Meet async/await” it only takes a couple of lines of code. The new Concurrency is all or nothing, forget about GCD and concert completion handlers. – lorem ipsum Aug 25 '23 at 12:33

2 Answers2

0

As the error message is warning you, you should avoid the Data(contentsOf:) approach as that runs synchronously, blocking the calling thread. The URLSession method, try await data(from:) is the way to go. It has lots of other advantages, too (more meaningful error handling, supports more complicated requests and HTTP auth challenges, etc.). There is no reason not to use the URLSession approach, and lots of reasons to avoid the Data(contentsOf:) approach.

You say you want to adopt Swift concurrency. Then you should eliminate the use of closures in requestMessages and use async-await. And do not use Task {…}. And do not use DispatchQueue.main.async {…}.


For example, a Swift concurrency rendition of requestMessages would be:

private func requestMessages() async {
    do {
        let object = try await APIManager.requestMessages()
        guard let messageSections = object.messageSections else {
            alert(message: object.description)
            return
        }
        self.messageSections = messageSections
        tableView.reloadData()
    } catch {
        print(error.localizedDescription)
    }
}

Now that assumes you have an async rendition of requestMessages in APIManager. Either refactor that or, for now, just write a wrapper:

extension APIManager {
    static func requestMessages() async throws -> ServerResponse {
        try await withCheckedThrowingContinuation { continuation in
            requestMessages { result in
                continuation.resume(with: result)
            }
        }
    }
}

If you haven't seen it already, you might want to check out WWDC 2021 video Swift concurrency: Update a sample app.


Needless to say, the whole idea of retrieving images (which is a very expensive and slow process) in order to determine the image ratios is a non-starter. You should update the cells’ aspect ratios on the fly. Generally we would download the images just-in-time, and update the cell accordingly. But there is not enough here for us to diagnose that particular problem any further.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
0

There's a lot which needs to be improved. Here are some tips:

Showing images in a view is always a challenge, since often, you receive a URL or a list of URLs and loading them in an asynchronous manner might be challenging, at least the first time you implement a solution.

Rendering Image URLs as an Image

The struct below represents an example which you may receive from some remote URL via accessing the service endpoint:

struct User {
    var firstName: String
    var lastName: String 
    var imageUrl:  URL?
}

As you can see, you don't get image data, but instead a URL. This is a very common use case.

Thus, SwiftUI provides a very convenient way to solve this in a direct and concise way:

Nothing special for firstName and lastName, you use a Text view. For the an URL, where you really want to show the actual image, and not the URL string, use a AsyncImage. See https://developer.apple.com/documentation/swiftui/asyncimage and do a little research how to use it.

UIKit

You still have to use UIKit? Use a UIHostingController and embed the above SwiftUI approach.

If you can use this approach, your data loading mechanic becomes very straight forward and simple.

Service Functions

So, what is this? This is a function calling some "service" and returning data. It usually is asynchronous and oftentimes returns one and only one result. It may also return multiple results, which you may represent as a Combine Publisher or an Async Stream.

Its function signature is just that, it gets Input and returns Output. It also helps to "hide" the underlying details. It could load data from the device, from a remote service, CoreData, or a Distributed Actor system, etc. Doesn't matter, it has always the same signature. When it throws and error, this is a generic error, not a specific one thrown from to the current underlying data source.

Example:

func fetchMessages() async throws [Message]

This function does not need any input parameter, it "knowns" what to do.

Or, with a parameter which limits the number of messages returned:

func fetchMessages(max: Int) async throws [Message]

Alternative form, using Combine:

func fetchMessages() -> AnyPublisher<[Message], Error>
func fetchMessages(max: Int) -> AnyPublisher<[Message], Error>

These functions don't need anything else than its input parameter and return a result or fail and return an error.

IMPORTANT: You can take these functions as is and use them in a Unit Test. No other context, object or anything should be necessary, to call them!

The Combine variant is more versatile than the async/await variant, since you get a "publisher", which "could" emit more than just one list of messages. A publisher may "update" your view automatically, when the underlying data changes. The view just needs to subscribe to the publisher. For a chat messenger, this would be more appropriate, IMHO.

You use these functions either in a Model (preferred) or directly in a SwiftUI View (only async/await variant).

CAVEAT:

The function may need some "Context" which defines some details how to perform the operation.

Solution: use an environment parameter:

func fetchMessages(env: Env) async throws [Message]
func fetchMessages(env: Env, max: Int) async throws [Message]
func fetchMessages(env: Env) -> AnyPublisher<[Message], Error>

Whatever Env is, it provides all necessary context.

In SwiftUI you can setup an environment "natively". You may use SwiftUI to define your environment, transport it to any child view, and then pass it from the view to its associated Model, which finally calls the service functions passing it the environment value.

So, leveraging an environment makes it possible to "fake" or "mock" your service functions. This is perfect to make previewing your SwiftUI views with fake data, without changes to your code (just setup the environment differently).

If you had all this, the answer to your original question would likely be very obvious ;)

CouchDeveloper
  • 18,174
  • 3
  • 45
  • 67