[譯] 優化 Swift 的編譯時間

DeepMissea發表於2019-02-16

優化 Swift 的編譯時間

在 Swift 所有的特性中,有一件事有時會相當惱人,那就是在用 Swift 編寫更大規模的專案時,它一般會編譯多久。儘管 Swift 編譯器在保證執行時安全方面做的更多,但是它的編譯時間要比 Objective-C 編譯時間長很多。(所以)我想研究一下,是否我們可以幫助編譯器讓他工作的更快。

所以,上週我投身於 Hyper 上的一個較大的 Swift 專案。它大概有 350 個原始檔以及 30,000 行的程式碼。最後我設法將這個專案的平均構建時間減少了 20%。所以我想在我這周的部落格上詳細的介紹我是怎麼做的。

現在,在我們開始之前,我只想說我不想這篇文章以任何形式的方式來批判 Swift 或它的團隊工作。我知道 Swift 編譯器的開發者,包含 Apple 公司和開源社群,都在持續地對編譯器速度、功能和穩定性做出重大改進。希望這篇博文能隨著時間的流逝而顯得多餘,但在那之前,我只是想提供一些我發現可以提升編譯速度的實用技巧。

Step 1: 採集資料

在開始優化工作之前,建立一個能衡量你改進的基準總是好的。我是通過在 Xcode 裡,給應用的 target 新增兩個簡單的指令碼作為執行指令碼階段來實現的。

編譯原始檔之前,新增下面的指令碼:

echo "$(date +%s)" > "buildtimes.log"複製程式碼

在最後,新增這個指令碼:

startime=$(<buildtimes.log)
endtime=$(date +%s)
deltatime=$((endtime-startime))
newline=$`
`

echo "[Start] $startime$newline[End] $endtime$newline[Delta] $deltatime" > "buildtimes.log"複製程式碼

現在,這個指令碼只會測算編譯器編譯應用自己的原始檔的時間(為了測量出整個引用的編譯時間,你可以使用 Xcode 的特性來掛載(hook)到 Build StartsBuild Succeeds 上)。由於編譯時間非常依賴於編譯它的裝置,所以我也 git ignored 了 buildtimes.log 檔案

接下來,我想突出哪些個別程式碼塊耗費了額外的長時間來編譯,以便識別瓶頸,這樣我就可以修復它。要做到這個,只需要通過向 Xcode 中 Build Setting 裡的 Other Swift Flags 傳遞下面的引數給 Swift 編譯器來設定一個臨界值:

-Xfrontend -warn-long-function-bodies=500複製程式碼

使用上面的引數後,在你的專案中,如果有任何函式耗費了超過 500 毫秒的編譯時間,你就會得到一個警告。這是我開始設定的臨界值(並且隨著我對更多瓶頸的修復,這個值在不斷的降低)。

Step 2: 消除所有的警告

在設定了函式編譯時間過長的警告之後,你可能會在專案中開始發現一些。最開始,你會覺得編譯時間過長的函式是隨機的,但是很快模式(patterns)就開始出現了。這裡我注意到了兩個使 Swift 3.0 編譯器編譯函式時間過長的常見模式:

自定義運算子(特別是帶有通用引數的過載)

當 Swift 出現時,對於大多數 iOS 和 macOS 開發者來說,運算子過載是全新的概念之一,但就像許多新鮮事物一樣,我們很興奮的使用它們。現在,我不打算在這討論自定義或過載運算子是好是壞,但它們的確對編譯時間有很大影響,尤其是如果使用更加複雜的表示式。

思考下面的運算子,它將兩個 IntegerConvertible 型別的數字加起來,構成了自定義的數字型別:

func +<A: IntegerConvertible,
       B: IntegerConvertible>(lhs: A, rhs: B) -> CustomNumber {
    return CustomNumber(int: lhs.int + rhs.int)
}複製程式碼

然後我們用它來讓幾個數字相加:

func addNumbers() -> CustomNumber {
    return CustomNumber(int: 1) +
           CustomNumber(int: 2) +
           CustomNumber(int: 3) +
           CustomNumber(int: 4) +
           CustomNumber(int: 5)
}複製程式碼

