Swift Engineering Tips

Swift Engineering Tips

2020, Mar 10    

Currently in the open source community, the most common way of importing a dependency is to download the source code(either via Cocoapod or Carthage) and then build the source code in the workspace of the target app/framework. Such method works well when the project using free and open sourced components, but it simply conflicts with the business model when developers need to deliver a framework under a priced contract that the service vendor need to hide the technical details of the SDKs. Compared to the previous way of building a iOS framework binary with ObjC, for a long time, it was almost impractical to build a framework binary using or partially using Swift due the following reasons:

  • The compatibility issue: for binaries compiled in a fixed swift version, the .swiftmodule declaration is not compatible with consumer framework/app written in other swift versions.
  • For adding incremental features for an existing ObjC framework via Swift and enable the ObjC source code to access the Swift functions & classes, only a subset of Swift’s languages feature can be used. But there is not much guidance for that. This becomes a problem for big tech companies when there are bunch of ObjC frameworks/ apps released under production environment for long but when teams starts to gradually moving to Swift tech stacks.

In my recent projects, I had made some trials on building iOS framework binaries with Swift as primary language. For the reason why I decided to make such attempt is due to the improvement of module stability from Xcode 10 and Swift 5.0 onwards. The auto-generated .swiftinterface contract ensures once a component has been build into binary and the binary is further released, the binary can be further imported by Apps or frameworks written in all the furture Swift versions.

system-architecture-drafts

In this article, I will talk about my past month’s experience in the above two points:

  • Building a framework mixed with ObjC and Swift with furture language version compatibility.
  • Best practises to expose Swift classs & functions so the ObjC side can fully access.

SwiftModule Configuration

Since Xcode 10.2 and Swift 4.2 onwards, the IDE introduced a new build phase setting named as: BUILD_LIBRARY_FOR_DISTRIBUTION. The default value for the item in static/dynamic lib targets are No. Once it is turned on, there will be an auto generated .swiftinterface file for every architecture type the target builds. In the file, it declares the interfaces which can be read by multiple versions of Swift compilers.

// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.2 (swiftlang-1103.0.30 clang-1103.0.30)
// swift-module-flags: -target armv7-apple-ios9.0 -enable-objc-interop -enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -O -module-name XXXXContext
import Foundation
@_exported import XXXXContext
import Swift
import UIKit
@objc public enum XXXXBizModuleType : Swift.Int {
  case native = 0
  case web = 1
  public typealias RawValue = Swift.Int
  public init?(rawValue: Swift.Int)
  public var rawValue: Swift.Int {
    get
  }
}
@objc @objcMembers public class XXXXBizModule : ObjectiveC.NSObject, XXXXContext.XXXXLaunchable {
  @objc public var id: Swift.String {
    get
  }
  @objc public var name: Swift.String {
    get
  }
}

Note: In the interface file, declarations like inheritance and method parameter type are all in the form of ${module-namespace}.${type-name}. Giving a type with a same name as the Swift module will cause compilation issue, this is due to the compiler will not be able to tell the difference on whether the name refers to the exact type or the package module.

Once the ./swiftinterface file is properly generated in the framework bundle, the swift-built framework can be further integrated with Xcode at different versions.

ObjC Bridging Best Practise

Currently in order to access Swift from ObjC code, for each of the build targets, a ObjC compatibility header can be automatically generated to provide the ObjC code access the Swift code in the same build target. Here are the tips I have summarized so far to ensure a maximised Swift to ObjC interoperability.

  • Prefer @objcMembers instead of @objc when exposing the whole Swift class to ObjC. Declaring @objcMembers before class declaration will give the compiler a hint to create a declaration for the class and the whole members under this class. For example, the source code below with @objcMembers declaration equals to adding @objc for every single declarations for the class and the class members.
@objcMembers final public class XXXXXXNotification: NSObject {
    /// Notificaton type
    public var type: XXXXXXNotificationType
    /// The info carried by the notification
    public var xxxxInfo: XXXXXXXInfo?
}

is equivalent to:

@objc final public class XXXXXXNotification: NSObject {
    /// Notification type
    @objc public var type: XXXXXXNotificationType
    /// The info carried by the notification
    @objc public var xxxxInfo: XXXXXXXInfo?
}
  • Avoid using the following swift specific language features when exposing Swift to ObjC:
    • Struct is not supported in ObjC, so you can not add the @objc/@objcMember annotation. Since struct is a very common feature in Swift, you need a careful thought once you decide to expose a Swift type to ObjC, you shall not declare it as struct. (Because in Swift, a lot of common language features are currently implemented in Struct, like Error, Result, etc)
    • For declaring a swift enumeration compatible to ObjC source code, value-associating and all the raw type except Int shall be avoided.
    • For declaring a swift class which can be accessed by ObjC, the swift class needs to be directly or transitively inherited from NSObject
    • ObjC class can not inherit from Swift super class. Unfortunately this is not a clear message deliverred by apple, unless you carefully read through apple’s official ObjC to Swift migration guide.
    • Conversion between Swift collection types to ObjC collection types are supported. Where: NSString <-> String, NSNumber <-> Int,Float,Double, "<null>" <-> nil, NSArray <-> Array, id <-> any. It is recommneded to conduct a nil check if you dont want to pass those nil values to NSDictionary and eventually get a "<null>" from it.

Limitations: the known limitation is that currently the ObjC compatibility header of a framework does not work when the framework is imported as a dependency by other projects. (refer to the ongoing topic). The reason is that under release build configuration, the -Swift.h bridge header will contain arch specific marcos to make the interface only valid for specific machros.

#if 0
#elif defined(__arm64__) && __arm64__
// Generated by Apple Swift version 5.2.4 (swiftlang-1103.0.32.9 clang-1103.0.32.53)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wgcc-compat"

It is found that only the debug mode simulator package’s bridge header does not contain this limitation.

// Generated by Apple Swift version 5.2.4 (swiftlang-1103.0.32.9 clang-1103.0.32.53)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wgcc-compat"

Hence the workaround to make a Swift framework directly importable by a ObjC framework is to build the binary in release mode, but release the -Swift.h with the one generated under debug build mode.

References:

TOC