Skip to content

Instantly share code, notes, and snippets.

@pd95
Created July 1, 2020 21:04
Show Gist options
  • Save pd95/a39c4bd69f3a569de99c7e0682716ac2 to your computer and use it in GitHub Desktop.
Save pd95/a39c4bd69f3a569de99c7e0682716ac2 to your computer and use it in GitHub Desktop.
Illustrate Memory leak in Publisher.replaceError
//
// ContentView.swift
// Bug-Combine-Leak
//
import Combine
import SwiftUI
final class Store: ObservableObject {
struct User: Codable {
let login: String
let avatar_url: String
let name: String?
}
@Published var user: User?
@Published var userImage: UIImage = UIImage(systemName: "photo")!
var cancellables = Set<AnyCancellable>()
let baseURL = URL(string: "https://api.github.com/")!
func fetchUserInfo(_ user: String) {
let request = URLRequest(url: baseURL.appendingPathComponent("users/\(user)"))
URLSession.shared
.dataTaskPublisher(for: request)
.map { $0.data }
.decode(type: User?.self, decoder: JSONDecoder())
.replaceError(with: nil) // FIXME
.receive(on: RunLoop.main)
.sink(receiveCompletion: { (result) in
print("fetchUserInfo result", result)
}) { (user: User?) in
self.user = user
}
.store(in: &cancellables)
}
func fetchAvatar() {
if let avatarUrl = URL(string: user?.avatar_url ?? "") {
URLSession.shared
.dataTaskPublisher(for: URLRequest(url: avatarUrl))
.map { $0.data }
.tryMap { UIImage(data: $0)! }
.replaceError(with: UIImage(systemName: "photo")!) // FIXME
.receive(on: DispatchQueue.main)
.assign(to: \.userImage, on: self)
.store(in: &cancellables)
}
}
}
struct ContentView: View {
@ObservedObject var store: Store
@State private var username = "pd95"
@State private var loadAvatar = false
var body: some View {
VStack {
Text("Who is?")
.font(.largeTitle)
.bold()
TextField("Github username", text: $username, onCommit: fetchUser)
.textContentType(.username)
.font(.headline)
.padding()
.background(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary))
HStack {
Button(action: fetchUser) {
Text("Check")
.font(.headline)
.padding()
.background(RoundedRectangle(cornerRadius: 8).fill(Color.blue))
.foregroundColor(Color.white)
}
.padding()
if store.user != nil {
Button(action: fetchAvatar) {
Text("Load Avatar")
.font(.headline)
.padding()
.background(RoundedRectangle(cornerRadius: 8).fill(Color.yellow))
}
.padding()
}
}
Divider()
if store.user != nil {
Text(store.user?.login ?? "n/a")
Text(store.user?.name ?? "n/a")
if loadAvatar {
Image(uiImage: store.userImage)
.resizable()
.scaledToFit()
}
}
}
.padding()
}
func fetchUser() {
loadAvatar = false
self.store.fetchUserInfo(self.username)
}
func fetchAvatar() {
loadAvatar = true
self.store.fetchAvatar()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(store: Store())
}
}
@pd95
Copy link
Author

pd95 commented Jul 1, 2020

This code illustrates a memory leak bug I've come across while using SwiftUI and Combine.

The example does the following:

  1. Ask for a username (default pd95)
  2. After pressing the button "Check", a query is run to get the user record from GitHub for the given username. Upon success, the button "Load Avatar" appears.
  3. After pressing the button "Load Avatar", the avatar image for the given user is load.

When checking for memory leaks on each step, you will see that after "Check" there are 3 leaks and it's clear from the details that they are caused by Store.fetchUserInfo.
3 leaks

And after "Load Avatar" I see 6 memory issues caused by Store.fetchAvatar.
6 leaks

To fix those issues, we have to comment .replaceError(with: ...) (on line 29 and line 45) .

For me, this tastes like a real bug in Combine framework.

Any comments are welcome :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment