1

I'm developing a messaging app that fetches messages and their associated media from a server. I'm using a UICollectionView to display these messages, initially showing a placeholder until the actual data is loaded.

I fetch the data using a variety of asynchronous calls (using libraries such as KingfisherManager for images, FileStorage for audio and video files, and AppleMusicAPI for music tracks). Once I've retrieved the data, I try to update the respective cells by reloading their sections. However, the cells don't always update to display the fetched data, instead showing the placeholder indefinitely.

Here is a simplified version of my data fetching and UI updating code:

func createMessage(messageDictionary: [String: Any], completion: @escaping (MKMessage?) -> Void) {
let message = Message(dictionary: messageDictionary)
let mkMessage = MKMessage(message: message)

if message.type == kSPOTIFYLINK {
        print("spotify link")
        let placeholderSpotifyURL = URL(string: "") ?? URL(fileURLWithPath: "")
        let placeholderTrack = SpotifyTrackItem(title: "", artists: [""], album: "", artwork: nil, trackURLPreview: placeholderSpotifyURL, spotifyURL: placeholderSpotifyURL, placeholder: true)
        mkMessage.kind = .custom(placeholderTrack)
        
        // Check cache first
        if let cachedTrack = SpotifyCache.object(forKey: message.spotifyLink as NSString) {
            mkMessage.kind = .custom(cachedTrack)
            if let section = mkMessage.section {
                print(section)
                let indexSet = IndexSet(integer: section)
                DispatchQueue.main.async {
                    self.messageCollectionView.messagesCollectionView.reloadSections(indexSet)
                }
            }
        } else {
            spotifyMusicApi.fetchSpotifySongInfo(from: message.spotifyLink) { [weak self] trackItem, error in
                if let trackItem = trackItem {
                    print(trackItem)
                    // Save to cache
                    self?.SpotifyCache.setObject(trackItem, forKey: message.spotifyLink as NSString)
                    mkMessage.kind = .custom(trackItem)
                    if let section = mkMessage.section {
                        print(section)
                        let indexSet = IndexSet(integer: section)
                        DispatchQueue.main.async {
                            self?.messageCollectionView.messagesCollectionView.reloadSections(indexSet)
                        }
                    }
                } else if let error = error {
                    print(error)
                }
            }
        }
    }
// Similar asynchronous calls for other types of data...

My problem is that the UI is not consistently updated after the asynchronous calls complete. The placeholders are not always replaced by the loaded data, even though I'm updating the UI on the main thread and trying to reload the specific sections.

In my messaging application, I initially load 10 chat messages when the view loads. These chats are fetched from a database, and then displayed in a UICollectionView. I use pagination to handle the potentially large number of chat messages, loading more messages as the user scrolls up.

To load older messages, I have implemented a scrollViewDidEndDecelerating method, which is triggered when the user finishes scrolling. Inside this method, I check if the refresh control (refreshController) is refreshing. If it is, I call the getOldMessagesInBackground function:

override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    if refreshController.isRefreshing {
        getOldMessagesInBackground()
    }
} 


private func getOldMessagesInBackground() {
        var query: Query = FirebaseReference(.Messages).document(FUser.currentId()).collection(chatId)
            .order(by: kDATE, descending: true)
            .limit(to: 10)
    if let lastSnapshot = lastDocumentSnapshot {
        query = query.start(afterDocument: lastSnapshot)
    }

    query.getDocuments { (snapshot, error) in
        guard let snapshot = snapshot else { return }
        
        if snapshot.documents.isEmpty {
            print("no more messages")
            self.messagesCollectionView.refreshControl = nil
            self.areAllMessagesLoaded = true
            return
        }

        let newMessages = ((self.dictionaryArrayFromSnapshot(snapshot.documents)) as NSArray).sortedArray(using: [NSSortDescriptor(key: kDATE, ascending: false)]) as! [Dictionary<String, Any>]
        self.loadedMessageDictionaries = newMessages + self.loadedMessageDictionaries

        self.lastDocumentSnapshot = snapshot.documents.last

        DispatchQueue.main.async {
            self.maxMessageNumber = self.loadedMessageDictionaries.count - self.displayingMessagesCount - 1
            self.minMessageNumber = self.maxMessageNumber - kNUMBEROFMESSAGES
            self.insertOldMessages(newMessages)
            self.messagesCollectionView.reloadDataAndKeepOffset()
            self.refreshController.endRefreshing()
        
        }
    }
}



private func insertOldMessage(_ messageDictionary: Dictionary<String, Any>) {
    let incoming = IncomingMessage(collectionView_: self)
    incoming.createMessage(messageDictionary: messageDictionary) { [weak self] message in
        guard let self = self, let message = message else { return }
        message.section = 0
        if !self.mkmessages.contains(where: { $0.messageId == message.messageId }) {
            self.mkmessages.insert(message, at: 0)
        } 
    }
}

