這套架構的核心,是我在無數個被 API 炸掉的午夜,與一個「非人類助手」共同參悟出來的生存法則。
身為 iOS 工程師,我們最大的壓力來源通常不是複雜的 UI,而是**「後端不按牌理出牌的 API」**。當髒資料導致 App 出錯,面對老闆的連環追問:「為什麼別人的 App 沒事,我們的會閃退?」、「這不是昨天才修過嗎?」,你需要的不是更多的 try?,而是這套強大的「救災架構」。
🚩 BadBackend 奇葩行為大賞 (血汗處刑清單)
在給出解決方案前,我們先看看這些讓開發者血壓飆升的真實案例。這不是虛構,這是我們的日常:
- 【薛丁格的 ID】:有資料時
id: "123",沒資料時欄位直接失蹤,或是給 id: null。 - 【型別人格分裂】:
price 這一秒是 Double (99.0),下一秒變 String ("99.0")。 - 【Bool 的創意大賽】:
true 有時是 1,有時是 "Y",有時是 "on",甚至還給過 "checked"。 - 【外殼變色龍】:今天資料包在
data,明天改叫 items,後天直接吐 Array 不包殼。
🏛️ 救災架構視覺化
為了讓你理解這套系統是如何在混亂中維持秩序,我們來看這張資料流向圖:
graph TD
subgraph JSON_Source [原始髒資料]
A[Missing Keys / Nulls]
B[Type Mismatch: '123' vs 123]
C[Corrupted Array Elements]
end
subgraph Defense_Layer [DTO 防禦層 - SafeBox / SafeArray]
D{SafeBox Decoder}
E{SafeArray Recovery}
D -->|Type Rescue| F[Normalizing Types]
D -->|Key Missing| G[Inject Default Value]
E -->|Element Fail| H[Insert Default Instance]
end
subgraph Domain_Layer [Domain 轉換層]
I[toDomain Mapping]
J{關鍵欄位驗證策略}
I --> J
J -->|情境一| K[給予隨機 ID / 保證渲染]
J -->|情境二| L[回傳 nil / 直接過濾]
end
JSON_Source --> Defense_Layer
Defense_Layer --> Domain_Layer
Domain_Layer --> M[ViewModel / UI]
style Defense_Layer fill:#f96,stroke:#333,stroke-width:2px
style Domain_Layer fill:#bbf,stroke:#333,stroke-width:2px
🛠️ 核心救災工具包
SafeBox 是整套架構的基石,處理三種情況:null 補預設值、型別錯置嘗試轉換、欄位缺失補預設值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
| protocol SafeValue: Codable, Sendable {
static var defaultValue: Self { get }
}
extension String: SafeValue { static var defaultValue: String { "" } }
extension Int: SafeValue { static var defaultValue: Int { 0 } }
extension Double: SafeValue { static var defaultValue: Double { 0.0 } }
extension Bool: SafeValue { static var defaultValue: Bool { false } }
@propertyWrapper
struct SafeBox<T: SafeValue>: Codable, Sendable {
var wrappedValue: T
init(wrappedValue: T) { self.wrappedValue = wrappedValue }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
wrappedValue = T.defaultValue; return
}
if let value = try? container.decode(T.self) {
wrappedValue = value; return
}
// 型別錯置:嘗試救援,失敗則補預設值
wrappedValue = Self.rescue(from: container) ?? T.defaultValue
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
try c.encode(wrappedValue)
}
private static func rescue(from container: SingleValueDecodingContainer) -> T? {
switch T.self {
case is String.Type:
if let v = try? container.decode(Int.self) { return "\(v)" as? T }
if let v = try? container.decode(Double.self) { return "\(v)" as? T }
case is Int.Type:
if let v = try? container.decode(String.self), let i = Int(v) { return i as? T }
if let v = try? container.decode(Double.self) { return Int(v) as? T }
case is Double.Type:
if let v = try? container.decode(String.self), let d = Double(v) { return d as? T }
if let v = try? container.decode(Int.self) { return Double(v) as? T }
case is Bool.Type:
if let v = try? container.decode(String.self) {
return ["1", "y", "yes", "true", "on", "checked"].contains(v.lowercased()) as? T
}
if let v = try? container.decode(Int.self) { return (v == 1) as? T }
default: break
}
return nil
}
}
// 欄位缺失時補 defaultValue,不拋錯
extension KeyedDecodingContainer {
func decode<T: SafeValue>(_ type: SafeBox<T>.Type, forKey key: Key) throws -> SafeBox<T> {
(try? decodeIfPresent(type, forKey: key)) ?? SafeBox(wrappedValue: T.defaultValue)
}
}
|
🏛️ 實戰:關鍵欄位的處置策略
在 toDomain() 階段,身為開發者的你需要決定如何處理「核心缺失(如 ID 遺失)」的情況。這裡有兩種主流實踐:
解決方案:ID 缺失處置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| // ✨ 乾淨的 Domain (對應 UI)
struct ProductItem: Identifiable, Sendable {
let id: String
let title: String
}
// 💩 髒髒的 DTO (對應 API)
struct ProductDTO: Codable, DomainConvertible {
@SafeBox var product_id: String
@SafeBox var product_name: String
static func defaultInstance() -> ProductDTO {
.init(product_id: "", product_name: "")
}
func toDomain() -> ProductItem? {
// --- 策略 A:保命派 (給予臨時身分證) ---
// 即使 ID 缺失也強制渲染,適用於「必須顯示資料」的情境。
// 優點:App 絕對不會空屏。缺點:可能導致重複點擊無效或 UI 狀態錯亂。
/*
let finalID = product_id.isEmpty ? "TEMP-ID-\(UUID().uuidString)" : product_id
return ProductItem(id: finalID, title: product_name)
*/
// --- 策略 B:潔癖派 (直接過濾無效資料) ---
// ID 缺失代表這筆資料無法跳轉詳情或操作,直接回傳 nil,
// 讓 ViewModel 透過 compactMap 過濾,確保 UI 上每筆都是可操作的。
guard !product_id.isEmpty else {
Logger.badBackend.error("🔥 [關鍵缺失] 商品「\(product_name)」ID 遺失,已自動過濾。")
return nil
}
return ProductItem(id: product_id, title: product_name.isEmpty ? "未命名商品" : product_name)
}
}
|
📋 附錄:SafeBox 的代價
雖然這套架構能擋下 90% 的背刺,但在使用前,你必須清楚它的局限性:
- 語義錯誤無法偵測:
SafeBox 只能保證型別不崩潰,無法偵測邏輯錯誤。 - 效能開銷:內部多次嘗試
decode 與動態轉型(Dynamic Casting)在處理大數據時會稍慢。 - 掩蓋溝通契機:App 太穩了,你可能會懶得叫後端修 API。請記住,救災是為了爭取時間,不是為了縱容錯誤。
💡 總結:架構即尊嚴
這套架構的核心哲學是**「嚴以律己,寬以待人」**:
- 對外(Network 層):展現最大的包容力。
- 對內(Domain 層):執行最專業的審核。
這不只是為了寫 Code,更是為了守護你的下班時間與職涯尊嚴。不要期待後端會改 API,你的架構穩定才是永遠的。
Demo
本文使用 Claude 共同完成