SwiftUI:原理

原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、声明式的界面开发方式
  • 二、预览
  • 三、some关键词的解释
  • 四、ViewBuilder的解释
  • 五、链式调用修改 View 的属性原理
  • 六、List的解释
  • 七、@State的解释
  • 八、Animating的解释
  • 九、生命周期
  • Demo
  • 参考文献

简介

SwiftUI 的最低支持的版本是iOS 13,可能想要在实际项目中使用,还需要等待一两年时间。在 view的描述表现力上和与 app 的结合方面,SwiftUI 要胜过 FlutterDart的组合很多。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 进行比较,之后框架负责对变更的部分进行高效的重新绘制。


二、预览

SwiftUIPreviewApple 用来对标 FlutterHot 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 说起。ViewSwiftUI 的一个最核心的协议,代表了一个屏幕上元素的描述。这个协议中含有一个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)
}

前面的 alignmentspacing 没啥好说,最后一个 content 比较有意思。看签名的话,它是一个() -> Content类型,但是我们在创建这个VStack 时所提供的代码只是简单列举了两个 Text,并没有实际返回一个可用的 Content

这里使用了 Swift 5.1 的另一个新特性 Funtion builders。如果你实际观察 VStack 这个初始化方法的签名,会发现content前面其实有一个@ViewBuilder标记,而 ViewBuilder则是一个由 @_functionBuilder 进行标记的 struct

@_functionBuilder public struct ViewBuilder { /* */ }

使用 @_functionBuilder 进行标记的类型 (这里的 ViewBuilder),可以被用来对其他内容进行标记 (这里用 @ViewBuildercontent 进行标记)。被用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"))
)

除了按顺序接受和构建 ViewbuildBlock 以外,ViewBuilder 还实现了两个特殊的方法:buildEitherbuildIf。它们分别对应 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")
    }
}

其他的命令式的代码在 VStackcontent 闭包里是不被接受的,比如下面这样就不行。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 通过一个 .shadowmodifiermodified 变量的类型将转变为_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>
}

ModifiedView 上的一个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 遵守 ViewModifier遵守 ViewModifier 的情况下,_ModifiedContent 也将遵守 View,这是我们能够通过 View 的各个 modifier extension 进行链式调用的基础。

extension _ModifiedContent : _View 
    where Content : View, Modifier : ViewModifier 
{
    ...
}

shadow 的例子中,SwiftUI 内部会使用 _ShadowEffect这个 ViewModifier,并把image自身和 _ShadowEffect 实例存放到_ModifiedContent 里。不论是 image 还是 modifier,都只是对未来实际视图的描述,而不是直接对渲染进行的操作。在最终渲染前,ViewModifierbody(content: Self.Content) -> Self.Body将被调用,以给出最终渲染层所需要的各个属性。


六、List的解释

a、静态List

这里的 ListHStack 或者 VStack 之类的容器很相似,接受一个view builder并采用声明的方式列举了两个 LandmarkRow。这种方式构建了对应着UITableView的静态cell的组织方式。

var body: some View
{
    List
    {
        LandmarkRow(landmark: landmarkData[0])
        LandmarkRow(landmark: landmarkData[1])
    }
}

我们可以运行 app,并使用XcodeView Hierarchy 工具来观察 UI,结果可能会让你觉得很眼熟。实际上在屏幕上绘制的 UpdateCoalesingTableView 是一个 UITableView 的子类,而两个 ListCoreCellHost也是 UITableViewCell 的子类。对于 List 来说,SwiftUI 底层直接使用了成熟的UITableView 的一套实现逻辑,而并非重新进行绘制。

不过在使用 SwiftUI 时,我们首先需要做的就是跳出 UIKit 的思维方式,不应该去关心背后的绘制和实现。使用 UITableView 来表达List也许只是权宜之计,也许在未来也会被另外更高效的绘制方式取代。由于SwiftUI层只是 View 描述的数据抽象,因此和FlutterWidget 一样,背后的具体绘制方式是完全解耦合,并且可以进行替换的。这为今后 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>>>

因为这个函数签名中并没有出现 ContentContent 仅只 List<Selection, Content> 的类型声明中有定义,所以在这与其说是一个约束,不如说是一个用来反向确定 List 实际类型的描述。

Data : RandomAccessCollection

这基本上等同于要求第一个输入参数是 Array

RowContent : View

对于构建每一行的 rowContent 来说,需要返回是 View 是很正常的事情。注意 rowContent 其实也是被 @ViewBuilder 标记的,因此你也可以把 LandmarkRow 的内容展开写进去。不过一般我们会更希望尽可能拆小 UI 部件,而不是把东西堆在一起。

Data.Element : Identifiable

要求 Data.Element (也就是数组元素的类型) 上存在一个可以辨别出某个实例的满足 Hashableid。这个要求将在数据变更时快速定位到变化的数据所对应的 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 中对它的修改,会直接反应到当前 ViewshowFavoritesOnly 去设置它的 value。而 Statevalue 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 和对应的动画类型的新的 Viewanimation(_:)接受的参数 Animation并不是直接定义 View 上的动画的数值内容的,它是描述的是动画所使用的时间曲线、动画的延迟等这些和 View 无关的东西。具体和 View 有关的,想要进行动画的数值方面的变更,由其他的诸如 rotationEffectscaleEffect 这样的 modifier来描述。

要注意,SwiftUImodifier 是有顺序的。在我们调用 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,而针对不同 Viewanimation(_:)调用则可能对应多个不同的 Transaction

withAnimation(.basic())
{
    self.showDetail.toggle()
}

九、生命周期

UIKit 开发时,我们经常会接触一些像是 viewDidLoadviewWillAppear这样的生命周期的方法,并在里面进行一些配置。SwiftUI里也有一部分这类生命周期的方法,比如.onAppear.onDisappear,它们也被统一在了 modifier 这面大旗下。

但是相对于UIKit来说,SwiftUI中能使用的生命周期方法比较少,而且相对要通用一些。本身在生命周期中做操作这种方式就和声明式的编程理念有些相悖。个人比较期待ViewCombine能再深度结合一些,把依赖生命周期的操作也用绑定的方式搞定。

相比于.onAppear.onDisappear,更通用的事件响应是.onReceive(_:perform:),它定义了一个可以响应目标 Publisher的任意的 View,一旦订阅的 Publisher发出新的事件时,onReceive就将被调用。


Demo

Demo在我的Github上,欢迎下载。
SwiftUIDemo

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345