Skip to content

Instantly share code, notes, and snippets.

@MrRooni
Last active March 12, 2023 14:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MrRooni/a10f949a976e26f07a4eacd4b8509111 to your computer and use it in GitHub Desktop.
Save MrRooni/a10f949a976e26f07a4eacd4b8509111 to your computer and use it in GitHub Desktop.

I'm experiencing a memory usage-based termination in my SwiftUI Apple Watch app. I've traced the problem down to a struct I've created called ImageCycler.

ImageCycler is a view that cycles through an array of images, displaying each one for 1.5 seconds before transitioning to the next. When it reaches the end, it starts back at the beginning. It's powered by the ImageCylerModel. This is how you use it:

struct ImageCycler_Previews: PreviewProvider {
    static var previews: some View {
        ImageCycler(["Image1", "Image2", "Image3", "Image4"])
    }
}

When using Image and giving it an image name directly, the memory usage of this view grows unbounded until the app is terminated by the OS. Here's what that implementation looks like:

import SwiftUI

struct ImageCycler: View {
    @StateObject var viewModel: ImageCyclerModel

    init(_ imageNames: [String]) {
        _viewModel = StateObject(wrappedValue: ImageCyclerModel(imageNames: imageNames))
    }

    var body: some View {
        Image(viewModel.currentImageName) // This image name chages every 1.5 seconds
            .resizable()
            .aspectRatio(contentMode: .fit)
            .animation(.default, value: self.viewModel.currentImageName)
            .onAppear {
                self.viewModel.cycleImages()
            }
    }
}

And here's the memory usage captured just prior to the app being killed:

UnboundedMemory

Thanks to user pd95 on the Hacking With Swift forums, I found that if we switch the Image initialization to use UIImage, things get much better. Here's what that implementation looks like:

import SwiftUI

struct ImageCycler: View {
    @StateObject var viewModel: ImageCyclerModel

    init(_ imageNames: [String]) {
        _viewModel = StateObject(wrappedValue: ImageCyclerModel(imageNames: imageNames))
    }

    var body: some View {
        Image(uiImage: UIImage(named: viewModel.currentImageName) ?? UIImage.remove /* Just some dummy image that's non-optional */)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .animation(.default, value: self.viewModel.currentImageName)
            .onAppear {
                self.viewModel.cycleImages()
            }
    }
}

And here's the memory usage captured with this implementation. It's worth noting that the app still gets killed eventually, but these images are not optimized for the Watch. Currently they are ~3000x3000 pixel PNGs.

CollectedMemory

For completeness, here's the implementation of ImageCyclerModel:

import Foundation

class ImageCyclerModel: ObservableObject {
    let imageNames: [String]
    @Published var currentImageName: String
    private var index = 0
    private var timer: Timer?

    init(imageNames: [String]) {
        self.imageNames = imageNames
        currentImageName = imageNames.first ?? ""
    }

    func cycleImages() {
        if let timer {
            timer.invalidate()
        }

        timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(1.5), repeats: true, block: { _ in
            self.index += 1
            DispatchQueue.main.async { [weak self] in
                guard let self else {
                    return
                }

                self.currentImageName = self.imageNames[self.index % self.imageNames.count]
            }
        })
    }

    deinit {
        if let timer {
            timer.invalidate()
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment