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

SwiftUI 加载网络图片时内存泄漏、暴涨及界面冻结问题求解

问题现象

项目加载Firebase文档中URL字段指向的网络图片时,出现两类异常:

  • 图片请求返回data后界面完全冻结,所有图片实例化为UIImage加载完成前无法响应交互
  • 应用内存从150MB左右暴涨至1000MB,存在明显的内存异常占用问题

相关代码

数据模型定义

struct Card: Identifiable, Codable {
    @DocumentID var id: String?
    var question: String
    var answer: String
    var status: String
    var url: String
}

数据仓库层

final class CardRepository: ObservableObject, CardRepositoryProtocol {
    
    private let store = Firestore.firestore()
    var resultSubject = CurrentValueSubject<[Card], Error>([])
    
    func get() {
        store.collection(StorageCollection.EnglishCard.getPath)
            .addSnapshotListener { [unowned self] snapshot, err in
                if let err = err {
                    resultSubject.send(completion: .failure(err))
                }
                if let snapshot = snapshot {
                    let cards = snapshot.documents.compactMap {
                        try? $0.data(as: Card.self)
                    }
                    resultSubject.send(cards)
                }
            }
    }
}

ViewModel层

class CardViewModel: CardViewModelProtocol, ObservableObject {
    
    internal var repository: CardRepositoryProtocol
    @Published private(set) var state: CardViewModelState = .loading
    private var cancellables: Set<AnyCancellable> = []
    @Published var grid: [Int] = []
    @Published var cards: [Card] = []
    let numberOfColumn = 2
    
    required init (_ repository: CardRepositoryProtocol ) {
        self.repository = repository
        bindingCards()
        get()
    }
    
    
    private func bindingCards() {
        repository.resultSubject
            .sink { [unowned self] comp in
                switch comp {
                case .failure(let err):
                    self.state = .failed(err: err)
                case .finished:
                    print("finised")
                }
            } receiveValue: { [unowned self] res in
                self.state = .success(cards: res)
                cards = res
                getGrid(data: res)
            }.store(in: &cancellables)

    }
    
    private func getGrid(data: [Card]) {
        grid.removeAll()
        for i in stride(from: 0, to: data.count, by: numberOfColumn){
            
            if i != data.count{
                
                self.grid.append(i)
            }
            
        }
    }
}

页面视图层

struct ContentView <ModelProtocol>: View where ModelProtocol: CardViewModelProtocol {
    @ObservedObject var viewModel: ModelProtocol
    var body: some View {
        switch viewModel.state {
        case .loading:
            ProgressView()
        case .failed(err: let err):
            Text(err.localizedDescription)
        case .success(cards: _):
            if viewModel.grid.count != 0 {
                
                //TopBard(viewModel: viewModel)
                ScrollView(.vertical, showsIndicators: false) {
                    let cards = viewModel.cards
                    VStack(spacing: 10) {
                        ForEach(self.viewModel.grid, id: \.self) { i in
                            HStack(spacing: 10) {
                                ForEach(i...(i + viewModel.numberOfColumn - 1), id: \.self) { j in
                                    if j != cards.count {
                                        Item(url:cards[j].url)
                                    }
                                }
                            }
                        }
                    }
                }
            } else {
                ProgressView()
            }
        }
        
    }
}
struct Item: View {
    
    @ObservedObject var imageLoader: ImageLoader
    @State var image: UIImage = UIImage()
    init (url: String) {
        imageLoader = ImageLoader(urlString: url)
    }
    
    var body: some View {
        VStack {
            ZStack {
                RoundedRectangle(cornerRadius: 12).foregroundColor(.green)
                    .frame(width: DeviceScreen.size.width/2 - 40, height: 200)
                Image(uiImage: image)
                    .resizable()
                    .frame(width: DeviceScreen.size.width/2 - 40, height: 200)
                    .aspectRatio(contentMode: .fit)
                    .onReceive(imageLoader.didChange) { data in
                        self.image = UIImage(data: data) ?? UIImage()
                    }
            }
            Button("Get Index") {
            }
        }
        
    }
}

