京東APP訂單業務Swift優化總結

小顧iOSer發表於2021-07-30

原創:京東零售技術平臺

技術分類:優化、APP、分析

收錄於:Github

隨著Swift ABI穩定,開發者對Swift的關注也持續升溫,一些開源框架甚至已經不再提供ObjC版本了,部分蘋果新出的系統庫也是Swift Only。

在這樣的背景下,京東商城訂單業務在不同場景下嘗試更多的使用Swift開發,比如:

  • 京東App部分訂單業務頁面

  • 京東App物流小元件

  • “京東工作站”,為公司內部提供的整合部分工作環境與開發環境,以及部分工作流的macOS應用

在改造過程中,Swift的高效安全與便捷和一些優秀特性給團隊留下了深刻的印象。有很多特性是開發者在寫ObjC時不會太多考慮的。比如,Swift的靜態派發方式、值型別的使用、靜態多型、Errors+Throws、柯里化與函式合成以及豐富高階函式等等,而且相對於OOP,Swift也能更好的支援面向協議程式設計、泛型程式設計以及更抽象函數語言程式設計,解決了很多ObjC時代開發者面臨的痛點問題。

結合Swift和ObjC的異同點,我們從Swift優勢出發,重新審視和優化了專案的功能程式碼,優化點包括但不限於如下幾個方面。

將部分方法動態派發替換為靜態派發

Swift執行速度比ObjC快的原因之一就是其派發方式:靜態派發(值型別)和函式表派發(引用型別)。使用靜態派發ARM架構可直接用bl指令跳轉到對應函式地址,呼叫效率最高並且有利於編譯器的內聯優化。值型別無法繼承父類,編譯時期能確定型別,滿足靜態派發的條件。對於引用型別,不同編譯器的設定也會對派發方式有影響。比如WMO全模組編譯下,系統會自動填用隱式final等關鍵字來修飾沒有被子類繼承的類,從而儘可能多的使用靜態派發。

在我們的專案中,針對所有使用Class的類做了整體檢查。除非必要應完全避免繼承NSObject,少用NSObject的子類。對於不需要考慮繼承或者多型的場景,儘可能的使用final 或者 private等關鍵字修飾。

另外需要關注的是,ObjC也引入了方法的靜態派發。在Xcode12中整合的最新LLVM已經支援 ObjC 通過對方法指定__attribute__((objc_direct)) 的方式,來將原本的動態訊息派發改為靜態派發。

檢查所有Class 儘可能替換為結構體或者列舉

Swift中的結構體和列舉是值型別,Class是引用型別。在Swift中使用值型別還是引用型別是開發者需要思考和評估的。

在我們開發的京東物流小元件和基於SwiftUI開發的macOS應用中,我們目前更多的使用了結構體和列舉。先對比下值型別與引用型別的區別,值型別(Struct Enum等等):

  • 在棧上建立,建立速度快

  • 記憶體佔用小。整體佔用的記憶體就是內部屬性記憶體對齊後的大小

  • 記憶體回收快,用棧幀控制入棧出棧即可,沒有處理堆記憶體的開銷

  • 不需要引用計數 (結構體中使用引用型別作為屬性除外)

  • 一般是靜態派發,執行速度快,也方便編譯器優化,如內聯等

  • 賦值時深拷貝。系統通過Copy-On-Write,避免不必要的copy,減少拷貝開銷

  • 沒有隱式資料共享,具有獨立性不可變性

  • 可通過mutating去修改結構體中的屬性。這樣在保證值型別的獨立性的同時,也能支援對部分屬性的修改。

  • 執行緒安全,一般來說沒有競態條件和死鎖(要注意確定值在各個子執行緒中是被copy過的)

  • 不支援繼承,避免OOP子類過於耦合父類的問題。

  • 可通過協議和泛型實現抽象。但實現協議的結構體記憶體大小不同,因此無法直接放入陣列中,為了儲存的一致性,傳參賦值時系統會引入中間層Existential Container。此處如果結構體屬性較多會複雜一點,但蘋果也會有優化(Indirect Storage With Copy-On-Write),較少開銷。總體來說,值型別的多型是有成本的,系統會盡量優化。開發者要考慮的是:減少動態多型把協議直接當做類來使用,需要更多考慮靜態多型,多結合泛型約束來使用。

