原創:有趣知識點摸索型文章
創作不易,請珍惜,之后會持續更新,不斷完善
個人比較喜歡做筆記和寫總結,畢竟好記性不如爛筆頭哈哈,這些文章記錄了我的IOS成長歷程,希望能與大家一起進步
溫馨提示:由于簡書不支持目錄跳轉,大家可通過command + F 輸入目錄標題后迅速尋找到你所需要的內容
目錄
- 一、聲明式的界面開發方式
- 二、預覽
- 三、some關鍵詞的解釋
- 四、ViewBuilder的解釋
- 五、鏈式調用修改 View 的屬性原理
- 六、List的解釋
- 七、@State的解釋
- 八、Animating的解釋
- 九、生命周期
- Demo
- 參考文獻
簡介
SwiftUI
的最低支持的版本是iOS 13
,可能想要在實際項目中使用,還需要等待一兩年時間。在 view
的描述表現力上和與 app 的結合方面,SwiftUI
要勝過 Flutter
和 Dart
的組合很多。Swift
雖然開源了,但是 Apple
對它的掌控并沒有減弱。Swift
的很多特性幾乎可以說都是為了SwiftUI
量身定制的。
另外,Apple
在背后使用Combine.framework
這個響應式編程框架來對 SwiftUI.framework
進行驅動和數據綁定,相比于現有的RxSwift/RxCocoa
或者是ReactiveSwift
的方案來說,得到了語言和編譯器層級的大力支持。
一、聲明式的界面開發方式
描述「UI 應該是什么樣子」而不再是用一句句的代碼來指導「要怎樣構建 UI」。比如傳統的 UIKit
,我們會使用這樣的代碼來添加一個 Hello World
的標簽,它負責創建 label
,設置文字并將其添加到 view
上。
func viewDidLoad()
{
super.viewDidLoad()
let label = UILabel()
label.text = "Hello World"
view.addSubview(label)
// 省略了布局的代碼
}
而相對起來,使用SwiftUI
我們只需要告訴SDK
需要一個文字標簽。
var body: some View
{
Text("Hello World")
}
接下來,框架內部讀取這些view
的聲明,負責將它們以合適的方式繪制渲染。注意,這些 view
的聲明只是純數據結構的描述,而不是實際顯示出來的視圖,因此這些結構的創建并不會帶來太多性能損耗。相對來說,將描述性的語言進行渲染繪制的部分是最慢的,這部分工作將交由框架以黑盒的方式為我們完成。
如果 View
需要根據某個狀態 (state)
進行改變,那我們將這個狀態存儲在變量中,并在聲明view
時使用它。
@State var name: String = "Tom"
var body: some View
{
Text("Hello \(name)")
}
狀態發生改變時,框架重新調用聲明部分的代碼,計算出新的view
聲明,并和原來的 view
進行比較,之后框架負責對變更的部分進行高效的重新繪制。
二、預覽
SwiftUI
的 Preview
是 Apple
用來對標 Flutter
的 Hot Reloading
的開發工具。Xcode
將對代碼進行靜態分析 (得益于 SwiftSyntax 框架),找到所有遵守 PreviewProvider
協議的類型進行預覽渲染。另外,你可以為這些預覽提供合適的數據,這甚至可以讓整個界面開發流程不需要實際運行 app 就能進行。
這套開發方式帶來的效率提升相比 Hot Reloading
要更大。Hot Reloading
需要你有一個大致界面和準備相應數據,然后運行 app
,停在要開發的界面,再進行調整。如果數據狀態發生變化,你還需要restart app
才能反應。SwiftUI 的 Preview
相比起來,不需要運行app
并且可以提供任何的假數據,在開發效率上更勝一籌。
三、some關鍵詞的解釋
struct ContentView: View
{
var body: some View
{
Text("Hello World")
}
}
一眼看上去可能會對 some
比較陌生,為了講明白這件事,我們先從 View
說起。View
是 SwiftUI
的一個最核心的協議,代表了一個屏幕上元素的描述。這個協議中含有一個associatedtype
。
public protocol View : _View
{
associatedtype Body : View
var body: Self.Body { get }
}
這種帶有 associatedtype
的協議不能作為類型來使用,而只能作為類型約束使用。
Error
func createView() -> View
{
...
}
OK
func createView<T: View>() -> T
{
...
}
想要 Swift
幫助自動推斷出 View.Body
的類型的話,我們需要明確地指出body
的真正的類型。在這里,body
的實際類型是 Text
。
struct ContentView: View
{
var body: Text
{
Text("Hello World")
}
}
當然我們可以明確指定出 body
的類型,但是這帶來一些麻煩。每次修改body
的返回時我們都需要手動去更改相應的類型。新建一個View
的時候,我們都需要去考慮會是什么類型。
其實我們只關心返回的是不是一個 View
,而對實際上它是什么類型并不感興趣。some View
這種寫法使用了 Swift
的新特性 Opaque return types
。它向編譯器作出保證,每次 body
得到的一定是某一個確定的遵守 View
協議的類型,但是請編譯器網開一面,不要再細究具體的類型。返回類型確定單一這個條件十分重要,比如,下面的代碼也是無法通過的。
var body: some View
{
if someCondition
{
// 這個分支返回 Text
return Text("Hello World")
}
else
{
// 這個分支返回 Button,和 if 分支的類型不統一
return Button(action: {}) {
Text("Tap me")
}
}
}
這是一個編譯期間的特性,在保證associatedtype protocol
的功能的前提下,使用 some
可以抹消具體的類型。
四、ViewBuilder的解釋
創建 Stack
的語法很有趣。
VStack(alignment: .leading)
{
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
一開始看起來好像我們給出了兩個 Text
,似乎是構成的是一個類似數組形式的 [View]
,但實際上并不是這么一回事。這里調用了 VStack
類型的初始化方法。
public struct VStack<Content> where Content : View
{
init(
alignment: HorizontalAlignment = .center,
spacing: Length? = nil,
@ViewBuilder content: () -> Content)
}
前面的 alignment
和spacing
沒啥好說,最后一個 content
比較有意思。看簽名的話,它是一個() -> Content
類型,但是我們在創建這個VStack
時所提供的代碼只是簡單列舉了兩個 Text
,并沒有實際返回一個可用的 Content
。
這里使用了 Swift 5.1
的另一個新特性 Funtion builders
。如果你實際觀察 VStack
這個初始化方法的簽名,會發現content
前面其實有一個@ViewBuilder
標記,而 ViewBuilder
則是一個由 @_functionBuilder
進行標記的 struct
。
@_functionBuilder public struct ViewBuilder { /* */ }
使用 @_functionBuilder
進行標記的類型 (這里的 ViewBuilder
),可以被用來對其他內容進行標記 (這里用 @ViewBuilder
對 content
進行標記)。被用function builder
標記過的 ViewBuilder
標記以后,content
這個輸入的 function
在被使用前,會按照 ViewBuilder
中合適的 buildBlock
進行 build
后再使用。如果你閱讀 ViewBuilder
的文檔,會發現有很多接受不同個數參數的 buildBlock
方法,它們將負責把閉包中一一列舉的 Text
和其他可能的 View
轉換為一個 TupleView
并返回。由此,content
的簽名() -> Content
可以得到滿足。實際上構建這個 VStack
的代碼會被轉換為類似下面這樣的等效偽代碼(不能實際編譯)。
VStack(alignment: .leading)
{ viewBuilder -> Content in
let text1 = Text("Turtle Rock").font(.title)
let text2 = Text("Joshua Tree National Park").font(.subheadline)
return viewBuilder.buildBlock(text1, text2)
}
當然這種基于 funtion builder
的方式是有一定限制的。比如ViewBuilder
就只實現了最多十個參數的 buildBlock
,因此如果你在一個 VStack
中放超過十個View
的話,編譯器就會不太高興。不過對于正常的 UI 構建,十個參數應該足夠了。如果還不行的話,你也可以考慮直接使用 TupleView
來用多元組的方式合并 View
。
TupleView<(Text, Text)>
(
(Text("Hello"), Text("Hello"))
)
除了按順序接受和構建 View
的 buildBlock
以外,ViewBuilder
還實現了兩個特殊的方法:buildEither
和 buildIf
。它們分別對應 block
中的 if...else
的語法和 if
的語法。也就是說,你可以在 VStack
里寫這樣的代碼。
var someCondition: Bool
VStack(alignment: .leading)
{
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
if someCondition
{
Text("Condition")
}
else
{
Text("Not Condition")
}
}
其他的命令式的代碼在 VStack
的 content
閉包里是不被接受的,比如下面這樣就不行。let
語句無法通過 function builder
創建合適的輸出。
VStack(alignment: .leading)
{
let someCondition = model.condition
if someCondition
{
Text("Condition")
}
else
{
Text("Not Condition")
}
}
五、鏈式調用修改 View 的屬性原理
var body: some View
{
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
可以試想一下,在 UIKit
中要動手形成這個效果的困難程度。我大概可以保證,99%
的開發者很難在不借助文檔或者 copy paste
的前提下完成這些事情,但是在SwiftUI
中簡直信手拈來,這點和Flutter
很像。在創建 View
之后,用鏈式調用的方式,可以將View
轉換為一個含有變更后內容的對象。比如復原一下上面的代碼。
let image: Image = Image("turtlerock")
let modified: _ModifiedContent<Image, _ShadowEffect> = image.shadow(radius: 10)
image
通過一個 .shadow
的 modifier
,modified
變量的類型將轉變為_ModifiedContent<Image, _ShadowEffect>
。如果你查看 View
上的 shadow
的定義,它是這樣的。
extension View
{
func shadow(
color: Color = Color(.sRGBLinear, white: 0, opacity: 0.33),
radius: Length, x: Length = 0, y: Length = 0)
-> Self.Modified<_ShadowEffect>
}
Modified
是 View
上的一個typealias
,在struct Image: View
的實現里,我們有:
public typealias Modified<T> = _ModifiedContent<Self, T>
_ModifiedContent
是一個SwiftUI
的私有類型,它存儲了待變更的內容,以及用來實施變更的 Modifier
。
struct _ModifiedContent<Content, Modifier>
{
var content: Content
var modifier: Modifier
}
在 Content
遵守 View
,Modifier
遵守 ViewModifier
的情況下,_ModifiedContent
也將遵守 View
,這是我們能夠通過 View
的各個 modifier extension
進行鏈式調用的基礎。
extension _ModifiedContent : _View
where Content : View, Modifier : ViewModifier
{
...
}
在 shadow
的例子中,SwiftUI
內部會使用 _ShadowEffect
這個 ViewModifier
,并把image
自身和 _ShadowEffect
實例存放到_ModifiedContent
里。不論是 image
還是 modifier
,都只是對未來實際視圖的描述,而不是直接對渲染進行的操作。在最終渲染前,ViewModifier
的 body(content: Self.Content) -> Self.Body
將被調用,以給出最終渲染層所需要的各個屬性。
六、List的解釋
a、靜態List
這里的 List
和 HStack
或者 VStack
之類的容器很相似,接受一個view builder
并采用聲明的方式列舉了兩個 LandmarkRow
。這種方式構建了對應著UITableView
的靜態cell
的組織方式。
var body: some View
{
List
{
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
我們可以運行 app
,并使用Xcode
的 View Hierarchy
工具來觀察 UI
,結果可能會讓你覺得很眼熟。實際上在屏幕上繪制的 UpdateCoalesingTableView
是一個 UITableView
的子類,而兩個 ListCoreCellHost
也是 UITableViewCell
的子類。對于 List
來說,SwiftUI
底層直接使用了成熟的UITableView
的一套實現邏輯,而并非重新進行繪制。
不過在使用 SwiftUI
時,我們首先需要做的就是跳出 UIKit
的思維方式,不應該去關心背后的繪制和實現。使用 UITableView
來表達List
也許只是權宜之計,也許在未來也會被另外更高效的繪制方式取代。由于SwiftUI
層只是 View
描述的數據抽象,因此和Flutter
的Widget
一樣,背后的具體繪制方式是完全解耦合,并且可以進行替換的。這為今后 SwiftUI
更進一步留出了足夠的可能性。
b、動態 List
List(landmarkData.identified(by: \.id))
{ landmark in
LandmarkRow(landmark: landmark)
}
除了靜態方式以外,List
當然也可以接受動態方式的輸入,這時使用的初始化方法和上面靜態的情況不一樣。
public struct List<Selection, Content> where Selection : SelectionManager, Content : View
{
public init<Data, RowContent>(
_ data: Data, action: @escaping (Data.Element.IdentifiedValue) -> Void,
rowContent: @escaping (Data.Element.IdentifiedValue) -> RowContent)
where
Content == ForEach<Data, Button<HStack<RowContent>>>,
Data : RandomAccessCollection,
RowContent : View,
Data.Element : Identifiable
//...
}
Content == ForEach<Data, Button<HStack<RowContent>>>
因為這個函數簽名中并沒有出現 Content
,Content
僅只 List<Selection, Content>
的類型聲明中有定義,所以在這與其說是一個約束,不如說是一個用來反向確定 List
實際類型的描述。
Data : RandomAccessCollection
這基本上等同于要求第一個輸入參數是 Array
。
RowContent : View
對于構建每一行的 rowContent
來說,需要返回是 View
是很正常的事情。注意 rowContent
其實也是被 @ViewBuilder
標記的,因此你也可以把 LandmarkRow
的內容展開寫進去。不過一般我們會更希望盡可能拆小 UI 部件,而不是把東西堆在一起。
Data.Element : Identifiable
要求 Data.Element
(也就是數組元素的類型) 上存在一個可以辨別出某個實例的滿足 Hashable
的id
。這個要求將在數據變更時快速定位到變化的數據所對應的 cell
,并進行 UI 刷新。
c、List : View的困惑
在下面的代碼中,我們期望 List
的初始化方法生成的是某個類型的 View
。但是你看遍 List
的文檔,都找不到 List : View
之類的聲明。
var body: some View
{
List
{
//...
}
}
難道是因為 SwiftUI
做了什么手腳,讓本來沒有滿足 View
的類型都可以充當一個 View
嗎?當然不是這樣…如果你在運行時暫定 app
并用lldb
打印一下List
的類型信息,可以看到下面的信息。
(lldb) type lookup List
...
struct List<Selection, Content> : SwiftUI._UnaryView where ...
SwiftUI
視圖_UnaryView
協議雖然是滿足 View
的,但它被隱藏起來了,而滿足它的 List
雖然是 public
的,但是卻可以把這個協議鏈的信息也作為內部信息隱藏起來。這是Swift
內部框架的特權,第三方的開發者無法這樣在在兩個public
的聲明之間插入一個私有聲明。
七、@State的解釋
這里出現了兩個以前在 Swift
里沒有的特性:@State
和 $showFavoritesOnly
。
@State var showFavoritesOnly = true
Toggle(isOn: $showFavoritesOnly)
{
Text("Favorites only")
}
如果你點到 State
的定義里面,可以看到它其實是一個特殊的struct
。
@propertyWrapper public struct State<Value> : DynamicViewProperty, BindingConvertible
{
/// Initialize with the provided initial value.
public init(initialValue value: Value)
/// The current state value.
public var value: Value { get nonmutating set }
/// Returns a binding referencing the state value.
public var binding: Binding<Value> { get }
/// Produces the binding referencing this state value
public var delegateValue: Binding<Value> { get }
}
@propertyWrapper
標注和@_functionBuilder
類似,它修飾的struct
可以變成一個新的修飾符并作用在其他代碼上,來改變這些代碼默認的行為。這里 @propertyWrapper
修飾的 State
被用做了 @State
修飾符,并用來修飾 View
中的 showFavoritesOnly
變量。
和 @_functionBuilder
負責按照規矩重新構造函數的作用不同,@propertyWrapper
的修飾符最終會作用在屬性上,將屬性包裹起來,以達到控制某個屬性的讀寫行為的目的。如果將這部分代碼展開,它實際上是這個樣子的。
// @State var showFavoritesOnly = true
var showFavoritesOnly = State(initialValue: true)
// Toggle(isOn: $showFavoritesOnly)
Toggle(isOn: showFavoritesOnly.binding)
// if !self.showFavoritesOnly
if !self.showFavoritesOnly.value
把變化之前的部分注釋了一下,并且在后面一行寫上了展開后的結果。可以看到 @State
只是聲明State struct
的一種簡寫方式而已。State
里對具體要如何讀寫屬性的規則進行了定義。對于讀取,非常簡單,使用 showFavoritesOnly.value
就能拿到 State
中存儲的實際值。而原代碼中 $showFavoritesOnly
的寫法也只不過是 showFavoritesOnly.binding
的簡化。binding
將創建一個 showFavoritesOnly
的引用,并將它傳遞給 Toggle
。再次強調,這個 binding
是一個引用類型,所以 Toggle
中對它的修改,會直接反應到當前 View
的 showFavoritesOnly
去設置它的 value
。而 State
的 value didSet
將觸發 body
的刷新,從而完成 State -> View
的綁定。
SwiftUI
中還有幾個常見的 @
開頭的修飾,比如 @Binding
,@Environment
,@EnvironmentObject
等,原理上和 @State
都一樣,只不過它們所對應的 struct
中定義讀寫方式有區別。它們共同構成了 SwiftUI
數據流的最基本的單元。
八、Animating的解釋
直接在 View
上使用 .animation
或者使用 withAnimation { }
來控制某個 State
,進而觸發動畫。
button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
對于只需要對單個 View
做動畫的時候,animation(_:)
要更方便一些,它和其他各類 modifier
并沒有太大不同,返回的是一個包裝了對象View
和對應的動畫類型的新的 View
。animation(_:)
接受的參數 Animation
并不是直接定義 View
上的動畫的數值內容的,它是描述的是動畫所使用的時間曲線、動畫的延遲等這些和 View
無關的東西。具體和 View
有關的,想要進行動畫的數值方面的變更,由其他的諸如 rotationEffect
和 scaleEffect
這樣的 modifier
來描述。
要注意,SwiftUI
的 modifier
是有順序的。在我們調用 animation(_:)
時,SwiftUI
做的事情等效于是把之前的所有 modifier
檢查一遍,然后找出所有滿足 Animatable
協議的view
上的數值變化,比如角度、位置、尺寸等,然后將這些變化打個包,創建一個事物(Transaction)
并提交給底層渲染去做動畫。在上面的代碼中,.rotationEffect
后的 .animation(nil)
將rotation
的動畫提交,因為指定了nil
所以這里沒有實際的動畫。在最后,.rotationEffect
已經被處理了,所以末行的.animation(.spring())
提交的只有.scaleEffect
。
在withAnimation { }
閉包內部,我們一般會觸發某個 State
的變化,并讓View.body
進行重新計算.
Button(action: {
withAnimation
{
self.showDetail.toggle()
}
}) {
//...
}
如果需要,你也可以為它指定一個具體的 Animation
。這個方法相當于把一個animation
設置到 View
數值變化的 Transaction
上,并提交給底層渲染去做動畫。從原理上來說,withAnimation
是統一控制單個的 Transaction
,而針對不同 View
的 animation(_:)
調用則可能對應多個不同的 Transaction
。
withAnimation(.basic())
{
self.showDetail.toggle()
}
九、生命周期
在 UIKit
開發時,我們經常會接觸一些像是 viewDidLoad
,viewWillAppear
這樣的生命周期的方法,并在里面進行一些配置。SwiftUI
里也有一部分這類生命周期的方法,比如.onAppear
和.onDisappear
,它們也被統一在了 modifier
這面大旗下。
但是相對于UIKit
來說,SwiftUI
中能使用的生命周期方法比較少,而且相對要通用一些。本身在生命周期中做操作這種方式就和聲明式的編程理念有些相悖。個人比較期待View
和 Combine
能再深度結合一些,把依賴生命周期的操作也用綁定的方式搞定。
相比于.onAppear
和.onDisappear
,更通用的事件響應是.onReceive(_:perform:)
,它定義了一個可以響應目標 Publisher
的任意的 View
,一旦訂閱的 Publisher
發出新的事件時,onReceive
就將被調用。
Demo
Demo在我的Github上,歡迎下載。
SwiftUIDemo