【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

作者:刘夏,现在就职于字节跳动的移动中台团队

审阅:Leo,iOS 开发,老司机技术周报编辑,抖音 iOS 根底体会负责人

Session 10216 围绕 Swift 言语中的 Automatic Reference Counting (自动引证计数)机制讲述了实践过程中目标生命周期改变或许引发的问题以及如何从言语或代码规划层面去规避这些问题。提到 ARC 或许许多 Objective-C 程序员都非常了解(实践上 Objective-C 的 ARC 特性启发自 Swift),这儿所描述的多数问题在 Objective-C 代码中也相同存在,能够借鉴其处理办法。

ARC 的基本概念

Swift 供给了 structenum 之类的值类型,在实践中咱们应该尽或许运用值类型,值类型在传递和赋值时将进行仿制,然后防止一些引证类型运用时潜在的风险(比方目标被预期之外的代码持有导致内存问题或线程安全问题)。但 Swift 中也供给了 class 这种引证类型,当你运用 class 时 Swfit 会经过 ARC 机制来办理目标的内存。由于 class 的运用也非常广泛(比方承继来自 Objective-C 的类),所以为了写出有效的 Swift 代码,了解 ARC 的作业原理显得十分重要。

Swift 中一个目标的生命周期开端于 init() 并于目标最终一次被运用后完毕, ARC 会在目标生命完毕后开释其内存然后完成自动内存办理。ARC 经过引证计数来盯梢一个目标的生命周期,Swift 的编译器会自动插入 retain/release 语句进行引证计数的增减:在运行时履行 retain 会添加引证计数,而履行 release 则会减少引证计数,当引证计数减少到 0 目标就会被开释,下面让咱们经过一个比如来看了解:

 class Traveler {
   var name: String
   var destination: String?
 }
 func test() {
   let traveler1 = Traveler(name: "Lily")
   let traveler2 = traveler1
   traveler2.destination = "Big Sur"
   print("Done traveling")
 }

在上述比如中,咱们声明了一个名为 Traveler 的类,它有 namedestination 两个特点。在 test() 函数中:1)首要一个 Traveler 目标被创立并赋予 traveler1,此刻引证行为开端,然后这个引证被拷贝到 traveler2,此刻对 traveler1 的引证完毕:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

由于目标构造时引证计数为 1,所以依据规则赋值给 traveler2 后应该进行 release

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

traveler2 的引证开端于赋值,在其 destination 特点被更新后引证完毕:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

于是关于 traveler2 也应该在对应方位进行 retainrelease

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

如此一来初始计数为 1 的 Traveler 目标能够在 print语句之前将计数归零然后正确开释:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

从上述比如能够看到 Swift 中目标生命周期是依据运用状况的(use-based),对一个目标能确保一个最小生命周期(留意实践上的生命周期或许更长可是不会更短),即从初始化开端到最终一次运用后完毕,这和 C++ 栈目标依据 scope 的生命周期(RAII)不同:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

然而在实践状况中,编译器的会对实践插入 retainrelease 指令方位和数量进行调整(取决于优化策略收效状况),导致咱们观察到的目标生命周期或许会超过最小生命周期:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

weak 和 unowned 带来的问题及处理办法

在多数状况下,目标切当生命周期并不会影响程序的行为,可是关于 weakunowned 以及 deinitializer 等言语特性,假如你的程序依靠于目标观察到的切当生命周期而不是编译器确保的最小生命周期,那么你很或许在未来会遇到一系列的问题。这类代码在当下能正常运行仅仅一个偶尔,目标观察到的生命周期会随着未来 Swift 编译器完成细节的改动而改变,这类 bug 或许无法在开发环境中被发现,并或许躲藏相当长一段时间,可是,当编译器升级带来 ARC 优化水平的提升,或许咱们自己代码的其它改动导致 ARC 优化策略收效,此类问题就会露出出来。

不像 Swift 中默认的强引证类型(strong references),weakunowned 引证类型并不会参加引证计数办理,因此,weakunowned 引证常常会被用来打破目标间的循环引证。咱们看一个循环引证的比如:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶
test 函数中 traveler 目标和 account 目标相互持有一个强引证,导致函数完毕后彼此的引证计数依旧为 1,无法开释。这种状况下你能够经过一个 weak 或许 unowned 引证来打破循环引证。由于它们不会参加引证计数办理,所以拜访被引证的目标时它或许已被开释,当这种状况发生的时分,Swift 运行时会对 weak 引证的拜访返回 nil,而对 unowned 引证的拜访发生 trap。

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

在这个比如中,咱们运用 weak 引证来打破循环引证是没问题的。可是,假如仅仅由于你此刻观察到目标实践生命周期还没完毕,就在编译器确保的最小生命周期之外仍然运用 weak 引证去拜访一个目标,那么这块代码在未来就或许会发生 bug,让咱们看一个比如:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

此处在 account.printSummary() 调用时 traveler 目标的运用现已完毕,依据最小生命周期的确保,此刻 traveler 目标是能够被合法开释的,导致 traveler!.name 引发 crash。尽管这儿能够用 optional binding 来防止 crash,一旦后续编译器或许代码的变化导致目标生命周期被优化,这儿依旧会留下一个静默的 bug:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

