查看原文
其他

Swift代码优化指南 | 如何最大化实现性能提升?

快手大前端技术 快手大前端技术 2021-12-20

导读


基于快手主站拥抱 Swift 的美好愿景,主站的业务需求和重构需求都会逐步使用 Swift 进行开发。本篇文章总结了一些 Swift 优化方案,从内存占用、编译期、运行时速度提升等角度,结合底层原理进行分析总结。


文 / Richard

编辑 / 乔

全文共4309字,预计阅读时间10分钟。


#

编译速度优化

费时的类型推断

现实运用中的取舍

#

内存分配与占用

再讨论 Struct 的使用

用轻便的 Enum 替代 String

优化集合类型

减少不必要的桥接

善用写时复制

#

运行时速度优化

了解三种函数派发

使用 final,private 优化派发方式

#

结语

编译速度优化



费时的类型推断


Swift 的类型推断允许我们在编写代码时省略许多变量和表达式的类型。在大多数主流语言中,类型信息可以从表达式树的叶子结点流向根节点,如下方例子中的变量 three 可以从 round(pi) 获得 Int 类型的推断,然而Swift 还允许类型信息从表达式树的根节点向下流动到叶子节点,比如下例中2.71828从变量 e 获得了 Float 的类型推断。


func round(_ x: Double) -> Int { /* ... */ }var pi: Double = 3.14159var three = round(pi)
func identity<T>(_ x: T) -> T { return x }var e: Float = -identity(2.71828)


这种双向的类型推断过程分为两个阶段:约束生成和约束求解。第一阶段和自动布局引擎 Autolayout原理类似,求解器通过获取表达式和一些上下文信息,生成一组类型约束来描述各种子表达式的类型关系;约束求解过程中,求解器会为约束系统中的每个类型变量分配具体类型逐一尝试,最坏的情况下可能需要指数级别的求解时间。结合 Swift 的类型重载(函数重载、运算符重载),类型检查的速度会变得更慢。每个重载集都将解的数量乘以 N;再加上 Swift 大量的隐式转换:子类和父类的转换、具体类型和协议或泛型之间的转换等,需要创建、求解的约束数量级会越来越大,编译时间会越来越长。


例如,编译“ if flag == false ”这样的表达式会比“ if !flag ”所需时间长很多。判等符号常常给我们一种错觉,认为符号两边的类型一定都是布尔值。但实际上,一方面 == 经过了运算符重载(Swift 标准库中对 == 的重载有七十余种),另一方面等号右边的 false 可能是遵循 ExpressibleByBooleanLiteral 协议的任何对象(Bool, NSNumber, ObjCBool 或者其他自定义类型)。因此 flag == false 必须找出 == 的具体实现和符合 ExpressibleByBooleanLiteral 类型的所有可能组合,再决定出最优的匹配。类似地,因为有运算符重载和 Numeric Literals,面对像 1+1+1 这样的表达式,编译器要做的计算要比想象中更多。因此,在声明变量时显示指定类型、减少包括大量运算符的表达式以及减少类型重载等,都能减少类型推断,从而提升编译速度。


现实运用中的取舍


上述例子只是提出了通过优化代码来提高编译速度的可能,实际操作中我们还是需要在良好的代码风格和编译时间中做好取舍。多数情况下,我们或许并不需要为了提升一点编译时间牺牲优雅的 Swift 语法糖和良好的代码风格。例如,“!xx ”的表达式在现实场景中阅读体验可能没有“ if xx == false ”这样显示定义好(代码阅读过程中非符号很容易被忽略)。相对编译速度的提升,良好的编码风格更重要,因为编译器会持续做优化,而难读懂的代码永远难读懂。


了解了 Swift 的类型推断,在定义对象和声明方法前,可以再思考下面几个问题:

  • 这个对象会在堆区还是栈区进行分配和销毁?

  • 在传递对象时,是否会有引用计数的资源耗费产生?

  • 在调用对象方法时,这个方法会被静态派发还是动态派发?


下列话题将围绕这一系列问题来展开。



内存分配与占用



再讨论 Struct 的使用