引用型別(Class Function Closure等等):

  • 引用型別在記憶體使用上沒有值型別高效,在堆上建立並需要有棧指標指向該區域,增加了堆記憶體分配和回收的開銷

  • 賦值消耗小,一般是淺拷貝複製指標。但有引用計數成本

  • 多個指標可指向同一記憶體,獨立性差,容易誤操作

  • 非執行緒安全,要考慮原子性,多執行緒需要執行緒鎖配合

  • 需要引用計數來控制記憶體釋放,使用不當會有野指標、記憶體洩漏和迴圈引用的風險

  • 允許繼承,但繼承的Side effect就是子類與父類的緊耦合。比如系統的 UIStackView主要目的只是用來佈局使用,但卻不得不繼承UIView的所有屬性和方法。

由此可見,Swift提供了更強大的值型別試圖來解決ObjC時代OOP的子類與父類的緊耦合、物件隱式資料共享、非執行緒安全、引用計數等典型痛點。翻看Swift 標準庫會發現其主要由值型別組成,基本型別集合如 Int,Double,Float,String,Array,Dictionary,Set,Tuple也都是結構體。當然,雖然值型別有眾多優點,但也不是說要完全拋棄Class,還是要根據實際情況分析,實際的Swift開發中更多的是一個種結合的方式,完全不使用OOP也是不現實的。

優化結構體記憶體

和使用C語言結構體一樣,Swift結構體的大小就是內部屬性記憶體對齊後的大小。結構體中屬性放置在不同的順序會影響最後的記憶體大小。可使用系統提供的 MemoryLayout檢視相應結構體佔用記憶體大小。

我們從一些細節層面做了review,比如對於Int32完全滿足的場景沒有必要使用Int,不要使用String或者Int代替應該使用Bool的場景,記憶體小的屬性儘量放在後面等等。

struct GameBoard {  var p1Score: Int32  var p2Score: Int32  var gameOver: Bool }struct GameBoard2 {  var p1Score: Int32  var gameOver: Bool   var p2Score: Int32}//基於CPU定址效率考慮,GameBoard2位元組對齊後佔用空間更多MemoryLayout<GameBoard>.self.size  //4 + 4 + 1 = 9(bytes)MemoryLayout<GameBoard2>.self.size //4 + 4 + 4 = 12(bytes)
複製程式碼

使用靜態多型替換動態多型

上面提到值型別的時候,我們有提到靜態多型,靜態多型是指編譯器能在編譯時期確定型別的多型。這樣編譯器可以型別降級,在編譯時可產生特定型別的方法。

將泛型定義為遵守某個協議的約束可以避免直接把協議直接當做類來傳參使用,否則編譯器會報錯,相當於介面支援多型,但呼叫時要用特定的型別呼叫,從而達到了靜態多型的目的。對於靜態多型,編譯器會充分利用其靜態特性做優化,同時在設定了WMO全模組優化(Whole Module Optimization)的情況下會盡量控制由此可能產生的程式碼增長。

簡而言之,開發者要儘可能多考慮靜態多型。比如在使用協議作為函式的引數時,可以引入泛型。WWDC中有很經典的討論:

protocol Drawable {    func draw()}struct Line: Drawable {    var x: Double = 0    func draw() {    }}func drawACopy<T: Drawable>(local: T) {//指定T必須遵守Drawable    local.draw()}let line = Line()drawACopy(local: line)//Success (傳入具體的實現了Drawable的結構體,編譯器可推斷其型別)let line2: Drawable = Line()drawACopy(local: line2)//Error,編譯器不允許直接使用Drawable協議作為入參
複製程式碼

面向協議為協議提供擴充套件預設實現

對於類的繼承父類和遵守協議,Swift更願意選擇後者。ObjC中OOP的形式,在Swift裡基本都可以使用 Structs/Enums + Protocols + Protocol extensions + Generics 來實現邏輯抽象。

我們儘量減少了專案中使用OOP的場景,儘可能的只用值型別面向協議和利用泛型,這樣編譯器能做更多的靜態優化,更能降低OOP超類帶來的緊耦合。

同時,Protocol extension能夠為protocol提供一個預設實現,這也是區別於ObjC協議的很重要的優化。

使用時要注意,應該用具體的型別去呼叫Protocol extension中的方法,而不是用通過型別推斷得到的Protocol來呼叫。使用Protocol呼叫時,如果該方法沒有在Protocol中定義,Protocol extension中的預設實現將被呼叫,即使具體的型別中有實現對應方法。因為此時編譯器此時只能找到預設實現。

優化錯誤處理

相對於ObjC, Swift 中對 Error 和 Throw 的處理更加完善,這樣顯而易見的好處是API更友好,提高可讀性,利用編輯器檢測降低出錯概率。ObjC時代大家往往不會考慮丟擲異常的操作,這個也是習慣ObjC編碼的程式設計師在封裝底層API時需要注意的。常見的是使用繼承Error協議的Enum。

enum CustomError: Error {   case error1   case error2}
複製程式碼

產生Error後也可以丟擲讓外部處理,支援throw的方法後編譯器會做強檢測是否有處理throw。要注意 () throws -> Void 和 () -> Void 是不同的 Function Type。

//(Int)->Void可以賦值給(Int)throws->Voidlet a: (Int) throws -> Void = { n in}//反之型別不匹配 編譯報錯let b: (Int) -> Void = { n throws in}
複製程式碼

rethrows:如果一個函式入參是一個支援throw的函式,那麼通過rethrows可以標識該函式同樣可以丟擲Error。這樣在使用該函式時,編譯器會檢測是否需要try-catch。

這是我們在封裝基礎功能時需要考慮的,系統中友好的示例很多,比如map函式在系統中的定義:

public func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]let a = [1, 2, 3]enum CustomError: Error {  case error1  case error2}do {  let _ = try a.map { n -> Int in    guard n >= 0 else {      //如果map接受的closure內部有丟擲throw,編譯器會強制檢測外部是否有try-catch      throw CustomError.error1    }    return n * n  }} catch CustomError.error1 {} catch {}
複製程式碼

用Guard減少if巢狀

關鍵性檢測可以使用 Guard,其優勢是可以增強可讀性,較少過多的if巢狀。使用Guard時,一般else裡面會是 return、 throw、continue、 break等。

//if巢狀過多,難以閱讀,增加後期維護成本if (xxx){  if (xxx) {    if (xxx) {    }  }}//使用Guard,整體更清晰,便於後期維護let dict : Dictionary = ["key": "0"]guard let value1 = dict["key"], value == "0" else {  return}guard let value2 = dict["key2"], value == "0" else {  return}print("\(value1) \(value2)")
複製程式碼

利用Defer

被defer修飾的closure會在當前作用域退出的時候呼叫,主要用來避免重複新增返回前需要執行的程式碼,提高可讀性。

比如在我們macOS應用中有對檔案讀寫的操作,這時候使用defer可以確保不會忘記關閉檔案。

func write() throws {  //...  guard let file = FileHandle(forUpdatingAtPath: filepath) else {    throw WriteError.notFound  }  defer {    try? file.close()  }  //...}
複製程式碼

另外比較常用的場景是釋放鎖的時候,以及非逃逸閉包回撥等。

但是defer不要過度使用,使用時要注意closure捕獲變數和作用域的問題。

比如如果在if語句中使用defer,則跳出if時,該defer就會被執行。

用可選繫結替換所有的強制拆包

對於可選值,要盡最大可能甚至完全避免強制拆包。大部分情況下如果遇到了需要使用 ! 的情況,很可能說明最初的設計是不合理的。包括downCasting時,由於型別轉換本身就有可能失敗,要避免使用 as! ,儘量使用as?,當然try!也要避免。

對於可選值,永遠要使用可選繫結檢測,確保可選變數具有真正的值存在,然後再進行操作:

var optString: String?if let _ = optString {}
複製程式碼

多考慮懶載入

將專案中不需要必須建立的屬性,改為懶載入。Swift的懶載入相對於ObjC來說可讀性更好,也更容易實現,使用Lazy修飾就好。

lazy var aLabel: UILabel = {    let label = UILabel()    return label}()
複製程式碼

使用函數語言程式設計 減少狀態變數宣告與維護

在類裡面宣告過多的狀態變數是不利於後期維護的。Swift裡函式可以作為函式引數、返回值以及變數,可以很好的支援函數語言程式設計。利用函式式能有效減少全域性變數或者狀態變數。

指令式程式設計更關注解決問題的步驟。直接反應機器指令序列。有變數(對應儲存單元),賦值語句(對應獲取與儲存指令),表示式(對應 指令算數計算),控制語句(對應跳轉指令)。

函數語言程式設計更關注資料的對映關係和資料的流向,即輸入和輸出。函式被當做變數,既可以作為其它函式的引數(輸入值),也可以從函式中返回(輸出值)。將計算描述為表示式求值,自變數的對映f(x)->y,給定x,會穩定對映為y。函式內儘量不訪問函式作用域之外的變數,只依賴入參,減少狀態變數的宣告與維護。同時少用可變變數(物件),多用不可變變數(結構體)。這樣就不會有其他side effects干擾。

利用柯里化把接受多個引數的函式變換成接受一個單一引數的函式,將部分引數快取到函式內部。同時利用函式合成增加可讀性。比如做加法乘法計算,我們可以封裝加法和乘法函式然後逐一呼叫:

func add(_ a: Int, _ b: Int) -> Int { a + b }func multiple(_ a: Int, _ b: Int) -> Int { a * b }let n = 3multiple(add(n, 7), 6) //(n + 7) * 6 = 60
複製程式碼

也可以使用函式式:

//柯里化add和multiple函式: 由兩個入參改為一個並返回一個(Int)->Int型別函式func add(_ a: Int) -> (Int) -> Int { { $0 + a} } func multiple(_ a: Int) -> (Int) -> Int { { $0 * a} } //函式合成 自定義中置運算子 > 增加可讀性infix operator > : AdditionPrecedencefunc >(_ f1: @escaping (Int)->Int,       _ f2: @escaping (Int)->Int) -> (Int) -> Int {  {f2(f1($0))} }//生成新的函式 newFnlet n = 3let newFn = add(7) > multiple(6) // (Int)->Intprint( newFn(n) ) //(n + 7) * 6 = 60
複製程式碼

可以看到,從使用multiple(add(n, 7), 6) 到 let newFn = add(7) > multiple(6), newFn(n),整體更清晰了,尤其是在更復雜的場景下,其優勢會更明顯。

總結

Swift提供了豐富簡便的語法糖以及強大的型別推斷,這些都讓Swift變得很容易上手入門。但是要從效能考慮或者是設計出更完美API的角度出發,還是需要投入更多的實踐才行。訂單團隊正在iOS小元件、AppClips、京東工作站(macOS桌面應用)等場景下嘗試儘可能多的使用Swift與SwiftUI開發,開發效率與專案穩定性都有不錯的表現。目前京東集團內部對Swift的基礎設施正在逐步完善中,我們相信也希望未來集團內有更多的同學參與到Swift的開發中進來。

推薦收藏:乾貨:Github

相關文章