@objc() name in Swift

Hi,

Overview

  • I have a file CarComponents.swift in 3 targets (Xcode targets) - App, Unit tests and Extension targets
  • When I ran the extension target scheme and NSKeyedUnarchiver.unarchivedObject is executed an error is thrown

Error:

Error Domain=NSCocoaErrorDomain Code=4864 "*** -[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (MyApp.CarComponents) for key (root) because no class named "MyApp.CarComponents" was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target). If the class was renamed, use setClassName:forClass: to add a class translation mapping to NSKeyedUnarchiver" UserInfo={NSDebugDescription=*** -[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (MyApp.CarComponents) for key (root) because no class named "MyApp.CarComponents" was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target). If the class was renamed, use setClassName:forClass: to add a class translation mapping to NSKeyedUnarchiver}

My understanding

It is looking for MyApp.CarComponents, but is being run from an extension target

(lldb) po CarComponents.self
DemoExtension.CarComponents

Questions

  1. How to resolve this issue?
  2. Or am I missing something bigger?

Code

nonisolated class CarComponentsValueTransformer: ValueTransformer {
....
....
    override public func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { return nil }

        do {
            let CarComponents = try NSKeyedUnarchiver.unarchivedObject(
                ofClass: CarComponents.self,
                from: data as Data
            )
            return CarComponents
        } catch {
            assertionFailure("Failed to transform `Data` to `CarComponents`")
            return nil
        }
    }
}
1 Like

Have you tried @objc(ColorComponents) class ColorComponents ... ?

If you really need to share the same class between multiple targets, you should put it into a framework bundle that's linked into the app, extension, and unit tests. Compiling the class separately into each binary might happen to work for NSCoding as long as each binary always has the same version of the class, and using @objc explicitly to drop the module prefix will paper over the naming differences, but code sharing (especially when you're explicitly moving the data between the different parts of your app) should really be done via a framework.

2 Likes

Thanks @tera, I did try @objc but faced issues when I was running the test cases. The NSKeyedUnarchiver.unarchivedObject failed because multiple CarComponents being available and it found it to be ambiguous.

So I had removed it.

My crude understanding was when @objc was used all 3 targets were calling it CarComponents without the module name differentiation.

Thanks @allevato, pardon my ignorance. I am trying to understand this better.

In my case I had added these files to multiple targets, so would it be better to move all commonly used files to swift package?

Let me summarize, please correct me if I am wrong:

  • If I move the common files to framework / swift package, then each target would have a dependency on that framework / swift package.
  • Now each of those files would all belong to respective targets so extension target wouldn't be looking for App.CarComponents.

I do not fully understand the issue... It's totally fine to share a file in several targets. When you build an app you are building one of those targets, so the file (and what's inside) is included in your app binary, but the other targets are unrelated.

What's relevant is the name of the class that is getting serialised in the archive.

This mini example mocks what ends up being in the archive:

@objc(A) class A: NSObject {}
print(NSStringFromClass(A.self)) // A

@objc class B: NSObject {}
print(NSStringFromClass(B.self)) // AppName.B

class C: NSObject {}
print(NSStringFromClass(C.self)) // AppName.C

Hence I suggested @objc(ColorComponents) class ColorComponents ... to strip out the module name completely.

1 Like

Thanks @tera for clarifying about adding files to the target and @objc

Please bear with me, I could be wrong but I think this is what is happening:

App and Extension targets are straight forward, what ever files get added to the target gets added.
The Unit test target is slightly different
The unit test has the host application as the App
The unit test also has Allow testing Host Application APls checked
So my understanding is that all files added to App target are accessible using the Unit test target

I think there is a mistake with my project setup, I had added a lot of the files to all 3 targets, which doesn't seem correct. Logically App files shouldn't be added to the Unit Test target. Only the extension files need to be added to the Unit test target.

Let me try to clean up my project with the above changes and then come back.

Right, hosted unit tests are loaded into the same address space as the host application, IIRC, so having the same Objective-C class compiled into both with the same @objc name will, at a minimum, warn you about a runtime collision at application startup. I'm less familiar with NSKeyedArchiver's behavior in this situation but it sounds like it's even more strict about this.

This can be avoided by using a framework. Not sure if moving them to a "Swift package" in Xcode will produce a framework for you, but you can manually create a framework target and move the file there, then have the rest of your targets link to it.

1 Like

Yes! Only the test itself should be in the unit test target.

2 Likes

Thanks a lot @tera and @allevato, really helpful

Looks like NSKeyedUnarchiver.unarchivedObject(ofClass: from:) needs to use class that is fully qualified.