了解过 Swift 的同学可能已经听过许多关于用 Struct 代替 Class、纯值类型代替引用类型的建议。官方文档《Choosing Between Structures and Classes》(https://developer.apple.com/documentation/swift/choosing_between_structures_and_classes)中建议的第一条也提醒我们 Use structures by default。


网络上已经有许多关于Struct 和 Class 各自优劣的详尽比对文章,之前我们也推送过相关文章:A站 的 Swift 实践 —— 上篇A站 的 Swift 实践 —— 下篇。App 内每个线程会维护独立的栈内存,使用Struct可以节省很多对于线程安全的维护。


Swift 的 Struct 在理想状况下会表现出种种优点,但在绝大多数现实场景中都没有理想状况下完美。我们把 Swift 类型划分为值类型和引用类型,现实生活中却存在着大量的组合类型(包含引用类型的值类型,这里暂且称之为组合类型)。


来看下面一段代码:


struct User { var age: Int var uid: String var phoneNum, name, bio, address: String}
func isLegal(user: User) -> Bool { user.age >= 18}
let user = User(...)let anotherUser = user


注:Swift中的字符串类型 (String) 和集合类型 (Array, Dictionary, Set) 为值语义,但为了解决频繁复制导致的内存性能问题,字符串和集合类型底层实现了写时复制 (COW) 机制,数据存储在堆内存。这里我们可以简单地把它们理解为引用类型。


由于 Struct 作为值类型在栈区管理内存,将 user 赋值给 anotherUser 会产生一份新的栈区拷贝。但又因为 user 包含了 uid 等五个字符串属性,它们的底层数据存储在堆区,通过指针和引用计数管理内存。对 user 的栈区拷贝会同时拷贝uid, phoneNum, name, bio, address 这5份指向堆区的引用指针。假如对它进行6次复制,就会产生30次指针拷贝。相对于只有1份指针的 Class,这种组合类型十分耗费内存,引用计数的管理开支也是 Class 的倍数级。


如果把组合类型 user 作为参数传递给方法调用,对 user 所有引用类型成员都会进行一次 retain 和 release,对于以下的方法调用,这5个引用属性会触发10次引用计数操作。


func isLegal(user: User) -> Bool { retain(user.phoneNum._storage) retain(user.name._storage) retain(user.bio._storage) retain(user.uid._storage) retain(user.address._storage) return user.age >= 18 release(user.phoneNum._storage) release(user.name._storage) release(user.bio._storage) release(user.uid._storage) release(user.address._storage)}


实际业务场景下,数据请求、模型转换以及用户交互等可能导致 user 对象进行多次传递,每次传递都会触发相关成员引用计数的操作。如果把 user 定义为 Class,retain 和 release 操作就能上移到整个类的层级,引用计数导致的性能损耗也会大幅度减少。


在实践中,如果需要 immutable 或者值语义优先用 Struct;需要引用语义用 Class;对于可能会被频繁拷贝的组合类型,考虑用 Class 定义,从而减少引用技术的资源损耗和指针拷贝的内存占用。


用轻便的 Enum 替代 String


Swift 的枚举在栈区进行内存管理,并且有非常细分的内存布局策略。为了减少内存占用,对于是否存在成员、有单个成员或是多个成员、是否携带关联值 (Associated Values)、单个成员还是多个成员携带关联值的Enum,编译器有不同的分解方式。


enum Empty { }enum Tiny { case a}enum Small { case a, b, c}enum MultiPayload { case a(num: Int), b(str: String), c}enum SinglePayload { case a(num: Int, float: Float, double: Double) case b,c}MemoryLayout<Empty>.size // 0 byteMemoryLayout<Tiny>.size // 0 byteMemoryLayout<Small>.size // 1 byteMemoryLayout<MultiPayload>.size // 1 + max(8, 16) = 17 bytesMemoryLayout<SinglePayload>.size // 1 + (8 + 8 + 8) = 25 bytes


从上面代码可以观察出:

  • 对于空枚举(Empty)或单一成员枚举(Tiny),枚举类型本身不占用内存;

  • 对于包含多个成员,并且所有成员都不携带关联值的枚举(Small),枚举类型占用一个字节内存;这个策略很好理解,编译器可以给每个成员用数字标签进行表示;此处笔者做了一个实验,当枚举包含超过256名成员时,打印的枚举类型大小为两个字节,符合前述的猜想;

  • 对于仅有单个成员携带关联值的枚举(SinglePayload),编译器会根据关联值的内存大小和剩余成员变量的数量进行内存布局优化,选择占用内存最少的方案;

  • 对于有多个成员携带关联值的枚举(MultiPayload),Swift 的内存布局实现了 C 语言的共同体,枚举类型为成员内存最大的关联值的内存再加一字节大小的标签。


Swift 的枚举在内存布局上的处理非常细致,尽可能地将更多的信息打包存放在更少的字节中,所以对于上述大量操作 String 类型可能造成的性能瓶颈,可以适当使用 Enum 替代 String。


优化集合类型


集合类型的内存分配策略


Swift 中数组和字符串的内存分配都采用了增量分配 (Geometric allocation approach) 的策略,来看下面代码:


var array = [1,2,3,4,5]array.capacity // 5array.append(6)array.capacity // 10array.append(contentsOf: [7,8,9,10])array.capacity // 10array.append(contentsOf: Array(11...41))array.capacity // 42array.append(contentsOf: [42, 43])array.capacity // 84


创建包含1,2,3,4,5这五个元素的数组,数组容量为5,添加一个新的元素6,数组的容量会从5翻倍至10,以此类推。当数组长度 (count) 超出容量 (capacity) 时,系统会将数组的容量翻倍。除此之外,为了让数组的内存保持连续,每次扩容会把数组的元素拷贝到一份新的连续内存地址。


预分配内存


为了减少频繁内存分配带来的负担,Swift 提供 reserveCapacity 接口来一次性分配连续内存给数组。如果在实际场景中已知需要多大的数组,可以通过 reserveCapacity 来提前预留连续的内存空间,以避免后续由写入操作带来多次内存分配和释放导致的性能损耗。


通过脚本对比从50次到50,000次 for 循环写数组操作,可以观察到,频繁写入的数组长度越小,使用 reserveCapacity 带来的性能提升更明显。



惰性序列


Swift 中通过使用诸如 map, compactMap与 filter 的高阶函数可以写出优雅的函数式代码,但对于较大的集合,使用不当会带来负担。假设有这样一道题,找出数组中能被 33 整除的第一个大于 200 的数字,使用高阶函数可以很容易地写出如下代码(先不考虑这个实现是否合理):


largeArray.filter { $0 % 33 == 0 }.first(where: { $0 > 200 })


由于 Swift 中的函数默认为急迫函数 (eager function),代码会立即遍历所有元素,找到并返回231。某些场景下我们可能不需要对集合的所有元素进行遍历,而 Swift 提供的惰性序列很好地解决了这个问题。惰性序列在值被取用时才进行求值,节约了不必要的计算量存储空间。


largeArray.lazy.filter { $0 % 33 == 0 }.first(where: { $0 > 200 })


对比使用高阶函数、添加条件判断以便提前返回的 for 循环以及使用惰性序列这三种情况下的数组遍历,可以观察到,使用了惰性序列的性能几乎和提前返回的 for 循环持平,执行速度是高阶函数的一百多倍:



减少不必要的桥接


在 Objective-C 框架的背景下,Swift代码调用OC的内容,由于编译和运行时特性的差异,需要通过桥接让 OC 和 Swift 代码能够互相调用。用 @objc 关键词定义 Swift 方法,可以将函数暴露给 OC 运行时,编译期会生成两份函数信息,一份给 Swift 调用,另外一份跳板函数信息存放在 OC 运行时结构中。


集合类型的桥接还可能会额外消耗 O(n) 时间,进行遍历逐个转换。减少OC 和 Swift 之间一些非必要的桥接可以减小编译器的工作量,从而编译源文件时符号查找时间也能更快。


ContiguousArray 替换 NSArray


Swift 引入了 ContiguousArray,其设计的初衷是为了减少 Objc 桥接工作的负担。Swift.Array 底层存放的 _ArrayBuffer 承载了需要桥接到 NSArray 的功能,所以增加了一些类型检查的操作。而 ContiousArray 底层的 _ContiguousArrayBuffer 删除了桥接相关的代码。因此在不需要将数组桥接到 NSArray 或传递给 Objective-C API 的场景下,使用 ContiguousArray 可能比 Array 更有效,并且具有更可预测的性能。概括来说:Swift 中需要暴露给OC使用的数组,选择NSArray。如果需要引用属性,可以选择ContiguousArray(元素为引用类型),否则选用Array。


善用写时复制


值类型为了维持值语义,会在每次赋值、参数传递以及修改时进行复制。虽然 Swift 本身会做写时复制优化,在修改时减少复制频率,但是这仅针对于标准库提供的集合和字符串类型有效。对于简单的自定义结构体,编译器或许会做一些写时复制的优化,但由于无迹可寻,编译器的优化行为无法得到保证,自行实现写实复制是最直接、也最有保障的方式。


通过访问“isKnownUniquelyReferenced”方法(https://developer.apple.com/documentation/swift/2429905-isknownuniquelyreferenced)可以检查 class 对象是否只有一份引用,利用这个函数,可以封装一份内部持有 class 的结构体。在每次写入操作前,对内部持有的 class 对象进行唯一引用检查,如果不是唯一引用再进行写操作,达到写时复制的作用。



运行时速度优化


了解三种函数派发

和其他编译型语言类似,Swift 的函数派发可以分为静态和动态两种机制,而动态派发又分为函数表派发和消息派发。


Swift 函数的派发方式和对象类型、函数声明位置有关。值类型使用静态派发;类(不管是否继承自NSObject)中定义的函数默认使用函数表派发;在继承自 NSObject 的类中重写了基类的函数,比如 UIViewController 的生命周期方法,会通过消息派发调用;类的 Extension 中声明的函数默认使用静态派发;协议中声明的带有默认实现的函数会使用函数表派发,协议由于特殊的 PWT 结构,存在多种派发可能;如果用 @objc + @dynamic 修饰过的函数,会通过 Objective-C 的消息派发方式进行调用。


简单来说,静态派发在编译期能确定函数的内存地址,从而直接找到对应实现;函数表派发通过读取函数表 vtable 找到对应的函数指针;消息派发则为 objc_msgsend 方式;这三种派发方式可查看如下链接文章进行了解(网址:https://betterprogramming.pub/a-deep-dive-into-method-dispatches-in-swift-65a8e408a7d0?gi=cac13485c979 )。由于在调用效率上,静态派发 > 函数表派发 > 消息派发,如果能减少不必要的动态派发,运行时函数调用速度得到显著提升。


使用 final, private 优化派发方式


动态派发的目的是支持函数多态,如果对于不需要重写的函数,可以用 final 修饰符修饰,编译器会将函数优化为静态派发。


如果对象只在声明的文件中可见,可以用 private 或 fileprivate 进行修饰。编译器会对private 或 fileprivate对象进行检查,确保没有其他继承关系的情形下,自动打上final标记,进而使得对象获得静态派发的特性。


需要注意的是,单独使用 @objc 修饰函数并不会使得函数派发变成消息派发,@objc 的作用仅是将 Swift 函数暴露给 Objective-C 调用,所以 @objc 可以和 final, private 关键词一起修饰函数定义。

结语


早在2015年底,Swift 已经开源,上述某些基于语法的性能优化实现原理也都可以在源码中找到答案。本篇从语法角度优化代码,短期来看收益或许没有代码设计、架构重构、修一个 OOM bug 所带来的收益高,但是长远来看对养成良好的代码书写习惯有所裨益,某种程度上从根源减少错误的产生。


更多 Swift 语言的性能优化和开源社区正在做的努力没有在本文中一一列举,希望本篇文章可以抛砖引玉,引发大家更多的讨论和总结。


参考资料

[1] https://github.com/apple/swift/tree/main/docs

[2] https://belkadan.com/blog/tags/swift-regrets/

[3] https://forums.swift.org/t/memory-used-by-enums/2060/2

[4] https://developer.apple.com/videos/play/wwdc2016/416/

[5] https://medium.com/@lucianoalmeida1/understanding-swift-copy-on-write-mechanisms-52ac31d68f2f

[6] WWDC 2015: Session 409, Session 414 / WWDC 2016: Session 216 / WWDC 2018: Session 229


欢迎加入

快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。


在这里你可以获得:

  • 提升架构设计能力和代码质量

  • 通过大数据解决用户痛点的能力 

  • 持续优化业务架构、挑战高效研发效能

  • 和行业大牛并肩作战


我们期待你的加入!请发简历到:

app-eng-hr@kuaishou.com



: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存