在開發(fā)應(yīng)用時(shí),SwiftUI提高了開發(fā)效率。
SwiftUI大概可以滿足任何現(xiàn)代應(yīng)用程序需求的95%,而剩下的5%則是通過退回到以前的UI框架。
我們有兩種主要的回退方法:
- SwiftUI的
UIViewRepresentable/NSViewRepresentable - SwiftUI Introspect
什么是SwiftUI Introspect
SwiftUI Introspect是一個(gè)開源庫(kù)。它的主要目的是獲取和修改任何SwiftUI視圖的底層UIKit或AppKit元素。
這是可能的,因?yàn)樵S多SwiftUI視圖(仍然)依賴于它們的UIKit,例如:
- 在macOS中,
Button在幕后使用NSButton - 在iOS中,
TabView在幕后使用UITabBarController
我們很少需要知道這樣的實(shí)現(xiàn)細(xì)節(jié)。然而,知道這一點(diǎn)給了我們另一個(gè)強(qiáng)大的工具,我們可以在需要的時(shí)候使用。這正是SwiftUI Introspect發(fā)揮作用的地方。
SwiftUI Introspect的使用
SwiftUI Introspect在func introspectX(customize: @escaping (Y) -> ()) -> some View模式之后提供了一系列視圖修飾符,其中:
-
X是我們的目標(biāo)視圖 -
Y是底層的UIKit/AppKit視圖/視圖控制器類型
假設(shè)我們想要從ScrollView中移除彈性效果。目前,SwiftUI沒有相應(yīng)的API或修飾符允許我們這樣做。
ScrollView在底層使用UIKit的UIScrollView。我們能使用Introspect的func introspectScrollView(customize: @escaping (UIScrollView) -> ()) -> some View方法獲取底層的UIScrollView,并禁用彈性效果:
import Introspect
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Color.red.frame(height: 300)
Color.green.frame(height: 300)
Color.blue.frame(height: 300)
}
.introspectScrollView { $0.bounces = false }
}
}
}

在iOS系統(tǒng)中,用戶可以通過向下滑動(dòng)表單來關(guān)閉表單。在UIKit中,我們可以通過isModalInPresentation UIViewController屬性阻止這種行為,讓我們的應(yīng)用程序邏輯控制表單的顯示。在SwiftUI中,我們還沒有類似的方法。
同樣,我們可以使用Introspect來抓取呈現(xiàn)表UIViewController,并設(shè)置isModalInPresentation屬性:
import Introspect
import SwiftUI
struct ContentView: View {
@State var showingSheet = false
var body: some View {
Button("Show sheet") { showingSheet.toggle() }
.sheet(isPresented: $showingSheet) {
Button("Dismiss sheet") { showingSheet.toggle() }
.introspectViewController { $0.isModalInPresentation = true }
}
}
}

