You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Firestore监听器时出现内存泄漏(是否为误报?)

iOS Firestore群聊应用内存泄漏问题

问题现象

开发的iOS应用从Firestore获取并监听群聊内容,仅在以下场景出现内存泄漏:

  • 启用过滤条件if !message.isSaved || message.hasBlocked || message.isBlocked,且存在至少一个Firestore监听器时;
  • 移除全部过滤条件后,泄漏完全消失。
    目前怀疑是Instrument误报,因为应用内存从未超过200MB,运行状态稳定,附带Instrument泄漏检测结果截图。

相关代码

GroupchatVC代码

private var lastDocumentSnapshot: DocumentSnapshot?
    
private var listeners: [ListenerRegistration] = [] // 修正变量名,统一管理监听器
    
private var isListening = true
    
private var theyBlockedU: [String] = []
private var uBlockedThem: [String] = []
private var groupsDictionary: [String: Group] = [:] // 补充原代码未声明的变量
private var groups = [Group]()

override func viewDidLoad() {
    super.viewDidLoad()
    fetchGroups()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.isListening = true
}

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    self.isListening = false
    // 统一移除所有监听器,释放内存
    listeners.forEach { $0.remove() }
    listeners.removeAll()
}

// MARK: - API
func fetchGroups() {
    self.fetchGroups() { [weak self] groups in
        guard let self = self else{return}
        groups.forEach { group in
            self.groupsDictionary[group.recentMessage.groupID] = group
        }
        self.groups = Array(self.groupsDictionary.values)
        self.groups.sort(by: { $0.recentMessage.timestamp.compare($1.recentMessage.timestamp) == .orderedDescending })
        self.tableView.reloadData()
    }
    self.tableView.refreshControl?.endRefreshing()
}

func fetchGroups(completion: @escaping([Group]) -> Void) {
    var query: Query!
    tableView.refreshControl?.beginRefreshing()

    if groups.isEmpty {
        query = COLLECTION_GROUPS.order(by: "timestamp", descending: false)
    } else {
        guard let lastDocSnapshot = lastDocumentSnapshot else {
            print("Error: lastDocumentSnapshot is nil")
            return
        }
        query = COLLECTION_GROUPS.order(by: "timestamp", descending: false).end(beforeDocument: lastDocSnapshot)
    }
    
    let listener = query.addSnapshotListener { [weak self] (snapshot, err) in
        guard let self = self else{return}
        if let err = err {
            print("\(err.localizedDescription)")
            return
        }
        
        guard let snapshot = snapshot else { return }
        if snapshot.isEmpty {
            self.isLoading = false
            self.stopActivityIndicator()
            self.tableView.refreshControl?.endRefreshing()
            return
        }
        
        guard let lastSnap = snapshot.documents.first else { return }
        self.lastDocumentSnapshot = lastSnap
        
        snapshot.documentChanges.forEach({ (change) in
            let dictionary = change.document.data()
            var message = GroupMessage(dictionary: dictionary)
            
            UserService.fetchUser(withUid: message.senderID) { [weak self] user in
                guard let self = self else { return }
                
                self.isUserBlocked(ownerID: message.ownerID) { isBlocked in
                    message.isBlocked = isBlocked

                    self.isBlockedByUser(ownerID: message.ownerID) { hasBlocked in
                        message.hasBlocked = hasBlocked

                        self.listenForGroupSaved(groupID: message.groupID) { isSaved in
                            guard let self = self else { return }
                            message.isSaved = isSaved
                                
                            let group = Group(user: user, recentMessage: message)
                        
                            if !message.isSaved || message.hasBlocked || message.isBlocked {
                                self.groups = self.groups.filter { $0.recentMessage.groupID != message.groupID }
                                self.tableView.reloadData()
                                return
                            } else {
                                if let index = self.groups.firstIndex(where: { $0.recentMessage.groupID == message.groupID }) {
                                    self.groups[index] = group
                                } else {
                                    self.groups.append(group)
                                }
                                self.groups.sort(by: { $0.recentMessage.timestamp.compare($1.recentMessage.timestamp) == .orderedDescending })
                                self.tableView.reloadData()
                            }
                        }
                    }
                }
            })
        })
        self.tableView.refreshControl?.endRefreshing()
        self.lastDocumentSnapshot = snapshot.documents.first
    }
    listeners.append(listener) // 将监听器加入统一管理数组
}