看上去很簡單,但是上面的 addNumbers() 函式會花費很長一段時間來編譯(在我 2013 年的 MBP 上超過 300 ms)。對比一下,如果我們用協議擴充套件來實現相同邏輯:

extension IntegerConvertible {
    func add<T: IntegerConvertible>(_ number: T) -> CustomNumber {
        return CustomNumber(int: int + number.int)
    }
}

func addNumbers() -> CustomNumber {
    return CustomNumber(int: 1).add(CustomNumber(int: 2))
                               .add(CustomNumber(int: 3))
                               .add(CustomNumber(int: 4))
                               .add(CustomNumber(int: 5))
}複製程式碼

通過這個改變,我們的 addNumbers() 函式現在編譯時間不到 1 ms這快了 300 倍!

所以,如果你大量的使用了自定義/過載運算子,特別是帶有通用引數的(或者如果你使用的第三方庫來做這些,比如許多自動佈局的庫),考慮一下用普通函式、協議擴充套件或其他的技術來重寫吧。

集合字面量

另一個我發現的編譯時間瓶頸是使用集合字面量,特別是編譯器需要做很多工作來推斷那些字面量的型別。讓我們假設你有一個函式,它要把模型轉換成一個類似 JSON 的字典,像這樣:

extension User {
    func toJSON() -> [String : Any] 
        return [
            "firstName": firstName,
            "lastName": lastName,
            "age": age,
            "friends": friends.map { $0.toJSON() },
            "coworkers": coworkers.map { $0.toJSON() },
            "favorites": favorites.map { $0.toJSON() },
            "messages": messages.map { $0.toJSON() },
            "notes": notes.map { $0.toJSON() },
            "tasks": tasks.map { $0.toJSON() },
            "imageURLs": imageURLs.map { $0.absoluteString },
            "groups": groups.map { $0.toJSON() }
        ]
    }
}複製程式碼

上面 toJSON() 函式在我的電腦上大概要 500 ms 的時間來編譯。現在讓我們試著逐行重構這個像字典的東西來代替字面量:

extension User {
    func toJSON() -> [String : Any] {
        var json = [String : Any]()
        json["firstName"] = firstName
        json["lastName"] = lastName
        json["age"] = age
        json["friends"] = friends.map { $0.toJSON() }
        json["coworkers"] = coworkers.map { $0.toJSON() }
        json["favorites"] = favorites.map { $0.toJSON() }
        json["messages"] = messages.map { $0.toJSON() }
        json["notes"] = notes.map { $0.toJSON() }
        json["tasks"] = tasks.map { $0.toJSON() }
        json["imageURLs"] = imageURLs.map { $0.absoluteString }
        json["groups"] = groups.map { $0.toJSON() }
        return json
    }
}複製程式碼

它現在編譯時間大概在 5 ms 左右,提高了 100 倍!

Step 3: 結論

上面的兩個例子非常清晰的說明了 Swift 編譯器的一些新特性,比如型別推演和過載,都是付出了時間開銷。如果我們仔細思考一下,也很符合邏輯。由於編譯器不得不做更多的工作來執行推演,所以花費了更多的時間。但是我們也看到了,如果我們稍微調整一下我們的程式碼,幫助編譯器更簡單的解決表示式,我們就可以很大程度的加快編譯時間。

現在,我不是說你要一直讓編譯時間來決定你寫程式碼的方式。有時可以讓它做更多的工作,讓你的程式碼更加清晰並且容易理解。但是在大型的專案中,每個函式要用 300-500 ms 範圍(或更多)的時間來編譯的編碼技術可能很快就會成為一個問題。我的建議是對你的編譯時間保持監控,使用上面的編譯標記設定一個合理的臨界值,並在發現問題的時候解決問題。

我確信上面的例子肯定沒有涵蓋所有潛在的編譯時間改進的方法,所有我很願意聽到你的意見。如果你有任何有用的改進大型 Swift 專案編譯時間的其他的技術,你可以寫在 Medium 上回復,或者在 Twitter @johnsundell 上聯絡我。

感謝閱讀!?

相關文章