图片加载工具类

class ImageLoader: ObservableObject {
    var didChange = PassthroughSubject<Data, Never>()
    var data = Data() {
        didSet {
            didChange.send(data)
        }
    }
    
    init (urlString: String) {
        guard let url = URL(string: urlString) else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else { return }
            DispatchQueue.main.async {
                self.data = data
            }
        }
        task.resume()
    }
    
}

根因分析
  • UI冻结:所有图片从Data转UIImage的解码操作全部放在主线程执行,高清图片解码是CPU密集型操作,多张图片同时解码会直接占满主线程时间片,阻断UI渲染和事件响应。
  • 内存暴涨:
    1. 未做图片缩放,直接加载解码原图。一张3000*4000分辨率的原图解码后内存占用可达30MB以上,几十张同时加载内存轻松破1GB
    2. 使用普通VStack/HStack嵌套布局,会在列表加载时一次性初始化所有子视图,所有图片请求同步发起,没有做可见区域懒加载
    3. 同时持有图片原始Data和解码后的UIImage对象,内存占用翻倍
  • 内存泄漏风险:ImageLoader没有持有网络任务引用,视图销毁时无法取消未完成的请求,无效请求返回后仍会创建图片对象占用内存;闭包中滥用[unowned self],既可能触发野指针崩溃,也存在循环引用导致对象无法释放的隐患。

修复方案

1. 图片解码缩放移到后台线程

不要在主线程执行UIImage(data:)解码,拿到网络数据后先在后台队列按实际展示尺寸缩放图片,处理完成再回主线程赋值,从根源避免主线程阻塞。
修改ImageLoader实现:

class ImageLoader: ObservableObject {
    // 改为直接发送缩放后的UIImage,不传递原始Data
    var didChange = PassthroughSubject<UIImage, Never>()
    private var dataTask: URLSessionDataTask?
    // 移除不必要的data属性持有,避免内存浪费
    
    init (urlString: String) {
        guard let url = URL(string: urlString) else { return }
        dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
            guard let data = data, let self = self else { return }
            // 后台队列做CPU密集的解码缩放操作
            DispatchQueue.global(qos: .userInitiated).async {
                let targetSize = CGSize(
                    width: (UIScreen.main.bounds.width/2 - 40) * UIScreen.main.scale,
                    height: 200 * UIScreen.main.scale
                )
                let thumbnail = UIImage(data: data)?.preparingThumbnail(of: targetSize)
                // 主线程更新UI
                DispatchQueue.main.async {
                    self.didChange.send(thumbnail ?? UIImage())
                }
            }
        }
        dataTask?.resume()
    }
    
    deinit {
        // 视图销毁时取消未完成的请求,避免无效内存占用
        dataTask?.cancel()
    }
}

对应修改Item视图的接收逻辑,去掉主线程解码操作:

.onReceive(imageLoader.didChange) { img in
    self.image = img
}

2. 替换懒加载布局

把ScrollView里的VStack/HStack替换为LazyVStack/LazyHStack,仅当视图滚动到可见区域时才创建对应Item、发起图片请求,避免一次性加载全量图片。

ScrollView(.vertical, showsIndicators: false) {
    let cards = viewModel.cards
    LazyVStack(spacing: 10) {
        ForEach(self.viewModel.grid, id: \.self) { i in
            LazyHStack(spacing: 10) {
                ForEach(i...(i + viewModel.numberOfColumn - 1), id: \.self) { j in
                    if j != cards.count {
                        Item(url:cards[j].url)
                    }
                }
            }
        }
    }
}

3. 修正闭包引用

把所有闭包中的[unowned self]替换为[weak self],避免野指针崩溃和循环引用导致的内存无法释放,闭包内使用self时提前解包即可。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.31 20:19:01