func listenForGroupSaved(groupID: String, completion: @escaping (Bool) -> Void) {
    guard let currentUser = Auth.auth().currentUser else {return}
    let docRef = COLLECTION_USERS.document(currentUser.uid).collection("isGroupSaved").document(groupID)
    let listener = docRef.addSnapshotListener { (snapshot, error) in
        if let error = error {
            print("Error listening for savedGroup changes: \(error.localizedDescription)")
            return
        }
        let isSaved = snapshot?.data()?["isSaved"] as? Bool ?? false
        completion(isSaved)
    }
    listeners.append(listener)
}

func isUserBlocked(ownerID: String, completion: @escaping(Bool) -> Void) {
    guard let currentUser = Auth.auth().currentUser else { return }
    let listener = COLLECTION_USERS.document(currentUser.uid).collection("blocked").addSnapshotListener { [weak self] (querySnapshot, err) in
        guard let self = self else { return }
        if let err = err {
            print("Error getting documents: \(err)")
            return
        }
        self.uBlockedThem = querySnapshot!.documents.map({ $0.documentID })
        let isBlocked = self.uBlockedThem.contains(ownerID)
        completion(isBlocked)
    }
    listeners.append(listener)
}

func isBlockedByUser(ownerID: String, completion: @escaping(Bool) -> Void) {
    guard let currentUser = Auth.auth().currentUser else{return}
    let listener = COLLECTION_USERS.document(currentUser.uid).collection("isBlockedBy").addSnapshotListener { [weak self] (querySnapshot, err) in
        guard let self = self else { return }
        if let err = err {
            print("Error getting documents: \(err)")
            return
        }
        self.theyBlockedU = querySnapshot!.documents.map({ $0.documentID })
        let isBlocked = self.theyBlockedU.contains(ownerID)
        completion(isBlocked)
    }
    listeners.append(listener)
}

Group结构体代码

struct Group {
    var user: User
    var recentMessage: GroupMessage
}

struct GroupMessage {
    var user: User?
    
    var text: String
    var senderID: String
    var groupID : String
    var ownerID: String
    var timestamp: Timestamp
    var isSaved: Bool
    var isBlocked: Bool
    var hasBlocked: Bool
    
    init(dictionary: [String: Any]) {
        self.text = dictionary["text"] as? String ?? ""
        self.senderID = dictionary["senderID"] as? String ?? ""
        self.groupID = dictionary["groupID"] as? String ?? ""
        self.ownerID = dictionary["ownerID"] as? String ?? ""
        self.timestamp = dictionary["timestamp"] as? Timestamp ?? Timestamp(date: Date())
        self.isSaved = dictionary["isSaved"] as? Bool ?? false
        self.isBlocked = dictionary["isBlocked"] as? Bool ?? false
        self.hasBlocked = dictionary["hasBlocked"] as? Bool ?? false
    }
}

问题分析与修复方案

核心问题

  1. 监听器未统一管理:原代码中所有Firestore监听器都是局部变量,未存入类级别的管理数组,导致VC消失时无法彻底移除监听器。这些监听器会持续持有VC引用,即使VC已不可见,内存也无法释放,尤其在过滤条件触发提前return时,关联监听器仍在后台运行。
  2. 过滤条件触发时未清理资源:满足过滤条件执行return时,之前创建的listenForGroupSaved、isUserBlocked等监听器未被移除,持续占用内存并持有VC引用。

修复步骤

  1. 统一管理监听器:将所有创建的ListenerRegistration实例加入listeners数组,在viewDidDisappear时统一遍历移除并清空数组,确保VC能被正确释放。
  2. 优化闭包逻辑:减少嵌套闭包层级,或在过滤条件触发时,单独移除当前消息关联的监听器(比如用字典存储groupID对应的监听器,过滤时移除对应条目)。
  3. 补全代码遗漏:添加原代码中用到但未声明的groupsDictionary变量,避免运行时错误。

额外建议

  • 即使当前内存稳定,也建议修复监听器管理问题,避免长期运行后内存累积引发性能下降。
  • 使用Xcode Memory Graph Debugger查看泄漏对象的引用链,确认监听器是否为泄漏根源。

内容的提问来源于stack exchange,提问作者Unknown

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 07:35:05