Search code examples
iosswiftcombine

How to merge multiple network calls’ responses into an array? With or without Combine?


Working with flagpedia.net/download/api

It has two endpoints:

1 - Returns [String:String] dictionary of code-country pairs, like [“us”:”United States”] as a json

2 - Returns an image data for a country code and specified image size, example url flagcdn.com/16x12/us.png

I have create two functions using regular completion handler

  • fetchCodes(completion: @escaping (Result<[String],Error>) -> Void) { … }
  • fetchImage(forCode: String, completion: @escaping (Result<UIImage,Error>) -> Void) { … }

And also their Combine variations

  • fetchCodes() -> AnyPublisher<[String],Error> { … }
  • fetchImage(forCode: String) -> AnyPublisher<UIImage,Error> { … }

Both methods works fine, and returns expected result. How could we merge them?

fetchCodes() method decodes json into dictionary and create an array from the keys.

After getting codes: [String] from fetchCodes() want to do something like this:

  • var results = [UIImage]()
  • for code in codes {
  • let image = fetchImage(forCode: code)
  • images.append(image)
  • }

How to achieve this?

I tried Publishers.ManyMerge and flatMap but wasn’t successful. Ended up getting warning regarding mismatch in return types.

Sorry for the indentation, I’m posting this on mobile.


Solution

  • Here's a great way to do it with the Combine operators:

    func fetchCodes() -> AnyPublisher<[String],Error> { fatalError() }
    func fetchImage(forCode: String) -> AnyPublisher<UIImage,Error> { fatalError() }
    
    func example() -> AnyPublisher<[String: UIImage], Error> {
        let codesAndImages = keysAndValues(fetchImage(forCode:))
        return fetchCodes()
            .flatMap { codes in
                combineLatest(codesAndImages(codes))
            }
            .map(Dictionary.init(uniqueKeysWithValues:))
            .eraseToAnyPublisher()
    }
    
    func keysAndValues<A, B>(_ get: @escaping (A) -> AnyPublisher<B, Error>) -> ([A]) -> [AnyPublisher<(A, B), Error>] {
        { xs in
            xs.map { x in
                Just(x).setFailureType(to: Error.self)
                    .zip(get(x))
                    .eraseToAnyPublisher()
            }
        }
    }
    
    func combineLatest<A>(_ xs: [AnyPublisher<A, Error>]) -> AnyPublisher<[A], Error> {
        xs.reduce(Empty<[A], Error>().eraseToAnyPublisher()) { state, x in
            state.combineLatest(x)
                .map { $0.0 + [$0.1] }
                .eraseToAnyPublisher()
        }
    }
    

    or you could do it like this which is flatter:

    func example() -> AnyPublisher<[String: UIImage], Error> {
        fetchCodes()
            .map { codes in
                codes.map { Just($0).setFailureType(to: Error.self).zip(fetchImage(forCode: $0)) }
            }
            .flatMap { zip($0) }
            .map(Dictionary.init(uniqueKeysWithValues:))
            .eraseToAnyPublisher()
    }
    
    func zip<Pub>(_ xs: [Pub]) -> AnyPublisher<[Pub.Output], Pub.Failure> where Pub: Publisher {
        xs.reduce(Empty<[Pub.Output], Pub.Failure>().eraseToAnyPublisher()) { state, x in
            state.zip(x)
                .map { $0.0 + [$0.1] }
                .eraseToAnyPublisher()
        }
    }
    

    Without Combine it gets a bit more tricky:

    func fetchCodes(completion: @escaping (Result<[String],Error>) -> Void) { }
    func fetchImage(forCode: String, completion: @escaping (Result<UIImage,Error>) -> Void) { }
    
    func example(_ completion: @escaping (Result<[String: UIImage], Error>) -> Void) {
        fetchCodes { codesResult in
            switch codesResult {
            case let .success(codes):
                loadImages(codes: codes, completion)
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }
    
    func loadImages(codes: [String], _ completion: @escaping (Result<[String: UIImage], Error>) -> Void) {
        var result = [String: UIImage]()
        let lock = NSRecursiveLock()
        let group = DispatchGroup()
        for code in codes {
            group.enter()
            fetchImage(forCode: code) { imageResult in
                switch imageResult {
                case let .success(image):
                    lock.lock()
                    result[code] = image
                    lock.unlock()
                    group.leave()
                case let .failure(error):
                    completion(.failure(error))
                }
            }
        }
        group.notify(queue: .main) {
            completion(.success(result))
        }
    }
    

    With the Combine version if a fetchImage fails, then the others are all canceled. With the callback version, that is not the case. Instead, the others will finish and then throw away the data downloaded.