天天看點

Swift之struct二進制大小分析

作者:京東雲開發者
随着Swift的日漸成熟和給開發過程帶來的便利性及安全性,京喜App中的原生業務子產品和基礎子產品使用Swift開發占比逐漸增高。本次讨論的是struct對比Class的一些優劣勢,重點分析對包體積帶來的影響及規避措施。

作者:京東零售 鄧立兵

一、基礎知識

1、類型對比

Swift之struct二進制大小分析

引用類型: 将一個對象指派給另一個對象時,系統不會對此對象進行拷貝,而會将指向這個對象的指針指派給另一個對象,當修改其中一個對象的值時,另一個對象的值會随之改變。【Class】

值類型: 将一個對象指派給另一個對象時,會對此對象進行拷貝,複制出一份副本給另一個對象,在修改其中一個對象的值時,不影響另外一個對象。【structs、Tuples、enums】。Swift中的【Array, String, and Dictionary】

兩者的差別可以查閱 Apple官方文檔

2、Swift中struct和Class差別

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、重複的複制。
           
Swift之struct二進制大小分析

解決方案:COW(copy-on-write) 機制

1、Copy-on-Write 是一種用來優化占用記憶體大的值類型的拷貝操作的機制。
2、對于Int,Double,String 等基本類型的值類型,它們在指派的時候就會發生拷貝。(記憶體增加)
3、對于 Array、Dictionary、Set 類型,當它們指派的時候不會發生拷貝,隻有在修改的之後才會發生拷貝。(記憶體按需延時增加)
4、對于自定義的資料類型不會自動實作COW,可按需實作。
           

那麼自定義的資料如何實作COW呢,可以參考官方代碼:

/*
我們使用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的:

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?

通過代碼實踐:

class HDClassDemo {
    var locShopName: String?
}
struct HDStructDemo {
    var locShopName: String?
}
           

編譯後計算 linkmap 的體積分别為:

1.54K HDClassDemo.o
1.48K HDStructDemo.o
           

并沒有得出 struct 會比 class 大的表現,通過 Hopper Disassembler 檢視 .o 檔案對比:

Swift之struct二進制大小分析

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

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 修飾變量時

class HDClassDemo {
    let locShopName: String? = nil
}
struct HDStructDemo {
    let locShopName: String?
}
           

編譯後計算 linkmap 的體積分别為:

1.25K	HDStructDemo.o
0.94K	HDClassDemo.o
           

通過 Hopper Disassembler 檢視 .o 檔案對比:

Swift之struct二進制大小分析

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

1、let 比 var 的二進制大小會小,減少部分主要是在 setter/modify 和 kvo 字段中。是以開發過程中養成好習慣,非必要不使用 var 修飾

2、在一個或者多個 let 修飾的情況下,struct 二進制大小的确是大于 class

最後,如果 struct 對象通過指派操作傳遞給其他類(OtherObject),比如這樣(項目中經常存在)

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 操作,猜測分别對 變量名稱和類型的兩次操作(下圖是7個變量時的讀寫操作):

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個變量的情況下大小分别為:

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個檔案

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 檔案,得到的檔案大小為:

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
           

對于的增加曲線圖為:

Swift之struct二進制大小分析

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 情況下,超過一定數量變量時,編譯錯誤來規避類似的問題。

自定義規則如下:

custom_rules:
  disable_more_struct_variable:
    included: ".*.swift"
    name: "struct不應包含超過10個的變量"
    regex: "^(struct).*(Decodable).*(((\n)*\\s(var).*){10,})"
    message: "struct不應包含超過10個的變量"
    severity: error
           

編譯報錯的效果如下:

Swift之struct二進制大小分析

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

1、regex次數問題

理論上的數量應該是 77 個才告警,但是配置數量超過 15 在編譯過程就會非常慢,在正則在 正則可視化頁面 運作穩定,但是使用 SwiftLint 卻幾乎卡死,問題暫未找到解決方案。可能需要閱讀 SwiftLint 源碼求助。

2、識别率問題

因為是根據 var 的次數進行比對,一旦出現注釋(//) 統計也會誤差。正則過于複雜,暫時也沒有找到解決方案。

本文涉及到的代碼、腳本、工具、資料都開源存放在 HDSwiftStructSizeDemo ,檔案結構說明如下:

.
├── 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