6

I've been doing some tests on my models to make sure they are equal when I encode them into JSON and then decode them back using JSONEncoder/Decoder. However, one of my tests failed, and the culprit was UIImage. I've made sure that no errors were thrown during the encoding/decoding process.

First of all, this is the test in question:

func testProfileImageCodable() throws {
    let image = ProfileImage(UIImage(systemName: "applelogo")!)
    try XCTAssertTrue(assertCodable(image))
}

Here's my "Codability" test, where I make sure that types are equal before and after encoding/decoding:

func assertCodable<T: Codable & Equatable>(
    _ value: T,
    decoder: JSONDecoder = .init(),
    encoder: JSONEncoder = .init()
) throws -> Bool {
    let encoded = try encoder.encode(value)
    let decoded = try decoder.decode(T.self, from: encoded)
    
    return value == decoded
}

Firstly, here's how I made UIImage work with Codable:

extension KeyedEncodingContainer {
    mutating func encode(_ value: UIImage, forKey key: Key) throws {
        guard let data = value.pngData() else {
            throw EncodingError.invalidValue(
                value,
                EncodingError.Context(codingPath: [key],
                debugDescription: "Failed convert UIImage to data")
            )
        }
        try encode(data, forKey: key)
    }
}

extension KeyedDecodingContainer {
    func decode(_ type: UIImage.Type, forKey key: Key) throws -> UIImage {
        let imageData = try decode(Data.self, forKey: key)
        if let image = UIImage(data: imageData) {
            return image
        } else {
            throw DecodingError.dataCorrupted(
                DecodingError.Context(codingPath: [key],
                debugDescription: "Failed load UIImage from decoded data")
            )
        }
    }
}

The UIImage lives in a ProfileImage type, so conforming it to Codable looks like this:

extension ProfileImage: Codable {
    enum CodingKeys: CodingKey {
        case image
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.image = try container.decode(UIImage.self, forKey: .image)
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.image, forKey: .image)
    }
}

Furthermore, ProfileImage's Equatable conformance uses isEqual(_:) on the UIImage property, which they say is "the only reliable way to determine whether two images contain the same image data."

Yet, my test still fails, and I'm not sure why. Any help would be greatly appreciated.

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
PrayForTech
  • 353
  • 2
  • 10

1 Answers1

7

the only reliable way to determine whether two images contain the same image data

They are wrong about that. That piece of the docs has misled me in the past too!

The way to compare two images for equality of content (the underlying bitmap) is to compare their pngData.


What's wrong with your code, however, at the deepest level, is that a UIImage has scale information which you are throwing away. For example, your original image's scale is probably 2 or 3. But when you call image(data:) on decoding, you fail to take that into account. If you did take it into account, your assertion would work as you expect.

I tweaked your code like this (there might be a better way, I just wanted to prove that scale was the issue):

struct Image: Codable {
    let image:UIImage
    init(image:UIImage) {
        self.image = image
    }
    enum CodingKeys: CodingKey {
        case image
        case scale
    }
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let scale = try container.decode(CGFloat.self, forKey: .scale)
        let image = try container.decode(UIImage.self, forKey: .image)
        self.image = UIImage(data:image.pngData()!, scale:scale)!
    }
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.image, forKey: .image)
        try container.encode(self.image.scale, forKey: .scale)
    }
}

Here's my test:

