Search code examples
jsonswiftuiimagejsondecoderequatable

UIImage not equivalent when encoding/decoding


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.


Solution

  • 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