Swift之struct二進制大小分析
theme: smartblue
隨着Swift的日漸成熟和給開發過程帶來的便利性及安全性,京喜App中的原生業務模塊和基礎模塊使用Swift開發佔比逐漸增高。本次討論的是struct對比Class的一些優劣勢,重點分析對包體積帶來的影響及規避措施。
一、基礎知識
1、類型對比
引用類型: 將一個對象賦值給另一個對象時,系統不會對此對象進行拷貝,而會將指向這個對象的指針賦值給另一個對象,當修改其中一個對象的值時,另一個對象的值會隨之改變。【Class】
值類型: 將一個對象賦值給另一個對象時,會對此對象進行拷貝,複製出一份副本給另一個對象,在修改其中一個對象的值時,不影響另外一個對象。【structs、Tuples、enums】。Swift中的【Array, String, and Dictionary】
兩者的區別可以查閲 Apple官方文檔
2、Swift中struct和Class區別
swift
1、class是引用類型、struct是值類型
2、類允許被繼承,結構體不允許被繼承
3、類中的每一個成員變量都必須被初始化,否則編譯器會報錯,而結構體不需要,編譯器會自動幫我們生成init函數,給變量賦一個默認值
4、當你需要繼承Objective-C某些類的的時候使用class
5、class聲明的方法修改屬性不需要`mutating`關鍵字;struct需要
6、如果需要保證數據的唯一性,或者保證在多線程數據安全,可以使用struct;而希望創建共享的、可變的狀態使用class
以上三點可以參考 深入理解Swift中的Class和Struct 進行更多細節的閲讀學習
二、struct優選
孔子曰:擇其善者而從之,其不善者而改之。
1、安全性
使用struct是值類型,在傳遞值的時候它會進行值的copy,所以在多線程是安全的。無論你從哪個線程去訪問你的 Struct ,都非常簡單。
2、效率性
struct存儲在stack中(這比malloc/free調用的性能要高得多),class存儲在heap中,struct更快。
3、內存泄露
沒有引用計數器,所以不會因為循環引用導致內存泄漏
基於這些因素,在日常開發中,我們能用 struct
的我們儘量使用 struct
。
三、struct的不完美
孟子曰:魚,我所欲也,熊掌亦我所欲也;二者不可得兼。
“熊掌” 再好,吃多了也難以消化。特別在中大型項目中,如果沒有節制的使用struct
,可能會帶來意想不到的問題。
1、內存問題
值類型 有哪些問題?比如在兩個 struct
賦值操作時,可能會發現如下問題:
1、內存中可能存在兩個巨大的數組;
2、兩個數組數據是一樣的;
3、重複的複製。
解決方案:COW(copy-on-write) 機制
swift
1、Copy-on-Write 是一種用來優化佔用內存大的值類型的拷貝操作的機制。
2、對於Int,Double,String 等基本類型的值類型,它們在賦值的時候就會發生拷貝。(內存增加)
3、對於 Array、Dictionary、Set 類型,當它們賦值的時候不會發生拷貝,只有在修改的之後才會發生拷貝。(內存按需延時增加)
4、對於自定義的數據類型不會自動實現COW,可按需實現。
那麼自定義的數據如何實現COW呢,可以參考官方代碼:
```swift
/
我們使用class,這是一個引用類型,因為當我們將引用類型分配給另一個時,兩個變量將共享同一個實例,而不是像值類型一樣複製它。
/
final class Ref
/
創建一個struct包裝Ref:
由於struct是一個值類型,當我們將它分配給另一個變量時,它的值被複制,而屬性ref的實例仍由兩個副本共享,因為它是一個引用類型。
然後,我們第一次更改兩個Box變量的值時,我們創建了一個新的ref實例,這要歸功於:isUniquelyReferencedNonObjC
這樣,兩個Box變量不再共享相同的ref實例。
/
struct Box
var value: T {
get { return ref.val }
set {
// isKnownUniquelyReferenced 函數來檢查某個引 用只有一個持有者
// 如果你將一個 Swift 類的實例傳遞給這個函數,並且沒有其他變量強引用 這個對象的話,函數將返回 true。如果還有其他的強引用,則返回 false。不過,對於 Objective-C 的類,它會直接返回 false。
if (!isUniquelyReferencedNonObjC(&ref)) {
ref = Ref(newValue)
return
}
ref.val = newValue
}
}
} // This code was an example taken from the swift repo doc file OptimizationTips // Link: https://github.com/apple/swift/blob/master/docs/OptimizationTips.rst#advice-use-copy-on-write-semantics-for-large-values ```
實例説明:我們想在一個使用struct
類型的User中使用copy-on-write的:
```swift struct User { var identifier = 1 }
let user = User() let box = Box(value: user) var box2 = box // box2 shares instance of box.ref.value
box2.value.identifier = 2 // 在改變的時候拷貝 box2.value=2 box.value=1
//打印內存地址 func address(of object: UnsafeRawPointer) { let addr = Int(bitPattern: object) print(NSString(format: "%p", addr)) } ```
注意這個機制減少的是內存的增加,以上可以參考 寫更好的 Swift 代碼:COW(Copy-On-Write) 進行更多細節的閲讀學習。
2、二進制體積問題
這是一個意向不到的點。發現這個問題的契機是 何驍
同學在對京喜項目進行瘦身的時候發現,在梳理項目中各個模塊的大小發現商詳模塊的包體積會比其他模塊要大很多。排除該模塊業務代碼多之外,通過對 linkmap
文件計算髮現,有兩個 struct
模型體積大的異常明顯:
| struct類型庫名 | 二進制大小 | | :-------------: | :------------: | | PGDomainModel.o | 507 KB |
通過簡單的將兩個對象,改成 class
類型後的二進制大小為:
| class類型庫名 | 二進制大小 | | :-------------: | :------------: | | PGDomainModel.o | 256 KB |
這兩個對象會存在在不同類中進行傳遞,根據值類型
的特性,增加也只是內存的大小,而不是二進制的大小。那麼問題就來了:
2.1、大小對比
回答該問題之前,先通過查閲資料發現,在 C語言
中 static stuct
佔用的二進制體積的確會大些,主要是因為static stuct
是 zero-initialized or uninitialized
, 也就是説它在初始化不是空的。它們會進入數據段,也就是説,即使在初始化 struct
的一個字段,二進制文件也包含了整個結構的完整 image
。 Swift
可能也類似。具體可以查詢: Why does usage of structs increase application's binary size?
通過代碼實踐:
swift
class HDClassDemo {
var locShopName: String?
}
struct HDStructDemo {
var locShopName: String?
}
編譯後計算 linkmap
的體積分別為:
swift
1.54K HDClassDemo.o
1.48K HDStructDemo.o
並沒有得出 struct
會比 class
大的表現,通過 Hopper Disassembler
查看 .o
文件對比:
發現有四處值得注意的點:
```swift 1、class特有的KVO特性,想對比 struct 會有體積的增加; 2、同樣的 getter/setter/modify 方法,class增加的體積也多一些,猜測有可能是class類型會有更多的邏輯判斷; 3、init 方法中,struct增加體積較多,應該是 struct 初始化的時候,給變量賦一個默認值的原因; 4、struct 中的 "getEnumTagSinglePayload value" 和 "storeEnumTagSinglePayload value" 佔用較大的,但是通過linkmap計算,這兩部分應該沒有被最終在包體積中。
通過閲讀 https://juejin.cn/post/7094944164852269069 這兩個字段是為 Any 類型服務,上面的例子不涉及 struct ValueWitnessTable { var initializeBufferWithCopyOfBuffer: UnsafeRawPointer var destroy: UnsafeRawPointer var initializeWithCopy: UnsafeRawPointer var assignWithCopy: UnsafeRawPointer var initializeWithTake: UnsafeRawPointer var assignWithTake: UnsafeRawPointer var getEnumTagSinglePayload: UnsafeRawPointer var storeEnumTagSinglePayload: UnsafeRawPointer var size: Int var stride: Int var flags: UInt32 var extraInhabitantCount: UInt32 } ```
所以結論是上面的寫法,struct
並沒有表現比 class
體積大。可能是 Apple 在後面已經優化解決掉了。
但是,測試驗證過程中發現另外一個奇特的地方,當使用 let
修飾變量時
swift
class HDClassDemo {
let locShopName: String? = nil
}
struct HDStructDemo {
let locShopName: String?
}
編譯後計算 linkmap
的體積分別為:
1.25K HDStructDemo.o
0.94K HDClassDemo.o
通過 Hopper Disassembler
查看 .o
文件對比:
在這種情況下,有兩個結論
1、let
比 var
的二進制大小會小,減少部分主要是在 setter/modify
和 kvo
字段中。所以開發過程中養成好習慣,非必要不使用 var
修飾
2、在一個或者多個 let
修飾的情況下,struct
二進制大小的確是大於 class
最後,如果 struct
對象通過賦值操作傳遞給其他類(OtherObject
),比如這樣(項目中經常存在)
```swift let sd = HDStructDemo() OtherObject().sdAction(sd: sd)
class OtherObject: NSObject { private var sd: HDStructDemo? func sdAction(sd: HDStructDemo) { self.sd = sd print(sd) } } ```
在其他類(OtherObject
)中的二進制中有多個內存地址的存儲和讀取端,一個變量會有兩次ldur
、str
操作,猜測分別對 變量名稱和類型的兩次操作:
00000000000003c0 ldur x4, [x29, var_F0]
00000000000003c4 str x4, [sp, #0x230 + var_228]
00000000000003c8 ldur x3, [x29, var_E8]
00000000000003cc str x3, [sp, #0x230 + var_220]
00000000000003d0 ldur x2, [x29, var_E0]
00000000000003d4 str x2, [sp, #0x230 + var_218]
00000000000003d8 ldur x1, [x29, var_D8]
00000000000003dc str x1, [sp, #0x230 + var_210]
00000000000003e0 ldur x17, [x29, var_D0]
00000000000003e4 str x17, [sp, #0x230 + var_208]
00000000000003e8 ldur x16, [x29, var_C8]
00000000000003ec str x16, [sp, #0x230 + var_200]
00000000000003f0 ldur x15, [x29, var_C0]
00000000000003f4 str x15, [sp, #0x230 + var_1F8]
00000000000003f8 ldur x14, [x29, var_B8]
00000000000003fc str x14, [sp, #0x230 + var_1F0]
0000000000000400 ldur x13, [x29, var_B0]
0000000000000404 str x13, [sp, #0x230 + var_1E8]
0000000000000408 ldur x12, [x29, var_A8]
000000000000040c str x12, [sp, #0x230 + var_1E0]
0000000000000410 ldur x11, [x29, var_A0]
0000000000000414 str x11, [sp, #0x230 + var_1D8]
0000000000000418 ldur x10, [x29, var_98]
000000000000041c str x10, [sp, #0x230 + var_1D0]
0000000000000420 ldur x9, [x29, var_90]
0000000000000424 str x9, [sp, #0x230 + var_1C8]
0000000000000428 ldur x8, [x29, var_88]
000000000000042c str x8, [sp, #0x230 + var_1C0]
這將勢必對整個App的包體積帶來巨大的增量。一定一定一定要結合項目進行合理的選擇。
2.2、如何取捨
在安全、效率、內存、二進制大小多個方面,如何取得平衡是關鍵。
單從二進制大小作為考量,這裏有一些經驗總結可以提供參考:
1、如果變量都是let修飾,class 遠勝於 struct,變量越多,優勢越大;7個變量的情況下大小分別為:
swift
3.12K HDStructDemo.o
1.92K HDClassDemo.o
2、如果變量都是var修飾,struct 遠勝於 class,變量越多,優勢越大:
``` 1個變量: 1.54K HDClassDemo.o 1.48K HDStructDemo.o
60個變量: 44.21K HDClassDemo.o 24.22K HDStructDemo.o
100個變量: 71.74K HDClassDemo.o 38.98K HDStructDemo.o ```
3、如果變量都是var修飾,但是都遵循 Decodable 協議,這裏又有乾坤:
這種情況有可能在項目中存在,並且規律不是簡單的誰大誰小,而是根據變量的不同,呈現不同的規則:
使用腳本快速創建分別包含1-200個變量的200個文件
sh
fileCount=200
for (( i = 0; i < $fileCount; i++ )); do
className="HDClassObj_${i}"
classFile="${className}.swift"
structName="HDStructObj_${i}"
structFile="${structName}.swift"
classDecodableName="HDClassDecodableObj_${i}"
classDecodableFile="${classDecodableName}.swift"
structDecodableName="HDStructDecodableObj_${i}"
structDecodableFile="${structDecodableName}.swift"
echo "class ${className} {" > $classFile
echo "struct ${structName} {" > $structFile
echo "class ${classDecodableName}: Decodable {" > $classDecodableFile
echo "struct ${structDecodableName}: Decodable {" > $structDecodableFile
for (( j = 0; j < $i; j++ )); do
line="\tvar name_${j}: String?"
echo $line >> $classFile
echo $line >> $structFile
echo $line >> $classDecodableFile
echo $line >> $structDecodableFile
done
echo "}" >> $classFile
echo "}" >> $structFile
echo "}" >> $classDecodableFile
echo "}" >> $structDecodableFile
done
得到200個文件後,選擇 arm64
架構編譯後,分析 linkmap
文件,得到的文件大小為:
swift
index Class Struct ClassDecodable StructDecodable
1 0.7 0.15 3.03 2.32
2 1.53 1.48 6.54 6.37
3 2.23 1.88 8.12 7.66
4 2.94 2.31 9.37 8.65
5 3.64 2.69 10.73 9.69
6 4.34 3.08 12.05 10.66
7 5.04 3.46 13.36 11.63
8 5.74 3.84 14.62 12.62
9 6.45 4.22 14.97 13.61
10 7.15 4.62 16.11 14.9
11 7.85 5.02 17.25 15.96
12 8.55 5.42 18.39 17.06
13 9.26 5.82 19.53 18.2
14 9.96 6.22 20.67 19.36
...
...
...
76 53.61 31.09 92.19 91.91
77 54.31 31.49 93.34 93.35
...
...
...
198 139.69 79.99 234.45 329.59
199 140.4 80.39 235.58 332
200 141.11 80.79 236.72 334.43
對於的增加曲線圖為:
HDStructDecodableObj在77個變量下體積將返超HDClassDecodableObj
根據曲線規則,可以得出 Class、Struct、ClassDecodable 增長是線性函數,對應的分別函數近似為:
Y = 0.825 + X * 0.705
Y = 1.0794 + X * 0.4006
Y = 5.3775 + X * 1.1625
HDClassDecodableObj 的函數規則分佈猜測可能是 一元二次函數(拋物線)
、對數函數
。在真實對比測試數據均不符合,也可能是 分段函數
吧。有知曉的同學請吿知。
四、預防策略
聖人云:不治已病治未病,不治已亂而治未亂。
京喜
從2020年開始陸續使用 Swift
作為業務開發的主要開發語言,特別是在 商詳、直播、購物車、結算、設置
等業務已經全量化。單單將 商詳
中的 PGDomainModel
、PGDomainData
從 struct
改成 class
類型,該模塊的二進制大小從 12.1M
左右減少到 5.5M
,這主要是因為這兩個對象本身的變量較多,並且被大量其他樓層類賦值使用導致,收益可謂是具大。其他模塊收益相對會少一些。
| 模塊名 | v5.33.6二進制大小 | v5.36.0二進制大小 | 二進制增量 | | :-------------------: | :-------------------: | :-------------------: | :------------: | | pgProductDetailModule | 12.1 MB | 5.5 MB | - 6.6 MB |
可以通過 SwiftLint
的自定義規則,當在 HDClassDecodableObj
情況下,超過一定數量變量時,編譯錯誤來規避類似的問題。
自定義規則如下:
yml
custom_rules:
disable_more_struct_variable:
included: ".*.swift"
name: "struct不應包含超過10個的變量"
regex: "^(struct).*(Decodable).*(((\n)*\\s(var).*){10,})"
message: "struct不應包含超過10個的變量"
severity: error
編譯報錯的效果如下:
規則也暫時發現的兩個問題:
1、regex次數問題
理論上的數量應該是 77
個才吿警,但是配置數量超過 15
在編譯過程就會非常慢,在正則在 正則可視化頁面 運行穩定,但是使用 SwiftLint
卻幾乎卡死,問題暫未找到解決方案。可能需要閲讀 SwiftLint
源碼求助。
2、識別率問題
因為是根據 var
的次數進行匹配,一旦出現註釋(//
) 統計也會誤差。正則過於複雜,暫時也沒有找到解決方案。
本文涉及到的代碼、腳本、工具、數據都開源存放在 HDSwiftStructSizeDemo ,文件結構説明如下:
shell
.
├── Asserts # 圖片資源
├── README.md
└── Struct對比
├── HDSwiftCOWDemo # 測試struct和class大小的工程(代碼)
│ ├── HDSwiftCOWDemo
│ └── HDSwiftCOWDemo.xcodeproj
├── LinkMap # 改造後的LinkMap源碼,支持二進制升/降排序序(工具)
│ ├── LinkMap
│ ├── LinkMap.xcodeproj
│ ├── README.md
│ ├── ScreenShot1.png
│ └── ScreenShot2.png
├── StructSize.playground # playground工程,主要驗證二進制增長的函數(代碼)
│ ├── Contents.swift
│ ├── contents.xcplayground
│ └── playground.xcworkspace
├── Swift-Struct/Class大小.xlsx # struct和class大小數據及圖表生成(數據:最終產物)
└── linkmap對比 # 記錄struct和class的linkmap數據(數據)
├── HDClassDecodableObj.txt
├── HDClassObj.txt
├── HDStructDecodableObj.txt
├── HDStructObj.txt
└── LinkMap.app
歡迎大家 🌟Star 🌟
五、參考資料
寫更好的 Swift 代碼:COW(Copy-On-Write)
Understanding Swift Copy-on-Write mechanisms