let im = UIImage(systemName:"applelogo")!
let encoded = try! JSONEncoder().encode(Image(image:im))
let decoded = try! JSONDecoder().decode(Image.self, from: encoded)
assert(im.pngData()! == decoded.image.pngData()!)
print("ok") // yep
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • I see. Yet, it still doesn't work! I changed my test to this: `func testProfileImageCodable() throws { let testImage = UIImage(systemName: "applelogo")! let encoded = try JSONEncoder().encode(ProfileImage(testImage)) let decoded = try JSONDecoder().decode(ProfileImage.self, from: encoded) XCTAssertTrue(decoded.image.pngData() == testImage.pngData()) } ` and it still fails. Maybe it's a problem with how I implemented UIImage's encoding/decoding? (sorry for the formatting, not being to add full code to SO comments is a pain) – PrayForTech Jun 26 '21 at 11:02
  • Here's a [gist](https://gist.github.com/nikitamounier/299e6083d2f0554b48173b2361bef4ad) with the relevant code if that's easier for you to read. – PrayForTech Jun 26 '21 at 11:08
  • 1
    Yeah, I see what's wrong, you've thrown away the `scale` information. – matt Jun 27 '21 at 02:12
  • I see, great catch! The `scale` information is actually something I most likely don't want to keep, since the UIImage will be displayed multiple times in different sizes throughout the app and in different screen sizes (so I'll let SwiftUI decide at what scale the image should be shown). However, what I will do is create a wrapper struct just for the testing where I will keep the scale information, so that my tests can pass. Thanks a lot! – PrayForTech Jun 27 '21 at 20:48
  • Oh, rewrite the tests to mask the bug? Good idea. Not. :) – matt Jun 27 '21 at 21:30
  • I edited to get rid of the extra property. But to get rid of the scale information entirely would be wrong; they will not be same image and your tests must not pretend they are. – matt Jun 27 '21 at 21:36
  • Doesn't the scale default to 1.0 though? They won't be images loaded into the Assets folder, they'll be coming straight from the user's photo library. – PrayForTech Jun 28 '21 at 22:40
  • But that's not what your test tests. You started with `UIImage(systemName:"applelogo")!`. That UIImage does not have scale 1.0. – matt Jun 28 '21 at 23:17
  • Moreover, I do not know for a fact that a UIImage obtained from the user's photo library has scale 1.0. Do you? – matt Jun 28 '21 at 23:17
  • 1
    Yes you're right, the `UIImage(systemName: "applelogo")!` is just a placeholder, I'm going to be bundling a mock image with my test package. The [documentation](https://developer.apple.com/documentation/uikit/uiimage/1624110-scale) says that "If you load an image from a file whose name includes the @2x modifier, the scale is set to 2.0. You can also specify an explicit scale factor when initializing an image from a Core Graphics image. All other images are assumed to have a scale factor of 1.0." – PrayForTech Jun 29 '21 at 10:44
  • 1
    I therefore assume that a UIImage from a user's photo library has scale 1.0 – but maybe it differs between devices, just like in the Asset.xcassets. For completeness' sake, I think you're right – I'll be encoding/decoding the scale property too. – PrayForTech Jun 29 '21 at 10:46
  • Again, do not adjust the test to how you think your code will or will not be used. Either your code can round trip an image — _any_ image — through coding / decoding or it cannot. Otherwise your use of testing is invalid. – matt Jun 29 '21 at 19:37
  • Am I missing something? The problem is that UIImage requires to conform to Codable, right? But in this line `try container.decode(UIImage.self, forKey: .image)` you are passing it to decoder as-is... So how do you solve the Codable issue? – Sirop4ik Jan 11 '22 at 13:38
  • Should not it be like this - `let image: Data = try container.decode(Data.self, forKey: .image)` ? – Sirop4ik Jan 11 '22 at 13:44
  • The answer doesn't compile, whats going on? Can you please update? @matt – erotsppa Jul 05 '23 at 01:18
  • @erotsppa Compiles fine for me, what's the issue for you? – matt Jul 05 '23 at 01:23
  • let image = try container.decode(UIImage.self, forKey: .image), UIImage does not conform to codable – erotsppa Jul 05 '23 at 01:27
  • @erotsppa Because you forgot to include the OP's code. This question is about the OP's code. My answer is merely an addition to it. The OP's code is what makes UIImage conform to Codable. My answer merely makes the OP's round-tripping test succeed. – matt Jul 05 '23 at 01:36
  • I see now that @Sirop4ik made the same mistake. It really helps to _read_ the question and the answer. – matt Jul 05 '23 at 01:38