一波N折的攜程酒店Swift-Objc混編實踐

說起Swift,對iOS開發者來說那是既熟悉又陌生,雖然早在2014年蘋果就發佈了Swift1.0版本,但在這之後的五六年時間裏,一直處於不溫不火的狀態。ABI的不穩定以及API的不向前兼容,更是被程序員調侃爲“自從學了 Swift 之後,每年都要學一門新語言”。

這種情況一直持續到2019年3月,在WWDC19大會上,終於傳來一個令人期待已久的好消息。伴隨着Swift5.0發佈的同時,也終於宣佈了Swift ABI的穩定,開發者們不禁奔走相告。因爲從此之後,Swift終於可以擺脫對編譯器版本的限制,不同版本Swift編譯的app無需再借助app內的runtime就能和操作系統互相之間無縫通訊。Swift 終於可以算是一門真正成熟的編程語言了。

在此之後,沉寂多年的Swift突然走上了一條快速發展的道路。蘋果公司開始快速發力對Swift的佈局,步伐快得令人有點猝不及防,在下半年的WWDC會上又接連推出了SwiftUI,Combine,以及RealityKit三款純Swift的Framework或API。雖然從兼容性(僅限iOS13及以上)角度來看,他們的實用性還早,但這一系列動作已經展現出蘋果公司對於Swift未來的決心,讓人驚呼Swift的未來已來。

從行業流行度的數據來看,Swift發展得遠比我們想象中要快。根據阿里手淘團隊不久前對app store排行榜TOP1000的APP進行文件掃描分析結果得知,美區使用Swift的APP佔比已經達到了78%,剩餘未使用的還是一些來自中國地區的產品,由此可見Swift在國外的熱度已經非常高了。即便是在中國區,TOP100的APP也有26家使用了Swift,超過了使用React Native和Flutter的數量,僅次於Objc,具體數據如下圖所示:

在一些熱門社區如StackOverFlow上,Swift問題的熱度也已經遠超Objective-C。一些Objective-C的問題開始無人關注或解答,蘋果官方的開發者網站更是早在2017年便開始不再提供Objective-C代碼的示例。另外,在最近兩年的校園招聘中,也有越來越多的學生表示他們已經直接從Swift開始學習iOS開發。

種種跡象表明,iOS開發語言的重心已經在悄悄倒向Swift,開發者們對Swift的信心正在被重新點燃。對於我們攜程酒店技術團隊而言,此時對Swift展開調研是一個很好的時機,這不僅僅是爲了跟上新技術的發展,也是爲了避免將來有技術踏空的風險。因爲也許很快Objective-C將不再是開發iOS的最優選擇,並且未來會有可能很難招聘到Objective-C的開發,尤其是校園招聘。

於是,我們迅速組織研發人力,對Swift開發在攜程主app內的可行性展開了調研和實踐。

一、先從哪裏開始呢

萬事開頭難,不過好在蘋果開發者網站給出了一些遷移的經驗和守則,其中第一條就說到“Remember that you can’t subclass a Swift class in Objective-C.Therefore, the class you migrate can’t have any Objective-C subclasses.” 既然Swift類不能被Objective-C繼承,那麼最適合首先遷移的還是那些底層工具類代碼,同時爲了讓架構看上去更清晰,我們決定新建一個Swift庫來管理所有遷移好的Swift代碼。

雖然在選擇是靜態庫還是動態庫的問題上糾結了很久,但由於目前攜程app的架構主要是由各bu之間互相依賴靜態庫的調用構成,所以最終我們還是選擇了對架構變動影響最小的靜態庫方式。幸運的是,Swift編譯靜態庫在xcode9就已經被蘋果支持,所以我們的此次實踐並不需要對app工程架構做出任何調整,直接以靜態庫的形式來引入Swift即可。

二、Objc& Swift混編

集成好Swift靜態庫之後,馬上開始準備我們第一次的Objective-C和Swift混編,不幸的是模擬器啓動後即崩潰了,控制檯上顯示“dyld: Library not loaded: @rpath/libswiftCore.dylib”,程序啓動時加載Swift動態庫失敗了。

在stackoverflow上查閱問題後得知,我們除了需要在Runpath Search Path中添加/usr/lib/swift之外,還需要將Always Embed Swift Standard Libraries設置爲Yes,如下圖所示:

但這個設置似乎和我們之前理解的ABI穩定有點衝突,ios12.2之前的版本因爲系統沒有內置Swiftruntime和動態庫,所以需要在app中打入Swift runtime。那麼Always Embed Swift Standard Libraries設置爲Yes之後,是不是就意味着我們在12.2之後的版本也會打上這個庫呢?

