阅读视图

发现新文章,点击刷新页面。

WWDC23 - What’s new in Swift

摘要:这个 Session 涉及了 Swift 的新语法特性和 Swift Macro 的话题,这些功能对于编写更加灵活和健壮的 API 以及高质量代码起到了很大的帮助。此外,也深入探讨了在受限环境下使用 Swift 的优势,并讨论了 Swift 在适配多种平台设备和语言方面的灵活性。

本文基于 Session 10164 梳理。

审核:

  • stevapple:学生,Swift/Vapor 社区贡献者

  • kemchenj:老司机技术核心成员 / 开源爱好者 / 最近在做 3D 重建相关的开发工作

今年是 Swift 的一次重大更新,这个 Session 将和大家一起聊聊 Swift 语言今年的一些改进、简洁的语法糖以及一些强大的新 API 的使用。一年前,Swift 项目的核心团队宣布了成立 Language Steering Group ,主要负责监督 Swift 语言和标准库的修改。从这之后,Language Steering Group 已经审核了 40 多个新的提案,这些会覆盖到我们今天所要讨论的几个。

有很多社区提案都有相同的思路和看法,所以我们会将这些类似的提案进行合并。而在这之中,Swift Macro(在 C 的中文教材中,一般称作,下文继续使用 Macro 进行代替)是提及最多的一个提案,所以我们后文也会具体讨论 Swift 5.9 中关于 Swift Macro 的新特性。

当然,语言的演进只是 Swift 社区工作的一部分,一门成功的语言需要的远不止这些,它还需要配合优秀的工具链、多平台的强大支持以及丰富的文档。为了全方位的监控进展,核心团队也正在组建一个 Ecosystem Steering Group ,这个新的团队也在 swift.org 的博客中有所提及,我们可以一起期待一下这个团队的进一步公告。

现在我们进入正题,来讨论一下今年 Swift 语言的更新。

if/elseswitch 语句表达式

Swift 5.9 中允许 if/elseswitch 作为表达式,从而输出返回值。这个特性将会为你的代码提供一种优雅的写法。例如,你如果有一个 let 变量,其赋值语句是一个非常复杂的三元表达式:

let bullet =
    isRoot && (count == 0 || !willExpand) ? ""
        : count == 0    ? "- "
        : maxDepth <= 0 ? "▹ " : "▿ "

对于 bullet 变量的赋值条件,你可能会觉得可读性十分差。而现在,我们可以直接使用 if/else 表达式来改善可读性:

let bullet =
    if isRoot && (count == 0 || !willExpand) { "" }
    else if count == 0 { "- " }
    else if maxDepth <= 0 { "▹ " }
    else { "▿ " }

如此修改后,我们的代码会让大家一目了然。

另外,在声明一个全局变量的时候,这种特性会十分友好。之前,你需要将它放在一个 closure 中,写起来是十分繁琐。例如以下代码:

let attributedName = {
    if let displayName, !displayName.isEmpty {
        AttributedString(markdown: displayName)
    } else {
        "Untitled"
    }
}()

但是当我们使用这个新特性,我们可以直接去掉累赘的 closure 写法,将其简化成以下代码:

let attributedName = 
    if let displayName, !displayName.isEmpty {
        AttributedString(markdown: displayName)
    } else {
        "Untitled"
    }

因为 if/else是一个带有返回值的表达式,所以这个特性可以避免之前啰嗦的写法,让代码更简洁。

Result Builder 相关工具链优化

Result Builder (结果生成器)是驱动 SwiftUI 声明式语法的 Swift 特性之一。在前几个版本中,Swift 编译器需要花费很长的时间来确定错误,因为类型检查器搜索了大量无效路径。

从 Swift 5.8 开始,错误代码的类型检查速度将大幅度提升,对错误代码的错误信息现在也更加准确。

例如,我们来看下以下代码:

在这个代码中,核心的问题就是 NavigationLink(value: .one) 中,.one 是一个类型错误的参数。但是在 Swift 5.7 旧版本中,会报出如图中展示的错误。Swift 5.8 对 Result Builder 诊断做了优化,不仅提高了诊断的准确性,而且也大幅度优化了时间开销。在 Swift 5.8 及之后的版本中,你将会立即查看到正确的语义诊断错误提示,例如下图:

repeat 关键字和 Type Parameter Pack

在日常使用 Swift 语言中,我们会经常使用 Array ,并结合泛型特性来提供一个存储任何类型的数组。由于 Swift 具有强大的类型推断能力,使用时只需要提供其中的元素,Swift 编译器将会自动推断出来这个数组的类型。

但是在实际使用中,这个场景其实具有局限性。例如我们有一组数据需要处理,且它们不仅仅是单一类型的 Result,而是多个类型的 Result 入参。

struct Request<Result> { ... }

struct RequestEvaluator {
    func evaluate<Result>(_ request: Request<Result>) -> Result
}

func evaluate(_ request: Request<Bool>) -> Bool {
    return RequestEvaluator().evaluate(request)
}

这里的 evaluate 方法只是一个实例,因为在实际使用过程中,我们会接收多个参数,就像下面这样:

let value = RequestEvaluator().evaluate(request)

let (x, y) = RequestEvaluator().evaluate(r1, r2)

let (x, y, z) = RequestEvaluator().evaluate(r1, r2, r3)

所以在实现的时候,我们还需要实现下面的这些多入参泛型方法:

func evaluate<Result>(_:) -> (Result)

func evaluate<R1, R2>(_:_:) -> (R1, R2)

func evaluate<R1, R2, R3>(_:_:_:) -> (R1, R2, R3)

func evaluate<R1, R2, R3, R4>(_:_:_:_:)-> (R1, R2, R3, R4)

func evaluate<R1, R2, R3, R4, R5>(_:_:_:_:_:) -> (R1, R2, R3, R4, R5)

func evaluate<R1, R2, R3, R4, R5, R6>(_:_:_:_:_:_:) -> (R1, R2, R3, R4, R5, R6)

如此实现之后,我们就可以接收 1 - 6 个参数。但是好巧不巧,如果需要传入 7 个参数:

let results = evaluator.evaluate(r1, r2, r3, r4, r5, r6, r7)

对于这种尴尬的场景,在旧版本的 Swift 中就需要继续增加参数定义,从而兼容 7 个入参的场景。但是 Swift 5.9 将会简化这个流程,我们引入 Type Parameter Pack 这个概念。

func evaluate<each Result>(_: repeat Request<each Result>) -> (repeat each Result)

我们来看引入 Type Parameter Pack 概念之后我们将如何修改这个场景。

  1. <each Result> - 这里代表我将创建一个名字叫 Result 的 Type Parameter Pack;
  2. repeat each Result - 这是一个 Pack Expansion,它将 Type Parameter Pack 作为实际上表示的类型。其实这里你可以理解成(each Result)...,即 Type Parameter Pack 类型的不定参数。所以 repeat关键字更像是一个运算符(Operator);

通过这样定义,我们就可以传入不受限制个数的参数,并且可以保证每一个入参都是独立泛型类型。

当然这个特性,最大的受益场景就是 SwiftUI ,因为当一个 View 内嵌多个 View 的时候,SwiftUI 官方的方法就是通过泛型以及 Result Builder 进行设计的,并且最大的子 View有 10 个为上限的数量限制。当引入了 Type Parameter Pack 这个特性之后,限制将被突破,API 设计也更加简洁和易读。

Stevapple: type parameter pack 是 variadic generics(可变泛型)系列 feature 的一部分,更大的范畴上是增强 Swift 泛型系统的一部分。Variadic generics 的概念其实可以这么理解:generics 是对类型进行抽象,而 variadic generics 希望在此基础上增加对参数数量的抽象。具体的提案可以查看这里

Macro

在 Swift 5.9 中,对于 Macro 的支持是重大的更新。通过 Macro ,你可以扩展语言本身的能力,消除繁琐的样板代码,并解锁 Swift 更多的表达能力。

我们先来看断言(assert)方法,它是用于检查一个条件是否为 true

assert(max(a, b) == c)

如果是 false ,断言将停止程序运行。在通常情况下,你获得到错误信息很少,因为你需要修改每一处断言调用,增加 message 信息,这样才能有效定位很多边界情况问题。

其实 XCTest 也提供了一个 XCAssertEqual 方法,可以展示更多的错误信息。但我们实际操作后发现,即使我们知道了两边的值,也无法定位到到底是 max 方法的错误,还是右边 c 变量的问题。

所以我们应该怎么扩展错误信息呢?在 Macro 特性推出之前,也许我们没有更好的方法。下面我们来看看如何用 Macro 特性来改善这一痛点。首先我们使用新的 hash(#) 的关键字来重写这段代码。

#assert(max(a, b) == c)

或许你会觉得这个写法很熟悉,因为 Swift 中已经有很多类似的关键字,例如 #file#selector(...)#warning 的语法。当我们断言失败后,则会使用文本符号来展示一个更加详尽的图,例如这样:

如果我们想自己定义一个这种带有 # 符号的 Macro 要如何定义呢?

我们可以使用 macro 关键字来声明一个 Macro:

@freestanding(expression)
public macro assert(_ condition: Bool) = #externalMacro(
    module: "PowerAssertPlugin",
    type: "PowerAssertMacro"
)

大多数的 Macro 被定义为 External Macro,这些 External Macro 会在编译器插件的独立程序中被定义。Swift 编译器将使用 Macro 的代码传递给插件,插件产生替换后的代码,并整合回 Swift 程序中。此处,Macro 将 #assert 扩展成可以展示每个值的代码,其工作流程如下图:

Expression Macro

以上介绍的 #assert 这个 Macro 还是一个独立表达式。我们之所以称之为“独立”,是因为它使用的是 # 这个关键字作为前缀调用的一种类型。这种类型是使用 @freestanding(expression) 进行声明的。这种表达式 Macro ,可以作为一个指定类型的 Value,放在对应的位置,编译器也会更加友好地检测它的类型。

在最新版本的 Foundation 中,Predicate API 提供了一个表达式 Macro 的极好的例子。Predicate Macro 允许你使用闭包的方式,并且会对类型安全进行校验。这种可读性很强,我们看下面的示例代码,一探便知:

let pred = #Predicate<Person> {
    $0.favoriteColor == .blue
}
let blueLovers = people.filter(pred)

在 Predicate Macro 中,输入的类型是一个 Type Parameter Pack Expansion,这也就是它为什么能接受任意类型、任意数量的闭包入参。而返回值是一个 Bool类型的值。在使用上,我们发现它仍旧使用 # 符号的 Hashtag 方式,所以依旧是一个表达式 Macro。我们可以来看看它的定义代码:

@freestanding(expression)
public macro Predicate<each Input>(
    _ body: (repeat each Input) -> Bool
) -> Predicate<repeat each Input>

从定义上我们可以看到,这里很巧妙地用到了 Type Parameter Pack 这个特性,并且其返回值是一个 Predicate 类型的实例,从而可以实现多处复用的 Predicate 范式定义。

Attached Macro

除了这种表达式 Macro,我们还有大量的代码,需要我们对输入的参数,做出自定义的修改或者增加条件限制,显然没有输入参数的表达式 Macro 无法应对这种场景。此时,我们引入其他的 Macro,来帮助我们按照要求生成模版代码。

我们举个例子,例如在开发中,我们需要使用 Path 枚举类型,它可以标记一个路径是绝对路径还是相对路径。此时我们可能会遇到一个需求:从一个集合中,筛选出所有的绝对路径。例如如下代码:

enum Path {
    case relative(String)
    case absolute(String)
}

let absPaths = paths.filter { $0.isAbsolute }

extension Path {
    var isAbsolute: Bool {
        if case .absolute = self { true }
        else { false }
    }

    var isRelative: Bool {
        if case .relative = self { true }
        else { false }
    }
}

我们会发现,对于 enum 中的每一个 case 我们都需要写一个这种 isCaseX 的方法,从而增加一个过滤方法。此处是 Path 只有两种类型,但是如果在 case 更多的场景,这是一个相当繁琐的工作,而且一旦新增会带来很大的代码改动。

此处,我们可以引入 Attached Macro ,来生成繁琐的重复代码。使用后的代码如下:

@CaseDetection
enum Path {
    case relative(String)
    case absolute(String)
}

let absPaths = paths.filter { $0.isAbsolute }

此处的 @CaseDetection 是我们自定义的一个 Attached Macro,这个 Macro 的标志是使用和 Property Wrappers 特性中相同的 @ 符号作为语法关键字,然后将其加到你需要添加的代码前方进行使用。它会根据你对于 Attached Macro 的实现,来生成代码,从而满足需求。

在上面这个例子中,我们的作用范围是 Path 这个 enum 类型的每一个 Member ,所以在使用的时候,表达是 @attached(member)。Attached Macro 提供 5 种作用范围给开发者使用:

Attached Macro 类型 作用范围
@attached(member) 为类型/扩展添加声明(Declaration)
@attached(peer) 为声明添加新的声明
@attached(accessor) 为存储属性添加访问方法(set/get/willSet/didSet)
@attached(memberAttribute) 为类型/扩展添加注解(Attributes)声明
@attached(conformance) 为类型/扩展添加遵循的协议

通过这些 Attached Macro 的组合使用,可以达到很好的效果。这里最重要的例子就是我们在 SwiftUI 中经常使用到的 observation 特性。

通过 observation 特性,我们可以观察到 class 中的 Property 的变化。想要使用这个功能,只需要让类型遵循 ObservableObject 协议,将每个属性标记为 @Published ,最后在 View 中使用 ObservedObject 的 Property Wrapper 即可。我们来写个示例代码:

// Observation in SwiftUI

final class Person: ObservableObject {
    @Published var name: String
    @Published var age: Int
    @Published var isFavorite: Bool
}

struct ContentView: View {
    @ObservedObject var person: Person
    
    var body: some View {
        Text("Hello, \(person.name)")
    }
}

我们会发现以上的 3 步,会有很多繁琐且复杂的工作需要我们手工处理。如果我们忘记了一步,就无法完成观察 Property 变化、自动触发 UI 刷新这样的需求。

当我们有了 Attached Macro 之后,我们就可以简化声明过程。例如我们有一个 @Observable 的 Macro ,可以完成以上操作。则我们的代码就可以精简成这样:

// Observation in SwiftUI

@Observable final class Person {
    var name: String
    var age: Int
    var isFavorite: Bool
}

struct ContentView: View {
    var person: Person
    
    var body: some View {
        Text("Hello, \(person.name)")
    }
}

我们发现我们的代码得到了极大的精简,这得益于我们组合使用 Attached Macro 的效果。在 Macro 声明部分的代码如下:

@attached(member, names: ...)
@attached(memberAttribute)
@attached(conformance)
public macro Observable() = #externalMacro(...).

下面我们来深入探究一下每一个 Attached Macro 的作用。

以下是关键代码:

@Observable final class Person {
    var name: String
    var age: Int
    var isFavorite: Bool
}

首先,Member Macro 会引入新的属性和方法,编译器将 Macro 代码替换后将变成这样:

@Observable final class Person {
    var name: String
    var age: Int
    var isFavorite: Bool

    // 这里定义了一个 `ObservationRegistrar` 实例,
    // 用于管理观察事件的实例,当属性发生变化将通知所有 Observer
    internal let _$observationRegistrar = ObservationRegistrar<Person>()

