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渲染和事件响应。 - 内存暴涨:
- 未做图片缩放,直接加载解码原图。一张3000*4000分辨率的原图解码后内存占用可达30MB以上,几十张同时加载内存轻松破1GB
- 使用普通
VStack/HStack嵌套布局,会在列表加载时一次性初始化所有子视图,所有图片请求同步发起,没有做可见区域懒加载 - 同时持有图片原始
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
相关产品推荐
相关产品推荐

