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:
- Save the images in the
Documents
directory under a unique file name and store only the file name in your model class. - 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:
- It is reasonably performant in all categories.
- It guarantees that you know what kind of data is stored in the CoreData property, it’s always jpeg.
- It does not require asking users for access to their Photo library.
- This will work well when migrating to iCloud sync using
NSPersistentCloudKitContainer
.
The only real downsides are:
- It’s less performant than getting the original image data directly from Photos library (option 5).
- 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!