Home Different ways of storing images in CoreData
Post
Cancel

Different ways of storing images in CoreData

Introduction

A project I’ve been working on consists of a list of items where each item has one or more images. The data model of this app is persisted in CoreData.

So the established wisdom is that there are basically two general solutions to this:

  1. Save the images in the Documents directory under a unique file name and store only the file name in your model class.
  2. Save the images directly in your CoreData model.

The first option works fine, and it is the one I started out with. But it will be an issue when I want add iCloud sync using NSPersistentCloudKitContainer. So I was curious what would be the best solution to option 2.

For this post, I tried a number of options and will now go through them one by one.

Basic setup

First, I created a separate Entity called Photo and added a one-to-many relation from Item to Photo. This is supposed to help CoreData keep things efficient and page in/out the required data so the memory usage does not balloon.

Each of the following scenarios is tested by calling UIImagePickerController in the app and selecting the same 3.7mb jpeg file from Photo Library. This image is then added 50 times in a loop to the selected Item to get an idea how the solution performs.

Displaying the images in the Item detail page uses a LazyHStack in a ScrollView to reduce the hit of loading all images then the detail page is opened. Measurements are done with the entire scroll view populated and displayed.

Option 1: Binary property + external storage + UIImage.jpegData

In this case I added a property named imageData of type Binary Data to the Photo entity and made sure to enable Allows External Storage in the attribute inspector.

Then in an extension of Photo I added the following code to map between UIImage and Data:

Photo+Extension.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
extension Photo {
    var wrappedImage: UIImage? {
        get {
            guard let imageData = imageData else {
                print("Error: Photo has invalid imageData!")
                return nil
            }

            guard let image = UIImage(data: imageData) else {
                print("Error: Photo could not convert to UIImage")
                return nil
            }

            return image
        }
        set {
            guard let jpegData = newValue?.jpegData(compressionQuality: 1) else {
                print("Error: Could not convert UIImage to data")
                return
            }

            imageData = jpegData
        }
    }
}

Results

Resource Measurement
Time to save 50 images 9 sec
Disk space used 337 mb
App ram usage when all images are loaded 496 mb

Overall this works pretty well. Saving is not very quick but stil acceptable. Performance of the app is fine. I wonder however if this will still work properly when I want to add iCloud syncing. It seems there might be some issues? So let’s store everything in CoreData itself in the next option.

Option 2: Transformable property using NSKeyedArchiver

In this case I added a property named imageDataTransformed of type Transformable to the Photo entity. It is configured using UIImageTransformer as the Transformer and UIImage as Custom Class. This is an idea I borrowed from Stewart Lynch (check out his excellent video series here).

UIImageTransformer.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class UIImageTransformer: ValueTransformer {
    static let name = NSValueTransformerName(rawValue: "UIImageTransformer")
    class UIImageTransformer: ValueTransformer {
        
        override func transformedValue(_ value: Any?) -> Any? {
            guard let image = value as? UIImage else { return nil }
            do {
                let data = try NSKeyedArchiver.archivedData(withRootObject: image, requiringSecureCoding: true)
                return data
            } catch {
                return nil
            }
        }
        
        override func reverseTransformedValue(_ value: Any?) -> Any? {
            guard let data = value as? Data else { return nil }
            do {
                let image = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)
                return image
            } catch {
                return nil
            }
        }
    }
    
    static func register() {
        ValueTransformer.setValueTransformer(UIImageTransformer(), forName: name)
    }
}

Make sure to hook this transformer up when setting up your persistent container:

1
2
3
        UIImageTransformer.register()
        container = NSPersistentContainer(name: "MyModel")
        ...etc...

For this solution to work you need to disable Code Generation on the Photo entity and generate the class defintion files from the CoreData editor. This way you can add #import UIKit to fix the warning about UIImage not being known.

Since the Photo entity now has a direct UIImage property called imageDataTransformed, there is no need for a property wrapper (except if you want to handle the nil case). Still, here it is:

Photo+Extension.swift

1
2
3
4
5
6
7
8
9
10
extension Photo {
    var wrappedImageDataTransformed: UIImage? {
        get {
            return imageDataTransformed ?? UIImage(systemName: "photo")!
        }
        set {
            imageDataTransformed = newValue
        }
    }
}

Results

Resource Measurement
Time to save 50 images 34 sec
Disk space used 854 mb
App ram usage when all images are loaded 900 mb

