Рекурсия асинхронного замыкания для структур

#swift #asynchronous #recursion #network-programming #closures

#swift #асинхронный #рекурсия #сетевое программирование #замыкания

Вопрос:

Я пишу сетевой клиент для Hacker News. Я использую их официальный API.

У меня возникли проблемы с модификацией моего сетевого клиента для работы со структурами вместо классов для комментариев к истории. Он отлично работает с классами, особенно с синхронным рекурсивным замыканием.

Это моя модель данных.

 class Comment: Item {
    var replies: [Comment?]?

    let id: Int
    let isDeleted: Bool?
    let parent: Int
    let repliesIDs: [Int]?
    let text: String?
    let time: Date
    let type: ItemType
    let username: String?

    enum CodingKeys: String, CodingKey {
        case isDeleted = "deleted"
        case id
        case parent
        case repliesIDs = "kids"
        case text
        case time
        case type
        case username = "by"
    }
}
 

Это пример моего сетевого клиента.

 class NetworkClient {
    // ...
    // Top Level Comments
    func fetchComments(for story: Story, completionHandler: @escaping ([Comment]) -> Void) {
        var comments = [Comment?](repeating: nil, count: story.comments!.count)
        
        for (commentIndex, topLevelCommentID) in story.comments!.enumerated() {
            let topLevelCommentURL = URL(string: "https://hacker-news.firebaseio.com/v0/item/(topLevelCommentID).json")!
            
            dispatchGroup.enter()
            
            URLSession.shared.dataTask(with: topLevelCommentURL) { (data, urlResponse, error) in
                guard let data = data else {
                    print("Invalid top level comment data.")
                    return
                }
                
                do {
                    let comment = try self.jsonDecoder.decode(Comment.self, from: data)
                    comments[commentIndex] = comment
                    
                    if comment.repliesIDs != nil {
                        self.fetchReplies(for: comment) { replies in
                            comment.replies = replies
                        }
                    }
                    
                    self.dispatchGroup.leave()
                } catch {
                    print("There was a problem decoding top level comment JSON.")
                    print(error)
                    print(error.localizedDescription)
                }
            }.resume()
        }
        
        dispatchGroup.notify(queue: .global(qos: .userInitiated)) {
            completionHandler(comments.compactMap { $0 })
        }
    }
    
    // Recursive method
    private func fetchReplies(for comment: Comment, completionHandler: @escaping ([Comment?]) -> Void) {
        var replies = [Comment?](repeating: nil, count: comment.repliesIDs!.count)
        
        for (replyIndex, replyID) in comment.repliesIDs!.enumerated() {
            let replyURL = URL(string: "https://hacker-news.firebaseio.com/v0/item/(replyID).json")!
            
            dispatchGroup.enter()
            
            URLSession.shared.dataTask(with: replyURL) { (data, _, _) in
                guard let data = data else { return }
                
                do {
                    let reply = try self.jsonDecoder.decode(Comment.self, from: data)
                    replies[replyIndex] = reply
                    
                    if reply.repliesIDs != nil {
                        self.fetchReplies(for: reply) { replies in
                            reply.replies = replies
                        }
                    }
                    
                    self.dispatchGroup.leave()
                } catch {
                    print(error)
                }
            }.resume()
        }
        
        dispatchGroup.notify(queue: .global(qos: .userInitiated)) {
            completionHandler(replies)
        }
    }
}
 

Вы вызываете сетевой клиент следующим образом для получения дерева комментариев для конкретной истории.

 var comments = [Comment]()

let networkClient = NetworkClient()
networkClient.fetchStories(from: selectedStory) { commentTree in
    // ...
    comments = commentTree
    // ...
}
 

Переключение модели данных класса комментариев на struct не очень хорошо работает с асинхронной рекурсией замыкания. Это отлично работает с классами, потому что на классы ссылаются, тогда как структуры копируются, и это вызывает некоторые проблемы.

Как я могу адаптировать свой сетевой клиент для работы со структурами? И есть ли способ переписать мои методы в один метод вместо двух? Один метод предназначен для комментариев верхнего уровня (root), в то время как другой — рекурсия для каждого ответа на комментарий верхнего уровня (root).

Ответ №1:

Рассмотрим этот блок кода

 let reply = try self.jsonDecoder.decode(Comment.self, from: data)
replies[replyIndex] = reply

if reply.repliesIDs != nil {
    self.fetchReplies(for: reply) { replies in
        reply.replies = replies
    }
}
 

Если Comment это была структура, это приведет к выборке reply , добавлению ее копии в replies массив, а затем к fetchReplies изменению оригинала reply (который вы должны были изменить на let , чтобы var эта строка даже компилировалась), а не копии в массиве.

Итак, вы можете захотеть сослаться replies[replyIndex] на свое fetchReplies закрытие, например:

 let reply = try self.jsonDecoder.decode(Comment.self, from: data)
replies[replyIndex] = reply

if reply.repliesIDs != nil {
    self.fetchReplies(for: reply) { replies in
        replies[replyIndex].replies = replies
    }
}
 

Кстати,

  • группа отправки не должна быть свойством, а скорее должна быть локальной переменной (тем более, что вы, похоже, вызываете этот метод рекурсивно!);
  • у вас есть несколько путей выполнения, при которых вы не покидаете группу (если data был nil или если reply.repliesIDs был nil или если синтаксический анализ JSON не удался); и
  • у вас есть пути выполнения, в которых вы преждевременно покидаете группу (если reply.repliesIDs не nil было, вы должны переместить leave() вызов в это закрытие обработчика завершения).

Я не проверял это, но я бы предложил что-то вроде:

 private func fetchReplies(for comment: Comment, completionHandler: @escaping ([Comment?]) -> Void) {
    var replies = [Comment?](repeating: nil, count: comment.repliesIDs!.count)
    let group = DispatchGroup() // local var
    
    for (replyIndex, replyID) in comment.repliesIDs!.enumerated() {
        let replyURL = URL(string: "https://hacker-news.firebaseio.com/v0/item/(replyID).json")!
        
        group.enter()
        
        URLSession.shared.dataTask(with: replyURL) { data, _, _ in
            guard let data = data else { 
                group.leave() // leave on failure, too
                return
            }
            
            do {
                let reply = try self.jsonDecoder.decode(Comment.self, from: data)
                replies[replyIndex] = reply
                
                if reply.repliesIDs != nil {
                    self.fetchReplies(for: reply) { replies in
                        replies[replyIndex].replies = replies
                        group.leave() // if reply.replieIDs was not nil, we must not `leave` until this is done
                    }
                } else {
                    group.leave() // leave if reply.repliesIDs was nil
                }
            } catch {
                group.leave() // leave on failure, too
                print(error)
            }
        }.resume()
    }
    
    dispatchGroup.notify(queue: .main) { // do this on main to avoid synchronization headaches
        completionHandler(replies)
    }
}
 

Комментарии:

1. Спасибо за ваш ответ! Я пробовал ваши предложения, но это не сработало. Я напечатал значение до и после для этой одной строки replies[replyIndex].replies = replies . До печати nil и после печати Optional([nil])

2. Я также попробовал принудительное разворачивание. replies[replyIndex]!.replies = replies Изначально это было так replies[replyIndex]?.replies = replies

3. Да, вам придется его развернуть.

4. Кстати, все проблемы с диспетчерской группой fetchReplies , на которые я указал, fetchComments также применимы.

5. Большое спасибо, Роб! Я настроил другие части своего приложения, и оно работает так, как ожидалось.