Lessons from writing Swift framework that works in Objective-C app

As mentioned in my previous post on Fix for Google Analytics pod not working in Swift framework a few months ago, I was helping to refactor a client’s outsourced mobile SDKs. Last month, one of their clients had issues using the iOS SDK in their Objective-C app. Turns out that the Swift framework, i.e. the iOS SDK, was written only for Swift apps, which kinda explained why it came only with a Swift demo app (had to write an Objective-C demo app later + add Objective-C code usage in the docs).

Thought it would be as simple as generating the Objective-C interface header and adding the @objc attribute to make the Swift API available in Objective-C and the Objective-C runtime. It was almost a rewrite and the public API went thru such drastic changes that the major version had to be incremented to indicate backwards incompatibility (see Semantic Versioning). Hence this blog post to share the lessons that I learnt while fixing the Swift framework to work in Objective-C apps.

1) Reduced inference for @objc in Swift 4

Thanks to this Swift evolution proposal, it is no longer sufficient to just add the @objc attribute to the class. It has to be explicitly added to all the properties and methods that need to be exposed to Objective-C.

And oh, btw, all Swift classes exposed to Objective-C need to extend the NSObject root class as well.

// before Swift 4 - name and getName would be inferred and exposed as well
@objc public class Person {
    public var name: String?

    public func getName() {
        return self.name
    }
}

// after Swift 4 - name and getName need to be tagged in order to be exposed
// and note the NSObject root class
@objc public class Person: NSObject {
    @objc public var name: String?

    @objc public func getName() {
        return self.name
    }
}

2) Swift structs not supported in Objective-C

Can’t seem to find a definitive article by Apple clearly stating incompatible features – found some StackOverflow posts quoting from Apple documentation now and then but the links point to non-existent docs. Anyway, found a mailing list message that says “Swift’s Struct type is one of the most praised features of the language, but is currently unavailable in Objective-C”.

Had to convert all the structs in the Swift framework, at least those in the public API, to classes. I went an extra step and declared all the properties with var (previously all were using let) and gave them default values – this was to skip writing init() for each class, which is a lot of code considering the number of structs that had to be converted.

// This will not work - the @objc is not all-powerful :P
@objc public struct Person: Codable {
    public let name: String?
}

// struct converted to class - note the NSObject class again
@objc public class Person: NSObject, Codable {
    @objc public var name: String? = nil
}

3) Swift String enums not supposed in Objective-C

This reply in a StackOverflow post quotes the Xcode 6.3 release notes to explain this: “Swift enums can now be exported to Objective-C using the @objc attribute. @objc enums must declare an integer raw type”.

And my solution? You guessed it – converted all the enums, in particular those used in the public API, to classes 😛 The enumeration definitions were changed to properties and declared as public, static and using let.

// Adding @objc will give the error
// "@objc enum raw type String is not an integer type"
public enum Region: String {
    case NORTH = "north"
    case SOUTH = "south"
    case EAST = "east"
    case WEST = "west"
}

// enum converted to class - note the NSObject class
@objc public class Region: NSObject {
    @objc public static let NORTH = "north"
    @objc public static let SOUTH = "south"
    @objc public static let EAST = "east"
    @objc public static let WEST = "west"
}

4) Int? cannot be exposed to Objective-C but all other Optionals can

This, as briefly covered in point 2 of my previous post on Designing Developer-Friendly JSON for API responses, was the biggest headache. The iOS SDK basically talks to my client’s APIs, parses the JSON, and makes the data available to the consumer via simple methods and models. Problem was that the JSON response would sometimes omit properties or set the values to null, and this causes issues when decoding them into an object. And no, I could not change the API.

In the code below, all Optionals can be exposed to Objective-C, except Int? (someone asked about it in this mailing thread). If name, address or hobbies is omitted from the sample JSON or has a value of null, the decoded Person object would just have a value of nil for that property.

@objc public class Person: NSObject, Codable {
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
    @objc public var hobbies: [String]? = nil
    @objc public var age: Int? = nil // not allowed
}

/* // Sample JSON - headache caused by omitting age or setting null
{
    "id": 3,
    "name": "Bob",
    "address": {
        "street": "1 Alpha Ave",
        "zip": 123456
    },
    "hobbies": ["cycling", "swimming"],
    "age": null
}
*/

A simple solution would be to declare age as Int and set a default value of 0. That is if the age property is always present in the JSON. If it is sometimes present and sometimes omitted, special handling would need to be done in the class so that let person = try JSONDecoder().decode(Person.self, from: jsonString.data(using: .utf8)) will not crash. A sample is shown below for the scenario that the children property in the JSON is sometimes omitted – the init() could have been avoided.

@objc public class Person: NSObject, Codable {
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
    @objc public var children: Int = 0

    // This entire method could have been omitted if children is not omitted
    required public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        id = try values.decode(Int.self, forKey: .id)
        name = try values.decode(String?.self, forKey: .name)
        address = try values.decodeIfPresent(Address.self, forKey: .address)

        do {
            children = try values.decode(Int.self, forKey: .children)
        } catch _ {
            children = 0
        }
    }
}

5) Special handling of properties named description

Some of the classes had a property named description. Problem arose cos classes exposed to Objective-C had to extend NSObject and description is a computed property in NSObject. Thus, special handling had to be done, as shown below.

@objc public class Person: NSObject, Codable {
    @objc public var name: String? = nil

    // Special handling of description property as it is a computed property 
    // in NSObject. Also, no need for @objc prefix.
    override public var description: String {
        get { return (self._description ?? "") }
        set { self._description = newValue }
    }
    private var _description: String? = nil
}

6) Classes cannot be named Category

Had a class that was named Category. An error popped up when trying to add the @objc attribute – “Redefinition of ‘Category’ as different kind of symbol”. This is a naming conflict due to the declaration typedef struct objc_category *Category;, which refers to the Objective-C Category language feature.

Kept the class name as Category in Swift so as to minimise code change, but gave another name, e.g. MyCategory, when it is used in Objective-C.

@objc(MyCategory)
public class Category: Model {
    @objc public var name: String? = nil
}

7) JSONDecoder.decode() does not work with extended classes

Found out thru trial and error that classes used with JSONDecoder.decode(), typically those modelling entities and JSON responses, have to directly extend NSObject and implement Codable.

These classes cannot extend a base class, e.g. class Employee: Person where class Person: NSObject, Codable, else JSONDecoder.decode() will not work and all the properties will be set to their default values, e.g. nil.

8) Swift generics not supported in Objective-C

This was another headache as all the methods for accessing the API in the SDK used Swift generics for the completion handlers, i.e. the callbacks. Had to change all the method signatures which constituted a big break in backwards compatibility, hence the increment of the major version.

/**
 * Using Swift generics
 * Definition:
 *   public func getPerson(
 *       id: Int, 
 *       completionHandler: @escaping (MyResponse<PersonResponse>) -> Void
 *   )
 */
MyApi.getPerson(id: 42, completionHandler: { (response) in
    // do something
})

/**
 * No more Swift generics
 * Definition:
 *   public func getPerson(
 *       id: Int, 
 *       completionHandler: @escaping (Person?, MyError?) -> Void
 *   )
 */
MyApi.getPerson(id: 42, completionHandler: { (person, error) in
    // do something
})

Conclusion

I’m sure there are probably other caveats lurking in the dark, but these are the things I’ve uncovered so far. One important lesson that I’ve gained from this is that when writing an SDK or even an app, make sure to find out more about your consumers first instead of rushing head in and rushing to fix language/version compatibility issues later. Hope this post will come in useful for some of you out there – ad huc!