So this solution is very slow and uses a lot of disk space and RAM. This is not a criticism on Stewart’s code by the way, maybe I did something wrong 🤓.

Option 3: Transformable property using UIImage.jpegData

Again, in this case I added a property named imageDataTransformed of type Transformable to the Photo entity. It is configured using UIImageTransformer as the Transformer and UIImage as Custom Class.

But let’s see if we can skip NSKeyedArchiver and use the simple UIImage.jpegData method from option 1.

UIImageTransformer.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class UIImageTransformer: ValueTransformer {
    static let name = NSValueTransformerName(rawValue: "UIImageTransformer")
    class UIImageTransformer: ValueTransformer {
        
        override func transformedValue(_ value: Any?) -> Any? {
            guard let image = value as? UIImage else { return nil }

            return image.jpegData(compressionQuality: 1.0)
        }
        
        override func reverseTransformedValue(_ value: Any?) -> Any? {
            guard let data = value as? Data else { return nil }

            return UIImage(data: data)
        }
    }
    
    static func register() {
        ValueTransformer.setValueTransformer(UIImageTransformer(), forName: name)
    }
}

I will not repeat the remarks about code generation and so on from option 2 here, they still apply of course.

Results

Resource Measurement
Time to save 50 images 12 sec
Disk space used 339 mb
App ram usage when all images are loaded 384 mb

Wow! This is a huge improvement over option 2, just by not using NSKeyedArchiver we have massively increased performance, disk space and ram requirements. 🎉

So far this solution seems like a winner. But it also seems a bit wasteful to get a jpeg file from Photo Library and to re-encode it using UIImage.jpegData. Some quality will be lost. So let’s try if using UIImage.pngData is a better option.

Option 4: Transformable property using UIImage.pngData

Again, in this case I added a property named imageDataTransformed of type Transformable to the Photo entity. It is configured using UIImageTransformer as the Transformer and UIImage as Custom Class.

The only difference here is that we store that data as pngData instead of jpegData. Here is the transformer code:

UIImageTransformer.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class UIImageTransformer: ValueTransformer {
    static let name = NSValueTransformerName(rawValue: "UIImageTransformer")
    class UIImageTransformer: ValueTransformer {
        
        override func transformedValue(_ value: Any?) -> Any? {
            guard let image = value as? UIImage else { return nil }

            return image.pngData(compressionQuality: 1.0)
        }
        
        override func reverseTransformedValue(_ value: Any?) -> Any? {
            guard let data = value as? Data else { return nil }

            return UIImage(data: data)
        }
    }
    
    static func register() {
        ValueTransformer.setValueTransformer(UIImageTransformer(), forName: name)
    }
}

Again, I will not repeat the remarks about code generation and so on from option 2 here, they still apply of course.

Results

Resource Measurement
Time to save 50 images 33 sec
Disk space used 852 mb
App ram usage when all images are loaded 920 mb

This was to be expected of course. Our little 3.7mb jpeg file ballooned into a 17mb png file and it is stored 50 times. Performance is really bad overall so let’s not go with this option! 😱

Still the thing about re-encoding the jpeg data kept on bothering me. So why not try to get the unmodified jpeg data directly from the Photo library.

Option 5: Binary property using original data from Photos library

In this case I added a property named imageDataOriginal of type Binary Data to the Photo entity. It is not configured with external storage so it is roughly equivalent to the solution from option 1 (but without the external storage part).

There is no need for an extension to Photo since we will be reading/writing the optional imageDataOriginal property of Photo directly. In my code I was using a UIImagePickerController wrapped in UIViewControllerRepresentable. Yes, I know I should switch to PhotoPicker but that is for a later time 🤓.

Here is the code, I hacked in this new solution in didFinishPickingMediaWithInfo so it’s not 100% production ready.