答案是肯定的,但這並不意味着最終在用戶端也一定會下載到這個庫。App store 和操作系統在安裝iOS或者watchOS的 app 時會通過一些列的優化,儘可能減少安裝包的大小,使得 app 以最小合適的大小被安裝到你的設備上,這個過程被稱作爲APP Thinning。

所以開發者只需儘管上傳兼容所有版本功能的app包,系統會負責將app剪裁到最適合用戶的最小體積來下發,每臺設備都只會下載符合各自機型和操作系統所需要的可執行文件和資源。也就是說每個用戶下載到的包大小差異取決於用戶手機的操作系統版本,這個過程如下圖所示:

三、Objc-> Swift

解決了混編問題之後,我們開始着手在Objective-C工程內嘗試調用Swift模塊,Swift模塊編譯後會生成一個以xxx-Swift.h結尾的頭文件,通過導入這個頭文件,如:

#import<SwiftLibA/SwiftLibA-Swift.h>

就可以在Objc項目裏引用Swift方法了,試了一下,在xcode裏很順利地跑了起來。但如上文所說,攜程整個app的架構是由對靜態庫的依賴構成,所以在CI平臺上是針對各個靜態庫單獨打包編譯的。在單獨編譯Objc庫的情況下,打包失敗了,控制檯又給我們留下一句話:“SwiftLibA/SwiftLibA-Swift.h’ file not found”。

在解答這個問題之前,先讓我們回顧一下C語言家族引入頭文件的兩種方式,分別是:

#include "path-spec"
#include<path-spec>

引號表示讓預處理器去源文件目錄下搜索頭文件,尖括號則表示去環境變量所指定的目錄下去搜索,瞭解完這個機制後,再來看上面的這個問題。Swift模塊編譯時產生的頭文件是放在build目錄中的,而不是在源文件目錄下,而我們的打包腳本只會在依賴項的源文件目錄中搜索,所以在單獨編譯Objc庫的時候就會找不到Swift頭文件。

要修改那個動輒上千行如天書一般難以理解的打包腳本,顯然不是最快的解決方案。我們也曾動過要換動態庫方式的念頭,但這個對工程變動的影響太大,短時間內應該得不到支持,而且蘋果也是推薦優先使用靜態庫,所以只能換個思路去解決這個問題。既然CI不支持在環境變量目錄中去搜索頭文件,那我們就把它從build目錄中copy出來當源文件使用(需加入git做版本控制)。

爲了方便這個操作,我們使用腳本在每次編譯完成後就把最新的Swift頭文件自動copy到Swift模塊所在的源文件目錄中,完整的腳本如下:

mkdir -p${include_dir}
cp${generated_header_file} ${include_dir}

# 去掉xxx-Swift.h 文件頭部註釋中的編譯器的版本號

sed -i"" "s/^\/\/ Generated by Apple.*$/\/\/ Generated byApple/g" ${generated_header_file}

# 拷貝xxx-Swift.h 文件到工程源碼目錄

header_file_in_proj=${SRCROOT}/${PROJECT}-Swift.h
needs_copy=true
if [ -f"$header_file_in_proj" ]; then
    echo "${header_file_in_proj} 已存在"
   
    new_content=$(cat ${generated_header_file})
    old_content=$(cat ${header_file_in_proj})
    if [ "$new_content" ="$old_content" ];then
        echo "文件內容一致,無需再Copy:"
        echo "${generated_header_file}"
        echo "${header_file_in_proj}"
 
        needs_copy=false
    fi
fi
 
if ["$needs_copy" = true ] ; then
   
    echo "文件內容不一致,需要Copy:"
    echo "複製文件:"
    echo "${generated_header_file} "
    echo "${header_file_in_proj} "
 
    cp ${generated_header_file}${header_file_in_proj}
fi

至此,在Objective-C項目內調用Swift靜態庫的問題全部得到解決,終於能讓Swift模塊可以愉快的在objc項目中被隨意使用了。

四、Swift-> Swift

本以爲項目會就此進入坦途,但沒過幾天,就迎來了新問題。隨着項目進行的需要,我們要把Swift靜態庫一拆爲二,彼此之間單向依賴,於是我們的問題就變成了Swift靜態庫如何互相之間調用的問題。乍一看這並不是什麼大問題,Objc調Swift都能解決,Swift調Swift還不簡單,幾行代碼就能實現,如下:

importFoundation
import SwiftLibB
 
