WWDC Quick Look 💓 By SwiftGGTeam
Refine Objective-C frameworks for Swift

Refine Objective-C frameworks for Swift

Watch original video

Highlight

This session demonstrates how to use Objective-C header file annotations, named macros, and a few Swift wrappers to change the framework that is full of implicit unwrapped optional values, Any collections, error throwing ambiguities, and cumbersome naming after importing Swift into an API with more specific types and call points that are more in line with Swift habits.

Core Content

Many teams have not rewritten the Objective-C framework into Swift at once. The old code continues to exist, but new businesses are increasingly using Swift to call these APIs. The Swift compiler will automatically import the Objective-C header and alsoNSStringNSDate, initializer, and error handling methods are rewritten into interfaces closer to Swift. The problem is that automatic conversion only knows the header The fact that it was written, I don’t know the intention of the framework maintainer not to write it out (00:25, 02:50).

SpaceKit in the speech is a typical example. It is a model framework written in Objective-C. The generated interface seen on the Swift side has a large number of implicitly unwrapped optional and collection types.Any, a certain save method will be treated as throw by Swift when “there are no changes that need to be saved”, and the initializer inheritance relationship will also confuse subclass authors. The root cause is insufficient header information; Swift can only import Objective-C according to the declared facts (01:33, 03:16).

The processing sequence given by Apple is very pragmatic: first open the Swift generated interface in Xcode 12 to see what the Swift client will actually see; then go back to the Objective-C header and use nullability, lightweight generics,NS_STRING_ENUMNS_DESIGNATED_INITIALIZERNS_SWIFT_NAMENS_REFINED_FOR_SWIFTandNS_ERROR_ENUMFix import results; finally check the API from the call point, instead of just looking at whether the interface list is pretty (02:01, 40:43).

Detailed Content

Use nullability to eliminate implicitly unwrapped optional values

(04:43) Objective-C’s pointer type may naturally benil, if Swift cannot get more precise information when importing, it will mark them as implicitly unwrapped optional. The first step in the Session is to write the real semantics into the header: empty local headers are allowed.nullable, put it elsewhereNS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_ENDinterval.

//
// SKMission.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKMission : NSObject

@property (readonly, nullable) NSString *name;

- (instancetype)initWithName:(nullable NSString *)name;

@end

NS_ASSUME_NONNULL_END

Key points:

  • NS_ASSUME_NONNULL_BEGINandNS_ASSUME_NONNULL_ENDTreat the middle pointer as nonnull by default to reduce repeated annotations. -nameProperties retainednullable, indicating that the mission may not have a name, and the Swift side will get optional. -initWithName:The parameters ofnullable, the caller must handle the incomingnilsemantics.
  • transcript A clear reminder: Swift will trust the header. If Objective-C actually returns a value marked as nonnullnil, Swift will not check for you at the return point, and subsequent crashes with empty strings, invalid objects, or null pointers may occur (08:09).

(10:31) If the maintainer cannot confirm whether a value is actually always non-null, they can use null unspecified to keep Swift implicitly unwrapped optional. This choice is more honest than writing nonnull by mistake, because the crash will occur at the point of use, rather than dragging the problem somewhere harder to locate.

Add type boundaries to collections and string constants

(11:07) Objective-C lightweight generics directly affect the collection types that Swift sees.NSArray<SKAstronaut *> *will be imported as an array with explicit element types instead of[Any]

//
// SKAstronaut.h
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKAstronaut : NSObject
// Stub declaration
@end

NS_ASSUME_NONNULL_END

//
// SKMission.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>
#import <SpaceKit/SKAstronaut.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKMission : NSObject

@property (readonly) NSArray<SKAstronaut *> *crew;

@end

NS_ASSUME_NONNULL_END

Key points:

  • NSArray<SKAstronaut *> *Write the element type of crew into the Objective-C header.
  • Swift does not need to import fromAnyManually convert toSKAstronaut.
  • This type of annotation also applies toNSSetandNSDictionary, suitable for processing model collections, cache collections and query result collections first.