    // access 方法会在属性被访问时调用,通过 ObservationRegistrar 的 access
    // 方法,并传入被访问的属性 keyPath,来触发事件
    internal func access<Member>(
        keyPath: KeyPath<Person, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    // withMutation 方法用于在修改属性时触发观察方法。在修改属性之前和之后分别触发观察事件,
    // 以便于观察者可以检测到属性的变化。这个方法通过 `ObservationRegistrar` 的 `withMutation`
    // 方法,传入被修改的属性的 keyPath 和一个闭包,这个闭包包含了对属性的修改操作
    internal func withMutation<Member, T>(
        keyPath: KeyPath<Person, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
}

其次,Member Attribute Macro 会为所有的属性添加 @ObservationTracked ,这个 Property Wrapper 会为属性添加 getset 方法。

最后,Conformance Macro 会让 Person 这个 class 遵循 Observable协议。

通过这三个宏的改造,编译器将代码进行展开后,我们的真实代码类似以下:

@Observable final class Person: ObservableObject {
    @ObservationTracked var name: String { get {  } set {  } }
    @ObservationTracked var age: Int { get {  } set {  } }
    @ObservationTracked var isFavorite: Bool { get {  } set {  } }

    internal let _$observationRegistrar = ObservationRegistrar<Person>()
    internal func access<Member>(
        keyPath: KeyPath<Person, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }
    internal func withMutation<Member, T>(
        keyPath: KeyPath<Person, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
}

虽然展开后的代码十分复杂,但是绝大多数代码都被 @Observable Macro 封装起来了,我们只需要输入以下的简洁版本即可实现。

@Observable final class Person {
    var name: String
    var age: Int
    var isFavorite: Bool
}

当你需要对 Macro 展开,希望更好地理解程序代码时,你可以使用 Xcode 的 Expand Macro 功能对 Macro 进行源代码展开。任何在 Macro 生成的代码中出现的错误消息都会引导你去展开代码,从而快速地发现问题。

Swift Macro 为 Swift 实现更具可读性的 API 、构建更简洁的实现语法提供了一种新的模式。Macro 可以根据我们的需求,批量生成 Swift 代码,并在程序中使用 @ 或者 # 语法快速引入,简化了业务代码,也提高了可读性。

本文只是对 Swift Macro 进行一个初步的介绍以便于你从零到一认识。如果想学习更多,可以查看《Write Swift Macro》这个 Session ,通过实践来编写自己的 Swift Macro。另外如果想对 Swift Macro 的设计理念有更深刻的了解,可以学习《Expand on Swift》 这个 Session。

Swift Foundation 升级

Swift 这门编程语言的定位是一种可扩展的语言,其设计理念是使用清晰、简洁的代码,方便阅读和编写。其中的一些强大的特性,例如泛型、async/await 支持等等,可以支撑像 SwiftUI 或 SwiftData 这种更贴近自然描述的框架,从而允许开发者将关注点聚焦在业务上。

为了实现更加宽泛的可扩展性,我们需要将 Swift 适配较于 Objective-C 更多的领域,其中也包括更底层的操作系统领域,这一部分之前是由 C 或 C++ 进行编写的。

最近我们在 Swift 社区中,开源了 Foundation 的 Swift 重构版本,这个措施将使 Apple 以外的平台也可以享受到 Swift 带来的更加高效的性能和开发优势。但是也以为着我们需要用 Swift 对大量的 Objective-C 和 C 代码进行重构。

截止至 macOS Sonoma 和 iOS 17,一些基本类型已经完成了 Swift 重构版本,例如 DateCalendarLocaleAttributedString。另外 Swift 实现的 Encoder 和 Decoder 性能相较旧版本也有所提升。

下图是我们通过跑这些类库 Benchmark 测试用例,所得出的 Swift 重构版本的性能提升数据:

这些性能提升除了得益于 Swift 整体工具链的性能,另外还有一个原因就是 macOS Sonoma 系统上,我们避免了语言调用时的桥接成本(Bridging Cost),不需要再调用 Objective-C 的代码。我们从 enumerateDates 这个方法的调用数据统计中可以看到这个变化:

Ownership & Non-copyable Type

在对 Foundation 进行重构时,有时在操作系统的底层操作中,为了达到更高的性能水平,需要更细粒度的掌控。Swift 5.9 引入了“所有权”(Ownership)的概念,用来描述在你的代码中,当值在传递时,是哪段代码在“拥有”该值。

也许这个 Ownership 用文字描述起来有一些抽象,我们来看下示例:

struct FileDescriptor {
    private var fd: CInt
    
    // 初始化方法,接受文件描述符作为参数
    init(descriptor: CInt) {
        self.fd = descriptor
    }

    // 写入方法,接受一个 UInt8 数组作为缓冲区,并抛出可能的错误
    func write(buffer: [UInt8]) throws {
        let written = buffer.withUnsafeBufferPointer {
            // 使用 Darwin.write 函数将缓冲区的内容写入文件描述符,并返回写入的字节数
            Darwin.write(fd, $0.baseAddress, $0.count)
        }
        // ...
    }
  
    // 关闭方法,关闭文件描述符
    func close() {
        Darwin.close(fd)
    }
}

这是一段 FileDescriptor 的封装代码,我们可以用它更加方便的进行写文件操作。但是在使用的时候我们会经常犯一些错误,例如:你可能会在调用 close() 方法后,再执行 write(buffer:)方法。再比如:你可能会在 write(buffer:) 方法后,忘记调用 close()方法。

对于上面说的第二个场景,我们可以将 struct 修改成 class,通过在 deinit 方法中,调用 close() 方法,以便于在示例释放的时候,自动关闭。

class FileDescriptor {
    // ...
    deinit {
        self.close(fd)
    }
}

但是这种做法也有它的缺点,例如它会造成额外的内存分配。虽然在通常情况下并不是大问题,但是在操作系统代码中某些受限的场景下是一个问题。

另外,class 构造出的示例,传递的都是引用。如果这个 FileDescriptor 在多个线程之间得到访问,导致竞态条件,或者持久化了这个实例导致引用计数始终大于 0 ,内存无法释放,进而引发内存泄漏。

再让我们重新回顾一下之前的 struct 版本。其实这个 struct 的行为也类似于引用类型。它会持有一个 fd 的整数,这个整数就好比引用了一个文件状态值,我们可以理解成打开了一个文件。如果我们复制了一个实例,相当于我们延长了这个文件的打开状态,如果后续代码中无意对其操作,这是不符合我们预期的。

Swift 的类型,无论是 struct 还是 class ,默认都是 Copyable 的。在大多数情况下,不会产生任何问题。但是有的时候,隐式复制的编译器行为,并不是我们希望的结果,尤其是在受限场景下,内存分配是我们要重点关注的问题。在 Swift 5.9 中,可以使用新的语法来强制声明禁止对类型进行隐式复制。当类型不能复制时,则可以像 class 一样提供一个 deinit 方法,在类型的值超出作用域时执行该方法。

struct FileDescriptor: ~Copyable {
    private var fd: CInt
  
    init(descriptor: CInt) { self.fd = descriptor }

    func write(buffer: [UInt8]) throws {
        let written = buffer.withUnsafeBufferPointer {
            Darwin.write(fd, $0.baseAddress, $0.count)
        }
        // ...
    }
  
    consuming func close() {
        Darwin.close(fd)
    }
  
    deinit {
        Darwin.close(fd)
    }
}

FileDescriptor 这样被声明为 ~Copyable 的类型,我们称之为 Non-copyable types 。我们通过这样声明可以解决之前提出的第一个场景。

这里的 close 操作,其实就相当于上下文已经放弃了这个实例的 Ownership,这也就是上面代码中 consuming 关键字的含义。当我们将方法标注为 consuming 后,就同时声明了 Ownership 的放弃操作,也就意味着在调用上下文中,后文将无法使用该值。

当我们按照这个写法,在实际业务代码中使用的时候,我们会按照这样的执行顺序进行操作:

let file = FileDescriptor(fd: descriptor)
file.write(buffer: data)
file.close()

因为 close 操作被我们标记了是 consuming 方法,则它必须在最后调用,以确保在此之前上下文代码具有该实例的 Ownership。如果我们写出了错误的调用顺序,编译器将会报错,并提示我们已经放弃了 Ownership ,无法继续调用其他方法。

Non-copyable Type 是 Swift 针对系统编程领域的一项强大的新功能,但目前仍处在早期阶段,后续版本将会不断迭代和扩展 Non-copyable Type 的功能。

与 C++ 的互操作性

混编代码

Swift 的推广普及其中一个重要的原因就是和 Objective-C 的互操作性。从一开始,开发者就可以使用 Swift 和 Objective-C 混编的方式,在项目中逐渐将代码替换成 Swift。

但是我们了解到,在很多项目中,不仅用到了 Objective-C,而且还用到了 C++ 来编写核心业务,互操作接口的编写比较麻烦。通常情况下,需要手动添加 bridge 层,Swift 经过 Objective-C ,再调用 C++ ,得到返回值后,再反向传出,这是一个十分繁琐的过程。

Swift 5.9 引入了 Swift 与 C++ 的互操作能力特性,Swift 会将 C++ 的 API 映射成 Swift API ,从而方便调用和获得返回值。

C++ 是一个功能强大的语言,具有自己的类、方法、容器等诸多概念。Swift 编译器能够识别 C++ 常见的习惯用法,因此大多数类型可以直接使用。例如下面这个 Person 类型,定义了 C++ 类型中常见的五个成员函数:拷贝构造函数、转移(有些中文教材也叫做移动)构造函数、(两个)赋值重载运算符、析构函数。

// Person.h
struct Person {
    // 拷贝构造函数: 通过从另一个Person对象进行拷贝来构造新的Person对象
    Person(const Person &);
    // 转移构造函数: 通过从另一个Person对象进行移动来构造新的Person对象
    Person(Person &&);
    // 拷贝赋值重载运算符: 将另一个Person对象的值赋给当前对象
    Person &operator=(const Person &);
    // 转移赋值重载运算符: 通过移动另一个Person对象的值来赋给当前对象
    Person &operator=(Person &&);
    // 析构函数: 清理Person对象所持有的资源
    ~Person();
    
    // string 类型,存储人员姓名
    std::string name;
    
    // const 代表只读,用于返回人员年龄
    unsigned getAge() const;
};

// 函数声明,返回一个 Person 对象的 vector 容器
std::vector<Person> everyone();

我们通过 Swift 可以直接调用这个 C++ 的 struct ,也可以直接使用上面定义的 vector<Person> 。补充一句:C++ 的常规容器,例如:vectormap 等,Swift 均是可以直接访问的。

// Client.swift
func greetAdults() {
    for person in everyone().filter { $0.getAge() >= 18 } {
        print("Hello, \(person.name)!")
    }
}

正如 greetAdults() 方法描述的这样,我们在 Swift 中可以直接调用 C++ 定义的类型,从而达到和 C++ 的优秀交互能力。

下面来说说“反向”用 C++ 调用 Swift 的场景。C++ 中使用 Swift 的代码基于与 Objective-C 相同的机制,即编译器会自动生成一个 Header 文件,我们可以在 Xcode 中找到生成后的 C++ header。然而与 Objective-C 不同的是,你不需要使用 @objc 这个注释对方法进行标注。C++ 大多数情况下是可以使用 Swift 完整的 API,包括属性、方法和初始化方法,无需任何桥接成本。

举个例子:

// Geometry.swift
struct LabeledPoint {
    var x = 0.0, y = 0.0
    var label: String = "origin"
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {  }
    var magnitude: Double {  }
}

这是一个 Swift 定义的 struct ,下面我们在 C++ 文件中来使用它:

// C++ client
#include <Geometry-Swift.h>

void test() {
    Point origin = Point()
    Point unit = Point::init(1.0, 1.0, "unit")
    unit.moveBy(2, -2)
    std::cout << unit.label << " moved to " << unit.magnitude() << std::endl;
}

我们可以看到,在遵循 C++ 语法习惯的前提下,所有的方法名都没有发生变化,无需对 Swift 代码进行定制化修改即可完成调用。

Swift 的 C++ 交互让大家在业务开发中更加容易。许多 C++ 习惯用法可以直接在 Swift 中表达,通常是自动完成的,但偶尔需要一些标记(annotation)来指示所需的语义。而且,可以直接从 C++ 访问 Swift API,无需注释或更改代码,提升了开发效率,也降低了迁移成本。

有些地方会将 annotation 翻译成“注释”,但是校对者 stevapple 在此处建议使用标记进行翻译,因为是用作编译器来声明额外的语义操作。个人也比较采纳。

C++ 的交互也是一个不断迭代的 feature,如果想了解更多,可以参看《Mix Swift and C++》这个 session。

构建系统

与 C++ 的交互在语言层面上十分重要,但是我们也不能忽视构建系统的适配。因为使用 Xcode 和 Swift Package Manager 来替换 C++ 的整套构建系统,也是开发者的一个障碍。

这也就是为什么我们要将这个 topic 单独拿出来讨论。Swift 与 CMake 开发社区合作改进了 CMake 对 Swift 的支持。你可以将 Swift 声明为项目使用的一种语言,并将 Swift 源文件加入 target 中,从而将 Swift 代码集成到 CMake 构建中。

# CMake

project(PingPong LANGUAGES CXX Swift)

add_library(PingPong
            Ping.swift,
            Pong.swift,
            TableTennisUtils.cpp
)

值得一提的是,你也可以在单个 Target 中混合使用 C++ 和 Swift ,CMake 将确保分别编译每个语言,并链接适用于两种语言适当的系统库和 Runtime 库。这也就意味着,你可以使用 Swift 来逐步取代跨平台的 C++ 项目。另外,Swift 社区还提供了一个包含 Swift 和混合 C++/Swift Target 的 CMake 实例存储库,其中包括使用桥接和生成的头文件,来帮助你上手。

Swift Concurrency - Actor 执行器

几年前,我们引入了基于 async/await 、Structured Concurrency 以及 actors 构建的并发模型。Swift 的并发模型是一个通用抽象模型,可以适配不同的环境和库。在这个通用抽象模型中有两个主要部分,Tasks 和 Actors:

  1. Tasks:代表可以在任意位置顺序执行的逻辑。如果有 await 关键字,tasks 可以被挂起,等其执行完成后继续恢复执行;
  2. Actors:是一种同步机制,提供对隔离状态的互斥访问权。从外部进入一个 actor 需要进行 await ,否则当前可能会将 tasks 挂起。

在内部实现上,Tasks 在全局并发池(Global Concurrent Pool)上执行。全局并发池根据环境决定如何调度任务。在 Apple 平台中,Dispatch 类库为每个系统提供了针对性优化的调度策略。

但是和前文问题一样,我们考虑更受限的环境下,多线程调度的开销我们无法接受。在这种情况下,Swift 的并发模型则会采用单线程的协同队列(Single-threaded Cooperative Queue)进行工作。同样的代码在多种情况下都可以正常工作,因为通用抽象模型可以描述的场景很广,可以覆盖到更多 case。

在标准的 Swift 并发运行场景下, Actors 是通过无锁任务队列(Lock-free Queue of Tasks)来实现的,但这不是唯一的实现方式。在受限环境下,没有原子操作(Atomics),可以使用其他的并发原语(Concurrency Primitive),比如自旋锁。如果考虑单线程环境,则不需要同步机制,但 Actors 模型仍然可被通用模型覆盖到。如此你可以在单线程和多线程环境中,使用同一份代码。

在 Swift 5.9 中,自定义 Actor 执行器(Executors)允许实现特定的同步机制,这使 Actors 变得更加灵活。我们来看一个例子:

// Custom actor executors

// 定义一个名为MyConnection的actor类,用于管理数据库连接
actor MyConnection {
    private var database: UnsafeMutablePointer<sqlite3>
  
    // 初始化方法,接收一个文件名作为参数,并抛出异常
    init(filename: String) throws {  }
  
    // 用于清理旧条目的方法
    func pruneOldEntries() {  }
  
    // 根据给定的名称和类型,从数据库中获取一个条目
    func fetchEntry<Entry>(named: String, type: Entry.Type) -> Entry? {  }
}

// 在外部调用时使用"await"来暂停当前任务,等待pruneOldEntries方法完成
await connection.pruneOldEntries()

这是一个管理数据库连接的 Actor 例子。Swift 确保代码对 Actor 互斥访问,所以不会出现对数据库的并发访问。但是如果你需要对同步访问进行控制要如何做呢?例如,当你连接数据库的时候,你想在某个队列上执行,而不是一个未知的、未与其他线程共享的队列。在 Swift 5.9 中,可以自定义 actor 执行器,可以这样实现:

actor MyConnection {
  private var database: UnsafeMutablePointer<sqlite3>

  // 执行方法的队列
  private let queue: DispatchSerialQueue

  // 这里自定义 actor 的执行器,nonisolated 定义为它是一个非孤立方法,即不需要在外部使用 await 关键字
  nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() }

  init(filename: String, queue: DispatchSerialQueue) throws {  }
  
  func pruneOldEntries() {  }
  func fetchEntry<Entry>(named: String, type: Entry.Type) -> Entry? {  }
}

await connection.pruneOldEntries()

上述代码中,我们为 actor 添加了一个串行调度队列,并且提供了一个 unownedExecutor 的实现,用于生成与该队列关联的执行器。通过这个改变,所有 actor 实例的同步方法将通过这个队列来执行。

当你在外部调用 await connection.pruneOldEntries() 时,其实现在真正的行为是在上方的队列里调用了 dispatchQueue.async 。有了这个自定义执行器后,我们可以全方位控制 Actor 的方法调度,甚至可以与未使用 Actor 的方法混用并调度他们的执行顺序。

我们可以通过调度队列对 actor 进行同步调度,是因为调度队列遵循了新的 SerialExecutor 协议。开发者可以通过实现一个符合该协议的类,从而定义自己的调度机制。

// Executor protocols

protocol Executor: AnyObject, Sendable {
    // 方法 1 
    func enqueue(_ job: consuming ExecutorJob)
}

protocol SerialExecutor: Executor {
    // 方法 2:
    func asUnownedSerialExecutor() -> UnownedSerialExecutor
    // 方法 3:
    func isSameExclusiveExecutionContext(other executor: Self) -> Bool
}

extension DispatchSerialQueue: SerialExecutor {  }

在这个协议中包括了一些核心操作:

  1. 检查代码是否已经在执行器上下文中执行:如上代码中的方法 3 isSameExclusiveExecutionContext(other:)。例如:你可以实现是否在主线程上执行。
  2. 可以获取这个 Executor 对应的执行器实例,并访问它:如上代码中的方法 2 asUnownedSerialExecutor()
  3. 将某个 Job 的所有权给到这个执行器:如上述代码中的方法 1 enqueue(_:)

Job 是需要在执行器上同步完成异步任务,这样的一个概念。从运行表现上来说,还是列举上面数据库连接的例子,enqueue方法将会在我们声明的队列上,调用 dispatchQueue.async 方法。

Swift 并发编程目前已经有了几年的经验,Tasks 和 Actor 这套模型也覆盖了诸多并发场景。从 iPhone 到 Apple Watch ,再到 Server ,其已适应不同的执行环境。这是一套复杂却又实用的系统,如果你希望了解更多,可以查看《Behind the Scenes》《Beyond the basics of Structured Concurrency》这两个 Session。

FoundationDB

最后我们介绍一点额外的东西,FoundationDB。这是一个分布式数据库,用于在普通硬件上运行的可扩展数据库解决方案。目前已经支持 macOS 、Linux 和 Windows。

FoundationDB 是一个开源项目,代码量很大,且使用 C++ 编写。这些代码是强异步的,具有自己的分布式 Actor 和 Runtime 实现。FoundationDB 项目希望对其代码进行现代化改造,并且认为 Swift 在性能、安全性和代码可读性上与其需求十分匹配。但是完全使用 Swift 重构是一个非常冒险的任务,所以在最新版代码中,开发人员利用 Swift 与 C++ 的交互新特性,进行部分的重构。

首先我们来看一下 FoundationDB 部分的 Actor 代码片段 C++ 实现:

// FoundationDB的“master data” actor的C++实现

// 异步函数,用于获取版本号
ACTOR Future<Void> getVersion(Reference<MasterData> self, GetCommitVersionRequest req) {
    // 查找请求代理的迭代器
    state std::map<UID, CommitProxyVersionReplies>::iterator proxyItr = self->lastCommitProxyVersionReplies.find(req.requestingProxy);
    ++self->getCommitVersionRequests;

    // 如果在映射中找不到代理的迭代器,则发送一个“Never”的响应并返回
    if (proxyItr == self->lastCommitProxyVersionReplies.end()) {
        req.reply.send(Never());
        return Void();
    }

    // 等待直到最新的请求编号至少达到 req.requestNum - 1
    wait(proxyItr->second.latestRequestNum.whenAtLeast(req.requestNum - 1));

    // 在回复的映射中查找与请求编号对应的回复
    auto itr = proxyItr->second.replies.find(req.requestNum);
    if (itr != proxyItr->second.replies.end()) {
        // 如果找到回复,则将其作为响应发送并返回
        req.reply.send(itr->second);
        return Void();
    }

    // ...
}

这段代码有很多内容,你并不需要了解这段 C++ 代码。其中,我只想指出一个关键的地方:因为 C++ 没有 async/await ,所以 FoundationDB 使用了类似于预处理器(Preprocessor)的方式进行模拟实现。

通过对应的编号进行筛选,从而显式实现了消息匹配,从而将正确的结果返回。最后,FoundationDB 有自己的引用计数智能指针来帮助自动管理内存。上述 C++ 代码描述的就是这样的一个过程。

但如果我们使用 Swift ,这个方法就可以直接使用异步函数的特性,使用 await 来表示对于请求的匹配,就节省了上述大量的代码逻辑。

// FoundationDB的“master data” actor的Swift实现

// 异步函数,用于获取版本号
func getVersion(
    myself: MasterData, req: GetCommitVersionRequest
) async -> GetCommitVersionReply? {
    // 增加getCommitVersionRequests计数
    myself.getCommitVersionRequests += 1

    // 检查是否存在请求代理的最后版本回复
    guard let lastVersionReplies = lastCommitProxyVersionReplies[req.requestingProxy] else {
        return nil
    }

    // ...
    // 等待直到latestRequestNum至少达到req.requestNum - 1
    var latestRequestNum = try await lastVersionReplies.latestRequestNum
        .atLeast(VersionMetricHandle.ValueType(req.requestNum - UInt64(1)))

    // 如果存在请求编号对应的最后回复,则返回该回复
    if let lastReply = lastVersionReplies.replies[req.requestNum] {
        return lastReply
    }
}

是不是可读性提高了不少?

另外,我们在这里使用了很多 C++ 类型,C++ 中的 MasterData 类型使用了引用计数智能指针。通过在 C++ 中对类型进行标注,Swift 编译器可以像任何其他类一样使用该类型,自动管理引用计数。

从这个例子中,我们获取到的经验是,我们可以在需要的时候,使用 Swift 的优势来改写逻辑,与现有的 C++ 代码进行接口互调操作,实现渐进式的代码替换,最终也可以推进项目 Swift 化的进程。

总结

这个 Session 中我们讨论了很多内容,不乏 Swift 的一些新的语法特性和 Swift Macros。这些功能可以实现更加灵活和健壮的 API,帮助你更好地编写高质量代码。

另外,我们也大篇幅的讨论了在受限环境下使用 Swift 的优势,以及 Swift 如何灵活的适配多种平台设备、语言,这些也为我们在编写代码中,获得更多的思考。

这些特性的出现和设计,都是通过 Swift 社区中从 idea 、公开讨论、结果反馈等等流程中孕育而生的。感谢各位开发者的支持,让 Swift 5.9 这门编程语言更加健壮。

PancakeHunny 闪电贷 LP 池操控攻击分析

背景

2021 年 10 月 20 日 UTC 时间上午 9 点,PancakeHunny 平台遭遇闪电贷智能合约攻击,攻击者通过操纵 PCS 上的 WBNB/TUSD 的流动性从而操纵了兑换比例,实现了 HUNNY 铸币合约的大量铸币,完成攻击。

最终攻击者获利 230 万美元(64.2 万是稳定币 + 435.31 ETH),并且大量铸造 HUNNY 代币,将 HUNNY 的价格从 0.3 抛售到 0.1 美元。 ​

这一操作的 TxHash 从 bscscan 上可以找到:0x1b698231965b72f64d55c561634600b087154f71bc73fc775622a45112a94a77。 ​ 下面我们来复盘一下整个攻击手法和流程。

代码中的根本原因

可以查看合约 VaultStrategyAlpacaRabbit,这个合约是可升级合约的原合约地址。目前该合约仍旧由线上 TUSD 单币池合约地址进行代理转发(也就是线上还没有进行更换),但是目前官方已经发现了问题,已经关闭了该池的铸币(那其实还不如直接存 Alpaca Finance)。 ​ 我们在 VaultStrategyAlpacaRabbit 合约中,可看到以下代码:

在上述代码中,黄色高亮的一行就是此次攻击的根本原因。原因就是因为这个 swap 的 Path 最终选用的是 [ALPACA, WBNB, TUSD]然而 TUSD/WBNB 的 LP Token 其流动性仅有 2 美元(这是目前的情况,可以查看 PCS 的流动性数据,于是攻击者就通过闪电贷放大资金量,从而控制这组 LP Token 的兑换汇率,从而进行攻击。 ​

接下来我们来分步骤解析这个过程: ​

  • 攻击者利用闪电贷,借出 270 万 TUSD,并且全部通过 [TUSD, WBNB] 的 Path 兑换成了 WBNB。根据 AMM 的恒定乘积公式 \(x \times y = k\) ,由于大量的 TUSD 进入到了 TUSD/WBNB Lp 池中,所以通过十分少量的 WBNB 沿着相反的 Path 就能兑换出大量的 TUSD
  • 第二步,攻击者会将一笔可观的 TUSD 数额放入 TUSD 单币池中,让其占据了该池 99% 的收益。此时因为步骤一种操控了 Lp 池,大量的 TUSD 会被兑换出来。
  • 第三步,攻击者会调用 getReward() 方法,这个方法会调用 _withdrawStakingToken() 方法,其中会返回 withdrawAmount 这个变量。
withdrawAmount = _stakingToken.balanceOf(address(this)).sub(stakingTokenBefore);

它会通过 _stakingToken 也就是我们的 TUSD 总量来计算。而 withdrawAmount 就是用来传入到 minter 中,其价值的 30% 为总量负责铸造 HUNNY 代币的数量控制变量,从而造成大量的 HUNNY 被铸造。

  • 攻击者抛售大量的 HUNNY 完成此次经济攻击。

复盘

攻击者完成本次攻击,是与以往的 BUNNY 攻击有所区别的,BUNNY 中的错误实在是太低级了,使用了账户余额的代币数量来铸造 BUNNY 。虽然 HUNNY 通过使用增量变量的方式避免了 BUNNY 的漏洞,但对于 LP Token 市值太低容易操纵这一环节没有戒备心,从而导致了经济漏洞。 ​

反思:在制作机枪池的时候,如果有 Minter 进行铸造操纵,一定要慎之又慎,来验证每一步用到的数量关系,再进行代码编写。

LP Token 价格计算推导及安全性

背景

在实现 CakeBot 的 USDT/USDC 池时,需要计算 LP Token 的代币价值,从而方便的给用户提示 LP Token 当前准确的价格,来计算收益率。所以对 LP Token 的价值计算做了一点深入的研究,并且还翻阅到 Alpha Finance 团队的关于安全获取 LP 价格的方法。 本位将这些学习笔记分享给大家。 ​

一般 LP Token 价格的获取方法

我们知道对于一般 Token 的价格,在 Cex 中其实是市场上交易撮合的成交价。在 Dex 中,由于 AMM 做市商模型通过一组 LP 来构建价格的锚定。所以如果我们想获取到一个 Token 的价格,都是通过对于稳定币 USDT、USDC 或者 BUSD 的币对关系,从而反映现实世界的价格。 ​

我们知道 LP Token 是不具有流动性池的,如果有那就是套娃了。那么我们应该如何去计算价格呢?其实我们只需要用总增发量和货币价格反推即可。 ​

\[Cap_x = P_x \times T_x\]

任意一个 Token X 的总市值是 $Cap_x$,是用当前的价格 $P_x$ 和当前总铸造数量 $T_x$相乘可得。对于 LP Token,我们可以用这个公式来反推币价。因为在 LP Token 中,总市值是可以通过两种币的数量和对应价格求得,并且总的制造数量也是已知的。 ​

所以我们可以如此计算 LP Token 总价格: ​

\[P_{LP} = \frac{Cap_{LP}}{T_{LP}} = \frac{r_0 \times price_0 + r_1 \times price_1}{totalSupply}\]

其中,$r_0$和 $r_1$就是 LP Token 合约中两种代币的存量,$price_0$和 $price_1$分别代表 $r_0$和 $r_1$ 对应 Token 的价格。市面上无论 BSC、ETH 还是 Polygon 还是 Heco 链等,其 LP 代币基本都是 fork Uniswap 的,所以 $r_0$和 $r_1$、$price_0$和 $price_1$ 都是能拿到的。 ​

上面的公式我们其实可以看出,是通过市值反推价格,也没有什么巨大的逻辑问题。当我们需要访问其币价的时候已经可以满足需求。在 Web3.js 前端中,我们就可以照此拿到结果。

export const getLpTokenPrice = async (
  lpAddress: string,
  lib: any,
  price0: BigNumber,
  price1: BigNumber
) => {
  const lpToken = getPancakeLp(lib, lpAddress);
  let [r0, r1] = (await lpToken.getReserves()).map((n) => bignumberToBN(n));
  let totalSupply = bignumberToBN(await lpToken.totalSupply());
  return r0
    .multipliedBy(price0)
    .plus(r1.multipliedBy(price1))
    .dividedBy(totalSupply);
};

至此,我的需求完成。

延时喂价漏洞

对于上文公式: ​

\[price_{lp}= \frac{r_0 \times price_0 + r_1 \times price_1}{totalSupply}\]

其实乍一看是不存在问题的。但是如果我们所做的需求,不仅仅是一个价格展示,而是一个借贷系统,用这种方式来获取清算系数,就会存在被闪电贷的风险。虽然 $price_0$和 $price_1$不能被操控,但是 $r_0$和 $r_1$是可以的。黑客可以通过操作 $r_0$ 和 $r_1$,从而对价格实现控制。

之前漫雾团队写过一篇「Warp Finance 被黑详解」的分析,采用了如下攻击流程:

  1. 通过 dydx 与 Uniswap 闪电贷借出 DAI 和 WETH;
  2. 用小部分 DAI 和 WETH 在 Uniswap 的 WETH-DAI LP 中添加流动性,获得 LP Token;
  3. 将 LP Token 抵押到 Wrap Finance 中;
  4. 用巨量的 WETH 兑换成 DAI,因为 WETH 迅速进入了 WETH-DAI 流动池,总数量大增。但是由于价格使用的是 Uniswap 的预言机,访问的是 Uniswap 的 LP 池,所以 WETH 的价格并未发生变化。从而导致 Wrap Finance 中的 WETH-DAI LP Token 价格迅速提高;
  5. 由于 LP Token 单价变高,导致黑客抵押的 LP Token 可以借出更多的稳定币来获息。

这里,我们发现漏洞的关键地方,其实是 $price$ 计算对于借贷项目中,使用的是他人的 LP 合约,还未等套利者来平衡价格,从而终究留出了时间差。 ​

为了解决这个问题,如果我们可以找到一种方式,从而规避价格查询,就能大概率防止上述漏洞。这里,Alpha Finance 给出了另外一个推导公式。

获取公平 LP 价格方法

首先我们在一个 LP 池中,我们能保证的是恒定乘积 $K$ 值的大小,我们定义价格比值是 $P$,那么我们会有以下关系式: ​

\[\begin{cases} K=r_0 \times r_1 \\ P = \frac{r_1}{r_0} \end{cases}\]

因为 $r_0$ 和 $r_1$ 在旧方法中是可以被操纵的,所以我们用 $K$ 和 $P$ 来反解真实的 $r’_{0}$ 和 $r’_1$ : ​

\[\begin{cases} r'_0 = \sqrt{K / P} \\ r'_1 = \sqrt{K \times P} \end{cases}\]

如此,我们在带入一开始计算 $price_{lp}$的公式中: ​

\[\begin{align} price_{lp} &= \frac{r'_0 \times price_0 + r'_1 \times price_1}{totalSupply} \\ & = \frac{\sqrt{K/P}·price_0 + \sqrt{K·P}·price_1}{totalSupply} \\ & = \frac{\sqrt{K · \frac{price_1}{price_0}·price_0^2} + \sqrt{K·\frac{price_0}{price_1}·price_1^2}}{totalSupply} \\ & = \frac{2\sqrt{K·price_0·price_1}}{totalSupply} \\ & = 2 \frac{\sqrt{r_0·r_1}·\sqrt{price_0 · price_1}}{totalSupply} \end{align}\]

我们可以发现,最终 Alpha Finance 给我们的推导式中,不会存在独立的 $r_0$ 和 $r_1$ ,取而代之的是它们的恒定乘积 $K$。

攻击可能性分析

使用以上公式,我们可以真正的避免攻击吗?

  1. ​$price_0$和 $price_1$ 首先是可信源获取的正确价格,无法操纵;
  2. $totalSupply$ 只是改变了质押数量,其变化与质押的两个代币数量有关系;
  3. 对于 $r_0$ 和 $r_1$ ,在 Alpha Finance 的博客中提供了两种思路:
    1. 直接进行代币兑换(类似于上述攻击手段),由于 $r_0 \times r_1$ 是定值 $K$,所以无论如何变化都不会影响计算结果;
    2. 直接将 Token 打入 LP Token 合约地址中,由于 $r_0$ 和 $r_1$ 都是在二次根式下,所以付出 $x$ 倍的成果,最终只能获得 $\sqrt{x}$ 倍的收益,这显然是亏本的;

综上,在已知情况下,是可以有效避免攻击的。

总结

通过这次对 LP Token 价格计算的研究,并且对延时喂价漏洞的探求,了解了 LP 抵押使用一般方式计算带来的风险。计算价格的需求,一定要根据所做业务的类型,谨慎选择。

参考链接


欢迎大家使用我个人研发的 Cakebot Finance 来体验去中心化 DAPP 挖矿。

Liquidity Providers 的数学原理

最近半年多一直在币圈玩 DeFi,这半年来增加了好多十分神奇的玩法,这都要得益于 Uniswap 的创新。

看了这么多 Up 主的视频,讲的都云里雾里的,其实就是简单的几个公式,这篇文章我们来从头推导一下无偿损失的公式,来计算一下为什么当单币种价格有强烈浮动的时候,为什么会有无偿损失

Liquidity Providers 代币是什么

流动性提供者代币(Liquidity Providers Token),也就是我们经常说的 LP 代币,这是一个什么东西呢?

可以举一个最简单的例子,加入我们现在使用 BUSD 来购买 BNB,我们经常使用交易所的朋友们知道,交易所的挣钱模式是用户在交易时从交易金额中进行抽成来赚取手续费

其实很多中心化交易所(CEX)看到了盈利模式中的用户激励机制,那就是你如果邀请别人试用我们的产品,那么交易所在进行手续费抽成的过程中,也会将其再次抽成,作为那些 KOL 的奖励

而 Uniswap 这种去中心化交易所(DEX)就无法利用这种机制进行用户增长,因为所有的 DEX 没有用户注册机制,只需要连接用户的数字钱包即可完成交易。

于是,Uniswap 就设计了另外一种激励模式,AMM(Automated Market Maker) - 自动化做市商模型。关于自动化做市商这里我们不详细的去讲,我们只要了解以下几个动作代表了什么意思就可以(这里使用 BNB 和 BUSD 交易以及 PancakeSwap 平台为例):

  1. 用 BNB 和 BUSD 换取 BNB-BUSD LP 代币:相当于你为 BNB 和 BUSD 流动池增加了代币。并且你获得了 BNB ⇄ BUSD 这个交易中的对应代币份额的抽成奖励占比;
    1. 这里就体现了 DEX 通过换取流动性证明,从而完成了一种激励模式;
    2. PancakeSwap 在文档中提到,其 0.2% 的交易手续费当中,有 0.17% 的比例是提供给 LP 持有者的抽成奖励的。剩下的 0.03% 是平台的利润。具体规则可以查看官方文档
  2. BNB-BUSD 代币挖矿:当我们拿到 BNB-BUSD LP Token 之后,我们可以继续放在 PancakeSwap 的 Farms 中进行挖矿(其年化 APR 如以下截图所示)。

所以,看到这里你应该也明白这个 LP Token 是个什么东西了。他其实就是你提供的流动性证明代币,持有这个代币你就可以获得对应币对在交易过程中手续费的抽成

并且平台为了让你去填充代币池,通过质押 LP 代币挖矿的形式来吸引你去兑换,这样你也获得了收益,平台也获得了交易币对的深度池

无偿损失是如何来的?

有很多朋友应该是被 LP 代币挖矿的高收益吸引过来的。在一顿操作之后开始了挖矿,但是在 Remove LP 代币的时候,发现自己损失了好多 U。本来是来挖矿赚收益的,最后矿没挖多少,自己却亏了好多。这是怎么一回事呢?

这种情况就是我们所说的** IL 无偿损失(Impermanet Loss),用来指在流动性代币价值与持有两种现货资产相比产生负收益的结果**。简单来说就是亏钱了!

为什么会产生无偿损失,我们以为 BNB 和 BUSD 提供流动性为例,来具体的描述一个场景。在例子之前我们需要了解以下变量:

常数 K(Constant Product)

常数 K 因子是用来为交易定价的一种方式,用这个常数来保证币对池子的价值平衡。 \(K = A \times B\) 这里面 \(A\)  和 \(B\)  是两个 Token 的数量。这里面我们用 BNB 和 BUSD 来举例子:

  • A - BNB 的数量
  • B - BUSD 的数量

A 和 B 带入到我们的例子中,后面用 \(C_{BNB}\) 和 \(C_{BUSD}\) 代替 \(K_{BNB-BUSD} = C_{BNB} \times C_{BUSD}\)

定价 P(Token Price)

这个是 Uniswap 中对于 LP 比对的一个条件,就是要保证当前配对时,币对中两个币的 U 本位价值相同。在这个例子中,我们引入 BNB 此时的价格 \(P_{BNB}\)  以及 BUSD 的价格 \(P_{BUSD}\)。此时我们可以得到等式: \(P_{BNB} \times C_{BNB} = P_{BUSD} \times C_{BUSD}\) 由于我们的 BUSD 其实是和 $ 锚定的,那么其实 \(P_{BUSD} = 1\),所以有以下式子: \(P_{BNB} \times C_{BNB} = C_{BUSD}\) \(P_{BNB} = \frac{C_{BUSD}}{C_{BNB}}\)

数量公式

我们通过上述公式来推出两个代币的定价 P 的公式:

\[\begin{cases} C_{BNB} = \sqrt{\frac{K_{BNB-BUSD}}{P_{BNB}}} \\ C_{BUSD} = \sqrt{K_{BNB-BUSD} \times P_{BNB}} \end{cases}\]

如此,我们就可以通过常数 K 以及当前的币价来推导我们代币的数量了。接下来我们来看一个实际的场景,来直接感受一下无偿损失。

实际场景

假如,我们在 1 BNB = 500 BUSD 的时候,组了一组 LP 。我们拿出了 20 个 BNB 和 10000 个 BUSD 进行 LP 流动性提供代币兑换。此时我们得到了这几个变量:

\[\begin{cases} P_{BNB} = 500 \\ P_{BUSD} = 1 \\ K = C_{BNB} \times C_{BUSD} = 2 \times 10^5 \end{cases}\]

并且,我们保证此时的 K 也是后续所有情况下的常数 K,即组完 LP 代币后即时生效的常数。

过了 10 天,BNB 涨价了,当前价格为 1 BNB = 550 BUSD 了。随之我们的价格也变成了如下关系:

\[\begin{cases} P_{BNB} = 550 \\ P_{BUSD} = 1 \end{cases}\]

我们带入到之前数量公式来计算此时 LP Token 等值的代币个数:

\[\begin{split} C'_{BNB} &= \sqrt{\frac{K_{BNB-BUSD}}{P_{BNB}}} \\ & = \sqrt{\frac{2 \times 10^5}{550}} \\ & \approx 19.069 \ BNB\\ C'_{BUSD} &= \sqrt{K_{BNB-BUSD} \times P_{BNB}} \\ & = \sqrt{2 \times 10^5 \times 550} \\ & \approx 10488.09\ BUSD \end{split}\]

在 BNB 涨价到 550 BUSD 数量到时候,我们发现等值的 LP Token 兑换只能换回 19.069 个 BNB 和 10488.09 个 BUSD。如果我们来换算成 BUSD 作为单位来对比一下前后收益:

  • 情况一:就是上述情况,我们持有来一定数量的 LP 代币,接触流动性后全部折算成 BUSD 可以得到以下结果:
\[\begin{split} V_1 & = 19.069 \times 550 + 10488.09 = 20976.04 \ BUSD \end{split}\]
  • 情况二:如果我们持续持有 20 个 BNB 和 10000 个 BUSD,那么此时我们折算成 BUSD 可以得到以下结果:

\(V_2 = 20 \times 550 + 10000 = 21000\ BUSD\) 经过以上分析,我们是可以看到如果我们持有 LP 代币不进行任何理财操作,其实我们组了 LP Token 后是会亏 \(21000 - 20976.04 = 23.96 \ BUSD\)的。 **

相关补充

LP Token 代币数量计算

其实 LP Token 也是一种代币,它也拥有自己的合约地址。比如 Pancakeswap 上的 BNB-BUSD LP 代币合约地址 0x58F876857a02D6762E0101bb5C46A8c1ED44Dc16 。是代币肯定就有一个方式来计算数量,这里 Pancakeswap 和 Uniswap 的计算方式一样,采用以下公式:

\[C_{BNB-BUSD\ LP} = \sqrt{C_{BNB}\times C_{BUSD}}\]

所以,当我们用 20 个 BNB 和 10000 个 BUSD 兑换 LP 代币的时候,我们会获得大约 447.21 个 LP Token。

简易的无偿损失表

这个我下一篇文会具体的推演一下如何决策以及如何与收益来共同计算。这里先引用 「Uniswap: A Good Deal for Liquidity Providers?」这篇文章中给出的结论:

  • a 1.25x price change results in a 0.6% loss relative to HODL
  • a 1.50x price change results in a 2.0% loss relative to HODL
  • a 1.75x price change results in a 3.8% loss relative to HODL
  • a 2x price change results in a 5.7% loss relative to HODL
  • a 3x price change results in a 13.4% loss relative to HODL
  • a 4x price change results in a 20.0% loss relative to HODL
  • a 5x price change results in a 25.5% loss relative to HODL

Losses/Price Change Function

写在最后

DeFi 是一个金融游戏,如果你什么也不做研究并且什么也不去动手计算,那么永远都是韭菜。所以希望大家对于有趣的项目,先做好研究以及风险评估,在去玩耍。

欢迎大家使用我个人研发的 Cakebot Finance 来体验去中心化 DAPP 挖矿。

Podspec 管理策略

引子

本文是 Core 的最后一篇,它与另外两篇文章「Podfile 解析逻辑」和「PodSpec 文件分析」共同支撑起 CocoaPods 世界的骨架。CocoaPods-Core 这个库之所以被命名为 Core 就是因为它包含了 Podfile -> Spec Repo -> PodSpec 这条完整的链路,将散落各地的依赖库连接起来并基于此骨架不断地完善功能。

从提供各种便利的命令行工具,到依赖库与主项目的自动集成,再到提供多样的 Xcode 编译配置、单元测试、资源管理等等,最终形成了我们所见的 CocoaPods。

今天我们就来聊聊 Spec Repo 这个 PodSpec 的聚合仓库以及它的演变与问题。

Source

作为 PodSpec 的聚合仓库,Spec Repo 记录着所有 pod 所发布的不同版本的 PodSpec 文件。该仓库对应到 Core 的数据结构为 Source,即为今天的主角。

整个 Source 的结构比较简单,它基本是围绕着 Git 来做文章,主要是对 PodSpec 文件进行各种查找更新操作。结构如下:

# 用于检查 spec 是否符合当前 Source 要求
require 'cocoapods-core/source/acceptor'
# 记录本地 source 的集合
require 'cocoapods-core/source/aggregate'
# 用于校验 source 的错误和警告
require 'cocoapods-core/source/health_reporter'
# source 管理器
require 'cocoapods-core/source/manager'
# source 元数据
require 'cocoapods-core/source/metadata'

module Pod
  class Source
    # 仓库默认的 Git 分支
    DEFAULT_SPECS_BRANCH = 'master'.freeze
    # 记录仓库的元数据
    attr_reader :metadata
    # 记录仓库的本地地址
    attr_reader :repo
    # repo 仓库地址 ~/.cocoapods/repos/{repo_name}
    def initialize(repo)
      @repo = Pathname(repo).expand_path
      @versions_by_name = {}
      refresh_metadata
    end
    # 读取 Git 仓库中的 remote url 或 .git 目录
    def url
      @url ||= begin
        remote = repo_git(%w(config --get remote.origin.url))
        if !remote.empty?
          remote
        elsif (repo + '.git').exist?
          "file://#{repo}/.git"
        end
      end
    end

    def type
      git? ? 'git' : 'file system'
    end
    # ...
  end
end

Source 还有两个子类 CDNSourceTrunkSource,TrunkSouce 是 CocoaPods 的默认仓库。在版本 1.7.2 之前 Master Repo 的 URL 指向为 Github 的 Specs 仓库,这也是造成我们每次 pod installpod update 慢的原因之一。它不仅保存了近 10 年来 PodSpec 文件同时还包括 Git 记录,再加上墙的原因,每次更新都非常痛苦。而在 1.7.2 之后 CocoaPods 的默认 Source 终于改为了 CDN 指向,同时支持按需下载,缓解了 pod 更新和磁盘占用过大问题。

Source 的依赖关系如下:

回到 Source 来看其如何初始化的,可以看到其构造函数 #initialize(repo) 将传入的 repo 地址保存后,直接调用了 #refresh_metadata 来完成元数据的加载:

def refresh_metadata
  @metadata = Metadata.from_file(metadata_path)
end

def metadata_path
  repo + 'CocoaPods-version.yml'
end

Metadata

Metadata 是保存在 repo 目录下,名为 CocoaPods-version.yml 的文件,用于记录该 Source 所支持的 CocoaPods 的版本以及仓库的分片规则

autoload :Digest, 'digest/md5'
require 'active_support/hash_with_indifferent_access'
require 'active_support/core_ext/hash/indifferent_access'

module Pod
  class Source
    class Metadata
      # 最低可支持的 CocoaPods 版本,对应字段 `min`
      attr_reader :minimum_cocoapods_version
      # 最高可支持的 CocoaPods 版本,对应字段 `max`
      attr_reader :maximum_cocoapods_version
      # 最新 CocoaPods 版本,对应字段 `last`
      attr_reader :latest_cocoapods_version
      # 规定截取的关键字段的前缀长度和数量
      attr_reader :prefix_lengths
      # 可兼容的 CocoaPods 最新版本
      attr_reader :last_compatible_versions
      # ...
    end
  end
end

这里以笔者 💻 环境中 Master 仓库下的 CocoaPods-version.yml 文件内容为例:

---
min: 1.0.0
last: 1.10.0.beta.1
prefix_lengths:
  - 1
  - 1
  - 1

最低支持版本为 1.0.0,最新可用版本为 1.10.0.beta.1,以及最后这个 prefix_lengths[1, 1, 1] 的数组。那么这个 prefix_lengths 的作用是什么呢 ?

要回答这个问题,我们先来看一张 Spec Repo 的目录结构图:

再 🤔 另外一个问题,为什么 CocoaPods 生成的目录结构是这样 ?

其实在 2016 年 CocoaPods Spec 仓库下的所有文件都在同级目录,不像现在这样做了分片。这个是为了解决当时用户的吐槽:Github 下载慢,最终解决方案的结果就如你所见:将 Git 仓库进行了分片

那么问题来了,为什么分片能够提升 Github 下载速度?

很重要的一点是 CocoaPods 的 Spec Repo 本质上是 Git 仓库,而 Git 在做变更管理的时候,会记录目录的变更,每个子目录都会对应一个 Git model。而当目录中的文件数量过多的时候,Git 要找出对应的变更就变得十分困难。有兴趣的同学可以查看官方说明

另外再补充一点,在 Linux 中最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。也就是说就算不用 Git 来管理 Specs 仓库,当目录下存在数以万计的文件时,如何高效查找目标文件也是需要考虑的问题。

Tips:关于文件系统层次结构有兴趣的同学可以查看FHS 标准,以及知乎这篇:传送门

回到 CocoaPods,如何对 Master 仓库目录进行分片就涉及到 metadata 类中的关键方法:

def path_fragment(pod_name, version = nil)
  prefixes = if prefix_lengths.empty?
               []
             else
               hashed = Digest::MD5.hexdigest(pod_name)
               prefix_lengths.map do |length|
                 hashed.slice!(0, length)
               end
             end
  prefixes.concat([pod_name, version]).compact
end

#path_fragment 会依据 pod_name 和 version 来生成 pod 对应的索引目录:

  1. 首先对 pod_name 进行 MD5 计算获取摘要;
  2. 遍历 prefix_lengths 对生成的摘要不断截取指定的长度作为文件索引。

AFNetworking 为例:

$ Digest::MD5.hexdigest('AFNetworking')
"a75d452377f3996bdc4b623a5df25820"

由于我们的 prefix_lengths[1, 1, 1] 数组,那么它将会从左到右依次截取出一个字母,即: a75 ,这三个字母作为索引目录,它正好符合我们 👆 目录结构图中 AFNetworking 的所在位置。

Versions

要找到 Podfile 中限定版本号范围的 PodSpec 文件还需要需要最后一步,获取当前已发布的 Versions 列表,并通过比较 Version 得出最终所需的 PodSpec 文件。

在上一步已通过 metadatapod_name 计算出 pod 所在目录,接着就是找到 pod 目录下的 Versions 列表:

获取 Versions:

def versions(name)
  return nil unless specs_dir
  raise ArgumentError, 'No name' unless name
  pod_dir = pod_path(name)
  return unless pod_dir.exist?
  @versions_by_name[name] ||= pod_dir.children.map do |v|
    basename = v.basename.to_s
    begin
      Version.new(basename) if v.directory? && basename[0, 1] != '.'
    rescue ArgumentError
    raise Informative, 'An unexpected version directory ...'
    end
  end.compact.sort.reverse
end

该方法重点在于将 pod_dir 下的每个目录都转换成为了 Version 类型,并在最后进行了 sort 排序。

#versions 方法主要在 pod search 命令中被调用,后续会介绍。

来搂一眼 Version 类:

class Version < Pod::Vendor::Gem::Version
  METADATA_PATTERN = '(\+[0-9a-zA-Z\-\.]+)'
  VERSION_PATTERN = "[0-9]+(\\.[0-9a-zA-Z\\-]+)*#{METADATA_PATTERN}?"
  # ...
end

该 Version 继承于 Gem::Version 并对其进行了扩展,实现了语义化版本号的标准,sort 排序也是基于语义化的版本来比较的,这里我们稍微展开一下。

Semantic Versioning

语义化版本号(Semantic Versioning 简称:SemVer)绝对是依赖管理工具绕不开的坎。语义化的版本就是让版本号更具语义化,可以传达出关于软件本身的一些重要信息而不只是简单的一串数字。 我们每次对 Pod 依赖进行更新,最后最重要的一步就是更新正确的版本号,一旦发布出去,再要更改就比较麻烦了。

SemVer 是由 Tom Preston-Werner 发起的一个关于软件版本号的命名规范,该作者为 Gravatars 创办者同时也是 GitHub 联合创始人。

那什么是语义化版本号有什么特别呢 ?我们以 AFNetworking 的 release tag 示例:

3.0.0
3.0.0-beta.1
3.0.0-beta.2
3.0.0-beta.3
3.0.1

这些 tags 并非随意递增的,它们背后正是遵循了语义化版本的标准。

基本规则

  • 软件的版本通常由三位组成,如:X.Y.Z。
  • 版本是严格递增的,
  • 在发布重要版本时,可以发布 alpha, rc 等先行版本,
  • alpha 和 rc 等修饰版本的关键字后面可以带上次数和 meta 信息,

版本格式:

主版本号.次版本号.修订号

版本号递增规则如下:

Code status Stage Example version
新品首发 从 1.0.0 开始 1.0.0
向后兼容的 BugFix 增加补丁号 Z 1.0.1
向后兼容的 Feature 增加次版本号 Y 1.1.0
向后不兼容的改动 增加主版本号 X 2.0.0
重要版本的预览版 补丁号后添加 alpha, rc 2.1.0-rc.0

关于 CocoaPods 的 Version 使用描述,传送门

CDNSource

CocoaPods 在 1.7.2 版本正式将 Master 仓库托管到 Netlify 的 CDN 上,当时关于如何支持这一特性的文章和说明铺天盖地,这里还是推荐大家看官方说明。另外,当时感受是似乎国内的部分 iOS 同学都炸了,各种标题党:什么最完美的升级等等。

所以这里明确一下,对于 CocoaPods 的 Master 仓库支持了 CDN 的行为,仅解决了两个问题:

  1. 利用 CDN 节点的全球化部署解决内容分发慢,提高 Specs 资源的下载速度。
  2. 通过 Specs 按需下载摆脱了原有 Git Repo 模式下本地仓库的磁盘占用过大,操作卡的问题。

然而,仅仅对 PodSpec 增加了 CDN 根本没能解决 GFW 导致的 Github 源码校验、更新、下载慢的问题。 只能说路漫漫其修远兮。

PS:作为 iOS 工程师,就经常被前端同学 😒 。你看这 CocoaPods 也太垃圾了吧!!!一旦删掉 Pods 目录重新 install 就卡半天,缓存基本不生效,哪像 npm 多快 balabala …

先来看 CDNSource 结构:

require 'cocoapods-core/source'
# ...
module Pod
  class CDNSource < Source
    def initialize(repo)
      # 标记是否正在同步文件
      @check_existing_files_for_update = false
      # 记录时间用于对比下载文件的新旧程度,以确认是否需要更新保存所下的资源
      @startup_time = Time.new
      # 缓存查询过的 PodSpec 资源
      @version_arrays_by_fragment_by_name = {}
      super(repo)
    end

    def url
      @url ||= File.read(repo.join('.url')).chomp.chomp('/') + '/'
    end

    def type
      'CDN'
    end
    # ...
  end
end

Source 类是基于 Github Repo 来同步更新 PodSpec,而 CDNSource 则是基于 CDN 服务所返回的 Response,因此将 Source 类的大部分方法重写了一个遍,具体会在 SourceManager 一节来展开。

最后看一下 TrunkSource 类:

module Pod
  class TrunkSource < CDNSource
    # 新版落盘后仓库名称
    TRUNK_REPO_NAME = 'trunk'.freeze

    TRUNK_REPO_URL = 'https://cdn.cocoapods.org/'.freeze

    def url
      @url ||= TRUNK_REPO_URL
      super
    end
  end
end

核心就是重写了返回的 url,由于旧版 Spec 仓库名称为 master 为了加以区分,CDN 仓库则改名为 trunk

Source Manager

Manager 作为 source 的管理类,其主要任务为 source 的添加和获取,而对 PodSpec 文件的更新和查找行为则交由 source 各自实现。不过由于一个 pod 库可能对应多个不同的 source,这里又产生出 Aggregate 类来统一 PodSpec 的查询。

它们的关系如下:

Manager 实现:

module Pod
  class Source
    class Manager
      attr_reader :repos_dir

      def initialize(repos_dir)
        @repos_dir = Pathname(repos_dir).expand_path
      end

      def source_repos
        return [] unless repos_dir.exist?
        repos_dir.children.select(&:directory?).sort_by { |d| d.basename.to_s.downcase }
      end

      def aggregate
        aggregate_with_repos(source_repos)
      end

      def aggregate_with_repos(repos)
        sources = repos.map { |path| source_from_path(path) }
        @aggregates_by_repos ||= {}
        @aggregates_by_repos[repos] ||= Source::Aggregate.new(sources)
      end

      def all
        aggregate.sources
      end
      # ...
    end
  end
end

Manager 类的初始化仅需要传入当前 repos 目录,即 ~/.cocoapods/repos,而 Aggregate 的生成则保存 repos_dir 了目录下的 Source,用于后续处理。

先看 Source 的生成,在 #source_from_path 中:

def source_from_path(path)
  @sources_by_path ||= Hash.new do |hash, key|
    hash[key] = case
                when key.basename.to_s == Pod::TrunkSource::TRUNK_REPO_NAME
                  TrunkSource.new(key)
                when (key + '.url').exist?
                  CDNSource.new(key)
                else
                  Source.new(key)
                end
  end
  @sources_by_path[path]
end

repos_dir 下的目录名称来区分类型,而 CDNSource 则需要确保其目录下存在名为 .url 的文件。同时会对生成的 source 进行缓存。

最后看 Aggregate 结构,核心就两个 search 方法:

module Pod
  class Source
    class Aggregate
      attr_reader :sources

      def initialize(sources)
        raise "Cannot initialize an aggregate with a nil source: (#{sources})" if sources.include?(nil)
        @sources = sources
      end
      # 查询依赖对应的 specs
      def search(dependency) ... end

      # 查询某个 pod 以发布的 specs
      def search_by_name(query, full_text_search = false) ... end

      # ...
  end
end

Source 源起

本节我们来谈谈 source 是如何添加到 repo_dir 目录下的。

由前面的介绍可知,每个 source 中自带 url,在 Source 类中 url 读取自 Git 仓库的 remote.origin.url 或本地 .git 目录,而在 CDNSource 中 url 则是读取自当前目录下的  .url 文件所保存的 URL 地址。

那 CDNSource 的  .url 文件是在什么时候被写入的呢 ?

这需要从 Podfile 说起。很多老项目的 Podfile 开头部分大都会有一行或多行 source 命令:

source 'https://github.com/CocoaPods/Specs.git'
source 'https://github.com/artsy/Specs.git'

用于指定项目中 PodSpec 的查找源,这些指定源最终会保存在 ~/.cocoapods/repos 目录下的仓库。

当敲下 pod install 命令后,在 #resolve_dependencies 阶段的依赖分析中将同时完成 sources 的初始化。

# lib/cocoapods/installer/analyzer.rb

def sources
  @sources ||= begin
    # 省略获取 podfile、plugins、dependencies 的 source url ...
    sources = ...

    result = sources.uniq.map do |source_url|
      sources_manager.find_or_create_source_with_url(source_url)
    end
    unless plugin_sources.empty?
      result.insert(0, *plugin_sources)
      plugin_sources.each do |source|
        sources_manager.add_source(source)
      end
    end
    result
  end
end

获取 sources url 之后会通过 sources_manager 来完成 source 更新,逻辑在 CocoaPods 项目的 Manager 扩展中:

# lib/cocoapods/sources_manager.rb

module Pod
  class Source
    class Manager

      def find_or_create_source_with_url(url)
        source_with_url(url) || create_source_with_url(url)
      end

      def create_source_with_url(url)
        name = name_for_url(url)
        is_cdn = cdn_url?(url)
		  # ...
        begin
          if is_cdn
            Command::Repo::AddCDN.parse([name, url]).run
          else
            Command::Repo::Add.parse([name, url]).run
          end
        rescue Informative => e
          raise Informative, # ...
        ensure
          UI.title_level = previous_title_level
        end
        source = source_with_url(url)
        raise "Unable to create a source with URL #{url}" unless source
        source
      end
      # ...
    end
  end
end

查找会先调用 #source_with_url 进行缓存查询,如未命中则会先下载 Source 仓库,结束后重刷 aggreate 以更新 source。

# lib/cocoapods-core/source/manager.rb

def source_with_url(url)
  url = canonic_url(url)
  url = 'https://github.com/cocoapods/specs' if url =~ %r{github.com[:/]+cocoapods/specs}
  all.find do |source|
    source.url && canonic_url(source.url) == url
  end
end

def canonic_url(url)
  url.downcase.gsub(/\.git$/, '').gsub(%r{\/$}, '')
end

另外,仓库的下载的则会通过 #cdn_url? 方法区分,最后的下载则 📦 在两个命令类中,概括如下:

  • Repo::AddCDN:即  pod repo add-cdn 命令,仅有的操作是将 url 写入 .url 文件中。
  • Repo::Add:即 pod repo add 命令,对于普通类型的 Source 仓库下载本质就是 git clone 操作。

简化后源的添加流程如下:

PodSpec 查询

同样在 #resolve_dependencies 的依赖仲裁阶段,当 Molinillo 依赖仲裁开始前,会触发缓存查询 #find_cached_set 并最终调用到 Aggregate 的 #search。完整调用栈放在 gist 上。

我们来看看 #search 入口:

# lib/cocoapods-core/source/aggregate.rb

def search(dependency)
  found_sources = sources.select { |s| s.search(dependency) }
  unless found_sources.empty?
    Specification::Set.new(dependency.root_name, found_sources)
  end
end

Aggregate 先遍历当前 sources 并进行 dependency 查找。由于 Git 仓库保存了完整的 PodSpecs,只要能在分片目录下查询到对应文件即可,最终结果会塞入 Specification::Set 返回。

Specification::Set 记录了当前 pod 关联的 Source,一个 pod 可能存在与多个不同的 Spec 仓库 中。

CDN 仓库查询

CDNSource 重写了 #search 实现:

# lib/cocoapods-core/cdn_source.rb

def search(query)
  unless specs_dir
    raise Informative, "Unable to find a source named: `#{name}`"
  end
  if query.is_a?(Dependency)
    query = query.root_name
  end

  fragment = pod_shard_fragment(query)
  ensure_versions_file_loaded(fragment)
  version_arrays_by_name = @version_arrays_by_fragment_by_name[fragment] || {}

  found = version_arrays_by_name[query].nil? ? nil : query

  if found
    set = set(query)
    set if set.specification_name == query
  end
end

逻辑两步走:

  1. 通过 #ensure_versions_file_loaded 检查 all_pods_versions 文件,如果不存在会进行下载操作。
  2. 如果当前 source 包含查询的 pod,会创建 Specification::Set 作为查询结果,并在 #specification_name 方法内完成 PodSpec 的检查和下载。

1. all_pods_versions 文件下载

依据前面提到的分片规则会将 pod 名称 MD5 分割后拼成 URL。

AFNetworking 为例,经 #pod_shard_fragment 分割后获取的 fragment 为 [a, 7, 5],则拼接后的 URL 为 https://cdn.cocoapods.org/all_pods_versions_a_7_5.txt,下载后的内容大致如下:

AFNetworking/0.10.0/0.10.1/.../4.0.1
AppseeAnalytics/2.4.7/2.4.8/2.4.8.0/...
DynamsoftBarcodeReader/7.1.0/...
...

所包含的这些 pod 都是分片后得到的相同的地址,因此会保存在同一份 all_pods_versions 中。

def ensure_versions_file_loaded(fragment)
  return if !@version_arrays_by_fragment_by_name[fragment].nil? && !@check_existing_files_for_update

  index_file_name = index_file_name_for_fragment(fragment)
  download_file(index_file_name)
  versions_raw = local_file(index_file_name, &:to_a).map(&:chomp)
  @version_arrays_by_fragment_by_name[fragment] = versions_raw.reduce({}) do |hash, row|
    row = row.split('/')
    pod = row.shift
    versions = row

    hash[pod] = versions
    hash
  end
end

def index_file_name_for_fragment(fragment)
  fragment_joined = fragment.join('_')
  fragment_joined = '_' + fragment_joined unless fragment.empty?
  "all_pods_versions#{fragment_joined}.txt"
end

另外每一份 pods_version 都会对应生成一个文件用于保存 ETag,具体会在下一节会介绍。

2. PodSpec 文件下载

#specification_name 将从 all_pods_versions 索引文件中找出该 pod 所发布的版本号,依次检查下载对应版本的 PodSpec.json 文件。

module Pod
  class Specification
    class Set
      attr_reader :name
      attr_reader :sources

      def specification_name
        versions_by_source.each do |source, versions|
          next unless version = versions.first
          return source.specification(name, version).name
        end
        nil
      end

      def versions_by_source
        @versions_by_source ||= sources.each_with_object({}) do |source, result|
          result[source] = source.versions(name)
        end
      end
      # ...
    end
  end
end

绕了一圈后回到 Source 的 #versions 方法,由于 CDN Source 不会全量下载 pod 的 PodSpec 文件,在 #version 的检查过程会进行下载操作。

Pod Search 查询命令

CocoaPods 还提供了命令行工具 cocoapods-search 用于已发布的 PodSpec 查找:

$ pod search `QUERY`

它提供了 Web 查询和本地查询。本地查询则不同于 #search,它需要调用 Aggregate 的 #search_by_name ,其实现同 #search 类似,最终也会走到 Source 的 #versions 方法。

注意,Gti 仓库的 #search_by_name 查询仍旧为文件查找,不会调用其 #versions 方法。

Repo 更新

pod install 执行过程如果带上了 --repo-update 命令则在 #resolve_dependencies 阶段会触发 #update_repositories 更新 Spec 仓库:

# lib/cocoapods/installer/analyzer.rb

def update_repositories
  sources.each do |source|
    if source.updateable?
      sources_manager.update(source.name, true)
    else
      UI.message "Skipping ..."
    end
  end
  @specs_updated = true
end

不过 #update 的实现逻辑在 CocoaPods 项目的 Manager 扩展中:

# lib/cocoapods/sources_managers.rb

def update(source_name = nil, show_output = false)
  if source_name
    sources = [updateable_source_named(source_name)]
  else
    sources = updateable_sources
  end

  changed_spec_paths = {}

  # Do not perform an update if the repos dir has not been setup yet.
  return unless repos_dir.exist?

  File.open("#{repos_dir}/Spec_Lock", File::CREAT) do |f|
    f.flock(File::LOCK_EX)
    sources.each do |source|
      UI.section "Updating spec repo `#{source.name}`" do
        changed_source_paths = source.update(show_output)
        changed_spec_paths[source] = changed_source_paths if changed_source_paths.count > 0
        source.verify_compatibility!
      end
    end
  end
  update_search_index_if_needed_in_background(changed_spec_paths)
end
  1. 获取指定名称的 source,对 aggregate 返回的全部 sources 进行 filter,如未指定则 sources 全量。
  2. 挨个调用 source.update(show_output),注意 Git 和 CDN 仓库的更新方式的不同。

Git 仓库更新

Git 仓库更新本质就是 Git 操作,即 git pullgit checkout 命令:

def update(show_output)
  return [] if unchanged_github_repo?
  prev_commit_hash = git_commit_hash
  update_git_repo(show_output)
  @versions_by_name.clear
  refresh_metadata
  if version = metadata.last_compatible_version(Version.new(CORE_VERSION))
    tag = "v#{version}"
    CoreUI.warn "Using the ..."
    repo_git(['checkout', tag])
  end
  diff_until_commit_hash(prev_commit_hash)
end

#update_git_repo 就是 git fetch + git reset --hard [HEAD] 的结合体,更新后会进行 cocoapods 版本兼容检查,最终输出 diff 信息。

CDN 仓库更新

Git 仓库是可以通过 Commit 信息来进行增量更新,那以静态资源方式缓存的 CDN 仓库是如何更新数据的呢 ?

像浏览器或本地缓存本质是利用 ETag 来进行 Cache-Control,关于 CDN 缓存可以看这篇:传送门

而 ETag 就是一串字符,内容通常是数据的哈希值,由服务器返回。首次请求后会在本地缓存起来,并在后续的请求中携带上 ETag 来确定缓存是否需要更新。如果 ETag 值相同,说明资源未更改,服务器会返回 304(Not Modified)响应码。

Core 的实现也是如此,它会将各请求所对应的 ETag 以文件形式存储:

⚠️ 注意,在这个阶段 CDNSource 仅仅是更新当前目录下的索引文件,即 all_pods_versions_x_x_x.txt

def update(_show_output)
  @check_existing_files_for_update = true
  begin
    preheat_existing_files
  ensure
    @check_existing_files_for_update = false
  end
  []
end

def preheat_existing_files
  files_to_update = files_definitely_to_update + deprecated_local_podspecs - ['deprecated_podspecs.txt']

  concurrent_requests_catching_errors do
    loaders = files_to_update.map do |file|
      download_file_async(file)
    end
    Promises.zip_futures_on(HYDRA_EXECUTOR, *loaders).wait!
  end
end

Pod Repo 更新命令

CocoaPods 对于 sources 仓库的更新也提供了命令行工具:

$ pod repo update `[NAME]`

其实现如下:

# lib/cocoapods/command/repo/update.rb

module Pod
  class Command
    class Repo < Command
      class Update < Repo
        def run
          show_output = !config.silent?
          config.sources_manager.update(@name, show_output)
          exclude_repos_dir_from_backup
        end
        # ...
      end
    end
  end
end

在命令初始化时会保存指定的 Source 仓库名称 @name,接着通过 Mixin 的 config 来获取 sources_manager 触发更新。

最后用一张图来收尾 CocoaPods Workflow:

总结

最后一篇 Core 的分析文章,重点介绍了它是如何管理 PodSpec 仓库以及 PodSpec 文件的更新和查找,总结如下:

  1. 了解 Source Manager 的各种数据结构以及它们之间的相互关系,各个类之间居然都做到了权责分明。
  2. 通过对 Metadata 的分析了解了 Source 仓库的演变过程,并剖析了存在的问题。
  3. 掌握了如何利用 CDN 来改造原有的 Git 仓库,优化 PodSpec 下载速度。
  4. 发现原来 CLI 工具不仅仅可以提供给用户使用,内部调用也不是不可以。

知识点问题梳理

这里罗列了五个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入收藏再次阅读:

  1. PodSpecs 的聚合类有哪些,可以通过哪些手段来区分他们的类型 ?
  2. 说说你对 Aggregate 类的理解,以及它的主要作用 ?
  3. Source 类是如何更新 PodSpec
  4. Core 是如何对仓库进行分片的,它的分片方式是否支持配置 ?
  5. CDN 仓库是如何来更新 PodSpec 文件 ?

Podspec 文件分析

引子

在上文 Podfile 解析逻辑 中(建议先阅读这篇文章),我们以 Xcode 工程结构作为切入点介绍了 Podfile 背后对应的数据结构,剖析了 Podfile 文件是如何解析与加载,并最终 “入侵” 项目影响其工程结构的。今天我们来聊一聊 CocoaPods-Core 中的另一个重要文件 — Podspec 以及它所撑起的 CocoaPods 世界。

一个 Pod 的创建和发布离不开 .podspec 文件,它可以很简单也能复杂,如 QMUIKit(后续介绍)。

今天我们就直奔主题,来分析 Podspec 文件。

Podspec

Podspec 是用于 描述一个 Pod 库的源代码和资源将如何被打包编译成链接库或 framework 的文件 ,而 Podspec 中的这些描述内容最终将映会映射到 Specification 类中(以下简称 Spec)。

现在让我们来重新认识 Podspec

Podspec 初探

Podspec 支持的文件格式为 .podspec.json 两种,而 .podspec 本质是 Ruby 文件。

问题来了,为什么是 JSON 格式而不像 Podfile 一样支持 YAML 呢?

笔者的理解:由于 Podspec 文件会满世界跑,它可能存在于 CocoaPods 的 CDN ServiceSpeces Repo 或者你们的私有 Specs Repo 上,因此采用  JSON 的文件在网络传输中会更友好。而 Podfile 更多的场景是用于序列化,它需要在项目中生成一份经依赖仲裁后的 Podfile 快照,用于后续的对比。

Podspec

Pod::Spec.new do |spec|
  spec.name         = 'Reachability'
  spec.version      = '3.1.0'
  spec.license      = { :type => 'BSD' }
  spec.homepage     = 'https://github.com/tonymillion/Reachability'
  spec.authors      = { 'Tony Million' => 'tonymillion@gmail.com' }
  spec.summary      = 'ARC and GCD Compatible Reachability Class for iOS and OS X.'
  spec.source       = { :git => 'https://github.com/tonymillion/Reachability.git', :tag => "v#{spec.version}" }
  spec.source_files = 'Reachability.{h,m}'
  spec.framework    = 'SystemConfiguration'
end

上面这份 Reachability.podspec 配置,基本通过命令行 pod lib create NAME 就能帮我们完成。除此之外我们能做的更多,比如,默认情况下 CococaPods 会为每个 Pod framework 生成一个对应的 modulemap 文件,它将包含 Podspec 中指定的公共 headers。如果需要自定义引入的 header 文件,仅需配置 moduel_map 即可完成。

下面是进阶版配置:

Pod::Spec.new do |spec|
  spec.name         = 'Reachability'
  # 省略与前面相同部分的配置 ...
  
  spec.module_name   = 'Rich'
  spec.swift_version = '4.0'

  spec.ios.deployment_target  = '9.0'
  spec.osx.deployment_target  = '10.10'

  spec.source_files       = 'Reachability/common/*.swift'
  spec.ios.source_files   = 'Reachability/ios/*.swift', 'Reachability/extensions/*.swift'
  spec.osx.source_files   = 'Reachability/osx/*.swift'

  spec.framework      = 'SystemConfiguration'
  spec.ios.framework  = 'UIKit'
  spec.osx.framework  = 'AppKit'

  spec.dependency 'SomeOtherPod'
end

像 👆 我们为不同的系统指定了不同的源码和依赖等,当然可配置的不只这些。

Podspec 支持的完整配置分类如下:

想了解更多的配置选项:传送门

Convention Over Configuration

说到配置,不得不提一下 CoC 约定大于配置。约定大于配置算是在软件工程较早出现的概念的了,大意是:为了简单起见,我们的代码需要按照一定的约定来编写(如代码放在什么目录,用什么文件名,用什么类名等)。 这样既简化了配置文件,同时也降低了学习成本。

约定大于配置可以说是通过 Ruby on Rails 发扬光大的。尽管它一直饱受争议,但是主流语言的依赖管理工具,如 Mavennpm 等都遵循 CoC 进行不断演进的,因为 CoC 能够有效帮助开发者减轻选择的痛感,减少无意义的选择。一些新的语言也吸收了这个思想,比如 Go 语言。如果用 C/C++ 可能需要定义复杂的 Makefile 来定义编译的规则,以及如何运行测试用例,而在 Go 中这些都是约定好的。

举个 🌰 :Podfile 中是可以指定 pod library 所链接的 Xcode project,不过大多情况下无需配置,CocoaPods 会自动查找 Podfile 所在的同级目录下所对应的工程文件 .project

Spec 的核心数据结构

Specification

在数据结构上 SpecificationTargetDefinition 是类似的,同为多叉树结构。简化后的 Spec 的类如下:

require 'active_support/core_ext/string/strip.rb'
# 记录对应 platform 上 Spec 的其他 pod 依赖
require 'cocoapods-core/specification/consumer'
# 解析 DSL
require 'cocoapods-core/specification/dsl'
# 校验 Spec 的正确性,并抛出对应的错误和警告
require 'cocoapods-core/specification/linter'
# 用于解析 DSL 内容包含的配置信息
require 'cocoapods-core/specification/root_attribute_accessors'
# 记录一个 Pod 所有依赖的 Spec 来源信息
require 'cocoapods-core/specification/set'
# json 格式数据解析
require 'cocoapods-core/specification/json'

module Pod
  class Specification
    include Pod::Specification::DSL
    include Pod::Specification::DSL::Deprecations
    include Pod::Specification::RootAttributesAccessors
    include Pod::Specification::JSONSupport
 
    # `subspec` 的父节点
    attr_reader :parent
    # `Spec` 的唯一 id,由 name + version 的 hash 构成
    attr_reader :hash_value
    # 记录 `Spec` 的配置信息 
    attr_accessor :attributes_hash
    # `Spec` 包含的 `subspec`
    attr_accessor :subspecs
     
    # 递归调用获取 Specification 的根节点
    def root
      parent ? parent.root : self
    end
     
	 def hash
   	if @hash_value.nil?
      	@hash_value = (name.hash * 53) ^ version.hash
		end
      @hash_value
    end
     
    # ...
  end
end

Specification 同样用 map attributes_hash 来记录配置信息。

注意,这里的 parent 是为 subspec 保留的,用于指向其父节点的 Spec

Subspecs

乍一听 Subspec 这个概念似乎有一些抽象,不过当你理解了上面的描述,就能明白什么是 Subspec 了。我们知道在 Xcode 项目中,target 作为最小的可编译单元,它编译后的产物为链接库或 framework。而在 CocoaPods 的世界里这些 targets 则是由 Spec 文件来描述的,它还能拆分成一个或者多个 Subspec,我们暂且把它称为 Spec子模块,子模块也是用 Specification 类来描述的。

子模块可以单独作为依赖被引入到项目中。它有几个特点:

  • 未指定 default_subspec 的情况下,Spec 的全部子模块都将作为依赖被引入;
  • 子模块会主动继承其父节点 Spec 中定义的 attributes_hash
  • 子模块可以指定自己的源代码、资源文件、编译配置、依赖等;
  • 同一 Spec 内部的子模块是可以有依赖关系的;
  • 每个子模块在 pod push 的时候是需要被 lint 通过的;

光听总结似乎还是云里雾里,祭出 QMUI 让大家感受一下:

Pod::Spec.new do |s|
  s.name             = "QMUIKit"
  s.version          = "4.2.1"
  # ...
  s.subspec 'QMUICore' do |ss|
    ss.source_files = 'QMUIKit/QMUIKit.h', 'QMUIKit/QMUICore', 'QMUIKit/UIKitExtensions'
    ss.dependency 'QMUIKit/QMUIWeakObjectContainer'
    ss.dependency 'QMUIKit/QMUILog'
  end

  s.subspec 'QMUIWeakObjectContainer' do |ss|
    ss.source_files = 'QMUIKit/QMUIComponents/QMUIWeakObjectContainer.{h,m}'
  end

  s.subspec 'QMUILog' do |ss|
    ss.source_files = 'QMUIKit/QMUIComponents/QMUILog/*.{h,m}'
  end

  s.subspec 'QMUIComponents' do |ss|
    ss.dependency 'QMUIKit/QMUICore'
     
    ss.subspec 'QMUIButton' do |sss|
      sss.source_files = 'QMUIKit/QMUIComponents/QMUIButton/QMUIButton.{h,m}'
    end
    # 此处省略 59 个 Components
  end
  # ...
end

不吹不黑,QMUI 是笔者见过国内开源作品中代码注释非常详尽且提供完整 Demo 的项目之一。

整个 QMUIKit 的 Spec 文件中,总共定义了 64subspec 子模块,同时这些子模块之间还做了分层。比如 QMUICore:

另外补充一点,CocoaPods 支持了不同类型的 SubSpec

# lib/cocoapods-core/specification/dsl/attribute_support.rb

SUPPORTED_SPEC_TYPES = [:library, :app, :test].freeze

:app:test 用于在项目中集成单元测试代码的 Subspec

Podspec From JSON

有了上文 Podfile 的了解,这次我们对 Podspec 的文件加载会更加轻车熟路。首先是由 #from_file 方法进行文件路径和内容编码格式的检查,将加载的内容转入 #from_string

def self.from_file(path, subspec_name = nil)
  path = Pathname.new(path)
  unless path.exist?
    raise Informative, "No Podspec exists at path `#{path}`."
  end

  string = File.open(path, 'r:utf-8', &:read)
  # Work around for Rubinius incomplete encoding in 1.9 mode
  if string.respond_to?(:encoding) && string.encoding.name != 'UTF-8'
    string.encode!('UTF-8')
  end

  from_string(string, path, subspec_name)
end

def self.from_string(spec_contents, path, subspec_name = nil)
  path = Pathname.new(path).expand_path
  spec = nil
  case path.extname
  when '.podspec'
    Dir.chdir(path.parent.directory? ? path.parent : Dir.pwd) do
      spec = ::Pod._eval_Podspec(spec_contents, path)
      unless spec.is_a?(Specification)
        raise Informative, "Invalid Podspec file at path `#{path}`."
      end
    end
  when '.json'
    spec = Specification.from_json(spec_contents)
  else
    raise Informative, "Unsupported specification format `#{path.extname}` for spec at `#{path}`."
  end

  spec.defined_in_file = path
  spec.subspec_by_name(subspec_name, true)
end

接着根据文件类型为 .podspec.json 分别采用不同的解析方式。在  JSONSupport 模块内将 #from_json 的逻辑拆成了两部分:

# `lib/cocoapods-core/specification/json.rb`
module Pod
  class Specification
    module JSONSupport
    # ①
    def self.from_json(json)
      require 'json'
      hash = JSON.parse(json)
      from_hash(hash)
    end
    # ②
    def self.from_hash(hash, parent = nil, test_specification: false, app_specification: false)
      attributes_hash = hash.dup
      spec = Spec.new(parent, nil, test_specification, :app_specification => app_specification)
      subspecs = attributes_hash.delete('subspecs')
      testspecs = attributes_hash.delete('testspecs')
      appspecs = attributes_hash.delete('appspecs')
  
      ## backwards compatibility with 1.3.0
      spec.test_specification = !attributes_hash['test_type'].nil?
  
      spec.attributes_hash = attributes_hash
      spec.subspecs.concat(subspecs_from_hash(spec, subspecs, false, false))
      spec.subspecs.concat(subspecs_from_hash(spec, testspecs, true, false))
      spec.subspecs.concat(subspecs_from_hash(spec, appspecs, false, true))
  
      spec
    end
    # ③
    def self.subspecs_from_hash(spec, subspecs, test_specification, app_specification)
      return [] if subspecs.nil?
      subspecs.map do |s_hash|
        Specification.from_hash(s_hash, spec,
                                :test_specification => test_specification,
                                :app_specification => app_specification)
      end
    end
  end
end

这里的逻辑也是比较简单:

  • ① 将传入的字符串转换为 json;
  • ② 将转换后的 json 转换为 Spec 对象并将 json 转换为 attributes_hash,同时触发 ③;
  • ③ 通过 self.subspecs_from_hash 实现递归调用完成 subspecs 解析;

Tips: 方法 ② 里的 Spec 是对 Specification 的别名。

Podspec From Ruby

QMUIKit.podspec 的文件内容,大家是否注意到其开头的声明:

Pod::Spec.new do |s|
  s.name             = "QMUIKit"
  s.source_files     = 'QMUIKit/QMUIKit.h'
  # ...
end

发现没 .podspec 文件就是简单直接地声明了一个 Specifiction 对象,然后通过 block 块定制来完成配置。像 namesource_files 这些配置参数最终都会转换为方法调用并将值存入 attributes_hash 中。这些方法调用的实现方式分两种:

  1. 大部分配置是通过方法包装器 attributeroot_attribute 来动态添加的 setter 方法;
  2. 对于复杂逻辑的配置则直接方法声明,如 subspecdependency 方法等(后续介绍)。

attribute wrappter

# `lib/cocoapods-core/specification/dsl.rb`
module Pod
  class Specification
    module DSL
      extend Pod::Specification::DSL::AttributeSupport
      # Deprecations must be required after include AttributeSupport
      require 'cocoapods-core/specification/dsl/deprecations'

      attribute :name,
                :required => true,
                :inherited => false,
                :multi_platform => false

      root_attribute :version,
                      :required => true
      # ...
    end
  end
end

可以看出 name 和 version 的方法声明与普通的不太一样,其实 attributeroot_attribute 是通过 Ruby 的方法包装器来实现的,感兴趣的同学看这里 「Python装饰器 与 Ruby实现」。

Tips: Ruby 原生提供的属性访问器 — attr_accessor 大家应该不陌生,就是通过包装器实现的。

这些装饰器所声明的方法会在其模块被加载时动态生成,来看其实现:

# `lib/cocoapods-core/specification/attribute_support.rb`
module Pod
  class Specification
    module DSL
      class << self
        attr_reader :attributes
      end

      module AttributeSupport
        def root_attribute(name, options = {})
          options[:root_only] = true
          options[:multi_platform] = false
          store_attribute(name, options)
        end

        def attribute(name, options = {})
          store_attribute(name, options)
        end

        def store_attribute(name, options)
          attr = Attribute.new(name, options)
          @attributes ||= {}
          @attributes[name] = attr
        end
      end
    end
  end
end

attributeroot_attribute 最终都走到了 store_attribute 保存在创建的 Attribute 对象内,并以配置的 Symbol 名称作为 KEY 存入 @attributes,用于生成最终的 attributes setter 方法。

最关键的一步,让我们回到 specification 文件:

# `/lib/coocapods-core/specification`
module Pod
  class Specification
    # ...
    
    def store_attribute(name, value, platform_name = nil)
      name = name.to_s
      value = Specification.convert_keys_to_string(value) if value.is_a?(Hash)
      value = value.strip_heredoc.strip if value.respond_to?(:strip_heredoc)
      if platform_name
        platform_name = platform_name.to_s
        attributes_hash[platform_name] ||= {}
        attributes_hash[platform_name][name] = value
      else
        attributes_hash[name] = value
      end
    end

    DSL.attributes.values.each do |a|
      define_method(a.writer_name) do |value|
        store_attribute(a.name, value)
      end

      if a.writer_singular_form
        alias_method(a.writer_singular_form, a.writer_name)
      end
    end
  end
end

Specification 类被加载时,会先遍历 DSL module 加载后所保存的 attributes,再通过 define_method 动态生成对应的配置方法。最终数据还是保存在 attributes_hash 中。

Attribute

Attribute 是为了记录该配置的相关信息,例如,记录 Spec 是否为根节点、Spec 类型、所支持的 platforms、资源地址通配符等。

  1. root_attribute 包装的配置仅用于修饰 Spec 根节点,比如版本号 version 只能由 Spec 根节点来设置,另外还有 sourcestatic_frameworkmodule_name 等;
  2. attribute 包装的配置则不限是否为 Spec 根结点。我们以 AFNetworking 的 source_files 为例:由于在 macOS 和 watchOS 上并没有 UIKit framwork,因此它单独将 UIKit 的相关功能拆分到了 AFNetworking/UIKit 中;
Pod::Spec.new do |s|
  # ...
  s.subspec 'NSURLSession' do |ss|
	 # ...
  end

  s.subspec 'UIKit' do |ss|
    ss.ios.deployment_target = '9.0'
    ss.tvos.deployment_target = '9.0'
    ss.dependency 'AFNetworking/NSURLSession'

    ss.source_files = 'UIKit+AFNetworking'
  end
end

#subspec

除了 attribute 装饰器声明的 setter 方法,还有几个自定义的方法是直接通过 eval 调用的。

def subspec(name, &block)
  subspec = Specification.new(self, name, &block)
  @subspecs << subspec
  subspec
end

def test_spec(name = 'Tests', &block)
  subspec = Specification.new(self, name, true, &block)
  @subspecs << subspec
  subspec
end

def app_spec(name = 'App', &block)
  appspec = Specification.new(self, name, :app_specification => true, &block)
  @subspecs << appspec
  appspec
end

这三种不同类型的 Subspeceval 转换为对应的 Specification 对象,注意这里初始化后都将 parent 节点指向 self 同时存入 @subspecs 数组中,完成 SubSpec 依赖链的构造。

#dependency

对于其他 pod 依赖的添加我们通过 dependency 方法来实现:

def dependency(*args)
  name, *version_requirements = args
  # dependency args 有效性校验 ...

  attributes_hash['dependencies'] ||= {}
  attributes_hash['dependencies'][name] = version_requirements

  unless whitelisted_configurations.nil?
    # configuration 白名单过滤和校验 ...

    attributes_hash['configuration_pod_whitelist'] ||= {}
    attributes_hash['configuration_pod_whitelist'][name] = whitelisted_configurations
  end
end

dependency 方法内部主要是对依赖有效性的校验,限于篇幅这里不列出实现,核心要点如下:

  1. 检查依赖循环,根据 Spec 名称判断 Spec 与自身,SpecSubSpec之间是否存在循环依赖;
  2. 检查依赖来源Podspec 中不支持 :git:path 形式的来源指定,如需设定可通过 Podfile 来修改;
  3. 检查 configuation 白名单,目前仅支持 Xcode 默认的 DebugRelease 的 configuration 配置;

创建并使用你的 Pod

最后一节来两个实践:创建 Pod 以及在项目中使用 SubSpecs

Pod 创建

pod 相关使用官方都提供了很详尽的都文档,本小节仅做介绍。

1. 创建 Pod

仅需一行命令完成 Pod 创建(文档):

$ pod lib create `NAME`

之后每一步都会输出友好提示,按照提示选择即可。在添加完 source code 和 dependency 之后,你还可以在 CocoaPods 为你提供的 Example 项目中运行和调试代码。

准备就绪后,可以通过以下命令进行校验,检查 Pod 正确性:

$ pod lib lint `[Podspec_PATHS ...]`

2. 发布 Pod

校验通过后就可以将 Pod 发布了,你可以将 PodSepc 发布到  Master Repo 上,或者发布到内部的 Spec Repo 上。

CocoaPods Master Repo

如果发布的 CocoaPods 的主仓库,那么需要通过 CocoaPods 提供的 Trunk 命令:

$ pod trunk push `[NAME.podspec]`

不过使用前需要先通过邮箱注册,详情查看文档

Private Spec Repo

对于发布到私有仓库的,可通过 CocoaPods 提供的 Repo 命令:

$ pod repo push `REPO_NAME` `SPEC_NAME.podspec`

文档详情 — 传送门

SubSpecs In Podfile

SubSpec 一节提到过,在 CocoaPods 中 SubSpec 是被作为单独的依赖来看待的,这里就借这个实操来证明一下。

在上文的实践中,我们知道每一个 Pod 库对应为 Xcode 项目中的一个个 target,那么当明确指定部分 SubSpec 时,它们也将被作为独立的 target 进行编译。不过这里需要明确一下使用场景:

1. Single Target

当主项目中仅有一个 target 或多个 target 引用了同一个 pod 库的多个不同 SubSpec 时,生成的 target 只会有一个。我们以 QMUIKit 为例,项目 Demo.project 下的 Podfile 配置如下:

target 'Demo' do
  pod 'QMUIKit/QMUIComponents/QMUILabel', :path => '../QMUI_iOS'
  pod 'QMUIKit/QMUIComponents/QMUIButton', :path => '../QMUI_iOS'
end

此时 Pods.project 下的 QMUIKit 的 target 名称为 QMUIKit

2. Multiple Target

如果我们的主项目中存在多个 target 且使用同一个 pod 库的不同 SubSpec 时,结果则有所不同。

现在我们在步骤 1 的基础上添加如下配置:

target 'Demo2' do
	pod 'QMUIKit/QMUIComponents/QMUILog', :path => '../QMUI_iOS'
end

可以发现,CocoaPods 为每个 tareget 对应的 SubSpec 依赖生成了不同的 QMUIKit targets。

Tips: 当主工程 target 依赖的 Subspec 数量过多导致的名称超过 50 个字符,将会对 subspec 后缀做摘要处理作为唯一标识符。

总结

本文是 CocoaPods-Core 的第二篇,重点介绍了 Podspec 的类构成和解析实现,总结如下:

  1. 初探 Podspec 让我们对其能力边界和配置分类有了更好的了解;
  2. 深入 Podspec 我们发现其数据结构同 Podfile 类似,都是根据依赖关系建立对应的树结构;
  3. Podspec 针对单个库的源码和资源提供了更精细化的管理,SubSpec 结构的推出让大型 library 的内部分层提供了很好的工具;
  4. 装饰器模式结合 Ruby 的动态特性,让 Podspec 的 DSL 特性的实现起来更加优雅;

知识点问题梳理

这里罗列了四个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入**收藏 **再次阅读:

  1. 说说 Podspec 所支持的配置有几类,分别具有哪些功能 ?
  2. PodspecSubSpec 之间有哪些关系 ?
  3. 说说 SubSpec 的特点以及作用 ?
  4. 谈谈 Podspec 中的 DSL 解析与 Podfile 的解析实现有哪些区别 ?

Ruby 黑魔法 - eval 和 alias

CocoaPods 是使用 Ruby 这门脚本语言实现的工具。Ruby 有很多优质的特性被 CocoaPods 所利用,为了在后续的源码阅读中不会被这些用法阻塞,所以在这个系列中,会给出一些 CocoaPods 的番外篇,来介绍 Ruby 及其当中的一些语言思想。

今天这一篇我们来聊聊 Ruby 中的一些十分“动态”的特性:eval 特性和 alias 特性

说说 Eval 特性

源自 Lisp 的 Evaluation

在一些语言中,eval 方法是将一个字符串当作表达式执行而返回一个结果的方法;在另外一些中,eval 它所传入的不一定是字符串,还有可能是抽象句法形势,Lisp 就是这种语言,并且 Lisp 也是首先提出使用 eval 方法的语言,并提出了 Evaluation 这个特性。这也使得 Lisp 这门语言可以实现脱离编译这套体系而动态执行的结果

Lisp 中的 eval 方法预期是:将表达式作为参数传入到 eval 方法,并声明给定形式的返回值,运行时动态计算

下面是一个 Lisp Evaluation 代码的例子( Scheme 方言 RRS 及以后版本):

;  f1 设置为表达式 (+ 1 2 3)
(define f1 '(+ 1 2 3))
 
; 执行 f1 (+ 1 2 3) 这个表达式,并返回 6
(eval f1 user-initial-environment)

可能你会觉得:这只是一个简单的特性,为什么会称作黑魔法特性?

因为 Evaluation 这种可 eval 特性是很多思想、落地工具的基础。为什么这么说,下面来说几个很常见的场景。

REPL 的核心思想

如果你是 iOSer,你一定还会记得当年 Swift 刚刚诞生的时候,有一个主打的功能就是 REPL 交互式开发环境

当然,作为动态性十分强大的 Lisp 和 Ruby 也有对应的 REPL 工具。例如 Ruby 的 irb 和 pry 都是十分强大的 REPL。为什么这里要提及 REPL 呢?因为在这个名字中,E 就是 eval 的意思。

REPL 对应的英文是 Read-Eval-Print Loop

  • Read 读入一个来自于用户的表达式,将其放入内存;
  • Eval 求值函数,负责处理内部的数据结构并对上下文逻辑求值;
  • Print 输出方法,将结果呈现给用户,完成交互。

REPL 的模型让大家对于语言的学习和调试也有着增速作用,因为“Read - Eval - Print” 这种循环要比 “Code - Compile - Run - Debug” 这种循环更加敏捷。

在 Lisp 的思想中,为了实现一个 Lisp REPL ,只需要实现这三个函数和一个轮循的函数即可。当然这里我们忽略掉复杂的求值函数,因为它就是一个解释器。

有了这个思想,一个最简单的 REPL 就可以使用如下的形式表达:

# Lisp 中
(loop (print (eval (read))))

# Ruby 中
while [case]
  print(eval(read))
end

简单聊聊 HotPatch

大约在 2 年前,iOS 比较流行使用 JSPatch/RN 基于 JavaScriptCore 提供的 iOS 热修复和动态化方案。其核心的思路基本都是下发 JavaScript 脚本来调用 Objective-C,从而实现逻辑注入。

JSPatch 尤其被大家所知,需要编写大量的 JavaScript 代码来调用 Objective-C 方法,当然官方也看到了这一效率的洼地,并制作了 JSPatch 的语法转化器来间接优化这一过程。

但是无论如何优化,其实最大的根本问题是 Objective-C 这门语言不具备 Evaluation 的可 eval 特性,倘若拥有该特性,那其实就可以跨越使用 JavaScript 做桥接的诸多问题。

我们都知道 Objective-C 的 Runtime 利用消息转发可以动态执行任何 Objective-C 方法,这也就给了我们一个启示。假如我们自制一个轻量级解释器,动态解释 Objective-C 代码,利用 Runtime 消息转发来动态执行 Objective-C 方法,就可以实现一个“准 eval 方法”

这种思路在 GitHub 上也已经有朋友开源出了 Demo - OCEval。不同于 Clang 的编译过程,他进行了精简:

  1. 去除了 Preprocesser 的预编译环节,保留了 Lexer 词法分析和 Parser 语法分析,
  2. 利用 NSMethodSignature 封装方法,结合递归下降,使用 Runtime 对方法进行消息转发。

利用这种思路的还有另外一个 OCRunner 项目。

这些都是通过自制解释器,实现 eval 特性,进而配合 libffi 来实现。

Ruby 中的 evalbinding

Ruby 中的 eval 方法其实很好理解,就是将 Ruby 代码以字符串的形式作为参数传入,然后进行执行。

str = 'Hello'
puts eval("str + ' CocoaPods'") # Hello CocoaPods

上面就是一个例子,我们发现传入的代码 str + ' CocoaPods'  在 eval 方法中已经变成 Ruby 代码执行,并返回结果 'Hello CocoaPods'  字符串。

「Podfile 的解析逻辑」中讲到, CocoaPods 中也使用了 eval 方法,从而以 Ruby 脚本的形式,执行了 Podfile 文件中的逻辑。

def self.from_ruby(path, contents = nil)
  # ... 
  podfile = Podfile.new(path) do
    begin
      # 执行 Podfile 中的逻辑
      eval(contents, nil, path.to_s)
    rescue Exception => e
      message = "Invalid `#{path.basename}` file: #{e.message}"
      raise DSLError.new(message, path, e, contents)
    end
  end
  podfile
end

当然,在 CocoaPods 中仅仅是用了 eval 方法的第一层,对于我们学习者来说肯定不能满足于此。

在 Ruby 中, Kernel 有一个方法 binding ,它会返回一个 Binding 类型的对象。这个 Binding 对象就是我们俗称的绑定,它封装了当前执行上下文的所有绑定,包括变量、方法、Block 和 self 的名称绑定等,这些绑定直接决定了面向对象语言中的执行环境。

那么这个 Binding 对象在 eval 方法中怎么使用呢?其实就是 eval 方法的第二个参数。这个在 CocoaPods 中运行 Podfile 代码中并没有使用到。我们下面来做一个例子:

def foo 
  name = 'Gua'
  binding
end

eval('p name', foo) # Gua

在这个例子中,我们的 foo 方法就是我们上面说的执行环境,在这个环境里定义了 name 这个变量,并在方法体最后返回 binding 方法调用结果。在下面使用 eval 方法的时候,当作 Kernel#binding 入参传入,便可以成功输出 name 变量。

TOPLEVEL_BINDING 全局常量

在 Ruby 中 main 对象是最顶级范围,Ruby 中的任何对象都至少需要在次作用域范围内被实例化。为了随时随地地访问 main 对象的上下文,Ruby 提供了一个名为 TOPLEVEL_BINDING 的全局常量,它指向一个封装了顶级绑定的对象。 便于理解,举个例子:

@a = "Hello"

class Addition
  def add
    TOPLEVEL_BINDING.eval("@a += ' Gua'")
  end
end

Addition.new.add

p TOPLEVEL_BINDING.receiver # main
p @a # Hello Gua

这段代码中,Binding#receiver 方法返回 Kernel#binding 消息的接收者。为此,则保存了调用执行上下文 - 在我们的示例中,是 main 对象。

然后我们在 Addition 类的实例中使用 TOPLEVEL_BINDING 全局常量访问全局的 @a 变量。

总说 Ruby Eval 特性

以上的简单介绍如果你曾经阅读过 SICP(Structture and Interpretation of Computer Programs)这一神书的第四章后,一定会有更加深刻的理解。

我们将所有的语句当作求值,用语言去描述过程,用与被求值的语言相同的语言写出的求值器被称作元循环;eval 在元循环中,参数是一个表达式和一个环境,这也与 Ruby 的 eval 方法完全吻合。

不得不说,Ruby 的很多思想,站在 SICP 的肩膀上。

类似于 Method Swizzling 的 alias

对于广大 iOSer 一定都十分了解被称作 Runtime 黑魔法的 Method Swizzling。这其实是动态语言大都具有都特性。

在 iOS 中,使用 Selector 和 Implementation(即 IMP)的指向交换,从而实现了方法的替换。这种替换是发生在运行时的。

在 Ruby 中,也有类似的方法。为了全面的了解 Ruby 中的 “Method Swizzling”,我们需要了解这几个关于元编程思想的概念:Open Class 特性与环绕别名。这两个特性也是实现 CocoaPods 插件化的核心依赖。

Open Class 与特异方法

Open Class 特性就是在一个类已经完成定义之后,再次向其中添加方法。在 Ruby 中的实现方法就是定义同名类

在 Ruby 中不会像 Objective-C 和 Swift 一样被认为是编译错误,后者需要使用 Category 和 Extension 特殊的关键字语法来约定是扩展。而是把同名类中的定义方法全部附加到已定义的旧类中,不重名的增加,重名的覆盖。以下为示例代码:

class Foo
  def m1
    puts "m1"
  end
end

class Foo
  def m2 
    puts "m2"
  end
end

Foo.new.m1 # m1
Foo.new.m2 # m2

class Foo
  def m1
    puts "m1 new"
  end
end

Foo.new.m1 # m1 new
Foo.new.m2 # m2

特异方法和 Open Class 有点类似,不过附加的方法不是附加到类中,而是附加到特定到实例中。被附加到方法仅仅在目标实例中存在,不会影响该类到其他实例。示例代码:

class Foo
  def m1
    puts "m1"
  end
end

foo1 = Foo.new

def foo1.m2()
  puts "m2"
end

foo1.m1 # m1
foo1.m2 # m2

foo2 = Foo.new
foo2.m1 # m1
# foo2.m2 undefined method `m2' for #<Foo:0x00007f88bb08e238> (NoMethodError)

环绕别名(Around Aliases)

其实环绕别名只是一种特殊的写法,这里使用了 Ruby 的 alias 关键字以及上文提到的 Open Class 的特性。

首先先介绍一下 Ruby 的 alias 关键字,其实很简单,就是给一个方法起一个别名。但是 alias 配合上之前的 Open Class 特性,就可以达到我们所说的 Method Swizzling 效果。

class Foo
  def m1
    puts "m1"
  end
end

foo = Foo.new
foo.m1 # m1

class Foo
  alias :origin_m1 :m1
  def m1
    origin_m1
    puts "Hook it!"
  end
end

foo.m1 
# m1
# Hook it!

虽然在第一个位置已经定义了 Foo#m1  方法,但是由于 Open Class 的重写机制以及 alias 的别名设置,我们将 m1 已经修改成了新的方法,旧的 m1 方法使用 origin_m1 也可以调用到。如此也就完成了类似于 Objective-C 中的 Method Swizzling 机制。

总结一下环绕别名,其实就是给方法定义一个别名,然后重新定义这个方法,在新的方法中使用别名调用老方法

猴子补丁(Monkey Patch)

既然说到了 alias 别名,那么就顺便说一下猴子补丁这个特性。猴子补丁区别于环绕别名的方式,它主要目的是在运行时动态替换并可以暂时性避免程序崩溃

先聊聊背景,由于 Open Class 和环绕别名这两个特性,Ruby 在运行时改变属性已经十分容易了。但是如果我们现在有一个需求,就是 **需要动态的进行 Patch ** ,而不是只要 alias 就全局替换,这要怎么做呢?

这里我们引入 Ruby 中的另外两个关键字 refine 和 using ,通过它们我们可以动态实现 Patch。举个例子:

class Foo
  def m1
    puts "m1"
  end
end

foo = Foo.new
foo.m1 # m1

"""
定义一个 Patch
"""

module TemproaryPatch
  refine Foo do 
    def m1 
      puts "m1 bugfix"
    end
  end
end

using TemproaryPatch

foo2 = Foo.new
foo2.m1 # m1 bugfix

上面代码中,我们先使用了 refine 方法重新定义了 m1 方法,定义完之后它并不会立即生效,而是在我们使用 using TemporaryPatch 时,才会生效。这样也就实现了动态 Patch 的需求。

总说 alias 特性

Ruby 的 alias 使用实在时太灵活了,这也导致了为什么 Ruby 很容易的就可以实现插件化能力。因为所有的方法都可以通过环绕别名的方式进行 Hook ,从而实现自己的 Gem 插件。

除了以上介绍的一些扩展方式,其实 Ruby 还有更多修改方案。例如 alias_methodextend 、 refinement 等。如果后面 CocoaPods 有所涉及,我们也会跟进介绍一些。

总结

本文通过 CocoaPods 中的两个使用到的特性 Eval 和 Alias,讲述了很多 Ruby 当中有意思的语法特性和元编程思想。Ruby 在众多的语言中,因为注重思想和语法优雅脱颖而出,也让我个人对语言有很大的思想提升。

如果你有经历,我也强烈推荐你阅读 SICP 和「Ruby 元编程」这两本书,相信它们也会让你在语言设计的理解上,有着更深的认识。从共性提炼到方法论,从语言升华到经验。

知识点问题梳理

这里罗列了四个问题用来考察你是否已经掌握了这篇文章,你可以在评论区及时回答问题与作者交流。如果没有建议你加入收藏再次阅读:

  1. REPL 的核心思想是什么?与 Evaluation 特性有什么关系?
  2. Ruby 中 eval 方法作用是什么?Binding 对象用来干什么?
  3. Ruby 是否可以实现 Method Swizzling 这种功能?
  4. Open Class 是什么?环绕别名如何利用?

二手房增值税分析 - 首付与差额

我给自己定了一个将近 700 天的计划,这就是在上海买房定居。于是开启了研究购房政策、研究小区和板块、研究购房注意事项等一系列的调研和研究。我将这些笔记归档成文章,发表在公众号与大家分享。

今天距离购房还有 665 天。

虽然说二手房的价格便宜,但其实除了购房款以外,还有大大小小的多个税款。当我们累加上这些价格后才是最终的支出价格。

这篇笔记仅适用于上海 2020 年购入二手房的相关政策,其他省份和城市都略有区别

二手房交易中,这篇我们只关注增值税。

首先,我们先要了解几个概念。

普通住宅认定标准

什么是普通住宅?

影响上海普通住宅认定标准的三个因素,一是总价,二是面积,三是圈层

首先,满足以下任意一行条件的即为普通住宅,反之则为非普通住宅:

圈层 面积 总价
内环内 < 140 平米 ≤ 450 万元
内外环之间 < 140 平米 ≤ 310 万元
外环外 < 140 平米 ≤ 230 万元

知道了普通住宅的认定标准后有什么用呢?接下来就是关于普通和非普通的相关征税内容。

增值税及其附加税

当所购房屋为普通住宅的时候,其增值税及其附加税按照下表为征收标准:

年限 征收标准
< 2 年 (税务核定价 ÷ 1.05) × (5% + 0.55%)
[2, 5) 年 免征
≥ 5 年 免征

对应的,非普通住宅有以下的标准:

年限 征收标准
< 2 年 (税务核定价 ÷ 1.05) × (5% + 0.55%)
[2, 5) 年 ((税务核定价- 买入价格) ÷ 1.05) × (5% + 0.55%)
≥ 5 年 ((税务核定价 - 买入价格) ÷ 1.05) × (5% + 0.55%)

这里的税务核定价是什么?又是如何确定的呢?

税务核定价

税务核定价,又被大家称之为二手房过户指导价。它是由当地税务部门确定的,核定价存在的意义主要是“兜底”,简单来说就是当买卖双方的网签价格低于核定价时,税务部门将不再根据网签价(也就是协商好的交易价格) 来确定税基,而是以核定价为标准来确定税基。

所以你应该明白了,核定价明显就是为了对付“阴阳合同”的。是的,它本质上是为了保证税收。

试想一下,如果买卖双方为了避税,网签价定为 1 元,那么这套房子就完全没有任何税费了。

那么税务核定价格一般是多少呢?

这个数据我在百度和谷歌也检索了很久,没有办法得到一组批量数据来做数据拟合。核定价格一般也不会对外公布,需要自己去税务机关咨询,也只能得到一个大概的数据。

因为在确定房屋的核定价时,要参考地段、面积等诸多因素,所以这个是很难作出表达式来加权表示的。

由于税务核定价一般是用来“兜底”的,所以我们在这篇文章里做计算的时候,假设税务核定价是网签价格的 85% 来计算。

核定价影响贷款额度

虽然在「公积金贷买房能省多少钱」一文中,我们假定了任意房子的总价格 35% 首付,65% 贷款。

但是贷款额度参考的基数其实是税务核定价格,这也就意味着 往往购买二手房,你的首付是要多交一笔的,是大于 35% 的 。这是为什么?我们简单做一下计算:

假设我们想购买的二手房网签总价为 $T_{网签}$ 元 ,且**假设税务核定价是网签价格的 85% **,此时税务核定价格 :

\[\begin{equation} \left\{ \begin{array}{lr} T_{贷款额度} = T_{核定} \times 65\% \notag \\ T_{核定} = T_{网签} \times 85\% \notag \\ \end{array} \\ \right. \end{equation}\]

带入后我们可以推导出:

\[\begin{equation} \left\{ \begin{array}{lr} T_{贷款额度} = T_{网签} \times 55.25\% \notag \\ T_{首付} = 1-55.25\% = 44.75\% \notag \\ \end{array} \right. \end{equation}\]

根据我们的假设条件,我们发现首付我们必须要凑够 44.75% 价格的首付,而贷款额度只有 55.25%

所以很多人都说,因为买了二手房,所以首付要高一些,将近 50%。其实就是上面这个原因。

增值税影响下的购房决策分析

同样的,我们假设网签总价为 $T_{网签}$ 元,且 假设税务核定价是网签价格的 85%。 另外,我从知乎上检索到这么一组数据 「2015-2019 年上海各区房价涨跌情况」。其中我需要用到各个区的二手房年化平均增长率,如下表总结:

区域 二手房价年化平均涨幅 区域 二手房价年化平均涨幅
长宁 13.30% 静安(包含老闸北) 16.04%
虹口 15.79% 闵行 14.02%
黄浦 15.99% 浦东(包含老南汇) 12.55%
普陀 15.81% 徐汇 13.55%

由上述数据我们可以计算出,上海二手房平均年化涨幅为 14.63%。有了涨幅数据之后,我们就可以求出差额的表达式,即 $T_{差额} = (税务核定价- 买入价格)$。当然,这里我们还要假设房屋年限为 $y$ 年

\[\begin{align} T_{差额} &= T_{核定价} - T_{买入价格} \notag \\ \ &= 0.85T_{网签} - T_{网签} \div (1 + 0.1463)^y \notag \\ \end{align}\]

接下来我们来计算增值税的表达式:

当房屋是普通住宅时:

\[\begin{equation} T_{增值税} = \left\{ \begin{aligned} & \frac{T_{核定价}}{1.05} \times 5.55\% = 0.04497 · T_{网签} && (y < 2)\\ & 0 && (y \geq 2) \end{aligned} \\ \right. \notag \\ \end{equation}\]

当房屋是非普通住宅时:

\[\begin{equation} T_{增值税} = \left\{ \begin{aligned} & 0.04497 · T_{网签} && (y < 2) \notag \\ \\ & (0.85T_{网签} - \frac{T_{网签}}{(1 + 0.1463)^y}) \times 0.0529 && (y \geq 2) \notag \\ \end{aligned} \right. \notag \\ \end{equation}\]

我们可以看到,当年限小于 2 年的时候,无论是普通住宅还是非普通住宅,其增值税都要支付将近 4.5% 的增值税。且是否是普通住宅对增值税毫无影响。

当年限大于等于 5 年的时候,普通住宅是免征增值税的,而非普通住宅是与年限和网签总价有一定关系的。我们来分析一下。

非普通住宅的年限影响

由于年份 $y$ 是离散的,所以我们将其投影在每一条折线上。以网签价格为横轴变量,来观察增值税与网签价的关系图:

我们可以得到以下结论:

  1. 房龄越高,其所得税缴纳越多。这也是可以理解的,因为随着房价的上涨,其差额会逐渐变大。
  2. 所得税的增长幅度与年限呈现下降趋势。随着年限增高,增长差值逐渐降低。

增值税占比

有没有什么方法来评估增值税最低的选择策略呢?我个人的想法是使用所占比例来考量。以下是我想的一个方法:

\[P_{增值税所占比例} \approx \frac{T_{增值税}}{T_{网签价} + T_{增值税}}\]

为什么这里是约等于呢?因为最后你需要支出的价格除了网签价格外,不仅只有增值税,还有其他的各种税款。 这里仅仅使用增值税来当作基数,可以放大增值税的比重,更能体现问题。

作出图像我们可以发现,当我们购买“低龄”的房子,其所得税所占比例是更底的,5 年的二手房其所得税所占比例均值是 1.79%

于是乎我们可以大致的计算出房龄与所得税所占比例的大致关系:

那么我们最好的策略是要选择最新的房子吗?其实并不是这样的,因为随着年限的增长,其单价也在逐渐升高。

换句话说,当我总价固定后,随着房龄的降低,其面积是越来越小的。房屋面积是和我们的需求息息相关的。

所以当我们做买房策略的时候,我们需要先确定需求面积和我们所支出的总价,然后尽可能的去购买新房就好了。

由于所得税在这里只能说明部分问题,具体的问题我将在后续的文中来具体分析。

总结

  1. 购买二手房时,首付高一些,将近 50%。 其原因是因为税务核算价往往偏低,从而拉低了贷款比重,进而使得首付提高;
  2. 当购买非普通住宅时,房龄越高,其所得税缴纳越多。因为随着房价的上涨,其差额会逐渐变大。
  3. 所得税的增长幅度与年限呈现下降趋势。随着年限增高,增长差值逐渐降低。
  4. 确定需求面积所支出的总价,尽可能的去购买新房。这与我们的正常认知也是相同的。

相关数据分析代码

以下代码由我个人编写,可在 Jupyter Notebook 环境下直接运行。

"""
网签价与增值税
"""
# encoding=utf-8
import numpy as np
import math
from matplotlib import pyplot
import matplotlib.pyplot as plt
from IPython.core.pylabtools import figsize # import figsize
#figsize(12.5, 4) # 设置 figsize
plt.rcParams['savefig.dpi'] = 300 #图片像素
plt.rcParams['figure.dpi'] = 300 #分辨率

# 总价
ths = [i * 125000 for i in range(1, 100)]

# 年限
ys = [i for i in range(5, 21)]

for y in ys:
    # 增值税计算
    trs = []
    for th in ths:
        tr = (0.85 * th - th / ((1 + 0.1463) ** y)) * 0.0529
        trs.append(tr)
        
    plt.plot(ths, trs, label=f'{y}年')

plt.legend()
plt.margins(0)
plt.subplots_adjust(bottom=0.10)
plt.xlabel('网签价/千万元') #X轴标签
plt.ylabel("增值税/元") #Y轴标签


"""
网签价与增值税占比
"""
# 年限
ys = [i for i in range(5, 21)]

for y in ys:
    # 增值税计算
    trs = []
    for th in ths:
        tr = (0.85 * th - th / ((1 + 0.1463) ** y)) * 0.0529
        trs.append(tr / (th + tr))
    print(sum(trs) / len(trs))
    if y >= 10:
        plt.plot(ths, trs, label=f'{y}年')
    else:
        plt.plot(ths, trs, marker='*', label=f'{y}年')

plt.legend()
plt.margins(0)
plt.subplots_adjust(bottom=0.10)
plt.xlabel('网签价/千万元') #X轴标签
plt.ylabel("所得税占总支出比例") #Y轴标签

"""
房龄与增值税占比
"""
p = [
    0.017910502320842965,
    0.021189641628359904,
    0.024032438906425032,
    0.026498963556469416,
    0.028640531033586977,
    0.030501094013867885,
    0.032118385667131544,
    0.03352486519307519,
    0.034748504347376426,
    0.035813445129716175,
    0.03674055234381547,
    0.03754787981470654,
    0.038251065262737784,
    0.03886366589974932,
    0.03939744452077101,
    0.039862614060287876,
]

plt.plot(p, y, marker='o')
plt.legend()
plt.margins(0)
plt.xlabel('增值税占总支出比例') #X轴标签
plt.ylabel("房子年限/年") #Y轴标签

Podfile 的解析逻辑

引子


在上文 CocoaPods 命令解析 中,我们通过对 CLAide 的源码分析,了解了 CocoaPods 是如何处理 pod 命令,多级命令又是如何组织和嵌套的,并解释了命令行输出所代表的含义。今天我们开始学习 Podfile

大多 iOS 工程师最先接触到的 CocoaPods 概念应该是 Podfile,而 Podfile 属于 cocoapods-core(以下简称 Core) 的两大概念之一。另外一个则是 Podspec (用于描述 Pod Library 的配置文件),只有当你需要开发 Pod 组件的时候才会接触。

在介绍 Podfile 的内容结构之前,必须要谈谈 Xcode 的工程结构。

Xcode 工程结构


我们先来看一个极简 Podfile 声明:

target 'Demo' do
	pod 'Alamofire', :path => './Alamofire'
end


它编译后的工程目录如下:


如你所见 Podfile 的配置是围绕 Xcode 的这些工程结构:**Workspace、Project、Target 及 Build Setting **来展开的。
作为包管理工具 CocoaPods 将所管理的 Pods 依赖库组装成一个个 Target,统一放入 Pods project 中的 Demo target,并自动配置好 Target 间的依赖关系。

之后将 Example 主工程和 Pods 工程一起打包到新建的 Example.workspace,配好主工程与 Pods 工程之间的依赖,完成最终转换。

接下来,我们来聊一聊这些 Xcode 结构:

Target - 最小可编译单元

A target specifies a product to build and contains the instructions for building the product from a set of files in a project or workspace.


首先是 Target,它作为工程中最小的可编译单元,根据 ****Build Phases****Build Settings** 将源码作为输入,经编译后输出结果产物**。
其输出结果可以是链接库、可执行文件或者资源包等,具体细节如下:

  • Build Setting:比如指定使用的编译器,目标平台、编译参数、头文件搜索路径等;
  • Build 时的前置依赖、执行的脚本文件;
  • Build 生成目标的签名、Capabilities 等属性;
  • Input:哪些源码或者资源文件会被编译打包;
  • Output:哪些静态库、动态库会被链接;

Project - Targets 的载体

An Xcode project is a repository for all the files, resources, and information required to build one or more software products.


Project 就是一个独立的 Xcode 工程,作为一个或多个 Targets 的资源管理器,本身无法被编译。
Project 所管理的资源都来自它所包含的 Targets。特点如下:

  • 至少包含一个或多个可编译的 Target;
  • 为所包含的 Targets 定义了一份默认编译选项,如果 Target 有自己的配置,则会覆盖 Project 的预设值;
  • 能将其他 Project 作为依赖嵌入其中;


下图为 Project 与所包含对 Targets 的关系

Workspace - 容器

A workspace is an Xcode document that groups projects


作为纯粹的项目容器,Workspace 不参与任何编译链接过程,仅用于管理同层级的 Project,其特点:

  • Workspace 可以包含多个 Projects
  • 同一个 Workspace 中的 Proejct 文件对于其他 Project 是默认可见的,这些 Projcts 会共享 workspace build directory
  • 一个 Xcode Project 可以被包含在多个不同的 Workspace 中,因为每个 Project 都有独立的 Identity,默认是 Project Name;

Scheme - 描述 Build 过程

An Xcode scheme defines a collection of targets to build, a configuration to use when building, and a collection of tests to execute.


Scheme 是对于整个 Build 过程的一个抽象,它描述了 Xcode 应该使用哪种 Build Configurations 、执行什么任务、环境参数等来构建我们所需的 Target。

Scheme 中预设了六个主要过程: Build、Run、Test、Profile、Analyze、Archive。包括了我们对 Target 的所有操作,每一个过程都可以单独配置。

CocoaPods-Core

The CocoaPods-Core gem provides support to work with the models of CocoaPods, for example the Podspecs or the Podfile.


CocoaPods-Core 用于 CocoaPods 中配置文件的解析,包括 PodfilePodspec 以及解析后的依赖锁存文件,如 Podfile.lock 等。

CocoaPods-Core 的文件构成


照例,我们先通过入口文件 lib/cocoapods-core.rb 来一窥 Core 项目的主要文件:

module Pod
  require 'cocoapods-core/gem_version'

  class PlainInformative < StandardError; end
  class Informative < PlainInformative; end

  require 'pathname'
  require 'cocoapods-core/vendor'
   
  # 用于存储 PodSpec 中的版本号
  autoload :Version,        'cocoapods-core/version'
  # pod 的版本限制
  autoload :Requirement,    'cocoapods-core/requirement'
  # 配置 Podfile 或 PodSpec 中的 pod 依赖
  autoload :Dependency,     'cocoapods-core/dependency'
  # 获取 Github 仓库信息
  autoload :GitHub,         'cocoapods-core/github'
  # 处理 HTTP 请求
  autoload :HTTP,           'cocoapods-core/http'
  # 记录最终 pod 的依赖信息
  autoload :Lockfile,       'cocoapods-core/lockfile'
  # 记录 SDK 的名称和 target 版本
  autoload :Platform,       'cocoapods-core/platform'
  # 对应 Podfile 文件的 class
  autoload :Podfile,        'cocoapods-core/podfile'
  # 管理 PodSpec 的集合
  autoload :Source,         'cocoapods-core/source'
  # 管理基于 CDN 来源的 PodSpec 集合
  autoload :CDNSource,      'cocoapods-core/cdn_source'
  # 管理基于 Trunk 来源的 PodSpec 集合
  autoload :TrunkSource,    'cocoapods-core/trunk_source'
  # 对应 PodSpec 文件的 class
  autoload :Specification,  'cocoapods-core/specification'
  # 将 pod 信息转为 .yml 文件,用于 lockfile 的序列化
  autoload :YAMLHelper,     'cocoapods-core/yaml_helper'
  # 记录 pod 依赖类型,是静态库/动态库
  autoload :BuildType,      'cocoapods-core/build_type'
  
  ...

  Spec = Specification
end


将这些 Model 类按照对应的依赖关系进行划分,层级如下:

Podfile 的主要数据结构


先来了解 Podfile 的主要数据结构

Specification

The Specification provides a DSL to describe a Pod. A pod is defined as a library originating from a source. A specification can support detailed attributes for modules of code  through subspecs.


Specification 即存储 PodSpec 的内容,是用于描述一个 Pod 库的源代码和资源将如何被打包编译成链接库或 framework,后续将会介绍更多的细节。

TargetDefinition

The TargetDefinition stores the information of a CocoaPods static library. The target definition can be linked with one or more targets of the user project.


TargetDefinition 是一个多叉树结构,每个节点记录着 Podfile 中定义的 Pod 的 Source 来源、Build Setting、Pod 子依赖等。该树的根节点指向 Podfile,而 Podfile 中的 root_target_definitions 则记录着所有的 TargetDefinition 的根节点,正常情况下该 list 中只有一个 root 即 **Pods.project**

为了便于阅读,简化了大量的 DSL 配置相关的方法和属性并对代码顺序做了调整,大致结构如下:

module Pod
  class Podfile
    class TargetDefinition
		# 父节点: TargetDefinition 或者 Podfile
      attr_reader :parent
      # 子节点: TargetDefinition
      attr_reader :children
      # 记录 tareget 的配置信息
      attr_accessor :internal_hash

      def root?
        parent.is_a?(Podfile) || parent.nil?
      end

      def root
        if root?
          self
        else
          parent.root
        end
      end

      def podfile
        root.parent
      end
       
      # ...
  end
end


对应上一节 Xcode 工程结构中的 Podfile 关系如下:


CocoaPods 正是巧妙利用了 Xcode 工程结构的特点,引入  Pods.project 这一中间层,将主工程的 Pods 依赖全部转接到 Pods.project 上,最后再将 Pods.project 作为主项目的依赖。尽管这么做也受到了一些质疑和诟病(所谓的侵入性太强),但笔者的观点是,正得益于 Pods.project 这一设计隔绝了第三方依赖库对于主项目的频繁更改,也便于后续的管理和更新,体现了软件工程中的开放-关闭原则

比如,在 Pod 1.7.0 版本中支持的 Multiple Xcodeproj Generation 就是解决随着项目的迭代而日益增大的 Pods project 的问题。试想当你的项目中存在上百个依赖库,每个依赖库的变更都会影响到你的主工程,这将是非常可怕的问题。

Podfile

The Podfile is a specification that describes the dependencies of the targets of one or more Xcode projects.


Podfile 是用于描述一个或多个 Xcode Project 中各个 Targets 之间的依赖关系。

这些 Targets 的依赖关系对应的就是 TargetDefinition 树中的各子节点的层级关系。如前面所说,有了 Podfile 这个根节点的指向,仅需对依赖树进行遍历,就能轻松获取完整的依赖关系

有了这层依赖树,对于某个 Pod 库的更新即是对树节点的更新,便可轻松的分析出此次更新涉及的影响。

简化调整后的 Podfile 代码如下:

require 'cocoapods-core/podfile/dsl'
require 'cocoapods-core/podfile/target_definition'

module Pod
  class Podfile

    include Pod::Podfile::DSL
    # podfile 路径
    attr_accessor :defined_in_file
    # 所有的 TargetDefinition 的根节点, 正常只有一个,即 Pods.project target
    attr_accessor :root_target_definitions
    # 记录 Pods.project 项目的配置信息
    attr_accessor :internal_hash
    # 当前 DSL 解析使用的 TargetDefinition
    attr_accessor :current_target_definition

    # ...
  end
end


直接看 dsl.rb,该文件内部定义了 Podfile DSL 支持的所有方法。通过 include 的使用将 Pod::Podfile::DSL 模块 Mix-in 后插入到 Podfile 类中。
想了解更多 Mix-in 特性,移步 Ruby 特性之 Mix-in

Lockfile

The Lockfile stores information about the pods that were installed by  CocoaPods.


Lockfile,顾名思义是用于记录最后一次 CocoaPods 所安装的 Pod 依赖库版本的信息快照。也就是生成的 **Podfile.lock。**

pod install 过程,Podfile 会结合它来确认最终所安装的 Pod 版本,固定 Pod 依赖库版本防止其自动更新。Lockfile 也作为 Pods 状态清单 (mainfest),用于记录安装过程的中哪些 Pod 需要被删除或安装或更新等。

以开头的 Podfile 经 pod install 所生成的 Podfile.lock 为例:

PODS:
  - Alamofire (4.6.0)

DEPENDENCIES:
  - Alamofire (from `./Alamofire`)

EXTERNAL SOURCES:
  Alamofire:
    :path: "./Alamofire"

SPEC CHECKSUMS:
  Alamofire: 0dda98a0ed7eec4bdcd5fe3cdd35fcd2b3022825

PODFILE CHECKSUM: da12cc12a30cfb48ebc5d14e8f51737ab65e8241

COCOAPODS: 1.10.0.beta.2


我们来分析一下,通过该 Lockfile 能够获取哪些信息:

Key 含义
PODS 记录所有 Pod 库的具体安装版本号
DEPENDENCIES 记录各 Pod 库之间的相互依赖关系,由于这里只有 Alamofire 且它无其他依赖,暂时无关看出区别
EXTERNAL SOURCES 记录部分通过外部源的 Pod 库(Git 引入、Path 引入)
SPEC CHECKSUMS 记录当前各 Pod 库的 Podspec 文件 Hash 值,其实就是文件的 md5
PODFILE CHECKSUM 记录 Podfile 文件的 Hash 值,同样是 md5,确认是否有变更
COCOAPODS 记录上次所使用的 CocoaPods 版本

Podfile 内容加载

Podfile 文件类型


你可以在 CocoaPods 的 /lib/cocoapods/config.rb 找到 Podfile 所支持的文件类型:

PODFILE_NAMES = [
   'CocoaPods.podfile.yaml',
   'CocoaPods.podfile',
   'Podfile',
   'Podfile.rb',
].freeze


CocoaPods 按照上述命名优先级来查找工程目录下所对应的 Podfile 文件。当发现目录中存在 CocoaPods.podfile.yaml 文件时会优先加载。很多同学可能只知道到 Podfile 支持 Ruby 的文件格式,而不了解它还支持了 YAML 格式。YAML 是 YAML Ain't Markup Language 的缩写,其 官方定义

YAML is a human friendly data serialization standard for all programming languages.


它是一种面向工程师友好的序列化语言。我们的 Lockfile 文件就是以 YAML 格式写入 Podfile.lock 中的。

Podfile 文件读取


回到 lib/cocoapods-core/podfile.rb 来看读取方法:

module Pod

  class Podfile

    include Pod::Podfile::DSL

    def self.from_file(path)
      path = Pathname.new(path)
      unless path.exist?
        raise Informative, "No Podfile exists at path `#{path}`."
      end
			# 这里我们可以看出,Podfile 目前已经支持了结尾是 .podfile 和 .rb 后缀的文件名
      # 其实是为了改善很多编译器使用文件后缀来确认 filetype,比如 vim
      # 相比与 Podfile 这个文件名要更加的友好
      case path.extname
      when '', '.podfile', '.rb'
        Podfile.from_ruby(path)
      when '.yaml'
        # 现在也支持了 .yaml 格式
        Podfile.from_yaml(path)
      else
        raise Informative, "Unsupported Podfile format `#{path}`."
      end
    end
end


from_filepod install 命令执行后的 verify_podfile_exists! 中被调用的:

def verify_podfile_exists!
    unless config.podfile
        raise Informative, "No `Podfile' found in the project directory."
    end
end


而 Podfile 文件的读取就是 config.podfile  里触发的,代码在 CocoaPods 的 config.rb 文件中:

def podfile_path_in_dir(dir)
    PODFILE_NAMES.each do |filename|
        candidate = dir + filename
        if candidate.file?
        return candidate
        end
    end
    nil
end

def podfile_path
    @podfile_path ||= podfile_path_in_dir(installation_root)
end

def podfile
    @podfile ||= Podfile.from_file(podfile_path) if podfile_path
end


这里的方法 podfilepodfile_path 都是 lazy 加载的。最后 Core 的 from_file 将依据目录下的 Podfile 文件类型选择调用 from_yaml 或者 from_ruby

Pod::Command::Install 命令到 Podfile 文件加载的调用栈如下:

Podfile From Ruby 解析


当我们通过 pod init 来初始化 CocoaPods 项目时,默认生成的 Podfile 名称就是 Podfile,那就从 Podfile.from_ruby 开始。

def self.from_ruby(path, contents = nil)
    # ①
    contents ||= File.open(path, 'r:utf-8', &:read)
    # 兼容 1.9 版本的 Rubinius 中的编码问题
    if contents.respond_to?(:encoding) && contents.encoding.name != 'UTF-8'
        contents.encode!('UTF-8')
    end

    # 对 Podfile 中不规范的单引号或双引号进行检查,并进行自动修正,及抛出错误
    if contents.tr!('“”‘’‛', %(""'''))
        CoreUI.warn "..."
    end
    # ②
    podfile = Podfile.new(path) do
        begin
	        eval(contents, nil, path.to_s)
        rescue Exception => e
	        message = "Invalid `#{path.basename}` file: #{e.message}"
   	     raise DSLError.new(message, path, e, contents)
        end
    end
    podfile
end


是对 Podfile 内容的读取和编码,同时对可能出现的单引号和双引号的匹配问题进行了修正。
pathblock 为入参进行 podfile 类的初始化并将其放回,保存在全局的 config.podfile 中。

Tips: 如果要在 Ruby 对象的初始化中传入参数,需要重载 Object 的 initialize 方法,这里的 Podfile.new(…) 本质上是 initialize 的方法调用。


initialize 方法所传入的尾随闭包 block 的核心在于内部的 eval 函数(在 CocoaPods 核心组件 中有提到):

eval(contents, nil, path.to_s)


它将 Podfile 中的文本内容转化为方法执行,也就是说里面的参数是一段 Ruby 的代码字符串,通过 eval 方法可以直接执行。
继续看 Podfile 的 initialize 方法:

def initialize(defined_in_file = nil, internal_hash = {}, &block)
    self.defined_in_file = defined_in_file
    @internal_hash = internal_hash
    if block
        default_target_def = TargetDefinition.new('Pods', self)
        default_target_def.abstract = true
        @root_target_definitions = [default_target_def]
        @current_target_definition = default_target_def
        instance_eval(&block)
    else
        @root_target_definitions = []
    end
end


它定义了三个参数:

参数 定义
defined_in_file Podfile 文件路径
internal_hash 通过 yaml 序列化得到的 Podfile 配置信息,保存在 internal_hash
block 用于映射 Podfile 的 DSL 配置

需要注意的是,通过 from_ruby 初始化的 Podfile 只传入了参数 1 和 3,参数 2 internal_hash 则是提供给 from_yaml 的。


block 存在,会初始化名为 Pods 的 TargetDefinition 对象,用于保存 Pods project 的相关信息和 Pod 依赖。然后调用 instance_eval 执行传入的 block,将 Podfile 的 DSL 内容转换成对应的方法和参数,最终将参数存入 internal_hash 和对应的 target_definitions 中。

Tips: 在 Ruby 中存在两种不同的方式来执行代码块 block,分别是 instance_evalclass_evalclass_eval 的执行上下文与调用类相关,调用者是类名或者模块名,而 instance_eval 的调用者可以是类的实例或者类本身。细节看 StackoverFlow

Podfile From YAML 解析


YAML 格式的 Podfile 加载需要借助 YAMLHelper 类来完成,YAMLHelper 则是基于 yaml 的简单封装。

def self.from_yaml(path)
    string = File.open(path, 'r:utf-8', &:read)
  
    # 为了解决 Rubinius incomplete encoding in 1.9 mode
  	# https://github.com/rubinius/rubinius/issues/1539
    if string.respond_to?(:encoding) && string.encoding.name != 'UTF-8'
        string.encode!('UTF-8')
    end
    hash = YAMLHelper.load_string(string)
    from_hash(hash, path)
end

def self.from_hash(hash, path = nil)
    internal_hash = hash.dup
    target_definitions = internal_hash.delete('target_definitions') || []
    podfile = Podfile.new(path, internal_hash)
    target_definitions.each do |definition_hash|
        definition = TargetDefinition.from_hash(definition_hash, podfile)
        podfile.root_target_definitions << definition
    end
    podfile
end


通过 from_yaml 将文件内容转成 Ruby hash 后转入 from_hash 方法。

区别于 from_ruby,这里调用的 initialize 将读取的 hash 直接存入 internal_hash,然后利用 TargetDefinition.from_hash 来完成的 hash 内容到 targets 的转换,因此,这里无需传入 block 进行 DSL 解析和方法转换。

Podfile 内容解析


前面提到 Podfile 的内容最终保存在 internal_hashtarget_definitions 中,本质上都是使用了 hash 来保存数据。由于 YAML 文件格式的 Podfile 加载后就是 hash 对象,无需过多加工。唯一需要处理的是递归调用 TargetDefinition 的 from_hash 方法来解析 target 子节点的数据。

因此,接下来的内容解析主要针对 Ruby 文件格式的 DSL 解析,我们以 pod 方法为例:

target 'Example' do
	pod 'Alamofire'
end


当解析到 pod 'Alamofire' 时,会先通过 eval(contents, nil, path.to_s 将其转换为 dsl.rb 中的方法:

def pod(name = nil, *requirements)
    unless name
        raise StandardError, 'A dependency requires a name.'
    end
    current_target_definition.store_pod(name, *requirements)
end


name 为 Alamofire,由于我们没有指定对应的 Alamofire 版本,默认会使用最新版本。requirements  是控制 该 pod 来源获取或者 pod target 的编译选项等,例如:

pod 'Alamofire', '0.9'
pod 'Alamofire', :modular_headers => true
pod 'Alamofire', :configurations => ['Debug', 'Beta']
pod 'Alamofire', :source => 'https://github.com/CocoaPods/Specs.git'
pod 'Alamofire', :subspecs => ['Attribute', 'QuerySet']
pod 'Alamofire', :testspecs => ['UnitTests', 'SomeOtherTests']
pod 'Alamofire', :path => '~/Documents/AFNetworking'
pod 'Alamofire', :podspec => 'https://example.com/Alamofire.podspec'
pod 'Alamofire', :git => 'https://github.com/looseyi/Alamofire.git', :tag => '0.7.0'

Tips:requirements 最终是以 Gem::Requirement 对象来保存的。关于 pod 详细说明请移步:Podfile 手册


对 name 进行校验后,直接转入 current_target_definition 毕竟 Pod 库都是存在 Pods.project 之下:

def store_pod(name, *requirements)
  return if parse_subspecs(name, requirements) # This parse method must be called first
  parse_inhibit_warnings(name, requirements)
  parse_modular_headers(name, requirements)
  parse_configuration_whitelist(name, requirements)
  parse_project_name(name, requirements)

  if requirements && !requirements.empty?
    pod = { name => requirements }
  else
    pod = name
  end

  get_hash_value('dependencies', []) << pod
  nil
end

def get_hash_value(key, base_value = nil)
  unless HASH_KEYS.include?(key)
    raise StandardError, "Unsupported hash key `#{key}`"
  end
  internal_hash[key] = base_value if internal_hash[key].nil?
  internal_hash[key]
end

def set_hash_value(key, value)
  unless HASH_KEYS.include?(key)
    raise StandardError, "Unsupported hash key `#{key}`"
  end
  internal_hash[key] = value
end


经过一系列检查之后,调用 get_hash_value 获取 internal_hashdependencies,并将 name 和 requirements 选项存入。

这里的 dependencies key 是定义在 TargetDefinition 文件的 **HASH_KEYS**,表示 Core 所支持的配置参数:

HASH_KEYS = %w(
    name
    platform
    podspecs
    exclusive
    link_with
    link_with_first_target
    inhibit_warnings
    use_modular_headers
    user_project_path
    build_configurations
    project_names
    dependencies
    script_phases
    children
    configuration_pod_whitelist
    uses_frameworks
    swift_version_requirements
    inheritance
    abstract
    swift_version
).freeze

Tips:freeze 表示该数组不可修改。另外,%w 用于表示其中元素被单引号括起的数组。 %W(#{foo} Bar Bar\ with\ space) => [“Foo”, “Bar”, “Bar with space”] 对应的还有 %W 表示其中元素被双引号括起的数组。


整个映射过程如下:

精细化的 Podfile 配置


最后一节让我们来展示一下 💪,看看 Podfile 所谓的 targets 之间的依赖关系可以玩出什么花来 😂。

Target 嵌套


最简单的 Podfile 就是文章开头所展示的,不过在 Podfile 中还可以对 Target 进行嵌套使用。假设在我们的主工程同时维护了三个项目,它们都依赖了 Alamofire,通过俄罗斯套娃就能轻松满足条件:

target 'Demo1' do
  pod 'Alamofire'

  target 'Demo2' do
    target 'Demo3' do
    end
  end
end


编译后的 Pods.project 项目结构如下:


我们知道,CocoaPods 在 Pods.project 中为每个在 Podfile 中声明的 Target 生成一个与之对应的专属 Target 来集成它的 Pod 依赖。对于有依赖关系的 Target 其生成的专属 Target 名称则会按照依赖关系叠加来命名,如  target Demo3 的专属 Target 名称为 Pods-Demo1-Demo2-Demo3。安装完成后主项目将会引入该专属 Target 来完成依赖关联,如 Demo3:


关于 Target 嵌套,一个父节点是可以有多个子节点的:

target 'Demo1' do
  pod 'Alamofire'

  target 'Demo2' do
  	pod 'RxSwift'
  end
  target 'Demo3' do
	  pod 'SwiftyJSON'
  end
end

Abstract Target


上面例子中,由于 Demo1 与 Demo2 都需要依赖 Alamofire,我们通过 Target 嵌套让 Demo2 来继承 Demo1 的 Pods 库依赖。这么做可能会有一个限制,就是当 Demo1 的 Pod 依赖并非 Demo2 所需要的时候,就会有依赖冗余。此时就需要 Abstract Target 登场了。例如:

abstract_target 'Networking' do
  pod 'Alamofire'

  target 'Demo1' do
    pod 'RxSwift'
  end
  target 'Demo2' do
    pod 'ReactCocoa'
  end
  target 'Demo3' do
  end
end


将网络请求的 pod 依赖抽象到 Networking target 中,这样就能避免 Demo2 对 RxSwift 的依赖。这种方式配置所生成的 Pods.project 并不会存在名称为 Networking 的 Target,它仅会在主工程的专属 Target 中留下印记:

总结


本文结合 Xcode 工程结构来展开 CocoaPods-Core 的 Podfile 之旅,主要感受如下:

  1. 再一次感受了 Ruby 语言的动态之美,给我一个字符串,还你一个未知世界;
  2. 结合 Xcode 工程结构更好的理解了 Podfile 的设计初衷,基础知识很重要;
  3. 所谓“算法无用论”这种事情,在计算机的世界是不存在的,没有好的数据结构知识如何更好的抽象;
  4. 了解 Podfile 的 DSL 是如何映射到内存中,又是如何来存储每个关键数据的

知识点问题梳理


这里罗列了四个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入**收藏 **再次阅读:

  1. 说说 TargetDefinition 的数据结构 ?
  2. 说说 TargetDefinition 与 Xcode Project 的关系 ?
  3. Podfile 的文件格式有几种,分别是如何加载 ?
  4. Lockfile 和 Podfile 的关系

公积金贷买房能省多少钱

我给自己定了一个将近 700 天的计划,这就是在上海买房定居。于是开启了研究购房政策、研究小区和板块、研究购房注意事项等一系列的调研和研究。我将这些笔记归档成文章,发表在公众号与大家分享。

今天距离购房还有 675 天。

这篇文章来讨论使用公积金贷款买房的最大化省钱空间。以下讨论均针对于上海市。因为公积金贷款比商业贷款利率低了近两个点,所以公积金贷款也是购房者的首选。下面我来较为全面的记录一下公积金贷款的所有细节问题。

使用公积金贷款的条件

  1. 购买上海市具有所有权的自住住房;
  2. 申请贷款前 6 个月连续正常缴存住房公积金;
  3. 借款人家庭没有尚未还清的公积金债务;

利率数据

首先先给出公积金贷款的贷款利率(以下表内加粗为一般大家所需要关注的数据):

贷款年限 首套房、二套房普通住宅(基准利率 - 2015.4.8 起开始执行) 二套房非普通住宅(1.1 倍利率)
五年以下(包含五年) 2.75% 3.025%
五年以上 3.25% 3.575%

最高贷款额度

这里不讨论非普通住宅。

  • 普通住宅:按所在地一般民用住宅建筑标准来建造的作为居住用途的房屋,目前多为多层住宅与高层住宅。
  • 非普通住宅:非普通住宅一般指宅建筑面积较大或用作商业用途的房子。
所购住房 首套房 首套房 二套房普通 二套房普通
贷款家庭 1 人贷款 ≥ 2 人贷款 1 人贷款 ≥ 2 人贷款
缴纳住房公积金最高可贷金额 50 万 100 万 50 万 100 万
缴纳补充公积金最高可贷金额 10 万 20 万 10 万 20 万
可贷金额总计 60 万 120 万 60 万 120 万
首付比列 ≤ 90 平米,不低于 20%; > 90 平米不低于 30% 同左 不低于 30\% 同左

这里面我们可以发现住房公积金和补充公积金是有个上限值,但是如何出自己的可贷公积金的数值呢?其实是有如下计算公式:假如我们另当前住房公积金账户余额是,补充公积金账户余额是,则最高可贷金额有如下关系:

\[\begin{cases} f(x, y)=40x+20y \\ 40x \leq 50 \times 10^{4} \\ 20y \leq 10 \times 10^{4} \end{cases}\]

通俗的翻译过来就是,公积金最高可贷金额 = 住房公积金余额 × 40 + 补充公积金余额 × 20。所以通过不等式反解我们可得,如果我们想最大的利用公积金,那么我们住房公积金最少需要有 12500 元的余额、补充公积金需要有 5000 块钱的余额,就满足了最高可贷金额的条件。

贷款年限和贷款人年龄限制

首先,如果是新建商品房,也就是房龄为 0 年的房子,最长贷款年限是 30 年。然后我们说二手房,它有以下规则:

房龄 最长贷款年限
≤ 5 年 30 年
6 - 19 年 35 年 - 房龄(假如是 10 年房龄,则最高可贷 25 年)
≥ 20 年 15 年

贷款算法

同商业贷款的算法相同,公积金贷款也分成等额本金和等额本息两种算法。考虑到更多的人愿意均摊风险以及银行希望获取到更高的利息收入,所以这里我均采用等额本息来计算还款总额

每月月供

\[月供额 = \frac{月利率 \times (1+月利率)^{还款月数}}{(1+月利率)^{还款月数} - 1} \times 贷款本金\]

其中月利率的计算方法:

\[月利率 = \frac{年利率}{12}\]

其他约束

除了我们上文提到的公积金余额对于贷款的约束,我们还要考虑房龄所带来的影响:

\[还款月数 = (35 - 房龄) \times 12\]

举例计算

以上就是我们要考虑的所有月数条件,那么我们来举一个例子来进行计算。假如我们现在要买一个 2010 年(房龄 10 年的二手房),总价是 400 万。使用了 35\% 的首付比例之后,我们需要贷款 260 万,最长贷款时间 25 年,也就是还款月数 300 个月

情况一:全部使用商业贷款

先要确定一下商业贷款的利息,我这里查阅了一下资料,由于上海的商贷金额往往较高,银行会对利息进行 95 折的优惠,从 4.9\% 的基准利率下调到 4.655\%。但今年国家出了 LPR 的利息策略,我们用当前 8 月份的 LPR 利息 4.65\% 来计算。首先来算月利率:

\[月利率 = \frac{4.65\%}{12} = 0.003875\]

然后我们来带入计算月供:

\[\begin{split} 月供额 &= \frac {0.003875 \times (1 + 0.003875)^{300}}{(1 + 0.003875)^{300} - 1} \times 2.6 \times 10^6 \\ &\approx 14673.899\ 元 \end{split}\] \[\begin{equation} \begin{split} 月供额 &= \frac {0.003875 \times (1 + 0.003875)^{300}}{(1 + 0.003875)^{300} - 1} \times 2.6 \times 10^6 \\ &\approx 14673.899\ 元 \end{split} \end{equation}\]

使用月供额来乘以 30 个月算出总还款:

\[总还款 = 30 * 14673.899 = 4402169.7\ 元\]

情况二:使用最大组合贷

由于公积金贷款只能贷 120 万,所以采用 120 万是公积金贷,利息是五年以上的 3.75\%。我们用当前 8 月份的 LPR 利息 4.65\% 当做商业贷款的利息,商业贷款 140 万

同上述计算法相同,带入公式。

① 公积金贷款

公积金贷款的月供:

\[公积金月供 \approx 6169.57 元\]

300 个月公积金总还款:

\[公积金总还款 = 1850871 \ 元\]

② 商业贷款

商业贷款月供:

\[商业贷款月供 \approx 7901.33\ 元\]

300 个月商贷总还款:

\[商贷总还款 = 2370399元\]

我们得到总还款数:

\[总还款 = 1850871 + 2370399 = 4221270 元\]

结果分析

从上面的计算结果来看,我们购买房龄为 10 年的二手房,加入总价为 400 万元,则选择组合贷可以节省 180900 元

数据分析

上面已经给出了等额本息的计算方法。所以接下来进行控制变量,我们让贷款年限映射到正实数区间,让其投影在 5 年与 30 年之间。保证贷款金额为 400 万。然后我们来计算纯商贷和使用组合贷的数据。蓝色线代表使用纯商贷模式,红色线代表使用组合贷模式。

由图一可以看出,还款年份数越多,其所需还款金额越多,也就意味着利息越高。而红(组合贷)与蓝(纯商贷)的差距也越来越大。并且由右图可知,当还款年份数越多,组合贷的优势愈加明显

总结

接下来我们来总结一下买房贷款的最大化利益策略:

  1. 所以贷款买房尽量可以使用公积金贷款。可贷最大公积金额度需要我们以及我们的配偶具有住房公积金余额 12500 元、补充公积金余额 5000 元。
  2. 根据最大贷款额原则(之后会讲到,结论是“首套房付最少的首付,用最大的杠杆贷最多的款,选择 30 年等额本息法,拒不提前还款”策略可达最大收益),当我们将还款时间设置为 30 年的上限阈值,在公积金贷款中也可以获得最大化的成本节约。当然,这就要求我们购买新房和房龄小的二手房。

如果你有任何问题以及更好的建议,可以在下方评论区留言

相关数据分析代码

以下代码由我个人编写,可在 Jupyter Notebook 环境下直接运行。

"""等额本息算法描述"""

import numpy as np
import math

# 等额本息算法
def averageCapitalPlusInterest(principal, anualInterestRate, month):
    """
    principal 表示贷款总额, anualInterestRate 表示年利率, month 表示房贷月份
    """
    # 计算每月应还款金额
    monthlyPayment = np.around(principal * averageCapitalPlusInterestRate(month, monthlyInterestRate(anualInterestRate)), 2)
    # 还款总额
    totalPayment = np.around(monthlyPayment * month, 2)
    return totalPayment

# 计算每月利率
def monthlyInterestRate(anualInterestRate):
    s = anualInterestRate / 12
    return s

# 计算比例系数
def averageCapitalPlusInterestRate(month, monthlyInterestRate):
    R = monthlyInterestRate
    N = month
    I = R * math.pow(1 + R, N) / (math.pow(1 + R, N) - 1)
    return I

"""贷款数据模拟"""
# 全商贷
r = 0.0465
ms = [12 * mi for mi in range(6, 31)]
t = 4000000
res = []

for m in ms:
    res.append(averageCapitalPlusInterest(t, r, m))
res = [np.around(x / 10000, 2) for x in res]

# 组合贷

r1 = 0.0465
r2 = 0.0375
t2 = 1200000
t1 = t - t2

rest = []

for m in ms:
    rest.append(averageCapitalPlusInterest(t1, r1, m) + averageCapitalPlusInterest(t2, r2, m))
rest = [np.around(x / 10000, 2) for x in rest]

"""做图"""

import matplotlib.pyplot as plt

year = [m // 12 for m in ms]

plt.subplot(221)
plt.plot(year, res)
plt.plot(year, rest, 'r')
plt.xlabel("Loan Time - Year")
plt.ylabel("Repayment Amount - RMB")

plt.subplot(222)
detal = []
for i in range(len(res)):
    detal.append(res[i] - rest[i])

rects = plt.bar(year, detal)
plt.xlabel("Loan Time - Year")
plt.ylabel("Savings - RMB")

for rect in rects:
    height = np.around(rect.get_height(), 1)
    plt.text(rect.get_x() + rect.get_width() / 2, height+1, str(height), ha="center", va="bottom")

CocoaPods 命令解析 - CLAide

引子


在上文 整体把握 CocoaPods 核心组件 中,我们通过对 pod install 的流程的介绍,引出 CocoaPods 的各个核心组件的角色分工和其主要作用,希望通过对这些组件的使用和介绍来帮助大家更好的了解 CocoaPods 的完整工作流以及背后的原理。

今天我们主要聊一聊为 CocoaPods 提供的命令行解析的工具 CLAide,它是如何来解析 Pod 命令以及 CocoaPods 的插件机制。

Open Class


开始之前,我们需要了解一个 Ruby 的语言特性:Open Classes

在 Ruby 中,类永远是开放的,你总是可以将新的方法加入到已有的类中,除了在你自己的代码中,还可以用在标准库和内置类中,这个特性被称为 Open Classes。说到这里作为 iOS 工程师,脑中基本能闪现出 Objective-C 的 Category 或者 Swift 的 Extensions 特性。不过,这种动态替换方法的功能也称作 Monkeypatch。(🐒 到底招谁惹谁了)

下面,我们通过在 Monkey.rb 文件中添加一个自定义类 Monkey 来简单看一下该特性,

class Monkey
  def eat
    puts "i have banana"
  end
end

monkey = Monkey.new

class Monkey
  def eat
    puts "I have apple"
  end
end

monkey.eat


直接在 VSCode 中运行,效果如下:

[Running] ruby "/Users/edmond/Desktop/Monkey.rb"
I have apple


可以看到,Monkey 类的实例输出已经改为 I have apple

需要注意,即使是已经创建好的实例,方法替换同样是生效的。 另外 ⚠️ Open Class 可以跨文件、跨模块进行访问的,甚至对 Ruby 内置方法的也同样适用 (谨慎)。


这强大的功能让我们可以很容易的对三方模块进行扩展,这也是 CocoaPods 的插件体系所依赖的基础。

举个例子,在 CocoaPods 主仓库 cocoapods/downloader.rb 中定义了一些 download 方法:

module Pod
  module Downloader
    # ...
  end
end


但是在 cocoapods-downloader 模块中,module Downloader 的方法并不能满足全部需求,于是在 cocoapods-downloader/api.rbapi.rb 中就对齐进行了扩展:

module Pod
  module Downloader
    module API
      def execute_command(executable, command, raise_on_failure = false)
        # ...
      end
      # ...
  end
end

CLAide


CLAide 虽然是一个简单的命令行解释器,但它提供了功能齐全的命令行界面和 API。它不仅负责解析我们使用到的 Pods 命令,例如:pod install, pod update 等,还可用于封装常用的一些脚本,将其打包成简单的命令行小工具。

备注:所谓命令行解释器就是从标准输入或者文件中读取命令并执行的程序。详见 Wiki

CLAide 功能概览


我们先通过 pod --help 来查看 CLAide 的真实输出效果:

$ pod
Usage:
    $ pod COMMAND
      CocoaPods, the Cocoa library package manager.

Commands:
    + cache               Manipulate the CocoaPods cache
    + deintegrate         Deintegrate CocoaPods from your project
    + env                 Display pod environment
    + init                Generate a Podfile for the current directory
  	...

Options:
    --allow-root          Allows CocoaPods to run as root
    --silent              Show nothing
    --version             Show the version of the tool
    ...


👆所展示的 UsageCommandsOptions section 及其内容均是由 CALide 的输出模版 Banner 来完成的。CALide 提供了 Command 基类帮助我们快速定义出标准且美观的命令。除了 pod 命令之外,例如:Xcodeproj 所提供的命令也是由 CALide 来实现的。

CALide 还提供了一套插件加载机制在命令执行前获取所有插件中的命令,例如:cocoapods-packeger 提供的 pod package NAME [SOURCE] 就是从其 source code 中的 lib/pod/commnad/package.rb 读取出来的,它令我们仅需一份 podspec 信息,即可完成 Cocoa 依赖库的 📦。

CALide 的目录结构

对于 Ruby 的项目结构,在 Rubygems.org 中有 文件结构手册 这个标准供大家参考学习。

首先来看 CALide 项目的文件入口 lib/calide.rb:

module CLAide
  VERSION = '1.0.3'.freeze
  require 'claide/ansi'
  require 'claide/argument'
  require 'claide/argv'
  require 'claide/command'
  require 'claide/help'
  require 'claide/informative_error'
end


我们接下来分析一下 lib/cladie/ 目录下的相关代码。

Command 抽象类


Command 是用于构建命令行界面的基础抽象类。所有我们添加的命令都需要继承自 Command,这些子类可以嵌套组合成更加精细的命令。

pod 命令正是由多个 Pod::Command < CLAide::Command 的子类组合而成的 abstract command。当然 pod 的 subcommand 同样也能声明为 abstact command,通过这样的方式我们就能达到多级嵌套命令的效果。有抽象命令当然也需要有具体执行任务的 normal command

举个例子:

$ pod update --help
Usage:
    $ pod update [POD_NAMES ...]
      Updates the Pods identified by the specified `POD_NAMES`

Options:
    --verbose                              Show more debugging information
    --no-ansi                              Show output without ANSI codes
    --help                                 Show help banner of specified command


对应的, pod update 这个命令的逻辑在 CLAide 中就是如下描述:

module Pod
  class Command
    class Update < Command
      self.arguments = [
        CLAide::Argument.new('POD_NAMES', false, true),
      ]
      
      self.description = <<-DESC
        Updates the Pods identified by the specified `POD_NAMES`.
      DESC
      
      def self.options
        [
          ["--sources", 'The sources from which to update dependent pods'],
          ['--exclude-pods', 'Pods to exclude during update'],
          ['--clean-install', 'Ignore the contents of the project cache and force a full pod installation']
        ].concat(super)
      end
    end
  end
end

当我们如此描述后,CLAide 会对这个类进行以下方式的解析:


此外,Command class 提供了大量基础功能,其中最核心的方法为 run,会在 normal command 小节会介绍。对于任何命令类型都可以设置以下几个属性和方法:

  • summary: 用于简单描述该命令的作用
  • options: 用于返回该命令的可选项及对应的描述,返回的 options 需要通过调用 super 插入到父类的可选项前
  • initialize: 如果需要获取命令行传递的实参,需要通过重载 initialize 方法来获取
  • validate!: 用于检查输入实参的有效性,如果校验失败,会通过调用 help! 方法来输出帮助信息
  • help!:用于错误信息的处理和展示


注意 ⚠️:这里我们说的 abstract commandnormal command 均是通过 Command 来实现的,只是它们的配置不同。

Abstract Command


abstract command 为不提供具体命令实现的抽象容器命令类,不过它可以包含一个或多个的 subcommands。我们可以指定 subcommands 中的 normal command 为默认命令,就能将 abstract command 作为作为普通命令直接执行了。

抽象命令的现实比较简单:

self.abstract_command = true


仅需设置 abstract_command,然后就可以继承它来实现普通命令或者多级嵌套的抽象命令。

pod 命令的实现为例:

module Pod
  class Command < CLAide::Command
    require 'cocoapods/command/install' # 1
    require 'cocoapods/command/update'
    # ...
    self.abstract_command = true
    self.command = 'pod'
    # ...
end


上述通过 require 引入的 updateinstall 等子命令都是继承自 Pod::Commandnormal command

Normal Command


相对于抽象命令,普通命令就需要设置传递实参的名称和描述,以及重载 run 方法。

Arguments

arguments 用于配置该命令支持的参数列表的 banner 输出,类型为 Array<Argument>],它最终会格式化成对应的信息展示在 Usage banner 中。

我们来看 pod updatearguments 是如何配置的:

self.arguments = [
    CLAide::Argument.new('POD_NAMES', false, true),
]


其中 Argument 的构造方法如下:

module CLAide
  class Argument
   
    def initialize(names, required, repeatable = false)
      @names = Array(names)
      @required = required
      @repeatable = repeatable
    end
end


这里传入的 names 就是在 Usage banner 中输出的 [POD_NAMES ...]

require 表示该 Argument 是否为必传参数,可选参数会用 [ ] 将其包裹起来。也就是说 pod update 命令默认是不需要传 POD_NAMES

repeatable 表示该 Argument 是否可以重复多次出现。如果设置为可重复,那么会在 names 的输出信息后面会添加 ... 表示该参数为复数参数。

举个例子:

$ pod update Alamofire, SwiftyJSON


我们可以指定 pod update 仅更新特定的依赖库,如果不传 POD_NAMES 将进行全量更新。

Run 方法


Command 类中定义了两个 run 方法:

def self.run(argv = [])
  # 根据文件前缀来匹配对应的插件
  plugin_prefixes.each do |plugin_prefix|
    PluginManager.load_plugins(plugin_prefix)
  end

  argv = ARGV.coerce(argv)
  # 解析 argument 生成对应的 command instance
  command = parse(argv) 
  ANSI.disabled = !command.ansi_output?
  unless command.handle_root_options(argv)
    command.validate!
    command.run
  end
rescue Object => exception
  handle_exception(command, exception)
end

def run
  raise 'A subclass should override the `CLAide::Command#run` method to ' \
  'actually perform some work.'
end


这里的 self.run 方法是 class method,而 run 是 instanced method。对于 Ruby 不太熟悉的同学可以看看这个:What does def self.function name mean?

作为 Command 类的核心方法,类方法 self.run 将终端传入的参数解析成对应的 commandargv,并最终调用 command 的实例方法 run 来触发真正的命令逻辑。因此,子类需要通过重载 run 方法来完成对应命令的实现。

那么问题来了,方法 Command::parse 是如何将 run 的类方法转换为实例方法的呢?

def self.parse(argv)
  # 通过解析 argv 获取到与 cmd 名称
  argv = ARGV.coerce(argv)
  cmd = argv.arguments.first
  # 如果 cmd 对应的 Command 类,则更新 argv,继续解析命令
  if cmd && subcommand = find_subcommand(cmd)
    argv.shift_argument
    subcommand.parse(argv)
  # 如果 cmd 为抽象命令且指定了默认命令,则返回默认命令继续解析参数
  elsif abstract_command? && default_subcommand
    load_default_subcommand(argv)
  else
    # 初始化真正的 cmd 实例
    new(argv)
  end
end

可以说,CLAide 的命令解析就是一个多叉树遍历,通过分割参数及遍历 CLAide::Command 的子类,最终找到用户输入的 normal command 并初始化返回。

这里还有一个知识点就是,CLAide::Command 是如何知道有哪些子类集成它的呢?

def self.inherited(subcommand)
  subcommands << subcommand
end


这里利用了 Ruby 提供的 Hook Method self.inherited 来获取它所继承的子类,并将其保存在 subcommands

另外,这里在真正执行 self.run 方法之前会遍历当前项目所引入的 Gems 包中的指定目录下的命令插件文件,并进行插件加载,具体内容将在 PluginManager 中展开。

ARGV 传入参数

CLAide 提供了专门的类 ARGV 用于解析命令行传入的参数。主要功能是对 Parse 解析后的 tuple 列表进行各种过滤、CURD 等操作。

按照 CALide 的定义参数分三种类型:

  • arg: 普通的实参,所谓的实参就是直接跟在命令后面的,且不带任何 -- 修饰的字符
  • flag: 简单理解 flag 就是限定为 bool 变量的 option 类型参数,如果 flag 前面添加带 --no- 则值为 false,否则为 true
  • option: 可选项参数,以 -- 为前缀且以 = 作为分割符来区分 key 和 value


而在 ARGV 内部又提供了私有工具类 Parser 来解析终端的输入,其核心方法为 parse:

module Parser
  def self.parse(argv)
    entries = []
    copy = argv.map(&:to_s)
    double_dash = false
    while argument = copy.shift
      next if !double_dash && double_dash = (argument == '--')
      type = double_dash ? :arg : argument_type(argument)
      parsed_argument = parse_argument(type, argument)
      entries << [type, parsed_argument]
    end
    entries
  end
  # ,,,
end


parse 的返回值为 [Array<Array<Symbol, String, Array>>] 类型的 tuple,其中 tuple 的第一个变量为实参的类型,第二个才是对应的实参。

依旧以 pod update 为例:

pod update Alamofire --no-repo-update --exclude-pods=SwiftyJSON


解析后,输出的 tuple 列表如下:

[
  [:arg, "Alamofire"],
  [:flag, ["repo-update", false]],
  [:option, ["exclude-pods", "SwiftyJSON"]]
]

Banner 与输出格式化


接下来,我们再来聊聊 CLAide 提供的格式化效果的 banner。

那什么是 banner 呢?回看第一个例子 pod --help 所输出的帮助信息,它分为三个 Section:

  • Usage:用于描该述命令的用法
  • Commands:用于描述该命令所包含的子命令,没有则不显示。在子命令前面存在两种类型的标识
    • + :用于强调该 command 是单独添加的子命令
    • > :用于表示指引的意思,表示该 command 是当前命令的默认实现
  • Options:用于描述该命令的可选项


这三段帮助信息就是对应的不同的 banner。

CLAide 对于输出的 banner 信息提供了 ANSI 转义,用于在不同的终端里显示富文本的效果。banner 的主要格式化效果如下:

  1. 对于 setcion 标题: UsageCommandsOptions 添加了下划线且加粗处理
  2. Command 配置为绿色
  3. Options 配置为蓝色
  4. 提示警告信息配置为黄色
  5. 错误信息则是红色


对于这些配色方案,CLAide 提供了 String 的 convince method 来完成 ANSI 转义:

class String
  def ansi
    CLAide::ANSI::StringEscaper.new(self)
  end
end


例如:

"example".ansi.yellow #=> "\e[33mexample\e[39m"
"example".ansi.on_red #=> "\e[41mexample\e[49m"
"example".ansi.bold   #=> "\e[1mexample\e[21m"


对于 Banner 的一些高亮效果也提供了 convince method:

def prettify_title(title)
  title.ansi.underline
end

def prettify_subcommand(name)
  name.chomp.ansi.green
end

def prettify_option_name(name)
  name.chomp.ansi.blue
end

PluginManager 载入插件

PluginManager 是 Command 的管理类,会在第一次运行命令 self.run 时进行加载,且仅加载命令类中指定前缀标识的文件下的命令。让我们先看 PluginManager.rb 的核心实现:

def self.load_plugins(plugin_prefix)
    loaded_plugins[plugin_prefix] ||=
    plugin_gems_for_prefix(plugin_prefix).map do |spec, paths|
        spec if safe_activate_and_require(spec, paths)
    end.compact
end

def self.plugin_gems_for_prefix(prefix)
    glob = "#{prefix}_plugin#{Gem.suffix_pattern}"
    Gem::Specification.latest_specs(true).map do |spec|
        matches = spec.matches_for_glob(glob)
        [spec, matches] unless matches.empty?
    end.compact
end

def self.safe_activate_and_require(spec, paths)
    spec.activate
    paths.each { |path| require(path) }
    true
rescue Exception => exception
    # ...
end


整体的流程大致是:

  1. 调用 load_plugins 并传入 plugin_prefix
  2. plugin_gems_for_prefix 对插件名进行处理,取出我们需要加载的文件
  3. 调用 safe_activate_and_require 进行对应的 gem spec 检验并对每个文件进行加载


CocoaPods 的插件加载正是依托于 CLAide 的 load_plugins,它会遍历所有的 RubyGem,并搜索这些 Gem 中是否包含名为 #{plugin_prefix}_plugin.rb 的文件。
例如,在 Pod 命令的实现中有如下配置:

self.plugin_prefixes = %w(claide cocoapods)


也就是说在 Pod 命令执行前,它会加载所有包含 claide_plugin.rbcocoapods_plugin.rb 文件的 Gem。通过在运行时的文件检查来加载符合要求的相关命令。

用 CLAide 实现一款 🥤 贩卖机

最后一节让我们一起来创建一个 CLAide 命令。需求是希望实现一个自动 🥤 贩卖机,它有如下功能:
主要售卖 ☕️ 和 🍵,这两种 🥤 都可以按需选择是否添加 🥛 和 🍬,对于 🍬 还可以选择不同的甜度。

  • ☕️:对于咖啡,我们提供了:BlackEye、Affogato、CaPheSuaDa、RedTux 的口味
  • 🍵:对于茶,你可以选择不同的品种,有黑茶、绿茶、乌龙茶和白茶,同时茶还提供了加 🧊 的选项

配置模版项目


基于上述构想,我们最终的 BeverageMaker 目录将由以下文件组成:

.
├── BeverageMaker.gemspec
│   # ...
├── exe
│   └── beverage-maker
├── lib
│   ├── beveragemaker
│   │   ├── command
│   │   │   ├── coffee.rb # 包含 abstract command 以及用于制作不同咖啡的 normal command
│   │   │   ├── maker.rb  # Command 抽象类
│   │   │   └── tea.rb    # normal command, 不同种类的 🍵 通过参数配置来完成
│   │   ├── command.rb
│   │   └── version.rb
│   └── beveragemaker.rb
└── spec
    ├── BeverageMaker_spec.rb
    └── spec_helper.rb

0x1 生成模版项目


首先,我们使用 bundler gem GEM_NAME 命令生成一个模版项目,项目取名为 BeverageMaker

$ bundle gem BeverageMaker
Creating gem 'BeverageMaker'...
MIT License enabled in config
Code of conduct enabled in config
      create  BeverageMaker/Gemfile
      create  BeverageMaker/lib/BeverageMaker.rb
      create  BeverageMaker/lib/BeverageMaker/version.rb
      create  BeverageMaker/BeverageMaker.gemspec
      create  BeverageMaker/Rakefile
      # ...
Initializing git repo in ~/$HOME/Desktop/BeverageMaker
Gem 'BeverageMaker' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html

0x2 修改 gemspec 配置


生成的项目中需要将 BeverageMaker.gemspec 文件所包含 TODO 的字段进行替换,作为示例项目相关链接都替换为个人主页了 😂。

另外,需要添加我们的依赖 'claide', '>= 1.0.2', '< 2.0''colored2', '~> 3.1'

colored2 用于 banner 信息的 ANSI 转义并使其能在终端以富文本格式输出。


最终 .gempsc 配置如下:

require_relative 'lib/BeverageMaker/version'

Gem::Specification.new do |spec|
  spec.name          = "BeverageMaker"
  spec.version       = BeverageMaker::VERSION
  spec.authors       = ["Edmond"]
  spec.email         = ["chun574271939@gmail.com"]

  spec.summary       = "BeverageMaker"
  spec.description   = "BeverageMaker"
  spec.homepage      = "https://looseyi.github.io"
  spec.license       = "MIT"
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")

  spec.metadata["allowed_push_host"] = "https://looseyi.github.io"

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "https://looseyi.github.io"
  spec.metadata["changelog_uri"] = "https://looseyi.github.io"

  spec.files         = Dir.chdir(File.expand_path('..', __FILE__)) do
    `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
  end
  # 1
  spec.bindir        = "exe"
  spec.executables   = "beverage-maker"
  spec.require_paths = ["lib"]
   
  spec.add_runtime_dependency 'claide',         '>= 1.0.2', '< 2.0'
  spec.add_runtime_dependency 'colored2',       '~> 3.1'
end

0x3 添加命令行入口


通过修改 .gemspecbindirexecutables 字段,把最终的 binary 执行文件暴露给用户,使其成为一个真正的 CLI:

spec.bindir        = "exe"
spec.executables   = "beverage-maker"


在默认生成的模版中指定的 bindir/bin 目录,这里我们替换为新建的 exe 目录,并在 exe 目录下创建一个名为 beverage-maker 的文件,它将作为 CLI 的入口,其内容如下:

#!/usr/bin/env ruby

require 'beveragemaker'

BeverageMaker::Command.run(ARGV)

添加命令实现


为了让 Demo 结构清晰,我们将不能类型的饮料制作分到了不同的文件和命令类中。

BeverageMaker


先来实现 beverage-maker 命令,它是一个 abstract command,其内容如下:

require 'claide'
require 'colored2'

module BeverageMaker
  # 引入具体的 coffee & tea maker
  require 'beveragemaker/command/coffee'
  require 'beveragemaker/command/tea'
  
  class Command < CLAide::Command

    self.command = 'beverage-maker'  
    self.abstract_command = true   
    self.description = 'Make delicious beverages from the comfort of your terminal.'

    def self.options
      [
        ['--no-milk', 'Don’t add milk to the beverage'],
        ['--sweetener=[sugar|honey]', 'Use one of the available sweeteners'],
      ].concat(super)
    end
  
    def initialize(argv)
      @add_milk = argv.flag?('milk', true)
      @sweetener = argv.option('sweetener')
      super
    end
  
    def validate!
      super
      if @sweetener && !%w(sugar honey).include?(@sweetener)
        help! "`#{@sweetener}' is not a valid sweetener."
      end
    end
  
    def run
      puts '* Boiling water…'
      sleep 1
      if @add_milk
        puts '* Adding milk…'
        sleep 1
      end
      if @sweetener
        puts "* Adding #{@sweetener}…"
        sleep 1
      end
    end
  end
end


正常来说,对于不同口味的咖啡和茶是可以用相同的命令模式来实现的,不过为了更好的展示 CLAide 的效果,我们将咖啡的生产配置为 abstact command,对于不同口味的咖啡,需要实现不同的 normal command。而茶的生产直接通过 normal command 实现,不同品种的茶叶会以参数的形式来配置。

Coffee


接着添加 ☕️ 的代码

class Coffee < Command
  # ...
  self.abstract_command = true

  def run
    super
    puts "* Grinding #{self.class.command} beans…"
    sleep 1
    puts '* Brewing coffee…'
    sleep 1
    puts '* Enjoy!'
  end

  class BlackEye < Coffee
    self.summary = 'A Black Eye is dripped coffee with a double shot of ' \
    'espresso'
  end
  # ...
end

Tea

class Tea < Command
  # ...
  self.arguments = [
    CLAide::Argument.new('FLAVOR', true),
  ]

  def self.options
    [['--iced', 'the ice-tea version']].concat(super)
  end

  def initialize(argv)
    @flavor = argv.shift_argument
    @iced = argv.flag?('iced')
    super
  end

  def validate!
    super
    if @flavor.nil?
      help! 'A flavor argument is required.'
    end
    unless %w(black green oolong white).include?(@flavor)
      help! "`#{@flavor}' is not a valid flavor."
    end
  end
  # ...
end

安装 🥤 贩卖机


我们知道,对于正常发布的 gem 包,可以直接通过 gem install GEM_NAME 安装。

而我们的 Demo 程序并未发布,那要如何安装使用呢?幸好 Gem 提供了源码安装的方式:

gem build *.gemspec
gem install *.gem


gem build 可以根据一个 .gemspec 生成一个 .gem 文件供 gem 安装,所以在拥有源码的情况下,执行上面命令就可以安装了。

执行结果如下:

$ gem build *.gemspec
WARNING:  description and summary are identical
WARNING:  See http://guides.rubygems.org/specification-reference/ for help
  Successfully built RubyGem
  Name: BeverageMaker
  Version: 0.1.0
  File: BeverageMaker-0.1.0.gem
  
$ gem install *.gem

Successfully installed BeverageMaker-0.1.0
Parsing documentation for BeverageMaker-0.1.0
Done installing documentation for BeverageMaker after 0 seconds
1 gem installed


编译通过!

现在可以开始我们的 🥤 制作啦!

$ beverage-maker
Usage:

    $ beverage-maker COMMAND

      Make delicious beverages from the comfort of yourterminal.

Commands:

    + coffee                    Drink brewed from roasted coffee beans

Options:

    --no-milk                   Dont add milk to the beverage
    --sweetener=[sugar|honey]   Use one of the available sweeteners
    --version                   Show the version of the tool
    --verbose                   Show more debugging information
    --no-ansi                   Show output without ANSI codes
    --help                      Show help banner of specified command


来一杯 black-eye ☕️,休息一下吧!

$ beverage-maker coffee black-eye
* Boiling water
* Adding milk
* Grinding black-eye beans
* Brewing coffee
* Enjoy!


如需本文的 Demo 代码,请访问:https://github.com/looseyi/BeverageMaker

总结


本文简单聊了 CLAide 的实现,并手动制作了一款 🥤 贩卖机来展示 CALide 的命令配置。主要感受如下:

  1. 通过对源码对阅读,终于了解了对  pod 命令的的正确使用姿势
  2. 仅需简单配置 Command banner,就能有比较精美的终端输出效果和帮助提示等
  3. 提供的抽象命令功能,方便的将相关逻辑收口到统一到命令中,方便查阅
  4. 从侧面简单了解了,如何在终端输出带富文本效果的提示信息

知识点问题梳理


这里罗列了四个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入**收藏 **再次阅读:

  1. CLAide 预设的 banner 有哪些,其作用分别是什么 ?
  2. CALide 中设定的 Argument 有几种类型,区别是什么 ?
  3. CALide 中抽象命令的和普通命令的区别 ?
  4. 要实现 CLI 需要修改 .gemspec 中的哪些配置 ?
❌