Highlight
这场 session 演示如何通过 Objective-C 头文件注解、命名宏和少量 Swift wrapper,把默认导入 Swift 后充满隐式解包可选值、Any 集合、错误抛出歧义和笨重命名的框架,改成类型更具体、调用点更符合 Swift 习惯的 API。
核心内容
很多团队没有一次性把 Objective-C framework 重写成 Swift。旧代码继续存在,新业务却越来越多地用 Swift 调用这些 API。Swift 编译器会自动导入 Objective-C header,也会把 NSString、NSDate、initializer、错误处理方法改写成更接近 Swift 的接口。问题在于,自动转换只知道 header 写出来的事实,不知道框架维护者没有写出来的意图(00:25、02:50)。
演讲里的 SpaceKit 就是一个典型例子。它是 Objective-C 写的模型框架,Swift 端看到的 generated interface 里有大量 implicitly unwrapped optional,集合类型落成 Any,某个保存方法会在“没有需要保存的变化”时被 Swift 当成 throw,initializer 继承关系也会让子类作者困惑。根因在 header 信息不足;Swift 只能按已经声明的事实导入 Objective-C(01:33、03:16)。
Apple 给出的处理顺序很务实:先在 Xcode 12 里打开 Swift generated interface,看 Swift 客户端实际会看到什么;再回到 Objective-C header,用 nullability、lightweight generics、NS_STRING_ENUM、NS_DESIGNATED_INITIALIZER、NS_SWIFT_NAME、NS_REFINED_FOR_SWIFT 和 NS_ERROR_ENUM 修正导入结果;最后从调用点检查 API,而不是只看接口列表是否漂亮(02:01、40:43)。
详细内容
用 nullability 消掉隐式解包可选值
(04:43)Objective-C 的 pointer 类型天然可能为 nil,Swift 导入时如果拿不到更精确的信息,就会把它们标成 implicitly unwrapped optional。Session 的第一步是把真实语义写进 header:允许为空的地方标 nullable,其他地方放进 NS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_END 区间。
//
// 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
关键点:
NS_ASSUME_NONNULL_BEGIN和NS_ASSUME_NONNULL_END把中间的 pointer 默认视为 nonnull,减少重复标注。name属性保留nullable,说明 mission 可能没有名字,Swift 端会得到 optional。initWithName:的参数也保留nullable,调用者必须处理传入nil的语义。- transcript 明确提醒:Swift 会相信 header。如果 Objective-C 实际返回了被标成 nonnull 的
nil,Swift 不会在返回点替你检查,后续可能出现空字符串、无效对象或空指针崩溃(08:09)。
(10:31)如果维护者无法确认某个值是否真的永远非空,可以用 null unspecified,让 Swift 保持 implicitly unwrapped optional。这个选择比错误地写 nonnull 更诚实,因为崩溃会出现在使用点,而不是把问题拖到更难定位的地方。
给集合和字符串常量补上类型边界
(11:07)Objective-C lightweight generics 会直接影响 Swift 看到的集合类型。NSArray<SKAstronaut *> * 会导入成元素类型明确的数组,而不是 [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
关键点:
NSArray<SKAstronaut *> *把 crew 的元素类型写进 Objective-C header。- Swift 导入后不需要从
Any手动转换成SKAstronaut。 - 这类标注同样适用于
NSSet和NSDictionary,适合先处理模型集合、缓存集合和查询结果集合。
(13:23)字符串常量也需要类型边界。SKRocketStageCount 原本只接收某几个火箭常量,但 Swift 看到的只是 String。NS_STRING_ENUM 会把 typedef reshape 成接近 Swift raw string enum 的 struct,让函数只接收 SKRocket。
//
// 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
关键点:
typedef NSString *SKRocket先把一组字符串常量归到同一个概念下。NS_STRING_ENUM改变 Swift 导入形态,让常量成为SKRocket的静态成员式用法。SKRocketStageCount(SKRocket)不再接受任意字符串,误传其他字符串的机会被压低。- snippet 同时把计数返回值写成
NSInteger。transcript 解释了原因:Swift 习惯用Int表示计数,UInt更适合位运算场景(11:33)。
把 initializer 规则写给 Swift
(15:24)Objective-C 的 designated initializer 是约定,不是语言强制规则。Swift 导入类时会依赖这个约定。SpaceKit 的 SKAstronaut 如果不标注,Swift 子类作者会被要求覆盖两个名字不同但语义相近的 initializer,还会在 code completion 里看到继承自 NSObject 的无参 init。
//
// 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
关键点:
initWithNameComponents:调用super init,所以它被标成NS_DESIGNATED_INITIALIZER。initWithName:调用self initWithNameComponents:,因此它是 convenience 路径。- 无参
init在实现里调用doesNotRecognizeSelector:,header 里用NS_UNAVAILABLE明确告诉 Swift 和 Objective-C 客户端不能走这条路。 - 标注后,Swift 能把继承和重写要求表达得更接近 Swift 类本身的 initializer 模型。
修正 Objective-C 错误处理约定
(20:00)Swift 会把遵循 Objective-C error convention 的方法导入成 throwing method。约定的关键点是:返回 false 就表示失败,即使 NSError ** 没有写入错误对象。SpaceKit 的旧 saveToURL:error: 会在“没有改动需要保存”时返回 false,这会让 Swift 抛出一个非公开 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
关键点:
- 旧方法加
NS_SWIFT_NOTHROW,告诉 Swift 不要按错误约定自动导入成throws。 DEPRECATED_ATTRIBUTE给迁移留出路径,Objective-C 客户端仍能看到旧接口。- 新方法把“是否真的写入文件”放进
wasDirtyout parameter,让BOOL返回值重新只表示成功或失败。 - 这个改动来自 transcript 对 error convention 的解释,不是单纯改命名。
用 Swift wrapper 表达 Objective-C 做不到的接口
(22:40)Objective-C 的错误约定占用了返回值,没法同时自然表达“可能抛错”和“返回是否保存”。Session 的解决方式是在 framework 内增加 Swift 文件,用 extension 包一层 Swift API,再用 NS_REFINED_FOR_SWIFT 把底层 Objective-C 方法从常规补全里藏起来。
//
// 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
}
}
关键点:
NS_REFINED_FOR_SWIFT让 Swift 名称前面多出两个下划线,Xcode 通常会把这种底层入口从补全和 generated interface 里隐藏。- Swift wrapper 暴露
save(to:) throws -> Bool,把错误和返回值分开表达。 ObjCBool是必要细节。transcript 说明 SwiftBool和 Objective-CBOOL在部分平台上内存表示不同,不能把 SwiftBool的指针直接传给 Objective-C。__save(to:wasDirty:)仍然可被 wrapper 调用,Objective-C API 没有被完全封死。
用命名宏和 error enum 做最后抛光
(31:35)Swift 会自动重写 Objective-C selector,但命名判断带有审美成分。NS_SWIFT_NAME 允许维护者直接写出 Swift 端应该看到的 base name 和 argument label。
//
// 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
关键点:
- Objective-C selector
previousMissionsFlownByAstronaut:默认拆分结果不一定符合 Swift 读法。 NS_SWIFT_NAME(previousMissions(flownBy:))明确把flownBy放到参数标签里。- transcript 还说明
NS_SWIFT_NAME可用于类型、常量、变量和全局函数,并能把全局函数 reshape 成静态方法、实例方法或属性(33:12)。
(37:02)NSError domain 加 NS_ENUM 在 Swift 里仍然难用。调用者要先转成 NSError,检查 domain,再把 code 转成 enum。把它改成 NS_ERROR_ENUM 后,Swift 会合成可以直接匹配的错误类型。
//
// SKError.h
// SpaceKit
//
#import <Foundation/Foundation.h>
extern NSString *const SKErrorDomain;
typedef NS_ERROR_ENUM(SKErrorDomain, SKErrorCode) {
SKErrorLaunchAborted = 1,
SKErrorLaunchOutOfRange,
SKErrorRapidUnscheduledDisassembly,
SKErrorNotGoingToSpaceToday
};
关键点:
NS_ERROR_ENUM(SKErrorDomain, SKErrorCode)把错误码和错误 domain 绑定起来。- Swift 会围绕它合成
SKError,并让它 conform toError。 - transcript 说明 Swift 还会合成用于
catch和case匹配的运算逻辑,匹配时同时检查 domain 和 code。 - 这让调用点可以表达成捕获某个 SpaceKit 错误,而不是手写 domain、code 和类型转换。
核心启发
给 Objective-C framework 做 Swift 导入体检
- 做什么:定期打开 public header 的 Swift generated interface,记录 optional、
Any集合、奇怪 initializer、throwing method 和命名问题。 - 为什么值得做:session 反复强调,Swift 客户端最终评价的是调用点。generated interface 是发现问题的入口。
- 怎么开始:在 Xcode 12 的 Related Items 菜单里打开 Swift 5 interface,把每个隐式解包可选值和
Any集合回溯到对应 header。
为老 API 增加 nullability 和泛型迁移计划
- 做什么:先给一个 header 增加首个 nullability 标注,再按 Xcode warning 把剩余位置补齐。
- 为什么值得做:Swift 会相信 Objective-C header。错误的 nonnull 可能在 Swift 端变成更晚出现的异常行为。
- 怎么开始:用
NS_ASSUME_NONNULL_BEGIN包住大多数 nonnull 区域,只在真实允许为空的位置写nullable;集合属性优先补NSArray<Element *>、NSSet<Element *>、NSDictionary<Key, Value>。
把字符串常量 API 收拢成 typed interface
- 做什么:查找本来只允许固定常量的
NSString *参数,把它们改为带NS_STRING_ENUM的 typedef。 - 为什么值得做:SpaceKit 的
SKRocketStageCount示例说明,纯字符串参数会让 Swift 调用者传入任意字符串。 - 怎么开始:先定义
typedef NSString *YourKind NS_STRING_ENUM;,再把常量声明和函数参数都改成这个 typedef,最后检查 Swift generated interface 的调用形态。
给 Swift 客户端写一层小而准的 wrapper
- 做什么:把 Objective-C 无法自然表达的接口藏到
NS_REFINED_FOR_SWIFT后面,用 Swift extension 暴露真正想让用户调用的方法。 - 为什么值得做:
save(to:) throws -> Bool示例把错误和返回值拆开了,Objective-C 原方法保留给底层调用。 - 怎么开始:先确认 Objective-C 方法已在 umbrella header 可见,再在 framework 的 Swift 文件中写 extension;需要传
BOOL *时使用ObjCBool。
把 NSError code 迁成 Swift 可匹配的错误
- 做什么:把成对的 error domain 和
NS_ENUM错误码改成NS_ERROR_ENUM。 - 为什么值得做:Swift 会合成 domain-aware 的匹配逻辑,调用者不用手写
NSErrordomain 和 code 检查。 - 怎么开始:从最常被
catch的错误枚举开始迁移,修改后在 Swift 调用点写出预期的catch形态,并用测试固定行为。
关联 Session
- What’s new in Swift — Swift 5.3 在 runtime、诊断、语言特性、系统库和 package 生态上的年度更新。
- Embrace Swift type inference — 解释 Swift 编译器如何从上下文推断类型,并展示 Xcode 12 的错误定位改进。
- Safely manage pointers in Swift — 深入 unsafe pointer、raw pointer 和内存绑定,补充混合语言边界的底层安全背景。
- Unsafe Swift — 从 force unwrap、集合越界和指针 API 讲清 Swift 中 unsafe 操作的边界。
- Distribute binary frameworks as Swift packages — 用 Swift Package 分发 binary framework,适合维护 framework API 的团队继续阅读。
评论
GitHub Issues · utterances