#if APP
    private typealias CarValueClass = App.CarComponents
#elseif EXTENSION
    private typealias CarValueClass = Extension.CarComponents
#endif

....
....

try NSKeyedUnarchiver.unarchivedObject(
    ofClass: CarValueClass.self,
    from: data as Data
)
1 Like

It's probably something else, as what's in the archive matches the class name in Obj-C. A mini test:

import Foundation

@objc(CarComponentsA) class CarComponentsA: NSObject, NSSecureCoding {
    static let supportsSecureCoding: Bool = true
    let value: String
    init(value: String) { self.value = value }
    required init?(coder c: NSCoder) {
        self.value = c.decodeObject(of: NSString.self, forKey: "v")! as String
    }
    func encode(with c: NSCoder) { c.encode(value as NSString, forKey: "v") }
}

@objc class CarComponentsB: NSObject, NSSecureCoding {
    static let supportsSecureCoding: Bool = true
    let value: String
    init(value: String) { self.value = value }
    required init?(coder c: NSCoder) {
        self.value = c.decodeObject(of: NSString.self, forKey: "v")! as String
    }
    func encode(with c: NSCoder) { c.encode(value as NSString, forKey: "v") }
}
func test() {
    let archiver = NSKeyedArchiver(requiringSecureCoding: true)
    archiver.outputFormat = .xml
    archiver.encode(CarComponentsA(value: "42"), forKey: "a")
    archiver.encode(CarComponentsB(value: "24"), forKey: "b")
    archiver.finishEncoding()
    let data = archiver.encodedData
    print("archived, data size: \(data.count)")
    let s = String(decoding: data, as: UTF8.self)
    print(s)
  
    let unarchiver = try! NSKeyedUnarchiver(forReadingFrom: data)
    let a = unarchiver.decodeObject(of: CarComponentsA.self, forKey: "a")!
    let b = unarchiver.decodeObject(of: CarComponentsB.self, forKey: "b")!
    unarchiver.finishDecoding()
    
    precondition(a.value == "42")
    precondition(b.value == "24")
}

test()
Output results
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>$archiver</key>
	<string>NSKeyedArchiver</string>
	<key>$objects</key>
	<array>
		<string>$null</string>
		<dict>
			<key>$class</key>
			<dict>
				<key>CF$UID</key>
				<integer>3</integer>
			</dict>
			<key>v</key>
			<dict>
				<key>CF$UID</key>
				<integer>2</integer>
			</dict>
		</dict>
		<string>42</string>
		<dict>
			<key>$classes</key>
			<array>
				<string>CarComponentsA</string>
				<string>NSObject</string>
			</array>
			<key>$classname</key>
			<string>CarComponentsA</string>
		</dict>
		<dict>
			<key>$class</key>
			<dict>
				<key>CF$UID</key>
				<integer>6</integer>
			</dict>
			<key>v</key>
			<dict>
				<key>CF$UID</key>
				<integer>5</integer>
			</dict>
		</dict>
		<string>24</string>
		<dict>
			<key>$classes</key>
			<array>
				<string>Mini.CarComponentsB</string>
				<string>NSObject</string>
			</array>
			<key>$classname</key>
			<string>Mini.CarComponentsB</string>
		</dict>
	</array>
	<key>$top</key>
	<dict>
		<key>a</key>
		<dict>
			<key>CF$UID</key>
			<integer>1</integer>
		</dict>
		<key>b</key>
		<dict>
			<key>CF$UID</key>
			<integer>4</integer>
		</dict>
	</dict>
	<key>$version</key>
	<integer>100000</integer>
</dict>
</plist>

The most interesting fragment of which is:

			<key>$classes</key>
			<array>
				<string>CarComponentsA</string>
				<string>NSObject</string>
			</array>
			<key>$classname</key>
			<string>CarComponentsA</string>
			
			...

			<key>$classes</key>
			<array>
				<string>Mini.CarComponentsB</string>
				<string>NSObject</string>
			</array>
			<key>$classname</key>
			<string>Mini.CarComponentsB</string>

Note how CarComponentsA is not prefixed with the module name while CarComponentsB is (in this case module name is Mini – that's due to the difference in class declarations @objc(CarComponentsA) vs @objc). The test is doing the whole roundtrip to check if unarchiving works (and it does).

Note I've switched the archive format to xml to make it obvious what's inside, with the default binary plist format it works similarly, just the output is more cryptic:

Although it is still visible that CarComponentsB is prefixed with the module name and CarComponentsA is not.

2 Likes

Thanks @tera for the detailed test, very insightful