写点什么

Swift 之 struct 二进制大小分析

  • 2023-03-30
    北京
  • 本文字数:7107 字

    阅读完需:约 23 分钟

Swift之struct二进制大小分析

作者:京东零售 邓立兵

随着 Swift 的日渐成熟和给开发过程带来的便利性及安全性,京喜 App 中的原生业务模块和基础模块使用 Swift 开发占比逐渐增高。本次讨论的是 struct 对比 Class 的一些优劣势,重点分析对包体积带来的影响及规避措施。

一、基础知识

1、类型对比


**引用类型:**将一个对象赋值给另一个对象时,系统不会对此对象进行拷贝,而会将指向这个对象的指针赋值给另一个对象,当修改其中一个对象的值时,另一个对象的值会随之改变。【Class】


**值类型:**将一个对象赋值给另一个对象时,会对此对象进行拷贝,复制出一份副本给另一个对象,在修改其中一个对象的值时,不影响另外一个对象。【structs、Tuples、enums】。Swift 中的【Array, String, and Dictionary】


两者的区别可以查阅Apple官方文档

2、Swift 中 struct 和 Class 区别

1、class是引用类型、struct是值类型2、类允许被继承,结构体不允许被继承3、类中的每一个成员变量都必须被初始化,否则编译器会报错,而结构体不需要,编译器会自动帮我们生成init函数,给变量赋一个默认值4、当你需要继承Objective-C某些类的的时候使用class5、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) 机制


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模型体积大的异常明显:



通过简单的将两个对象,改成class类型后的二进制大小为:



这两个对象会存在在不同类中进行传递,根据值类型的特性,增加也只是内存的大小,而不是二进制的大小。那么问题就来了:


2.1、大小对比


回答该问题之前,先通过查阅资料发现,在C语言static stuct占用的二进制体积的确会大些,主要是因为static stuctzero-initialized or uninitialized, 也就是说它在初始化不是空的。它们会进入数据段,也就是说,即使在初始化struct的一个字段,二进制文件也包含了整个结构的完整imageSwift可能也类似。具体可以查询:Why does usage of structs increase application's binary size?


通过代码实践:


class HDClassDemo {    var locShopName: String?}struct HDStructDemo {    var locShopName: String?}
复制代码


编译后计算linkmap的体积分别为:


1.54K HDClassDemo.o1.48K HDStructDemo.o
复制代码


并没有得出struct会比class大的表现,通过Hopper Disassembler查看.o文件对比:



发现有四处值得注意的点:


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.o0.94K  HDClassDemo.o
复制代码


通过Hopper Disassembler查看.o文件对比:



在这种情况下,有两个结论


1、letvar的二进制大小会小,减少部分主要是在setter/modifykvo字段中。所以开发过程中养成好习惯,非必要不使用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)中的二进制中有多个内存地址的存储和读取端,一个变量会有两次ldurstr操作,猜测分别对 变量名称和类型的两次操作(下图是 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.o1.92K  HDClassDemo.o
复制代码


2、如果变量都是 var 修饰,struct 远胜于 class,变量越多,优势越大:


1个变量:1.54K  HDClassDemo.o1.48K  HDStructDemo.o
60个变量:44.21K HDClassDemo.o24.22K HDStructDemo.o
100个变量:71.74K HDClassDemo.o38.98K HDStructDemo.o
复制代码


3、如果变量都是 var 修饰,但是都遵循 Decodable 协议,这里又有乾坤:


这种情况有可能在项目中存在,并且规律不是简单的谁大谁小,而是根据变量的不同,呈现不同的规则:


使用脚本快速创建分别包含 1-200 个变量的 200 个文件


fileCount=200for (( 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 "}" >> $structDecodableFiledone
复制代码


得到 200 个文件后,选择arm64架构编译后,分析linkmap文件,得到的文件大小为:


index  Class  Struct  ClassDecodable  StructDecodable1  0.7  0.15  3.03  2.322  1.53  1.48  6.54  6.373  2.23  1.88  8.12  7.664  2.94  2.31  9.37  8.655  3.64  2.69  10.73  9.696  4.34  3.08  12.05  10.667  5.04  3.46  13.36  11.638  5.74  3.84  14.62  12.629  6.45  4.22  14.97  13.6110  7.15  4.62  16.11  14.911  7.85  5.02  17.25  15.9612  8.55  5.42  18.39  17.0613  9.26  5.82  19.53  18.214  9.96  6.22  20.67  19.36.........76  53.61  31.09  92.19  91.9177  54.31  31.49  93.34  93.35.........198  139.69  79.99  234.45  329.59199  140.4  80.39  235.58  332200  141.11  80.79  236.72  334.43
复制代码


对于的增加曲线图为:



HDStructDecodableObj 在 77 个变量下体积将返超 HDClassDecodableObj


根据曲线规则,可以得出 Class、Struct、ClassDecodable 增长是线性函数,对应的分别函数近似为:


Y = 0.825 + X * 0.705Y = 1.0794 + X * 0.4006Y = 5.3775 + X * 1.1625
复制代码


HDClassDecodableObj 的函数规则分布猜测可能是一元二次函数(抛物线)对数函数。在真实对比测试数据均不符合,也可能是分段函数吧。有知晓的同学请告知。

四、预防策略

圣人云:不治已病治未病,不治已乱而治未乱。


京喜从 2020 年开始陆续使用Swift作为业务开发的主要开发语言,特别是在商详、直播、购物车、结算、设置等业务已经全量化。单单将商详中的PGDomainModelPGDomainDatastruct改成class类型,该模块的二进制大小从12.1M左右减少到5.5M,这主要是因为这两个对象本身的变量较多,并且被大量其他楼层类赋值使用导致,收益可谓是具大。其他模块收益相对会少一些。



可以通过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
复制代码


编译报错的效果如下:



规则也暂时发现的两个问题:

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

发布于: 刚刚阅读数: 2
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
Swift之struct二进制大小分析_swift_京东科技开发者_InfoQ写作社区