(13:23) String constants also require type boundaries.SKRocketStageCountOriginally only received certain rocket constants, but all Swift saw wasStringNS_STRING_ENUMWill reshape the typedef into a struct close to Swift raw string enum, so that the function only receivesSKRocket

//
// SKRocket.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

typedef NSString *SKRocket NS_STRING_ENUM;

extern SKRocket const SKRocketAtlas;
extern SKRocket const SKRocketTitanII;
extern SKRocket const SKRocketSaturnIB;
extern SKRocket const SKRocketSaturnV;

NSInteger SKRocketStageCount(SKRocket);

NS_ASSUME_NONNULL_END

Key points:

  • typedef NSString *SKRocketFirst, group a group of string constants under the same concept. -NS_STRING_ENUMChange the Swift import form so that constants becomeSKRocketStatic member usage. -SKRocketStageCount(SKRocket)Arbitrary strings are no longer accepted, and the chance of misrepresenting other strings is reduced.
  • snippet also writes the count return value asNSInteger. transcript explains why: Swift idiomaticallyIntrepresents counting,UIntMore suitable for bit operation scenarios (11:33).

Write initializer rules to Swift

(15:24) Objective-C’s designated initializer is a convention, not a language mandatory rule. Swift relies on this convention when importing classes. SpaceKitSKAstronautIf not marked, Swift subclass authors will be required to override two initializers with different names but similar semantics, and will also see in code completion that they inherit fromNSObjectof no ginsenginit

//
// SKAstronaut.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKAstronaut : NSObject

- (instancetype)initWithNameComponents:(NSPersonNameComponents *)name NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithName:(NSString *)name;
- (instancetype)init NS_UNAVAILABLE;

@property (strong, readwrite) NSPersonNameComponents *nameComponents;
@property (readonly) NSString *name;

@end

NS_ASSUME_NONNULL_END

//
// SKAstronaut.m
//

#import "SKAstronaut.h"

@interface SKAstronaut ()
@property (class, readonly, strong) NSPersonNameComponentsFormatter *nameFormatter;
@end

@implementation SKAstronaut

- (id)initWithNameComponents:(NSPersonNameComponents *)name {
    self = [super init];
    if (self) {
        _name = name;
    }
    return self;
}

- (id)initWithName:(NSString *)name {
    return [self initWithNameComponents:[SKAstronaut _componentsFromName:name]];
}

- (id)init {
  [self doesNotRecognizeSelector:_cmd];
  return nil;
}

- (NSString *)name {
    return [SKAstronaut.nameFormatter stringFromPersonNameComponents:self.nameComponents];
}

+ (NSPersonNameComponents*)_componentsFromName:(NSString*)name {
    return [self.nameFormatter personNameComponentsFromString:name];
}

+ (NSPersonNameComponentsFormatter *)nameFormatter {
    static NSPersonNameComponentsFormatter *singleton;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        singleton = [NSPersonNameComponentsFormatter new];
    });
    return singleton;
}

@end

Key points:

  • initWithNameComponents:callsuper init, so it is marked asNS_DESIGNATED_INITIALIZER
  • initWithName:callself initWithNameComponents:, so it is the convenience path.
  • No ginsenginitCalled in implementationdoesNotRecognizeSelector:, used in headerNS_UNAVAILABLEExplicitly tell Swift and Objective-C clients not to go this route.
  • After annotation, Swift can express inheritance and overriding requirements closer to the initializer model of the Swift class itself.

Fix Objective-C error handling convention

(20:00) Swift will import methods that follow the Objective-C error convention as throwing methods. The key point of the agreement is: returning false means failure, even ifNSError **No error object was written. SpaceKit oldsaveToURL:error:Will return false if “no changes need to be saved”, which will cause Swift to throw a non-public Foundation error type.

//
// SKMission.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKMission : NSObject

/// \returns \c YES if saved; \c NO with non-nil \c *error if failed to save;
///          \c NO with nil \c *error` if nothing needed to be saved.
- (BOOL)saveToURL:(NSURL *)url error:(NSError **)error NS_SWIFT_NOTHROW DEPRECATED_ATTRIBUTE;