那么有没有更好的办法来处理这个问题呢?这儿有一些技巧能够用来安全地处理 weakunowned 引证带来的问题,可是不同的技巧有前期完成本钱后期维护本钱的不同取舍,让咱们经过比如逐个来看:

  1. 运用 withExtendedLifetime(), 在调用 printSummary() 时自动确保 traveler 的生命周期,防止潜在的 bug:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

这样也能够到达相同的作用:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

可是这种处理计划很软弱,由于它将确保正确性的责任从编译器转交到程序员的身上,你需求考虑每次 weak 引证的拜访是否有潜在的 bug 然后对应运用 withExtendedLifetime() ,假如失去操控,或许导致整个 codebase 中到处是 withExtendedLifetime() ,然后带来后期的维护本钱。

(注:在 Objective-C ARC 中你能够运用 __attribute__((objc_precise_lifetime)) 或许 NS_VALID_UNTIL_END_OF_SCOPE 来标注变量以到达相似的作用)

  1. 经过从头规划类的 API 来规避问题:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

这种计划将 printSummary() 办法从 Account 类移动到了 Traveler 类中,然后将 Account 中的 traveler 特点标记为 private weak。如此一来再 traveler.printSummary() 被履行的时分 accounttraveler 的生命周期都能得到确保。

  1. weakunowned 引证不仅会带来性能上的开支,并且在 API 规划不当还会带来潜在的问题。所以在运用前应该停下来考虑引进 weakunowned 引证是否是有必要?它们是否是用来打破引证环的?能否在一开端就防止引证环的存在?这儿供给一种防止引证环的办法:经过从头规划你的代码,将环状关系转化成树状关系来处理:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

在之前的规划中,Account 需求引证 Traveler 仅仅由于需求拜访 Travelername 特点, 于是咱们能够将 name 提取到一个新的类 PersonalInfo 中,然后让 TravelerAccount 都去引证同个 PersonalInfo 目标:

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

这种办法尽管添加了前期的完成本钱,但这却是消除所有潜在目标生命周期问题的终极办法。

deinitializer 带来的问题及处理办法

让咱们来看另一个场景:deinitializer 中的副作用 ,它也会让目标的实践生命周期影响程序的行为。Swift 中一个类的 deinitializer 会在目标被开释前被调用,这让它发生的副作用能够被外部的程序所观察到,假如你写的代码依靠 deinitializer 的履行次序那么就或许埋下躲藏的 bug,并在今后目标的实践生命周期发生改变时迸发。

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

在上述代码中,当 Traveler 目标开释的时分会触发其持有的 TravelMetrics 目标履行 publish (上传当时核算出来的抢手景点)。

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

test() 函数中,metrics 目标会在最终调用 computeTravelInterest 核算现在最抢手的景点,那么问题就来了:假如 Traveler 的生命周期被优化缩短了,那么它 deinit 办法会在 computeTravelInterest 前就履行,而此刻抢手景点数据还没核算,publish 的就是错误的数据。

关于这个场景,前面讲到的三种办法仍然适用:

  1. 运用 withExtendedLifeTime() 确保 travelerdeinit 履行机遇:

    【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

  2. 修正完成,将 computeTravelInterest 的调用放到 Travelerdeinit 中,一起将 travelMetrisc 标记为 private

    【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

  3. 从头规划运用 defer 防止依靠 deinitializer 中的副作用:

    【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

Swift 编译器的相关新特性

这次 WWDC 之所以专门有个 session 来讲 ARC 目标生命周期其中一个原因是 Xcode 13 引进了一个新的优化选项: Optimize Object Lifetimes

【老司机精选】Swift 中的 ARC 机制: 从基础到进阶

它对应的 Swift 编译器参数是: -Xfrontend -enable-copy-propagation,敞开这项优化后会导致已有代码中一些目标实践生命周期被缩短,然后露出一些躲藏已久的 bug。留意这仍是一个试验性质的选项(所以需求经过 -Xfrontend 在编译器 driver 中敞开),现在默认没有敞开,后续合作其它的东西链支撑默认打开:

Disabling copy propagation now is only a temporary deferral, we will
still need to bring it back by default. However, by then we should
have:
- LLDB and runtime support for debugging deinitialized objects
- A variant of lifetime sortening that can run in Debug builds to
  catch problems before code ships
- Static compiler warnings for likely invalid lifetime assumptions
- Source annotations that allow those warnings to protect programmers
  against existing dangerous APIs

重视咱们

咱们是「老司机技术周报」,一个继续追求精品 iOS 内容的技术公众号。欢迎重视。

重视有礼,重视【老司机技术周报】,回复「2021」,领取 2017/2018/2019/2020 内参

支撑作者

在这儿给我们推荐一下 《WWDC21 内参》 这个专栏,一共有 102 篇关于 WWDC21 的内容,本文的内容也来源于此。假如对其余内容感兴趣,欢迎戳链接阅览更多 ~

WWDC 内参 系列是由老司机牵头组织的精品原创内容系列。 现已做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并召唤一群一线互联网的 iOS 开发者,结合自己的实践开发经验、苹果文档和视频内容做二次创造。