其他的例子:
想象一下,由于SwiftUI的一個(gè)小功能缺失,我們不得不在UIKit/AppKit中重新實(shí)現(xiàn)一個(gè)完整的復(fù)雜功能:Introspect是一個(gè)不可思議的時(shí)間節(jié)省器。
我們已經(jīng)看到了它的明顯好處:接下來,讓我們揭開SwiftUI Introspect是如何工作的。
SwiftUI Introspect如何工作的
我們將采用UIKit路徑:除了UI/NS前綴,AppKit的代碼是相同的。
為了清晰起見,本文中所示的代碼進(jìn)行了輕微的調(diào)整。最初的實(shí)現(xiàn)可以在SwiftUI Introspect的存儲(chǔ)庫(kù)中找到。
injection注入
正如上面的例子所示,Introspect為我們提供了各種視圖修飾符。如果我們看看它們的實(shí)現(xiàn),它們都遵循類似的模式。這里有一個(gè)例子:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
所有這些公共introspectX(customize:)視圖修飾符都是一個(gè)更通用的introspect(selector:customize:)的方便實(shí)現(xiàn):
extension View {
/// Finds a `TargetView` from a `SwiftUI.View`
public func introspect<TargetView: UIView>(
selector: @escaping (IntrospectionUIView) -> TargetView?,
customize: @escaping (TargetView) -> ()
) -> some View {
inject(
UIKitIntrospectionView(
selector: selector,
customize: customize
)
)
}
}
這里我們看到另一個(gè)介紹inject(_:)``View試圖修飾符,和第一個(gè)Introspect試圖,UIKitIntrospectionView:
extension View {
public func inject<SomeView: View>(_ view: SomeView) -> some View {
overlay(view.frame(width: 0, height: 0))
}
}
inject(_:)采用我們的原始視圖,并在頂部添加一個(gè)給定視圖的覆蓋層,其框架最小化。
例如,如果我們有以下視圖:
TextView(...)
.introspectTextView { ... }
最后的視圖將是:
TextView(...)
.overlay(UIKitIntrospectionView(...).frame(width: 0, height: 0))
接下來讓我們看看UIKitIntrospectionView:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
public func makeUIView(
context: UIViewRepresentableContext<UIKitIntrospectionView>
) -> IntrospectionUIView {
let view = IntrospectionUIView()
view.accessibilityLabel = "IntrospectionUIView<\(TargetViewType.self)>"
return view
}
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
UIKitIntrospectionView是Introspect到UIKit的橋梁,它做兩件事:
- 在
UIView層次結(jié)構(gòu)中注入一個(gè)IntrospectionUIView - 對(duì)
UIViewRepresentable具象的updateUIView生命周期事件做出反應(yīng)
這是IntrospectionUIView的定義:
public class IntrospectionUIView: UIView {
required init() {
super.init(frame: .zero)
isHidden = true
isUserInteractionEnabled = false
}
}
IntrospectionUIView是一個(gè)最小的、隱藏的、非交互的UIView:它的全部目的是給SwiftUI Introspect一個(gè)進(jìn)入U(xiǎn)IKit層次結(jié)構(gòu)的入口點(diǎn)。
總之,所有的.introspectX(customize:)視圖修改器覆蓋了一個(gè)微小的,不可見的,非交互的視圖在我們的原始視圖之上,確保它不會(huì)影響我們最終的UI。
實(shí)現(xiàn)原理
我們已經(jīng)看到了SwiftUI Introspect是如何獲取UIKit層次結(jié)構(gòu)的。庫(kù)剩下要做的就是找到我們要找的UIKit視圖或視圖控制器。
回到UIKitIntrospectionView的實(shí)現(xiàn)中,神奇的事情發(fā)生在updateUIView(_:context)中,這是UIViewRepresentable生命周期方法:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
...
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
在UIKitIntrospectionView的例子中,這個(gè)方法主要在兩個(gè)場(chǎng)景中被SwiftUI調(diào)用:
- 當(dāng)
IntrospectionUIView即將被添加到視圖層次結(jié)構(gòu)時(shí) - 當(dāng)
IntrospectionUIView要從視圖層次結(jié)構(gòu)中移除時(shí)
asyncdispatch有兩個(gè)函數(shù):
- 如果方法被調(diào)用時(shí),視圖將被添加到視圖層次結(jié)構(gòu),我們需要等待當(dāng)前runloop周期完成之前,我們的觀點(diǎn)是說(到視圖層次),那時(shí),也只有到那時(shí),我們就可以開始尋找我們的目標(biāo)視圖
- 如果在視圖即將從視圖層次結(jié)構(gòu)中刪除時(shí)調(diào)用該方法,則等待runloop循環(huán)完成可確保視圖已被刪除(從而使搜索失敗)
當(dāng)SwiftUI觸發(fā)updateUIView(_:context)這個(gè)方法時(shí),UIKitIntrospectionView調(diào)用selector方法我們從最初的便利修飾符實(shí)現(xiàn)中繼承過來的方法:
selector有一個(gè)(IntrospectionUIView) -> TargetViewType?方法簽名。它接受Introspect的IntrospectionUIView的視圖作為輸入,并返回一個(gè)可選的TargetViewType,這是我們想要達(dá)到的原始視圖或視圖控制器類型的通用表示。
如果搜索成功,我們就調(diào)用customize,這是我們?cè)谝晥D上應(yīng)用Introspect的視圖修改器時(shí)傳遞或定義的方法,從而對(duì)底層的UIKit/AppKit視圖或視圖控制器進(jìn)行更改。
回到我們的introspectTextView(customize:)示例,我們通過TargetViewSelector.siblingContaining來傳遞selector選擇器:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
TargetViewSelector是一個(gè)Swift的enum類型,使它成為一個(gè)靜態(tài)方法的容器,意味著可以直接調(diào)用,所有的TargetViewSelector方法都或多或少的遵循相同的模式,像我們的siblingContaing(from:):
public enum TargetViewSelector {
public static func siblingContaining<TargetView: UIView>(from entry: UIView) -> TargetView? {
guard let viewHost = Introspect.findViewHost(from: entry) else {
return nil
}
return Introspect.previousSibling(containing: TargetView.self, from: viewHost)
}
...
}
第一步是找到一個(gè)視圖的持有者(宿主):
SwiftUI將每個(gè)UIViewRepresentable視圖包裝在一個(gè)宿主視圖中,與PlatformViewHost<PlatformViewRepresentableAdaptor<IntrospectionUIView>>有關(guān)的,然后封裝到一個(gè)類型為_UIHostingView的“托管視圖”中,表示一個(gè)能夠托管SwiftUI視圖的UIView。
為了獲得視圖持有者,Introspect從另一個(gè)無(wú)Introspect enum中使用findViewHost(from:)靜態(tài)方法:
enum Introspect {
public static func findViewHost(from entry: UIView) -> UIView? {
var superview = entry.superview
while let s = superview {
if NSStringFromClass(type(of: s)).contains("ViewHost") {
return s
}
superview = s.superview
}
return nil
}
...
}
這個(gè)方法從我們的IntrospectionUIView開始,遞歸地查詢每個(gè)superview父視圖,直到找到一個(gè)視圖持有者:如果我們找不到視圖宿主,我們的IntrospectionUIView還不是屏幕層次結(jié)構(gòu)的一部分,我們的查找會(huì)立即停止。
一旦我們有了視圖的宿主,我們有了尋找目標(biāo)視圖的起點(diǎn),這就是TargetViewSelector.siblingContaing做的通過下面的Introspect.previousSibling(containing: TargetView.self, from: viewHost)命令:
enum Introspect {
public static func previousSibling<AnyViewType: UIView>(
containing type: AnyViewType.Type,
from entry: UIView
) -> AnyViewType? {
guard let superview = entry.superview,
let entryIndex = superview.subviews.firstIndex(of: entry),
entryIndex > 0
else {
return nil
}
for subview in superview.subviews[0..<entryIndex].reversed() {
if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
這個(gè)新的靜態(tài)方法接受所有viewHost的父視圖的子視圖(也就是viewHost的兄弟視圖),過濾在viewHost之前的子視圖,然后遞歸地搜索我們的目標(biāo)視圖(作為type參數(shù)傳遞),從最近的到最遠(yuǎn)的兄弟視圖,通過最終的findChild(ofType:in:)方法:
enum Introspect {
public static func findChild<AnyViewType: UIView>(
ofType type: AnyViewType.Type,
in root: UIView
) -> AnyViewType? {
for subview in root.subviews {
if let typed = subview as? AnyViewType {
return typed
} else if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
這個(gè)方法,通過傳遞我們的目標(biāo)視圖和一個(gè)我們的viewHost兄弟調(diào)用,將遍歷每個(gè)兄弟完整子樹視圖層次結(jié)構(gòu),尋找我們的目標(biāo)視圖,并返回第一個(gè)匹配的對(duì)象,如果有的話。
分析
既然我們已經(jīng)揭示了SwiftUI Introspect的所有內(nèi)部工作原理,那么回答常見的問題就容易多了:
它使用安全嗎?
只要我們不做太大膽的事,是安全的。重要的是要明白我們并不擁有底層的AppKit/UIKit視圖,而SwiftUI擁有。通過Introspect應(yīng)用的更改應(yīng)該可以工作,但是SwiftUI可能會(huì)在不通知的情況下隨意覆蓋它們。
這是未來的趨勢(shì)嗎?
不。隨著SwiftUI的發(fā)展,當(dāng)新的操作系統(tǒng)版本出現(xiàn)時(shí),情況可能會(huì)發(fā)生變化。當(dāng)這種情況發(fā)生時(shí),庫(kù)會(huì)更新新的補(bǔ)丁,但是我們的用戶需要在看到修復(fù)之前更新應(yīng)用程序。
我們應(yīng)該使用它嗎?
答案可能是肯定的。任何讀過這篇文章的人都完全了解庫(kù)是如何工作的:如果有什么東西壞了,我們應(yīng)該知道去哪里找并找到解決辦法。
SwiftUI Introspect的亮點(diǎn)在哪里?
向后兼容性。例如,讓我們想象一下,iOS15 List引入了下拉刷新的功能:我們知道SwiftUI Introspect允許我們?cè)趇OS13和14中添加列表下拉刷新(Introspect方法設(shè)置下拉刷新)。到那時(shí),我們可以使用Introspect針對(duì)舊的操作系統(tǒng)版本,并使用新的SwiftUI方式針對(duì)iOS15或更高版本。
這樣做可以保證不會(huì)出現(xiàn)問題,因?yàn)樾碌牟僮飨到y(tǒng)版本將使用SwiftUI的“原生”方法,只有過去的iOS版本才會(huì)使用Introspect。
什么時(shí)候不用SwiftUI Introspect?
當(dāng)我們想要完全控制一個(gè)視圖,并且無(wú)法承受與新OS版本的沖突時(shí):如果這是我們的情況,使用UIViewRepresentable/NSViewRepresentable會(huì)更安全、更有前瞻性。當(dāng)然,我們應(yīng)該總是盡可能地先找到一個(gè)“純粹的”SwiftUI方法,只有當(dāng)我們確信這是不可能的時(shí)候,才去尋找替代方法。
結(jié)論
SwiftUI Introspect是為數(shù)不多的可能是任何SwiftUI應(yīng)用程序必須擁有的庫(kù)之一。它的執(zhí)行優(yōu)雅、安全,它的優(yōu)點(diǎn)遠(yuǎn)遠(yuǎn)大于將其作為依賴項(xiàng)添加的缺點(diǎn)。
當(dāng)向我們的項(xiàng)目添加一個(gè)依賴項(xiàng)時(shí),我們應(yīng)該盡可能地理解這個(gè)依賴項(xiàng)是做什么的,我希望這篇文章能幫助你做到這一點(diǎn)。