/// @param[out] wasDirty If provided, set to \c YES if the file needed to be
///   saved or \c NO if there weren’t any changes to save.
- (BOOL)saveToURL:(NSURL *)url wasDirty:(nullable BOOL *)wasDirty error:(NSError **)error;

@end

NS_ASSUME_NONNULL_END

Key points:

  • Old method plusNS_SWIFT_NOTHROW, tells Swift not to automatically import intothrows
  • DEPRECATED_ATTRIBUTELeave a path for migration so that Objective-C clients can still see the old interface.
  • New method puts “whether the file is really written” intowasDirtyout parameter, letBOOLThe return value only indicates success or failure.
  • This change comes from transcript’s explanation of error convention, not just a change of name.

Use Swift wrapper to express interfaces that Objective-C cannot do

(22:40) Objective-C’s error convention occupies the return value, and cannot naturally express “may throw an error” and “return whether to save” at the same time. The solution to Session is to add Swift files in the framework, use extension to wrap a layer of Swift API, and then useNS_REFINED_FOR_SWIFTHide underlying Objective-C methods from regular completion.

//
// SKMission.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKMission : NSObject

/// \returns \c YES if saved; \c NO with non-nil \c *error if failed to save;
///          \c NO with nil \c *error` if nothing needed to be saved.
- (BOOL)saveToURL:(NSURL *)url error:(NSError **)error NS_SWIFT_NOTHROW DEPRECATED_ATTRIBUTE;

/// @param[out] wasDirty If provided, set to \c YES if the file needed to be
///   saved or \c NO if there weren’t any changes to save.
- (BOOL)saveToURL:(NSURL *)url wasDirty:(nullable BOOL *)wasDirty error:(NSError **)error NS_REFINED_FOR_SWIFT;

@end

NS_ASSUME_NONNULL_END

//
// SwiftExtensions.swift
//

import Foundation

extension SKMission {
    public func save(to url: URL) throws -> Bool {
        var wasDirty: ObjCBool = false
        try self.__save(to: url, wasDirty: &wasDirty)
        return wasDirty.boolValue
    }
}

Key points:

  • NS_REFINED_FOR_SWIFTAdd two underscores in front of the Swift name. Xcode usually hides this low-level entry from completions and generated interfaces.
  • Swift wrapper exposedsave(to:) throws -> Bool, express errors and return values ​​separately. -ObjCBoolare necessary details. transcript description SwiftBooland Objective-CBOOLMemory representation is different on some platforms, and Swift cannot beBoolThe pointer is passed directly to Objective-C. -__save(to:wasDirty:)It can still be called by the wrapper, and the Objective-C API is not completely sealed.

Use named macros and error enum for final polishing

(31:35) Swift will automatically rewrite the Objective-C selector, but the naming judgment has an aesthetic component.NS_SWIFT_NAMEAllows maintainers to directly write out the base name and argument label that the Swift side should see.

//
// SKMission.h
//
// View the generated interface to see how Swift imports this header.
//

#import <Foundation/Foundation.h>
#import <SKAstronaut/SKAstronaut.h>

NS_ASSUME_NONNULL_BEGIN

@interface SKMission : NSObject

- (NSSet<SKMission *> *)previousMissionsFlownByAstronaut:(SKAstronaut *)astronaut NS_SWIFT_NAME(previousMissions(flownBy:));

@end

Key points:

  • Objective-C selector previousMissionsFlownByAstronaut:The default splitting results may not conform to Swift reading. -NS_SWIFT_NAME(previousMissions(flownBy:))clearlyflownByPut it in the parameter label.
  • transcript also statesNS_SWIFT_NAMEIt can be used for types, constants, variables and global functions, and can reshape global functions into static methods, instance methods or properties (33:12).

(37:02) NSError domain addedNS_ENUMStill hard to use in Swift. The caller must first convert toNSError, check domain, and then convert code into enum. change it toNS_ERROR_ENUMFinally, Swift synthesizes an error type that can be directly matched.

//
//  SKError.h
//  SpaceKit
//

