原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的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