@objcMembers
public classSwiftLibA: NSObject {
    public func sayHello(name: String) {
        SwiftLibB().sayHello(name: name)
        print("Hello, this is " +name + "!")
        print("-- Printed by SwiftLibA")
    }
}

代碼非常簡單,編譯整個工程也沒有遇到任何問題,但是跟之前遇到問題一樣的是當你試圖單獨編譯模塊SwiftLibA時,再次發生了報錯,“No such module 'SwiftLibB’”,編譯器找不到對SwiftLibB的引用。

根據之前的經驗,我們很快就斷定這是同一個原因,但是上文提過我們已經把Swift頭文件copy到源文件目錄中了,爲什麼突然不起作用了呢?很顯然是因爲Swift模塊間的互相調用跟Objc調用Swift不同,他們並不依賴那個編譯出來的頭文件。所以問題來了,Swift模塊間是通過什麼方式來對外暴露API的呢?

在官方文檔中我們找到了答案, “Swift uses an opaquearchive format called “swiftmodule” to describe the interface of a library”,意思是說Swift使用一個叫swiftmodule的文件來描述一個庫的接口申明,在編譯目錄下,我們果然找到了這個文件,如下圖所示:

明白了Swift模塊間的接口聲明方式後,接下去就要像之前導出XXX-Swift.h文件一樣,如法炮製,把swiftmodule文件也同樣導出到源文件目錄,然後再設置SwiftLibA的import path,並把這幾個文件添加到git庫中做版本管理。

一頓操作後大功告成,最後檢驗下成果,這時單獨編譯SwiftLibA終於沒有問題了,於是提交代碼,開始準備遠程打包然後收工,但令人意外的是MCD(攜程CI打包工具)竟然報錯了,“error: Module compiled with Swift 5.1 cannot be imported by the Swift 5.1.2compiler”。

爲什麼會這樣,仔細再看了下文檔,原來之前的話還有後半句被我們忽略了,“However, the “swiftmodule” format is also tied to the currentversion of the compiler”。原來swiftmodule是跟編譯器版本強相關的,不同版本編譯器編譯出來的庫是不能被互相兼容的,也就是說Swift5.0雖然已經做到了運行時ABI stability,但還沒有做到編譯時的模塊穩定(Module stability)。不過幸運的是當我們遇到這個問題的時候,Swift已經發布了5.1版本,及時加入瞭解決Module stability的方案,下面先用圖1來表示我們最初使用Swift模塊的方法。

圖1

圖2則是模塊穩定後的解決方案,唯一的區別只是將swiftmodule文件改成了swiftinterface文件,swiftinterface文件作爲 swiftmodule的一個補充,它是一個描述 module 公開接口的文本文件,不受編譯器版本限制,並可以被手動編輯。

圖2

比如,你用 Swift6編譯器編譯出了一個library,通過它的swiftinterface文件,這個庫就也可以在 Swift7編譯器上使用,如下圖所示:

下面就讓我們來實踐一下獲取,打開SwiftLibB的BuildSetting,找到Build Options -> Build Libraries for Distribution,設置爲YES,如下圖所示:

然後再重新編譯一下,打開build目錄,這時就能看到裏面多了幾個swiftinterface文件,這是一個可以被編輯的文件,也可以進行手動修改,如下圖所示:

swiftinterface文件中的內容大概如下:

//swift-interface-format-version: 1.0
//swift-compiler-version: Apple Swift version 5.1 (swiftlang-1100.0.270.13clang-1100.0.33.7)
//swift-module-flags: -target x86_64-apple-ios13.0-simulator -enable-objc-interop-enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -Onone-module-name SwiftLibB
importFoundation
import Swift
@objc@objcMembers public class SwiftLibB : ObjectiveC.NSObject {
  @objc public func sayHello(name: Swift.String)
  @objc override dynamic public init()
  @objc deinit
}

所以,除了swiftmodule外,我們還需要把swiftinterface文件也一起提供給第三方調用者,並一起copy到源文件目錄。

模塊的穩定意味者二進制庫的穩定,Swift庫之間的調用終於不用再依賴源碼或者編譯器版本,這對於Swift的發展來說是一個很大的進步,將更有助於推動Swift的發展。

五、Swift-> Objc

原本以爲到這裏應該是解決完了所有問題,但計劃不如變化來得快。雖然在設計之初我們在原則上約定了只允許ojbc引用swift,不允許被反過來引用,但很快我們就不得不推翻了這個約定。因爲我們發現這是一件不可避免的事情,比如我們很多引用都來自攜程公共團隊的底層模塊,這些模塊都是基於objc的,甚至還有一些第三方的objc庫,在公共底層庫沒轉Swift之前,這就是一個無法被避免的問題。