*ImagePicker.swift`

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import Foundation
import SwiftUI
import PhotosUI

/// Wraps UIImagePickerController
struct ImagePickerView: UIViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    @Binding var image: UIImage?
    @Binding var succeeded: Bool
    @Binding var originalImageData: Data?

    var sourceType: UIImagePickerController.SourceType

    // swiftlint:disable:next line_length
    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePickerView>) -> UIImagePickerController {
        let imagePicker = UIImagePickerController()
        imagePicker.delegate = context.coordinator
        imagePicker.sourceType = sourceType
        return imagePicker
    }

    // swiftlint:disable:next line_length
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePickerView>) {

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
        let parent: ImagePickerView

        init(_ parent: ImagePickerView) {
            self.parent = parent
        }

        // swiftlint:disable:next line_length
        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
            if let selectedImage = info[.originalImage] as? UIImage {
                parent.image = selectedImage
            }

            if let assetURL = info[.referenceURL] as? URL,
               let asset = PHAsset.fetchAssets(withALAssetURLs: [assetURL], options: nil).firstObject {

                PHImageManager.default().requestImageData(for: asset, options: nil, resultHandler: { (data, UTI, _, info) in
                    self.parent.originalImageData = data
                    self.parent.succeeded = true
                    self.parent.presentationMode.wrappedValue.dismiss()
                })
            } else {
                self.parent.succeeded = true
                parent.presentationMode.wrappedValue.dismiss()
            }

            func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
                parent.succeeded = false
                parent.presentationMode.wrappedValue.dismiss()
            }
        }
    }
}

So in my View code I have the following code to save:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if viewModel.imagePickerSucceeded {
    for count in 0...50 {

        let photo = Photo(context: context)
        photo.timestamp = Date()
        photo.id = UUID()
//        photo.wrappedImage = viewModel.newImage ?? nil
//        photo.wrappedImageTransformed = viewModel.newImage ?? nil
        photo.originalData = viewModel.originalImageData

        beer.addToPhotos(photo)
    }
    context.saveIfChanged()
}

This code is pretty straightforward and writes the Data from ImagePicker directly into the correct property. And it works pretty well for existing images.

⚠️ Downside 1: It does not work if you take a picture directly with the camera, this is something I would need to debug later.

⚠️ Downside 2: you need to start using NSPhotoLibraryUsageDescription in your Info.plist because this will directly access your Photo library. This alone makes this solution not very realistic.

⚠️ Downside 3: this stores whatever data comes from Photo library directly. I’ve tested with jpeg and heic files and that works fine, not sure how it would handle things like live photos or panorama etc. Use with caution!

Finally, the Image is retrieved like this (again, the code is a bit experimental so never mind the details please):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ScrollView(.horizontal) {
    LazyHStack {
        ForEach(beer.photoArray, id: \.self) { photo in
            if let originalData = photo.originalData,
                let wrappedImage = UIImage(data: originalData) {

                Image(uiImage: wrappedImage.resized(to: CGSize(width: 50, height: 50)))
                    .resizable()
                    .scaledToFill()
                    .frame(width: 50, height: 50)
                    .clipped()
                    .border(.green)
            } else {
                Image(systemName: "photo")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 50, height: 50)
                    .clipped()
                    .border(.red)
            }
        }
    }
}

Results

Resource Measurement
Time to save 50 images 2 sec
Disk space used 187 mb
App ram usage when all images are loaded 74 mb

So if it were not for all the downsides mentioned above, this is by far the quickest and most efficient option when saving. The actual loading of the detail view feels a bit slow but than can probably be solved. In any case, I would not recommend it before all the bugs mentioned above are worked out. 🪲

Conclusion

So in the end we have 5 options and I will summarize them first:

Solution Time to save 50 images Disk space used App ram usage
1: Binary property + external storage + UIImage.jpegData 9 sec 337 mb 496 mb
2: Transformable property using NSKeyedArchiver 34 sec 854 mb 900 mb
3: Transformable property using UIImage.jpegData 12 sec 339 mb 384 mb
4: Transformable property using UIImage.pngData 33 sec 852 mb 920 mb
5: Binary property using original data from Photos library 2 sec 187 mb 74 mb

In the end I will recommend solution 3 for now because of the following reasons:

  1. It is reasonably performant in all categories.
  2. It guarantees that you know what kind of data is stored in the CoreData property, it’s always jpeg.
  3. It does not require asking users for access to their Photo library.
  4. This will work well when migrating to iCloud sync using NSPersistentCloudKitContainer.

The only real downsides are:

  1. It’s less performant than getting the original image data directly from Photos library (option 5).
  2. You lose a bit of quality in the jpeg image when re-encoding.

Overall, these downsides are worth it for me. Feel free to get in contact with me if you have remarks/fixes or an even better solution!

This post is licensed under CC BY 4.0 by the author.