#import <Foundation/Foundation.h>

extern NSString *const SKErrorDomain;

typedef NS_ERROR_ENUM(SKErrorDomain, SKErrorCode) {
    SKErrorLaunchAborted = 1,
    SKErrorLaunchOutOfRange,
    SKErrorRapidUnscheduledDisassembly,
    SKErrorNotGoingToSpaceToday
};

Key points:

  • NS_ERROR_ENUM(SKErrorDomain, SKErrorCode)Bind the error code to the error domain.
  • Swift will be composed around itSKError, and make it conform toError.
  • transcript explains that Swift also synthesizes forcatchandcaseMatching operation logic, checking domain and code at the same time when matching.
  • This allows the call site to be expressed as catching a SpaceKit error, rather than hand-written domain, code, and type conversions.

Core Takeaways

Perform Swift import check for Objective-C framework

  • What to do: Periodically open the Swift generated interface of the public header, record optional,AnyCollections, weird initializers, throwing methods, and naming issues.
  • Why is it worth doing: session repeatedly emphasizes that the final evaluation of the Swift client is the call point. The generated interface is the entry point for finding problems.
  • How ​​to start: Open the Swift 5 interface in the Related Items menu of Xcode 12, and combine each implicitly unwrapped optional value withAnyThe collection traces back to the corresponding header.

Add nullability and generic migration plan for old API

  • What to do: First add the first nullability annotation to a header, and then press Xcode warning to fill in the remaining positions.
  • Why it’s worth doing: Swift will trust the Objective-C header. False nonnull may turn into unexpected behavior later on the Swift side.
  • How ​​to start: UseNS_ASSUME_NONNULL_BEGINWrap most nonnull areas and only write where nulls are actually allowed.nullable;Collection attributes are supplemented firstNSArray<Element *>NSSet<Element *>NSDictionary<Key, Value>

Collate the string constant API into a typed interface

  • What: Find only fixed constants allowedNSString *parameters, change them to takeNS_STRING_ENUMtypedef.
  • Why it’s worth doing: SpaceKitSKRocketStageCountThe example shows that a pure string parameter allows the Swift caller to pass in an arbitrary string.
  • How ​​to start: Define firsttypedef NSString *YourKind NS_STRING_ENUM;, then change the constant declaration and function parameters to this typedef, and finally check the calling form of Swift generated interface.

Write a small and accurate wrapper for Swift client

  • What: Hide interfaces that cannot be expressed naturally in Objective-CNS_REFINED_FOR_SWIFTLater, use Swift extension to expose the methods you really want users to call.
  • Why it’s worth doing:save(to:) throws -> BoolThe example separates the error and return value, and the original Objective-C method is reserved for the underlying call.
  • How ​​to start: First confirm that the Objective-C method is visible in the umbrella header, and then write the extension in the Swift file of the framework; you need to passBOOL *used whenObjCBool

Migrate NSError code into an error that Swift can match

  • What: Combine the pairs of error domain andNS_ENUMChange the error code toNS_ERROR_ENUM.
  • Why is it worth doing: Swift will synthesize domain-aware matching logic, and the caller does not need to write it by hand.NSErrordomain and code checks.
  • How ​​to start: Start with the most popularcatchThe error enumeration starts to be migrated, and after modification, the expected error enumeration is written at the Swift call point.catchmorphology, and fix the behavior with tests.
  • What’s new in Swift — Swift 5.3’s annual update on runtime, diagnostics, language features, system libraries, and package ecosystem.
  • Embrace Swift type inference — Explains how the Swift compiler infers types from context, and shows error localization improvements in Xcode 12.
  • Safely manage pointers in Swift — A deep dive into unsafe pointers, raw pointers, and memory bindings to supplement the underlying safety background of mixed language boundaries.
  • Unsafe Swift — Explain the boundaries of unsafe operations in Swift from force unwrap, collection out of bounds and pointer API.
  • Distribute binary frameworks as Swift packages — Distribute binary frameworks with Swift Packages, suitable for teams maintaining framework APIs. Continue reading.

Comments

GitHub Issues · utterances