不過好在蘋果官網早就提供瞭解決方案,在《ImportingObjective-C into Swift》一文中分別提供了Objc文件是在同一app target內被引用還是作爲Framework使用時的兩套解決方案。

在同一app target內被引用時較爲簡單,只需創建一個以“-Bridging-Header.h”爲後綴名的文件即可,並把需要暴露給Swift的objc 頭文件在這裏進行編輯就可以了,具體如何創建這個文件本文就不做贅述了。

我們在文章開頭部分曾介紹過攜程app架構主要採用的是靜態庫依賴的構成方式,所以上面的方案對我們並不適用。因爲Swift終於引入了命名空間的概念(Objective-C一直以來令人詬病的地方之一就是沒有命名空間),但是和C#這樣顯式在文件中指定命名空間的做法不同。Swift 的命名空間是基於 module 而不是在代碼中顯式地指明,每個 module 代表了 Swift 的一個命名空間,在這種情況下我們的Swift靜態庫無法採用Bridging header方式,這時就必須要把這些頭文件導入到Objective-C的umbrella header中,Swift 會通過這個文件看到所有你在 umbrella header 中公開暴露出來的頭文件。

看到這裏我們不禁有個疑問,到底什麼是umbrellaheader?其實這並非是個新鮮玩意,相反,這是早在2012年就由蘋果在LLVM DevMeeting提出並實現的概念,目的就是要顛覆傳統的頭文件引用方式。

我們知道在C/C++以及Object-C這一系列C語言家族的編程語言裏,在需要引用到其他庫的時候,通常是通過引用頭文件的方式來訪問。但這類機制有很多問題,其中最大的問題是預編譯效率不高,因爲頭文件的描述是基於文本(textual)形式的,所以預編譯器需要對其進行語義分析。由於這個過程是遞歸進行的,所以會導致編譯時間變得非常不可控,假設有N個源文件每個都有M個頭文件,那麼所帶來的編譯成本就是N x M,即便有很多頭文件是重複引用的也是如此。

所以LLVM引入Module的概念來解決這個問題,Module採用更高效的樹形結構描述來導入頭文件,整個Module只會編譯一次,頭文件也只解析一次,避免了被重複引用,這樣一來之前M x N的問題就變成了簡單的M+N。

而Module機制中一個很重要的文件就是modulemap,它是module和頭文件之間產生聯繫的關鍵,是用來描述頭文件和module結構在邏輯上的對應關係。如果一個庫(library)想要作爲module被使用,那就必須要有一個對應的“module.modulemap”文件,在這個文件中聲明要引用的頭文件,並和那些頭文件放在一起,一個C標準庫的 module map 文件可能是這樣的:

modulemap 中的內容是通過 module map 語言來實現的,module map 語言中有一些保留字,其中帶umbrella關鍵字的header申明就叫做umbrella header,作用是可以把它所在目錄下的所有頭文件都包含進來,這樣開發者中只要導入一次就可以使用這個 library 的所有 API 。

創建modulemap的方法很簡單,如果是動態庫在編譯的時候系統會自動替我們生成,如果是靜態庫則需要我們手動生成並編輯這個文件。

做到這裏不禁會聯想到目前攜程app項目內頭文件引用的災難,導致編譯效率極其低下,其實是時候用module的思路來重構一下我們的項目了,當然這又會是一項龐大的工程。

六、總結

至此,我們終於解決完了Swift在攜程app內應用的所有已知問題,讓Swift以靜態庫的形式完美集成到項目中,並可以在Swift和Objective-C之間互相調用,和攜程的CI平臺也能無縫集成。目前在實際項目中已經開始使用Swift來寫部分需求,未來的一些新功能我們也會考慮直接用Swift來開發。

在這次的實踐過程中我們領略到了Swift作爲一門先進語言的魅力,衆多的新特性讓研發效率有了顯著提高,經過我們Swift重寫的framework代碼量都有不同程度的下降。

由於篇幅和主題的原因,本文就止步於探討將Swift集成到Objc工程中的一些問題和經驗。對於Swift語言本身的一些探討有機會可以另作分享,我們相信更現代、更安全的 Swift 會變得越來越流行,希望有越來越多的開發者可以早日加入Swift的陣營。

作者介紹

睿東,2009年加入攜程,從事無線研發,現負責酒店無線研發工作。

本文轉載自公衆號攜程技術(ID:ctriptech)。

原文鏈接

https://mp.weixin.qq.com/s/N6ToEkN9c-2_rIvkv4o9hA

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章