Swift之struct二進位制大小分析

語言: CN / TW / HK

theme: smartblue

隨著Swift的日漸成熟和給開發過程帶來的便利性及安全性,京喜App中的原生業務模組和基礎模組使用Swift開發佔比逐漸增高。本次討論的是struct對比Class的一些優劣勢,重點分析對包體積帶來的影響及規避措施。

一、基礎知識

1、型別對比

00.jpg 引用型別: 將一個物件賦值給另一個物件時,系統不會對此物件進行拷貝,而會將指向這個物件的指標賦值給另一個物件,當修改其中一個物件的值時,另一個物件的值會隨之改變。【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、重複的複製。

01.jpg

解決方案: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 { var val : T init(_ v : T) {val = v} }

/ 建立一個struct包裝Ref: 由於struct是一個值型別,當我們將它分配給另一個變數時,它的值被複制,而屬性ref的例項仍由兩個副本共享,因為它是一個引用型別。 然後,我們第一次更改兩個Box變數的值時,我們建立了一個新的ref例項,這要歸功於:isUniquelyReferencedNonObjC 這樣,兩個Box變數不再共享相同的ref例項。 / struct Box { var ref : Ref init(_ x : T) { ref = Ref(x) }

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 stuctzero-initialized or uninitialized , 也就是說它在初始化不是空的。它們會進入資料段,也就是說,即使在初始化 struct 的一個欄位,二進位制檔案也包含了整個結構的完整 imageSwift 可能也類似。具體可以查詢: 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 檔案對比:

03.jpg

發現有四處值得注意的點:

```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 檔案對比:

04.jpg

在這種情況下,有兩個結論

1、letvar 的二進位制大小會小,減少部分主要是在 setter/modifykvo 欄位中。所以開發過程中養成好習慣,非必要不使用 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)中的二進位制中有多個記憶體地址的儲存和讀取端,一個變數會有兩次ldurstr 操作,猜測分別對 變數名稱和型別的兩次操作:

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

對於的增加曲線圖為:

05.jpg

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 作為業務開發的主要開發語言,特別是在 商詳、直播、購物車、結算、設定 等業務已經全量化。單單將 商詳 中的 PGDomainModelPGDomainDatastruct 改成 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

編譯報錯的效果如下:

06.jpg

規則也暫時發現的兩個問題:

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中的Class和Struct

寫更好的 Swift 程式碼:COW(Copy-On-Write)

Swift官方COW文件

Understanding Swift Copy-on-Write mechanisms

swift 結構體copy-on-write技術

什麼是COW?

資料來測試是否實現COW

COW自定義實現

arm彙編儲存指令str stur和讀取指令 ldr ldur的使用,對應xcode c++中的程式碼反彙編教程

正則視覺化頁面

正則表示式全集

SwiftLint

SwiftLint_Rule

SwiftLint-Advanced