private func insertOldMessages(_ messages: [Dictionary<String, Any>]) {
    for messageDictionary in messages {
        insertOldMessage(messageDictionary)
        displayingMessagesCount += 1
    }
}

Just for more clarify, this is how I fetch my inital chats:

private func downloadChats() {
    DispatchQueue.global(qos: .background).async {
        FirebaseReference(.Messages).document(FUser.currentId()).collection(self.chatId).limit(to: 10).order(by: kDATE, descending: true).getDocuments { (snapshot, error) in

            guard let snapshot = snapshot else {
                self.initialLoadCompleted = true

                return
            }

            self.loadedMessageDictionaries = ((self.dictionaryArrayFromSnapshot(snapshot.documents)) as NSArray).sortedArray(using: [NSSortDescriptor(key: kDATE, ascending: true)]) as! [Dictionary<String, Any>]

            self.lastDocumentSnapshot = snapshot.documents.last
            
      
            if let lastMessageDate = (self.lastDocumentSnapshot?[kDATE] as? Timestamp)?.dateValue() {
               if lastMessageDate > self.latestMessageDate {
                   self.latestMessageDate = lastMessageDate
               }
            }
        
            DispatchQueue.main.async {
                self.insertMessages()
                self.initialLoadCompleted = true
                self.listenForNewChats()
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.messagesCollectionView.reloadData()
                    self.messagesCollectionView.scrollToLastItem(animated: false)
                }
            }
        }
    }
}
 private func insertMessages() {
    maxMessageNumber = loadedMessageDictionaries.count - displayingMessagesCount
    minMessageNumber = maxMessageNumber - kNUMBEROFMESSAGES
    
    if minMessageNumber < 0 {
        minMessageNumber = 0
    }
    
     for i in (minMessageNumber ..< maxMessageNumber) {
         let messageDictionary = loadedMessageDictionaries[i]
         insertMessage(messageDictionary)
         displayingMessagesCount += 1
        // print("displayingMessagesCount: \(String(displayingMessagesCount))")
     }
     
     if displayingMessagesCount <= loadedMessageDictionaries.count {
         if messagesCollectionView.refreshControl == nil {
             messagesCollectionView.refreshControl = refreshController
             self.areAllMessagesLoaded = false
             
         }
     } else {
         messagesCollectionView.refreshControl = nil
     }
 }

private func insertMessage(_ messageDictionary: Dictionary<String, Any>) {
    //  print("insert message")
    let messageId = messageDictionary[kOBJECTID] as? String
    if let messageId = messageId, !mkmessages.contains(where: { $0.messageId == messageId }) {
        markMessageAsRead(messageDictionary)
        let incoming = IncomingMessage(collectionView_: self)
        incoming.createMessage(messageDictionary: messageDictionary) { [weak self] message in
            guard let self = self, let message = message else { return }
            self.mkmessages.append(message)
            let section = self.mkmessages.count - 1
            message.section = section
            self.messagesCollectionView.performBatchUpdates({
                self.messagesCollectionView.insertSections([self.mkmessages.count - 1])
                if self.mkmessages.count >= 2 {
                    self.messagesCollectionView.reloadSections([self.mkmessages.count - 2])
                }
            }, completion: { [weak self] _ in
                self?.messagesCollectionView.reloadDataAndKeepOffset()
            })
        }
    }
}

This is my assumption of what the major factors that contribute to the problem: (1) Incorrect section property on mkMessage: The section property on the mkMessage might not be correctly set when trying to reload the collection view section. This could be due to the asynchronous nature of the tasks. By the time the async operation completes and the UI update is triggered, the section value might have changed, leading to the UI update happening for the wrong cell.

(2) Changing section index due to message loading: When cells are being reloaded, there might be additional messages being loaded that could change the index of the sections. In other words, the positions of sections might shift as new messages are loaded or old messages are retrieved. This again leads to the wrong cells being updated because the section that was stored before the asynchronous operation does not point to the correct cell after the operation.

(3) Premature completion call: Additionally, calling completion(mkMessage) immediately after setting the placeholder and kicking off the async operation is not suitable. The completion handler is meant to be called after all operations (including async ones) are done. Calling it prematurely would signal that the message is ready to be displayed, even though the async operations (like fetching data from Spotify or Apple Music) are not complete. This would result in the message being displayed without the fetched data.

fun lab
  • 29
  • 3
  • I'd suggest trying to implement a much simpler collection view that updates its cell contents based on asynchronous network fetching. Once you have that working, then if your real app still doesn't behave as desired, you'll have a much better idea where the problem lies. – matt Jun 04 '23 at 14:11
  • the problem is definitely because of how I am updating my cell UI after asynchronous operations are complete – fun lab Jun 04 '23 at 14:42

0 Answers0