本文记录了我们团队将一个 10 万行 Swift 项目从 HandyJSON 迁移到 SmartCodable 的完整过程,包括迁移动机、踩过的坑、API 对照表,以及迁移后的效果对比。如果你的项目还在用 HandyJSON,希望这篇文章能帮你做出判断。
一、为什么要迁移
HandyJSON 的定时炸弹
HandyJSON 是国内 iOS 社区广泛使用的 JSON 解析库。它的优点很明显——API 简洁,支持 Any 类型,支持继承,几乎不需要额外的模板代码。我们团队用了两年多,一直没出什么问题。
直到 Swift 5.5 引入结构化并发之后,问题开始浮现。
HandyJSON 的核心实现依赖 Swift 运行时的内存布局反射——直接读取 struct/class 的内存 metadata,计算属性偏移量,然后写入值。这个机制有两个致命问题:
- 不是官方支持的 API。Swift 的内存布局在不同版本之间没有 ABI 稳定性承诺。Apple 每次更新 Swift 版本,都有可能改变 metadata 的结构,导致 HandyJSON 静默地写错内存位置。这不会崩溃,而是静默返回错误的数据——更危险。
- 与 Swift 并发模型冲突。Swift 5.5+ 的并发检查越来越严格,HandyJSON 的运行时反射无法被标记为 Sendable,在启用严格并发检查的项目中会产生大量警告。
我们在一次 Xcode 15 升级后遇到了一个诡异的 Bug:某个嵌套模型的属性偶尔解析为零值。排查了两天才发现是 HandyJSON 的内存偏移计算在新版 Swift 编译器下出了问题。这次事件让我们决定迁移。
为什么选择 SmartCodable
我们评估了三个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
手写 init(from:) | 零依赖,完全可控 | 样板代码爆炸,100 个 Model 就是地狱 |
| CodableWrapper / BetterCodable | 轻量,只用属性包装器 | 只解决默认值问题,不解决类型转换、Any 支持 |
| SmartCodable | 功能对齐 HandyJSON,基于原生 Codable | 学习成本低,API 设计与 HandyJSON 相似 |
SmartCodable 胜出的原因很简单:它是唯一一个在功能上能完全替代 HandyJSON 的方案,同时又基于 Apple 原生 Codable 协议,没有运行时安全隐患。
二、迁移前的准备
评估工作量
我们先做了一次全局搜索,统计 HandyJSON 的使用范围:
# 统计引用 HandyJSON 的文件数
grep -rl "HandyJSON" --include="*.swift" . | wc -l
# 统计 deserialize 调用次数
grep -rn "deserialize(from:" --include="*.swift" . | wc -l
# 统计 mapping 方法使用次数
grep -rn "mapping(mapper:" --include="*.swift" . | wc -l
# 统计 Any 类型属性
grep -rn "var.*: Any" --include="*.swift" . | wc -l
我们的项目情况:
- 约 200 个 Model 文件
- 80+ 处
deserialize调用 - 15 处
mapping(mapper:)自定义映射 - 8 处
Any类型属性 - 3 处继承关系
制定迁移策略
根据评估结果,我们制定了分步迁移策略:
- 第一步:全局替换协议名(工作量最大但最简单)
- 第二步:处理
mapping方法(需要逐个改写) - 第三步:处理
Any类型属性(加@SmartAny) - 第四步:处理继承关系(加
@SmartSubclass) - 第五步:处理枚举(
HandyJSONEnum→SmartCaseDefaultable) - 第六步:处理序列化(
toJSON()→toDictionary()) - 第七步:全量测试
三、逐步迁移
第一步:替换协议名(5 分钟)
这是最简单的一步,全局搜索替换即可:
import HandyJSON → import SmartCodable
HandyJSON → SmartCodable (作为协议名使用的地方)
SmartCodable 的 deserialize(from:) API 与 HandyJSON 完全一致,所以替换协议名后,所有反序列化代码不需要改动。
// HandyJSON(替换前)
guard let model = Model.deserialize(from: dict) else { return }
// SmartCodable(替换后)—— 调用方式完全一样
guard let model = Model.deserialize(from: dict) else { return }
唯一的小差异:HandyJSON 解码数组时返回 [Model]?,有些地方写了 as? [Model] 强转。SmartCodable 不需要这个强转,但保留也不会报错,可以后续清理。
// HandyJSON 写法
guard let models = [Model].deserialize(from: arr) as? [Model] else { return }
// SmartCodable 写法(as? [Model] 可以删掉,不删也没问题)
guard let models = [Model].deserialize(from: arr) else { return }
第二步:改写自定义映射(30 分钟)
这是工作量最大的一步。HandyJSON 用 mapping(mapper:) 方法,SmartCodable 用 mappingForKey(),语法不同:
HandyJSON:
struct Model: HandyJSON {
var nickName: String = ""
var userAge: Int = 0
var ignoreField: String = ""
mutating func mapping(mapper: HelpingMapper) {
mapper <<< self.nickName <-- ["nick_name", "realName"]
mapper <<< self.userAge <-- "user_age"
mapper >>> self.ignoreField // 忽略该字段
}
}
SmartCodable:
struct Model: SmartCodable {
var nickName: String = ""
var userAge: Int = 0
@SmartIgnored
var ignoreField: String = ""
static func mappingForKey() -> [SmartKeyTransformer]? {
[
CodingKeys.nickName <--- ["nick_name", "realName"],
CodingKeys.userAge <--- "user_age"
]
}
}
对照表:
| HandyJSON | SmartCodable | 说明 |
|---|---|---|
mapper <<< self.prop <-- "key" | CodingKeys.prop <--- "key" | 单字段映射 |
mapper <<< self.prop <-- ["k1", "k2"] | CodingKeys.prop <--- ["k1", "k2"] | 多候选映射 |
mapper >>> self.prop | @SmartIgnored var prop | 忽略字段 |
踩坑提醒:SmartCodable 的 mappingForKey() 是 static func,不是 mutating func。如果你的 mapping 中有依赖 self 的逻辑,需要调整。
第三步:处理 Any 类型(10 分钟)
HandyJSON 天然支持 Any 类型,SmartCodable 需要加 @SmartAny 属性包装器:
// HandyJSON
struct Model: HandyJSON {
var extra: [String: Any] = [:]
var tags: [Any] = []
var value: Any?
}
// SmartCodable
struct Model: SmartCodable {
@SmartAny var extra: [String: Any] = [:]
@SmartAny var tags: [Any] = []
@SmartAny var value: Any?
}
全局搜索 var.*: Any、var.*: [Any]、var.*: [String: Any],逐个加上 @SmartAny 即可。
第四步:处理继承(5 分钟)
HandyJSON 自动处理继承,SmartCodable 需要在子类上加 @SmartSubclass:
// HandyJSON —— 什么都不用加
class BaseModel: HandyJSON {
var name: String = ""
required init() {}
}
class SubModel: BaseModel {
var age: Int = 0
}
// SmartCodable —— 子类加 @SmartSubclass
class BaseModel: SmartCodable {
var name: String = ""
required init() {}
}
@SmartSubclass
class SubModel: BaseModel {
var age: Int = 0
}
注意:
@SmartSubclass是 Swift 宏,需要 Swift 5.9+ 和 Xcode 15+。如果你的项目还在用低版本,可以参考 低版本继承方案。
第五步:处理枚举(5 分钟)
// HandyJSON
enum Sex: String, HandyJSONEnum {
case man
case woman
}
// SmartCodable
enum Sex: String, SmartCaseDefaultable {
case man
case woman
}
全局替换 HandyJSONEnum → SmartCaseDefaultable。
第六步:处理序列化(10 分钟)
序列化的 API 名称有变化:
| HandyJSON | SmartCodable |
|---|---|
model.toJSON() | model.toDictionary() |
model.toJSONString() | model.toJSONString() |
models.toJSON() | models.toArray() |
models.toJSONString() | models.toJSONString() |
全局搜索 .toJSON() 替换为 .toDictionary()(注意排除 toJSONString)。数组序列化搜索替换即可。
第七步:全量测试
移除 HandyJSON 依赖,编译通过后进行全量测试。
我们的测试策略:
- 先开启 SmartSentinel 日志,跑一遍主流程:
SmartSentinel.debugMode = .verbose
- 观察日志中是否有异常的类型转换或缺失字段
- 重点验证有
mapping的模型、有Any类型的模型、有继承的模型 - 回归测试核心业务流程
四、迁移后的收获
解析异常不再是黑盒
HandyJSON 解析失败时,你只知道"解析返回了 nil",但不知道哪个字段出了问题。SmartCodable 的 SmartSentinel 日志系统会精确告诉你:
================================ [Smart Sentinel] ================================
UserModel 👈🏻 👀
╆━ UserModel
┆┄ age : Expected Int, got String — auto-converted
┆┄ email : Key not found — using default ""
====================================================================================
我们在迁移后第一周就通过 Sentinel 日志发现了 3 个后端接口返回类型不一致的问题——这些问题在 HandyJSON 时代被静默吞掉了。
告别运行时崩溃的恐惧
HandyJSON 的每次 Swift 版本升级都是一次赌博。SmartCodable 基于原生 Codable,Swift 版本升级时完全不需要担心底层兼容性。
类型转换更智能
SmartCodable 内置的类型转换比 HandyJSON 更全面:
// 后端返回 String 类型的 "123",Model 声明为 Int
var age: Int = 0
// HandyJSON: age = 0(转换失败,静默用默认值)
// SmartCodable: age = 123(自动转换成功)
编译速度
移除 HandyJSON 后,项目的 clean build 时间减少了约 8%(HandyJSON 的运行时反射代码量较大)。
五、迁移清单(Checklist)
供你在实际迁移时对照使用:
- 全局替换
import HandyJSON→import SmartCodable - 全局替换协议名
HandyJSON→SmartCodable(注意只替换作为协议使用的) - 改写所有
mapping(mapper:)→mappingForKey()+@SmartIgnored - 所有
Any/[Any]/[String: Any]属性加@SmartAny - 所有子类加
@SmartSubclass - 全局替换
HandyJSONEnum→SmartCaseDefaultable - 全局替换
.toJSON()→.toDictionary()(排除toJSONString) - 数组序列化
.toJSON()→.toArray() - 移除 HandyJSON 依赖(Podfile / Package.swift)
- 编译通过
- 开启
SmartSentinel.debugMode = .verbose,跑主流程 - 全量回归测试
- 关闭 Sentinel(
SmartSentinel.debugMode = .none) - 上线观察
六、总结
整个迁移过程比我们预想的顺利很多。200 个 Model 的项目,两个人花了一天半完成迁移和测试。其中 80% 的工作量是全局替换(第一步),真正需要手动处理的只有 mapping 改写和 @SmartAny 标注。
如果你的项目还在用 HandyJSON,我的建议是:不要等到被 Swift 版本升级逼着迁移,主动迁移的成本远低于被动修 Bug。
SmartCodable 的 API 设计明显考虑了 HandyJSON 用户的迁移体验——deserialize、didFinishMapping、designatedPath 这些核心 API 完全一致,迁移门槛很低。