在 Swift 中如何正確傳遞 Unsafe Pointers 參數(shù)
TL;DR
- Swift 中對于類型大小為空的變量使用
&
取地址是未定義行為,編譯為目標(biāo)碼之后的體現(xiàn)為一個根據(jù)之前代碼執(zhí)行結(jié)果產(chǎn)生的任意數(shù)值。這是一個 feature。 - Swift 中在多個線程中對同一個變量使用
&
將獲取「寫訪問」,會造成運行時崩潰。 - Swift 中對 computed property 取地址會取到臨時變量的地址。如果 computed property 是一個鎖,將造成鎖被拷貝到多個線程的執(zhí)行棧上,造成程序錯誤。
平平無奇但錯誤的代碼
在過去一個季度抖音規(guī)?;涞?Swift 組件的過程中,我負責(zé)的代碼在 CI 運行單測階段暴露了幾個問題,都與 Swift 中的 unsafe pointers 有關(guān)。
第一個是通過 Objective-C 中 associated object 技巧擴展出來的 property 在 release build 后再運行,set 之后只能 get 到 nil
;debug build 下則正常:
范例代碼一
第二個是下列代碼在 release build 后,在多線程環(huán)境有可能崩潰在 swift_endAccess
函數(shù)中:
@_implementationOnly import Darwin
public class UnfairLock {
var _lock: os_unfair_lock
public func withLock<R>(perform action: () -> R) -> R {
os_unfair_lock_lock(&_lock)
defer {
os_unfair_lock_unlock(&_lock)
}
return action()
}
public init() {
_lock = os_unfair_lock()
}
}
范例代碼二
是不是覺得很奇怪?以上兩段代碼既符合直覺,也沒有編譯錯誤。那么,為什么會產(chǎn)生上述問題呢?
歸因:ObjC 關(guān)聯(lián)對象訪存出錯
針對范例代碼一里面 ObjC associated object 訪存得到錯誤結(jié)果的問題,我們可以進入?yún)R編模式,看看到底 objc_get(set)AssociatedObject
得到的參數(shù)是什么。首先打開 Xcode 的 Always Show Disassembly(看完文章后記得關(guān)閉哦),在 objc_getAssociatedObject
和 objc_setAssociatedObject
打下斷點。
運行后我們可以看到 objc_setAssociatedObject
的 key
這個參數(shù)(arm64 上的 x1
寄存器)的值是 0x04000001ed295c71
,這個地址存儲的值是 0x00000001ed295c71
。我們預(yù)期通過 dis -s
指令對這個地址進行反匯編可以獲得該地址對應(yīng)的二進制鏡像名稱,然而這里卻提示「反匯編失敗」。這是為什么呢?
我們可以進一步檢查 x1
寄存器內(nèi)容的來源。下圖紅線為 x1
寄存器在 MyObject.myProperty.setter
(下稱 setter 函數(shù))內(nèi)的數(shù)據(jù)流??梢钥吹剑?/p>
- setter 函數(shù)初始棧高為
0x20
(sub sp, sp, #0x20
) x1
為 setter 函數(shù)執(zhí)行?;鶞?zhǔn)地址 +0x40
-0x48
= setter 函數(shù)執(zhí)行棧基準(zhǔn)地址 -0x8
。所以對應(yīng) setter 函數(shù)執(zhí)行棧上0x18
偏移的棧變量
而 setter 函數(shù)開頭調(diào)用的 Optional<Any>
的拷貝初始化函數(shù) outlined init with copy of Swift.Optional<Any>
的第二個參數(shù) x1
為 setter 函數(shù)執(zhí)行棧基準(zhǔn)地址 + 0x40
- 0x60
= setter 函數(shù)執(zhí)行?;鶞?zhǔn)地址 - 0x20
。結(jié)合之前獲得的 setter 函數(shù)執(zhí)行棧高 0x20
的信息,所以對應(yīng) setter 函數(shù)執(zhí)行棧上 0x0
偏移的棧變量。
于是我們可以知道,setter 函數(shù)執(zhí)行?;鶞?zhǔn)地址后的所有空間都有可能被 Optional<Any>
的拷貝初始化函數(shù)利用到。而實際上這個拷貝初始化函數(shù)的第二個參數(shù)就是拷貝操作的目標(biāo)地址。加上上圖中藍線的原點在判別完 x21
的內(nèi)容(即 objc_setAssociatedObject
接受到的 x1
)之后進行了條件跳轉(zhuǎn)(cbz,即 conditional branching if zero 的縮寫),跳過的內(nèi)容正是把 Any
橋接到 Objective-C(因為中途有調(diào)用 Swift._bridgeAnythingToObjectiveC
),所以我們可以大膽猜測:
x21
—— 即 objc_setAssociatedObject
接受到的 x1
,實際上是可以判斷 Optional<Any>
為空的信息——而這個并不是我們在源碼中給定的 key
——這就是導(dǎo)致我們使用 objc_get(set)AssociatedObject 不正常工作的原因。而我們一開 dis -s
之所以會失敗,是因為我們在嘗試對函數(shù)執(zhí)行棧進行反匯編。
深入:Void 全局變量編譯細節(jié)
為了了解更多代碼生成細節(jié),知道為什么生成了這樣的代碼,我們可以使用 Swift 編譯器的 -emit-sil(gen)
和 -emit-ir(gen)
參數(shù)來考察 SIL(原始 SIL)和 IR(原始 IR)的生成結(jié)果,看到底是哪一步產(chǎn)生了意外。檢查的順序應(yīng)該 -emit-silgen, -emit-sil, -emit-irgen, -emit-ir,確保先檢查原始 SIL(SILGen)和原始 IR(IRGen),再檢查優(yōu)化后的 SIL 和 IR。檢查 SILGen 的命令如下:
// 這里我保存的文件叫 UnsafePointers.swift
// 我的 Xcode 放置的路徑是 /Applications/Xcode-15.0.app
// 大家可以根據(jù)自己的情況對以下命令進行修改:
xcrun swift-frontend -c UnsafePointers.swift \
-enable-objc-interop \
-target arm64-apple-macos14.0 \
-sdk /Applications/Xcode-15.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk \
-emit-silgen > UnsafePointers.silgen.sil
上文的 setter 函數(shù)在 SILGen 中生成了三個區(qū)塊:bb0
是函數(shù)入口,在 363 行進行 switch-case 之后跳轉(zhuǎn)至 bb1
或 bb2
。但不論是 bb1
還是 bb2
,最后都會跳轉(zhuǎn)至 bb3
(如下圖藍線所示)。所以我們直接看 bb3
好了。
所以我們直接折疊 bb1
和 bb2
,然后可以畫出 objc_setAssociatedObject
第二個參數(shù) %32
的數(shù)據(jù)流??梢钥吹狡渥罱K來自于 %2
,而 %2
會對一個全局 Swift 符號取地址。
而這個符號正是我們定義的 myKey
。
因為 global_addr
是 SIL 指令,已經(jīng)是 SIL 這一層的「原語(最小不可分割語素)」了,所以我們應(yīng)該進一步查看 IRGen 的結(jié)果。
xcrun swift-frontend -c UnsafePointers.swift \
-enable-objc-interop \
-target arm64-apple-macos14.0 \
-sdk /Applications/Xcode-15.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk \
-emit-irgen > UnsafePointers.irgen.ll
在 IRGen 結(jié)果中,我們可以直接搜索 MyObject.myProperty.setter
的 Swift 改編符號(如果你也使用 UnsafePointers.swift 這個文件名那么就是 $s14UnsafePointers8MyObjectC10myPropertyypSgvs
)。我們可以看到,517 行給 objc_setAssociatedObject
的第二個參數(shù)已經(jīng)變成了 undef
。
為了進一步探究 SIL 中的 global_addr
指令為何在 lower 到 IR 之后會得到 undef
,我們可以動態(tài)調(diào)試一下 Swift 編譯器。這里我們使用簡化后的代碼以加速編譯器調(diào)試。因為 foo
是全局變量,所以第 4 行的 &foo
仍然會生成 global_addr
指令。
var foo: Void = Void()
func bar() -> UnsafeRawPointer {
UnsafeRawPointer(&foo)
}
然后我們在 IRGenSILFunction::visitGlobalAddrInst
中打下斷點,編譯上面的源代碼,從 DEBUG CONSOLE 中的 i-> dump()
結(jié)果可以看到,此次 GlobalAddrInst
實例 i
的內(nèi)容為被 &
引用的變量。最后代碼執(zhí)行進入了 2950 行,這里可以看到針對該全局變量的 ti: TypeInfo&
,如果其 isKnownEmpty
返回 true
就不會生成符號,因此地址是 undef
。這是一個 feature。
而 Swift 編譯器中的 TypeInfo
類型負責(zé)記載類型信息對應(yīng)信息。對于 TypeInfo::isKnownEmpty
而言,簡單來說如果可以在編譯器在編譯時可以確定大小的類型,并且大小為空,即可認為其會返回 true
。
歸因:多線程下取地址崩潰
要理解文章開頭范例代碼二中所出現(xiàn)的「多線程下對實例變量取地址」而導(dǎo)致的崩潰,更直接的方法是打開 Xcode 的 Thread Sanitizer 后運行程序。我們可以看到,Xcode 幫我們檢測出了「訪問競爭(access race)」。這是因為在 Swift 中對變量使用 & 即意味著需要獲取一個「寫訪問(write access)」,而目前的代碼有多個線程在訪問 UnfairLock.withLock
,那么也就有多個線程在嘗試對 UnfairLock._lock
獲取「寫訪問」。這個在 Swift 中不符合運行時 exclusivity enforcement,所以會崩潰。
想要修復(fù)這個問題,將獲得指針的時機移動至多線程代碼外(即 x.testLock
外)即可。
但是 exclusivity 沖突并不是這個寫法的全部問題,這個寫法還有一個非常隱蔽的問題:&
可能取到的不是變量本身的地址,而是一個臨時分配的變量。要理解這個問題,需要知道 Swift 是如何實現(xiàn)變量取地址的。
深入:Swift 變量取地址實現(xiàn)
在 Swift 中 var
關(guān)鍵字定義的變量滿足以下抽象:
- 一定包含一個
get
accessor - 可選包含一個
set
accessor - 可選包含一個存儲容器
然而,上面只是開發(fā)者在日常開發(fā)中能夠感知到的部分。上述抽象沒有解決的問題是:如果一個變量的存儲容器是可選的,那么我們應(yīng)該如何獲得這個變量的地址呢?所以編譯器還會為我們自動生成:
- 一定包含一個
_read
accessor - 可選包含一個
_modify
accessor
其中 _read
accessor 是一個對變量產(chǎn)生「讀訪問」,并且拋出一個只讀地址的協(xié)程。
而 _modify
accessor 是一個對變量產(chǎn)生「寫訪問」,并且拋出一個可寫地址的協(xié)程。
而通過上述 _read
和 _modify
accessor,我們就定義了獲取 var
關(guān)鍵字變量地址的手段。
「協(xié)程」可以理解為不保證棧平衡的函數(shù)(或稱「過程」)。協(xié)程本是過程的原始形態(tài)——過程引入棧平衡是為了實現(xiàn)本地變量,而這個特性在協(xié)程中無法實現(xiàn)。但是人們后來發(fā)現(xiàn)非平衡的??梢宰尯罄m(xù)執(zhí)行的代碼沿用之前的棧內(nèi)存內(nèi)容,而不用重復(fù)在棧上傳參,又或者開辟堆空間傳參,所以人們又開始利用起了「協(xié)程」。Swift 引入?yún)f(xié)程的目的也是做性能優(yōu)化。 需要注意的是 Swift Concurrency 并不是協(xié)程。
而在 Swift 中對變量使用 &
本質(zhì)上就是獲取 _modify
accessor 拋出的「變量可寫地址」。
我們可以從 SILGen 的結(jié)果中一窺究竟。
下面是 UnfairLock.withLock(perform:)
在未修改前的 SILGen 結(jié)果,紅色的線和方框代表了 &_lock
這句 Swift 源碼在 SIL 層面的數(shù)據(jù)流。
我們可以看到 &_lock
最初來自于 %6
,而 %6
正是對 self (%2)
使用了 #UnfiarLock.lock!modify
這個協(xié)程產(chǎn)生的,同時產(chǎn)生的 %7
則是協(xié)程產(chǎn)生的非平衡棧的 resumption 函數(shù),由 89 行的 endApply
調(diào)用,用以恢復(fù)棧平衡。
而當(dāng)前實現(xiàn)的 UnfiarLock.lock._modify
則是通過 ref_element_addr
這條 SIL 指令直接拋出了 UnfairLock.lock
在當(dāng)前 self
中的地址。
但是,當(dāng)情況變得復(fù)雜一些的時候,這個 _modify
accessor 拋出的將會變成一個臨時變量的地址——對,就是這個協(xié)程在非平衡棧里面分配的臨時變量。要造成這個結(jié)果很簡單:比如,把 os_unfair_lock
打包放入一個叫做 Data
的類型中,然后 var _lock: os_unfair_lock
改成 computed property,從 Data
中訪存 os_unfair_lock
:
private struct Data {
var lock = os_unfair_lock()
}
public class UnfairLock {
private var data = Data()
internal var _lock: os_unfair_lock {
get {
data.lock
}
set {
data.lock = newValue
}
}
public func withLock<R>(perform action: () -> R) -> R {
os_unfair_lock_lock(&_lock)
defer {
os_unfair_lock_unlock(&_lock)
}
return action()
}
public init() {
}
}
我們可以看到,此時 UnfairLock._lock.modify
的 SILGen 結(jié)果中出現(xiàn)了棧分配(683 行),然后分配后的棧地址內(nèi)又被 store
(復(fù)制)了 UnfairLock._lock
(687 行),隨后棧分配后的地址被 _modify
拋出,爾后在 _modify
的 resumption 函數(shù)(bb1
和 bb2
)中,棧地址中的內(nèi)容又被重新 set 回了 UnfairLock._lock
(694 行及 703 行)。
上面這種行為在多線程場景就是災(zāi)難性質(zhì)的——因為每一個線程都有自己獨立的執(zhí)行棧,而這種行為就是把一個鎖復(fù)制到了每一個在競爭這把鎖的線程的執(zhí)行棧上再加解鎖——最后的結(jié)果一定是程序出錯。
深入:理解取地址中的臨時變量
要理解對 var
取地址時可能取到臨時變量的地址,還是需要回到 Swift 對 var
關(guān)鍵字定義的變量的抽象:
- 一定包含一個
get
accessor - 可選包含一個
set
accessor - 可選包含一個存儲容器
而編譯器幫助合成 _read
或者 _modify
時,如果變量沒有實際存儲容器,那么也只能通過 get
和 set
實現(xiàn):當(dāng)出現(xiàn) computed property 時,其 _modify
會先通過 get
accessor 創(chuàng)建一個臨時變量,拋出臨時變量地址之后,在 resumption 時再使用 set
accessor 寫回這種實現(xiàn)了。
所以,如果開發(fā)者書寫如下代碼:
struct Foo {
var _bar: Int = 0
var bar: Int {
get {
self._bar
}
set {
self._bar = newValue
}
}
}
var foo = Foo()
withUnsafeMutablePointer(to: &foo, body: handleIntPtr)
func handleIntPtr(_ ptr: UnsafeMutablePointer<Int>) {
// ...
}
那么實際上編譯器會幫助合成并生成如下代碼:
struct Foo {
var bar: Int {
// ...
_read { // 編譯器合成代碼
let tempFoo = 棧分配
tempFoo = self.bar.getter
拋出不可變棧地址 tempFoo
}
_read.resumption { // 編譯器合成代碼
棧析構(gòu) tempFoo
}
_modify { // 編譯器合成代碼
var tempFoo = 棧分配
tempFoo = self.bar.getter
拋出可變棧地址 tempFoo
}
_modify.resumption { // 編譯器合成代碼
self.bar.setter = tempFoo
棧析構(gòu) tempFoo
}
}
}
var foo = Foo()
// 棧分配 tempFoo 以及隱式地址到指針轉(zhuǎn)換
let ptrToTempFoo: UnsafeMutablePointer<Int> = Foo.bar._modify(foo)
// 應(yīng)用 withUnsafeMutablePointer 的 body 閉包
handleIntPtr(ptrToTempFoo)
// 將臨時變量 set 回去,并完成 tempFoo 棧析構(gòu)
Foo.bar._modify.resumption(foo)
知道這一點之后,我們也可以嘗試手寫 UnfairLock._lock._modify
的實現(xiàn),直接拋出 data.lock
的地址,來消除臨時變量:
public class UnfairLock {
private var data = Data()
internal var _lock: os_unfair_lock {
_read {
yield data.lock
}
_modify {
yield &data.lock
}
}
// ...
}
此時我們可以通過 SILGen 的結(jié)果看到:UnfairLock._lock.modify
目前會委托到 UnfairLock.data.modify
(84 行),然后利用 UnfairLock.data.modify
的結(jié)果,然后取出 Data.lock
的地址(85 行),最后拋出(86 行):
而 UnfairLock.data.modify
則是直接拋出了 UnfairLock.data
在當(dāng)前 self
下的地址(第 61 行)。
上述技巧繞過了「使用 var
關(guān)鍵字變量的 get 和 set」來實現(xiàn) _modify
,同時也產(chǎn)生了一個「副作用」。大家能想到是什么嗎?
最佳實踐及準(zhǔn)入建設(shè)
在實際的日常開發(fā)活動中,我們并不想耗費如此多的心智在如何處理好給 unsafe pointers 傳參上,所以我們需要一套最佳實踐以及自動化準(zhǔn)入機制來保證我們的日常開發(fā)的執(zhí)行結(jié)果。
上述問題可以歸結(jié)為:
- 對
Void
取地址出現(xiàn)無意義數(shù)值 - 多線程使用
&
對變量取地址后崩潰 - 對 computed property 取地址得到的是臨時變量地址
下面我們分情況討論
ObjC 關(guān)聯(lián)對象訪存出錯
前文「ObjC 關(guān)聯(lián)對象訪存結(jié)果出錯」的本質(zhì)是:Swift 中對于空大小類型的全局變量取地址編譯到 LLVM IR 后是 undef
的,編譯為目標(biāo)碼之后的體現(xiàn)為一個根據(jù)之前代碼執(zhí)行結(jié)果產(chǎn)生的任意數(shù)值。所以「對大小為空的類型的全局變量取地址」本身就是一個未定義行為。這里的最佳實踐就是不允許這樣做。在準(zhǔn)入建設(shè)方面,我們可以設(shè)立靜態(tài)分析規(guī)則進行檢出。依據(jù)公司現(xiàn)有靜態(tài)分析設(shè)施,我們可以分析:1)標(biāo)準(zhǔn)庫以及2)文件內(nèi)定義的空大小類型,并且3)有選擇地加入系統(tǒng)庫的類型定義。
多線程下取地址崩潰
前文「多線程下對實例變量取地址發(fā)生崩潰」的本質(zhì)是:Swift 中,在多個線程中對同一個變量獲取「寫訪問」會引發(fā)「訪問競爭」,這是 Swift 運行時在開啟 runtime exclusivity enforcement 之后所不允許的。相關(guān)最佳實踐應(yīng)該是將「寫訪問」提出多線程代碼:
比如下列代碼通過 DispatchQueue.concurrentPerform
這個并發(fā)執(zhí)行的接口,在多個線程中通過 &
取地址對同一個變量 counter
獲取了「寫訪問」:
// ?
var counter = AtomicIntStorage() // zero init
DispatchQueue.concurrentPerform(iterations: 10) { _ in
for _ in 0 ..< 1_000_000 {
atomicFetchAddInt(&counter, 1) // Exclusivity violation
}
}
print(atomicLoadInt(&counter) // ???
我們可以將相關(guān)代碼提取出 DispatchQueue.concurrentPerform
的尾閉包:
// ?
var counter = AtomicIntStorage() // zero init
withUnsafeMutablePointer(to: &counter) { pointer in
DispatchQueue.concurrentPerform(iterations: 10) { _ in
for _ in 0 ..< 1_000_000 {
atomicFetchAddInt(pointer, 1) // OK
}
}
print(atomicLoadInt(pointer) // 10_000_000
}
但是上面是發(fā)生在極其局部的問題。泛化而言,面對運行時訪問競爭,蘋果使用 SIL 這種檢測能力很強的靜態(tài)檢測手段亦無法檢出,我們即可判斷:對于運行時的行為,我們需要運行時的設(shè)施進行檢測,所以我們需要研發(fā)流程和準(zhǔn)入同時進行調(diào)整:
- 研發(fā)流程中加入研判是否需要設(shè)計多線程測試用例的步驟;如果需要設(shè)計,則需要單獨在評審時提供多線程測試用例
- 準(zhǔn)入調(diào)整為要求單元測試覆蓋率 100%
- 準(zhǔn)入調(diào)整為在 CI 單測流水線在 Xcode test plan 中開啟 thread sanitizer,CI 消費 thread sanitizer 檢出結(jié)果
取地址得到臨時變量地址
前文「對變量取地址有可能取到臨時地址」的本質(zhì)是:Swift 中對 computed property 的默認實現(xiàn)取地址過程依賴 get
和 set
來分配臨時變量,然后再通過 set
設(shè)置回去。其在多線程中產(chǎn)生的后果是:鎖在各個線程的執(zhí)行過程中被 get
多份至各線程的執(zhí)行棧上;在普遍的代碼中產(chǎn)生的后果是:取地址的結(jié)果不穩(wěn)定。
所以這里最佳實踐也應(yīng)該分情況討論:
- 對于使用鎖的需求而言,蘋果的思路是提供封裝好的鎖,而不是鼓勵使用原始鎖(如
os_unfair_lock
或者pthread_mutex
)。但是蘋果在 iOS 16 才想起這件事,所以這里我們需要自行封裝好沒問題的鎖,并且鼓勵開發(fā)者只使用封裝好的鎖。
// 下列代碼是蘋果的封裝
enum MyState {
case idle
case loading
case complete(MyAsset)
case error(Error)
}
let protectedState = OSAllocatedUnfairLock(initialState: MyState.idle)
func myLoadMethod() {
protectedState.withLock { state in
state = .loading
}
var (resource, error) = loadMyResources()
if resource != nil {
protectedState.withLock { state in
state = .complete(resource)
}
} else {
protectedState.withLock { state in
state = .error(error!)
}
}
}
- 對于其他需求,這里最佳實踐應(yīng)該是:
- 禁用對
get
/set
實現(xiàn)的 computed property 取地址。 - 如果是對 stored property 取地址,也應(yīng)該使用左值存儲指針,而不是直接使用右值送參。且 stored property 及存儲指針的左值的生命周期可以涵蓋使用指針代碼的生命周期——比如 stored property 及存儲指針的左值是一個全局變量。
// ?
private var myKey: Int8 = 0
objc_getAssociatedObject(self, &myKey) // &myKey 是一個右值
extension NSObject {
var myProperty: Any? {
get {
objc_getAssociatedObject(self, &myKey)
}
set {
objc_setAssociatedObject(
self,
&myKey,
newValue,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
}
// ?
// 全局變量
private var myKey: Int8 = 0
// 全局變量,myKeyPtr 是一個左值。
private let myKeyPtr = UnsafeRawPointer(withUnsafeMutablePointer(to: &myKey) {$0})
// 通常來說 withUnsafeMutablePointer 獲得的指針值只保證在尾閉包中有效
// 這里利用了 myKey 是全局變量,生命周期貫穿全 app 啟動關(guān)閉
// 所以即使 return 了 withUnsafeMutablePointer 中尾閉包的指針值也沒有問題
extension NSObject {
var myProperty: Any? {
get {
objc_getAssociatedObject(self, myKeyPtr)
}
set {
objc_setAssociatedObject(
self,
myKeyPtr,
newValue,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
}
在準(zhǔn)入方面,我們可以:
- 通過靜態(tài)檢測對「在 Swift 中使用原始鎖」這種行為進行攔截,并不再鼓勵直接使用原始鎖(如
os_unfair_lock
或者pthread_mutex
);對自己開發(fā)水平自信的開發(fā)者依然可以使用原始鎖,但是相關(guān)代碼需要單獨豁免。 - 通過靜態(tài)檢測對「Swift 中 & 取地址之后作為右值使用」這種行為進行攔截,攔截后建議修復(fù)為使用左值存儲指針值后再使用
- 通過靜態(tài)檢測對「向 computed property 取地址」這種行為進行攔截。
總結(jié)
上述問題的本質(zhì)、后果總結(jié)如下
上述最佳實踐及處置手段總結(jié)如下:
作者介紹:
- 李禹龍,2020 年加入字節(jié)跳動,來自抖音 iOS 基礎(chǔ)技術(shù)團隊,專注 Swift 語言及 UI DSL 框架。