系列文章深度解读|SwiftUI 背后那些事儿

前言

今年苹果的WWDC你看了吗?苹果在2019年的WWDC的重头戏当然非SwiftUI莫属:全新的声明式语法、绑定式API、和响应式变成框架Combine。这一切的一切都预示着即将在Apple Native布局系统掀起一场革命。为此,苹果在很多方面都做了努力,这才促成了SwiftUI现在的样子。想要了解Swift的新特性、SwiftUI数据流和SwiftUI布局系统等新知识吗?一起来看吧。

Swift 5.1 新语法

单表达式隐式返回值

在 Swift 5.0 之前的语法中,如果一个闭包表达式只有一个表达式,那么可以省略 return 关键字。 现在 Swift 5.1 以后的版本中计算属性和函数语句同样适用。

// before swift 5.0 

struct Rectangle {
    var width = 0.0, height = 0.0
    var area1: Double {
        return width * height
    }
    
    func area2() -> Double {
        return width * height 
    }
}

// after switft 5.1
struct Rectangle {
    var width = 0.0, height = 0.0
    var area1: Double { width * height }
    
    func area2() -> Double { width * height }
}

关于这个新特性的完整提案可以参考这里SE-0255

根据结构体默认成员合成默认初始化器

在 Swift 5.0 之前结构体声明,编译器会默认生成一个逐一成员初始化器,同时如果都有默认值,还会生成一个无参的初始化器。
但如果此时结构体成员属性过多,且较多都有默认值,则只能使用逐一成员初始化器,会使每处调用的地方写法过于冗余,在传统 OOP 语言中可以使用 Builder 模式解决,
但在 Swift 5.1 之后编译器会按需合成初始化器,避免初始化写法的冗余。


struct Dog {
    var name = "Generic dog name"
    var age = 0
}
let boltNewborn = Dog()
let daisyNewborn = Dog(name: "Daisy", age: 0)
// before swift 5.0 
let benjiNewborn = Dog(name: "Benji")
// after switft 5.1 
let benjiNewborn = Dog(name: "Benji")

关于这个新特性的完整提案可以参考这里SE-0242

字符串插入运算符新设计

这个特性主要扩大了字符串插入运算符的使用范围,以前我们只能用在 String 的初始化中,但是不能在参数处理中使用字符串插入运算符。
在以前的语法中只能分开书写,虽然没什么大问题,但总归要多一行代码,现在可以只能使用了, 尤其是对于 SwiftUI,Text 控件就使用到了这种新语法,可以使我们在单行表达式中即可初始化 Tetx。


// before swift 5.0 
let quantity = 10
label.text = NSLocalizedString(
    "You have \(quantity) apples,
    comment: "Number of apples"
)

label.text = String(format: formatString, quantity)

// after switft 5.1 
let quantity = 10
return Text(.
"You have \(quantity) apples"
).

// 实际上编译器会翻译为如下几句
var builder = LocalizedStringKey.StringInterpolation(
    literalCapacity: 16, interpolationCount: 1
)
builder.appendLiteral("You have ")
builder.appendInterpolation(quantity)
builder.appendLiteral(" apples")
LocalizedStringKey(stringInterpolation: builder)

关于这个新特性的完整提案可以参考这里SE-0228

属性包装器

当我们在一个类型中声明计算属性时,大部分属性的访问和获取都是有相同的用处,这些代码是可抽取的,如我们标记一些用户偏好设置,在计算属性的设置和获取中直接代理到 UserDefault的实现中,我们可以通过声明 @propertyWarpper 来修饰,可以减少大量重复代码。
在 SwiftUI 中, @State @EnviromemntObject @bindingObject @Binding 都是通过属性包装器代理到 SwiftUI 框架中使其自动响应业务状态的变化。


// before swift 5.0 
struct User {
    static var usesTouchID: Bool {
        get {
            return UserDefaults.standard.bool(forKey: "USES_TOUCH_ID")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "USES_TOUCH_ID")
        }
    }
    static var isLoggedIn: Bool {
        get {
            return UserDefaults.standard.bool(forKey: "LOGGED_IN")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "LOGGED_IN")
        }
    }
}

// after switft 5.1 
@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T
    init(_ key: String, defaultValue: T) {
        print("UserDefault init")
        self.key = key
        self.defaultValue = defaultValue
        UserDefaults.standard.register(defaults: [key: defaultValue])
    }
    var value: T {
        get {
            print("getter")
            return UserDefaults.standard.object(forKey: key) as? T ??  defaultValue
        }
        set {
            print("setter")
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}
struct User2 {
    @UserDefault("USES_TOUCH_ID", defaultValue: false)
    static var usesTouchID: Bool
    @UserDefault("LOGGED_IN", defaultValue: false)
    var isLoggedIn: Bool
}

print("hello world")
let user = User2()
User2.usesTouchID = true
let delegate = User2.$usesTouchID
print("\(delegate)")
let detelate2 = user.$isLoggedIn

实际上属性包装器是在编译时期翻译为一下的代码, 并且编译器禁止使用 $ 开头的标识符

struct User2 {
    static var $usesTouchID = UserDefault<Bool>("USES_TOUCH_ID", defaultValue: false)
    static var usesTouchID: Bool {
        set {
            $usesTouchID.value = newValue
        }
        get {
            $usesTouchID.value 
        }
    }
    @UserDefault("LOGGED_IN", defaultValue: false)
    var isLoggedIn: Bool
}

使用属性包装器的好处除了可以减少重复代码,Swift Runtime 还保证了一下几点

  1. 对于实例的属性包装器是即时加载的
  2. 对于类属性的属性保证器是懒加载的
  3. 属性包装器是线程安全的
  4. 通过 $ 运算符可以获取到原始的属性包装器实例,这大量使用在 SwiftUI 的数据依赖中

关于这个新特性的完整提案可以参考这里SE-0258

注:在目前的 Beta 版本中 @PropertyDelegate@PropertyWrapper 是一致的,正式版本发布会应该只会保留一种语法。

不透明返回类型

在 Swift 5.0 之前我们如果想返回抽象类型一般使用 Generic Type 或者 Protocol, 使用泛型会显示的暴露一些信息给 API 使用者,不是完整的类型抽象。
但是使用 Protocol 也有几个限制: 泛型返回值在运行时都是一个容器,效率较差,返回值不能调用自身类型的方法,协议不允许拥有关联类型,由于编译时丢失了类型信息,编译器无法推断类型,导致无法使用 == 运算符。

在 Swift 5.1 中新增了 opaque result type,这个特性使用 some 修饰协议返回值,具有一下特性

  1. 所有的条件分支只能返回一个特定类型,不同则会编译报错
  2. 方法使用者依旧无法知道类型,(使用方不透明)
  3. 编译器知情具体类型,因此可以使用类型推断。
// before swift 5.0 
public protocol View : _View {
    associatedtype Body : View

    var body: Self.Body { get }
}
// compile error
func getText() -> View {
    return Text("") 
}

func getText<T: Text>() -> T {
    return Text("")
}
// after switft 5.1 
struct ContentView: View {
    var body: some View {
        Text("")
    }
}

关于这个新特性的完整提案可以参考这里SE-0244

Swift Style DSL / Function Builder

此项提案是一个比较特殊的提案,原因在于目前还在审核中,并没有正式加入 Swift 语言中,但是 Xcode11 自带的 Swift 5.1 已经集成了这项语法. 使用 Function Builder 可以使表达式语句隐含的返回在函数返回值中,
这样可以在嵌套逻辑和返回值表达式的 DSL 中十分具有表现力。如下面的语句是等同的

// Original source code:
@TupleBuilder
func build() -> (Int, Int, Int) {
  1
  2
  3
}

// This code is interpreted exactly as if it were this code:
func build() -> (Int, Int, Int) {
  let _a = 1
  let _b = 2
  let _c = 3
  return TupleBuilder.buildBlock(_a, _b, _c)
}

在 SwiftUI 中的所有布局类控件,几乎全部使用了 Function Builder 特性,可读性要远远大于 Flutter 的 DSL语法,以下是一个苹果 WWDC Session 的例子


head { 
    meta().charset("UTF-8")
  if cond { 
    title("Title 1")
  } else { 
    title("Title 2")
  } 
}

实际上会被翻译为
head {
  let a: HTML = meta().charset("UTF-8") 
  let d: HTML
  if cond {
    let b: HTML = title("Title 1")
    d = HTMLBuilder.buildEither(first: b) 
  } else {
    let c: HTML = title("Title 2")
    d = HTMLBuilder.buildEither(second: c) 
  }
  return HTMLBuilder.buildBlock(a, d) 
}

可读性大大加强,但值得注意的是目前在 SwiftUI 中使用 Function Builder 实现的 ViewBuilder 功能仅仅支持 10 个泛型参数,在 SwiftUI 中,如果同级别元素中,超过 10 个则会有奇怪的编译错误。
这点官方推荐使用组合降低 View 结构,这点和 Flutter 的推荐写法不同,超过 10 个 则会编译错误 -_-。

目前这份提议function-builders.md,还在草案阶段,所以实现是一份下划线关键字
不排除苹果会在 9月份正式版本来临之前稍加修改语法,但相信这份功能的放开也会在正式版本发布之期。

其他新特性

本文主要介绍了一些新的语法特性,且大部分都和 SwiftUI 相关,从 WWDC 的Session 演讲者都透露出 Swift 语言组的核心目标,Make Your Swift API Better

主要从一下几个体现

  1. Expressive 有表现力
  2. Clear 清晰没二义性
  3. Easy to use 简单易用

本文还有大量的 Swift 5.1 新特性没有提到,如 @dynamicCallable @dynamicMemberLookup WritableKeyPath
有兴趣的读者可以参考 Swift的完整演变提案 swift-evolution.

Swift 从 3.x Attribute

Swift 自 3.0 版本至今添加了很多 属性(Attribut) 标记,这些 Attribute 通过给编译器提供信息,增强了 Swift 的元编程能力,如果读者有兴趣可以参考这份完整的正式版本 Attribute大全

Swift/SwiftUI API Design Guide

Swift 已经经过 9(4年内部孵化) 个年头的发展,语言的设计指南已经区域问题,其中涉及到了很多不同于其他语言的观点,今年的 Session 着重的梳理了一下几点。

值类型和引用类型

当你设计一个数据结构时,优先选择结构体或者枚举,它们都是值类型,值类型具有一下几个有点。

  1. 值类型在栈上分配,性能要远远大于引用类型,且 Swift Runtime 有 COW 优化。
  2. 值类型没有引用计数,不会引起奇怪的多线程安全问题。
  3. 值类型的存储属性是扁平化的,避免在类继承情况下一个子类继承过多的存储属性导致实例在内存中过大,如 SwiftUI 使用 Modifier的结构体优化设计。

那什么时候我们才需要使用引用类型呢? 只有当以下几个场景存在时才有必要使用

  1. 你需要引用计数和构造和析构的时机。
  2. 数据需要集中管理或共享,如单例或者数据缓存实例等。
  3. ID 语义和 Equal语义冲突的时候。

协议还是泛型

随着 Swift 中 POP 的流行,很多人涉及 API 越来越习惯使用 协议,但是官方指出设计的要点, 设计是演进的而不是一步到位的,应该遵守以下步骤,

  1. 不要直接声明协议
  2. 从实际的业务场景出发
  3. 从中抽象出公用代码
  4. 从已有的协议中组合协议,而不是重新构建一套新的协议结构

DynamicMemberLookup & dynamicCallable

这个语法其实是 Swift 4.2 的语法了,但是在 Xcode11 中, IDE进一步获得了增强,可以轻松获得代码提示,如果使用过 ruby 和 python的开发者,可以轻易地理解,把一个实例当方法调用,
静态语言在保证安全的前提下可以通过编译,并可以动态调用,相信未来在和其他语言互通时可以大方光彩。

抽象数据访问

前面已经介绍过来 Swift 5.1 新增语法 PropertyWarpper, 在 Swift 中很多计算属性都围绕着数据访问如 懒加载,防御性复制,TLS数据区,而它们的模式都是一致的,
多使用 PropertyWarpper 可以抽象公用代码,加强 API 语义。
在 SwiftUI 中核心的数据流,均使用 PropertyWarpper 如 @Binding @State @EnviromentObject @Enviroment

SwiftUI 360° 分析

在 SwiftUI 中我们不再使用传统的 Cocoa 命令式布局系统,该用声明式布局,那么到底什么事声明式布局?什么是命令式布局?。
举个例子,假设你要设计如下布局

系列文章深度解读|SwiftUI 背后那些事儿
在命令式布局系统中,你要做以下事情,
  1. 初始化一个 ImageView 实例
  2. 设置它的位置信息
  3. 设置它的缩放级别
  4. 把它添加到当前的视图树中
  5. 如果有事件,设置下事件的代理或者回掉

开发者要做的事情繁多且易错,开发效率极为原始低下,但在声明式时代你只需要描述一下信息

屏幕上有个图片,在什么位置,是什么缩放比例即可, 至于怎么布局,一切交给框架,Coder do less, Framework do more.
至于声明式编程和命令式编程,可以参考* 声明式编程 命令式编程
实际上声明式编程早在上个世纪就已经提出并且演化很久,和命令式不同的是,声明式编程一般要求框架做的事情非常多,在早期计算机性能一般的年代,声明式编程并未大火,
一般多用于解析 DSL (Domain Specific Language) 中,比如早期的 HTML 布局,SQL 语言等

顺便提下早在 2006 年微软便提出 WPF 框架,其中使用 XAML 语言编写声明式 UI 代码,同时支持事件/数据绑定机制,配合宇宙第一 IDE Visual Studio 强大的拖拉拽功能,开发者体验爽到机制。
但是遗憾的是 Window Phone 并未在移动操作系统平台争得一番天地,以至于很多人对声明式 UI 不甚了解。

声明式 UI 会是未来吗?

结合最近多年大火的前端大火的框架 React 移动平台的 Reactive Native WeexFlutter 包括暂时只能编写Native平台的 SwiftUIJetpack Compose.
多方的数据论证和流行度可以说明,声明式编程在 UI 布局方面有得天独厚的优势, 相信未来 UI 布局也会是声明式编程的天下。

SwiftUI 中的 View

对于传统的的 Cocoa 编程中,一个 View 可能是一个 UIView 也可能是一个 NSView 代表了屏幕上可视的元素,且依赖于对应的平台。
而在 SwiftUI 的实现中 A View Defines a Piece of UI 一个 View 是一个真实屏幕上可见元素的描述,在不同的平台可以是 UIView 也可以说 NSView 或 其他实现,
View 是跨平台的描述信息,底层的实现被封装在框架内部实现。

SwiftUI View & Modifier

在传统的命令式编程布局系统中,我们对一些 UI 系统结构是通常是通过继承实现的,再编写代码时通过对属性的调用来修改视图的外观,如颜色透明度等。
但这会带来导致类继承结构比较复杂,如果设计不够好会造成 OOP 的通病类爆炸,并且通过继承来的数据结构,子类会集成父类的存储属性,会导致子类实例在内存占据比较庞大,即便很多属性都是默认值并不使用。
如图

系列文章深度解读|SwiftUI 背后那些事儿

在 SwiftUI 中奖视图的修饰期抽象为 Modifier, Modifier通常只含有 1-2 个存储属性,通过原始控件的 Extension 可以在视图定义中添加各种 Modifier,它们在运行时的实现通常是一个闭包,在运行时 SwiftUI 构建出真实的视图。

系列文章深度解读|SwiftUI 背后那些事儿

SwiftUI 元控件

在 SwiftUI 系统中我们使用结构体遵守 View 协议,通过组合现有的控件描述,实现 Body 方法,但 Body 的方法会不会无限递归下去?

在 SwiftUI 系统中定义了 6 个元/主 View Text Color Spacer Image Shape Divider, 它们都不遵守 View 协议,只是基本的视图数据结构。

其他常见的视图组件都是通过组合元控件和修饰器来组合视图结构的。如 Button Toggle 等。

关于 SwiftUI 的视图和修饰器可以参考 Github 整理的速查表Jinxiansen/SwiftUI

DataFlow in SwiftUI

任何程序都不可能是静态的,充满着数据状态,函数充满着副作用,传统的命令式编程通过成员变量来管理状态,这使得状态的复杂度成指数级增长。举一个最简单的例子。假设一个视图只有 4 种状态,组合起来就有16种,
但是人脑处理状态的复杂度是有限的,状态的复杂度一旦超过人脑的复杂度,就会产生大量的 Bug,并且修掉了这个产生了那个,试问哪位同学看见一个类充斥着几十上百个成员属性和全局变量不想拍桌子的。

系列文章深度解读|SwiftUI 背后那些事儿
系列文章深度解读|SwiftUI 背后那些事儿

在 Flutter 中控件分为StateLessStateFull 控件,数据流是单向的,随之诞生了 Bloc RxDart Redux 许多框架,虽然作者本人是 SwiftUI 的粉丝,但不得不说 Flutter 的很多思想非常先进。
这里简单分析下 SwiftUI 中如何处理完们的数据流。
这里先放出数据流的原则,我们关注两个点 Source of Truth 是指我们真是的业务逻辑数据,Dervied Value 是指 SwiftUI 框架中使用的数据。

系列文章深度解读|SwiftUI 背后那些事儿

Constant

通常部分 UI 数据是不可改变的,比如一个 Text 的 Color, 这部分我们可以直接使用 Modifier 的构造器讲属性传递进去即可,这里就不做多解释了。


Text("Hello world")
                .color(.red)

@State

那在某些情况下我们某些 UI 元素会有一系列的响应事件,会导致视图发现变化,那么 SwiftUI 是怎么做到的? 就是通过前面的新语法 Property warpper,
对所有使用 State 标记的属性 都会代理到 SwiftUI 中 State 的方法实现,同时框架,计算Diff,刷新界面。
顺便这里强调下在 SwiftUI 中 Views area a function of State not of a sequence of Eventt, View 是真实视图的状态,并不是一系列变化的事件。

struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    var body: some View {
        VStack {
            Text(episode.title)
            Button(action: {
                self.isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.cicle" : "play.circle")
            }
        }
    }
}

系列文章深度解读|SwiftUI 背后那些事儿

@Binding

再很多时候我们会总归抽象减少我们的代码长度,比如将上文中的 Button 抽象为一个 PlayerButton, 这时候存在一个问题,State 属性我们是再重新声明一份吗?

struct PlayButton: View {
    @State private var isPlaying = false
    var body: some View {
        Button(action: {
            self.isPlaying.toggle()
        }) {
            Image(systemName: isPlaying ? "pause.cicle" : "play.circle")
        }
    }
}

这样写会有一个问题,我们会产生两个 Derived Value,虽然两者都可通知 SwiftUI 做界面刷新,但是 PlayerViewPlayerButton 的数据同步又成了问题。

SwiftUI 推荐使用 @Binding 解决,我们来看下 Binding 的实现。


@propertyDelegate public struct Binding<Value> {
    public var value: Value { get nonmutating set }

    /// Initializes from functions to read and write the value.
    public init(getValue: @escaping () -> Value, setValue: @escaping (Value) -> Void)

}

Binding 结构体使用闭包捕获了原本的属性值,使得属性可以用引用的方式保留。


struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    var body: some View {
        VStack {
            Text(episode.title)
            PlayButton($isPlaying)
        }
    }
}

系列文章深度解读|SwiftUI 背后那些事儿
这里 State 实现了 BindingConvertible 协议,使得 State 可以直接转换为 Binding。

@BingableObject & Combine

UI 除了受用户点击影响 有时候还来自于外部的通知,如一个IM类消息,收到远程的消息,或者一个定时器被触发,

系列文章深度解读|SwiftUI 背后那些事儿

在 SwiftUI 中通过最新的 Combine 框架可以很方便的响应外部变化,开发者只需实现 BindableObject 协议即可

class PodcastPlayerStore: BindableObject {
    var didChange = PassthoughSubject<Void, Never>()
    func advance() {
        // ..
        didChange.send()
    }
}

struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    @State private var currentTime: TimeInterval = 0.0 
    var body: some View {
        VStack {
            Text(episode.title)
            PlayButton($isPlaying)
        }
        .onReceive(PodcastPlayerStore.currentTimePublisher) { newTime in 
            self.currentTime = newTime
        }
    }
}

@ObjectBinding

使用 State 的方式可以通知单个视图的变化,但是有时候我们需要多个视图共享一个元素信息,并且在数据信息发送变化时通知 SwiftUI 刷新所有布局,这时候可以使用 @ObjectBinding

final class PodcastPlayer: BindableObject {
    var isPlaying: Bool = false {
        didSet {
            didChange.send(self)
        }
    }

    func play() {
        isPlaying = true
    }

    func pause() {
        isPlaying = false
    }

    var didChange = PassthroughSubject<PodcastPlayer, Never>()
}

struct EpisodesView: View {
    @ObjectBinding var player: PodcastPlayer
    let episodes: [Episode]

    var body: some View {
        List {
            Button(
                action: {
                    if self.player.isPlaying {
                        self.player.pause()
                    } else {
                        self.player.play()
                    }
            }, label: {
                    Text(player.isPlaying ? "Pause": "Play")
                }
            )
            ForEach(episodes) { episode in
                Text(episode.title)
            }
        }
    }
}
@propertyDelegate public struct ObjectBinding<BindableObjectType> : DynamicViewProperty where BindableObjectType : BindableObject {
    @dynamicMemberLookup public struct Wrapper {
        public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<BindableObjectType, Subject>) -> Binding<Subject> { get }
    }

    public var value: BindableObjectType

    public init(initialValue: BindableObjectType)

    public var delegateValue: ObjectBinding<BindableObjectType>.Wrapper { get }

    public var storageValue: ObjectBinding<BindableObjectType>.Wrapper { get }
}

系统通过 ObjectBinding<BindableObjectType>.Wrapper 感知外部数据的变化。

@EnviromemntObject

有时候一些环境变量是共享的,我们可以通过 EnviromentObject 获取共享的信息,这些共享的数据信息回沿着 View 树的的结构向下传递。
类似于 Flutter 的 Theme 和 ScopeModel ,比较简单这里就不多做解释了

let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: LandmarkList().environmentObject(UserData()))

struct LandmarkList: View {
    @EnvironmentObject private var userData: UserData
    
    var body: some View {
        Text("")
    }

系列文章深度解读|SwiftUI 背后那些事儿

@Enviroment

前面提到的环境信息一般是指用户自定义共享信息,但是同时系统存在大量的内置环境信息,如时区,颜色模式等,可以直接订阅系统的环境信息,使得 SwiftUI 自动获取到环境信息的变化,自动刷新布局。

struct CalendarView: View {
    @Environment(\.calendar) var calendar: Calendar
    @Environment(\.locale) var locale: Locale
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var body: some View {
        return Text(locale.identifier)
    }
}

总结

以上是 SwiftUI 官方在各种教程和 WWDC session 提到的数据流管理方案,总结下来是以下几点

  1. 对于不变的常量直接传递给 SwiftUI 即可。
  2. 对于控件上需要管理的状态使用 @State 管理。
  3. 对于外部的事件变化使用 BindableObject 发送通知。
  4. 对于需要共享的视图可变数据使用 @ObjectBinding 管理。
  5. 不要出现多个状态同步管理,使用 @Binding 共享一个 Source of truth
  6. 对于系统环境使用 @Enviroment 管理。
  7. 对于需要共享的不可变数据使用 @EnviromemntObject 管理。
  8. @Binding 具有引用语义,可以很好的和 @Binding @objectBinding @State 协作,避免出现多个数据不同步。

SwiftUI 的布局算法

相信很多人看过 SwiftUI 后对 SwiftUI 内部的布局算法是比较好奇的,在 Session 237 Building Custom Views with SwiftUI 摘要介绍了一下。
SwiftUI 会通过 body 的返回值获取描述视图的控件信息,转换为对应的内部视图信息,交给 2D 绘图引擎 Metal 或者 Open GL 绘制,其中比较复杂的 Toggle 可能引用自原本的UIKit实现。

Content View

对于以下的代码,

import SwiftUI

struct ContentView : View {
    var body: some View {
        Text("Hello World!")
    }
}

它们的结构是如下的 RootView -> ContentView -> Text,
那么 Text 是如何出现在屏幕上的?官方的介绍是如下三个步骤

  1. 父视图为子视图提供预估尺寸
  2. 子视图计算自己的实际尺寸
  3. 父视图根据子视图的尺寸将子视图放在自身的坐标系中

比较重要的是第二步,对于一个视图描述,通常有三种设置尺寸的方式

  1. 无需计算,根据内容推断,如 Image 是和图片等大,Text 是计算出来的可视范围,类似 NSString 根据字体计算宽高。
  2. Frame 强制指定宽高
  3. 设置缩放比例 如 Image 设置 aspectRatio.

顺便苹果提了一点 (傲娇脸) SwiftUI 中将计算出的模糊坐标点会对齐到清晰的像素点,避免出现锯齿感。

那么对于使用 Modifier 的布局结构如

import SwiftUI

struct ContentView : View {
    var body: some View {
        Text("Hello World!")
        .padding(10)
        .background(Color.Green)
    }
}
struct Avocado : View {
    var body: some View {
        Image("20x20_avocado")
            .frame(width: 30, height: 30)
    }
}

系列文章深度解读|SwiftUI 背后那些事儿
系列文章深度解读|SwiftUI 背后那些事儿
看上去它们 Frame 和 Background Padding 等元素都出现在了视图结构中,但它们其实都是 View 的约束信息,并不是真正的 View。

HStack/VStack

HStack 和 ZStack 的非常类似安卓的 LinerLayout,算法也同 Flex 布局比较相似。
对于如下的布局, 苹果都会在控件之间添加上符合苹果人机交互指南的间距,保证 UI 的优雅和一致性。

系列文章深度解读|SwiftUI 背后那些事儿
HStack {
    VStack {
        Text("")
        Text("5 stars")
    }
    .font(.caption)
    VStack {
        HStack {
            Text("Avocado Toast").font(.title)
            Spacer()
            Image("20x20_avocado")
        }
        Text("Ingredients: Avocado, Almond Butter, Bread, Red Pepper Flakes")
            .font(.caption)
            .lineLimit(1)
    }
}

系列文章深度解读|SwiftUI 背后那些事儿

对于如上的 Stack 是怎么计算的?设 Stack 主轴方向长度为 W1

  1. 根据人机交互指南的预留出边距 S, 边距根据元素的排列可能有多个
  2. 得到剩余的主轴宽度 W2= W1 - N * S
  3. 平均分配一个预估宽度
  4. 计算一些具备明确宽高的元素 如 Image 设置了 Frame的元素的等。
  5. 沿主轴方向从前到后计算,,如果计算出来的宽度小于预估宽度则正常显示,不够则截断
  6. 最后的元素为剩余宽度,如果不够显示则阶段
  7. 默认的交叉轴对齐方式为 Center,Stack 占据包括最大元素的边界。

默认的计算是顺序计算布局,如果某些元素比较重要,可以使用 LayoutPriority Modifier 提高布局优先级避免出现视图截断。

交叉轴对齐方式

很多时候我们开发布局系统会嵌套很多 HStack 和 ZStack 有时候我们除了内部对齐,还有按照自定义的对齐比例的对齐

extension VerticalAlignment {
    public static let top: VerticalAlignment
    public static let center: VerticalAlignment
    public static let bottom: VerticalAlignment
    public static let firstTextBaseline: VerticalAlignment
    public static let lastTextBaseline: VerticalAlignment
}

extension HorizontalAlignment {

    public static let leading: HorizontalAlignment
    public static let center: HorizontalAlignment
    public static let trailing: HorizontalAlignment
}

extension VerticalAlignment {
    private enum MidStarAndTitle: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> Length {
            return d[.bottom]
        }
    }
    static let midStarAndTitle = VerticalAlignment(MidStarAndTitle.self)
}

// 自定义对齐方式
HStack(alignment: .midStarAndTitle) {
    VStack {
        Text("")
        Text("5 stars")
    }
    .font(.caption)
    VStack {
        HStack {
            Text("Avocado Toast").font(.title)
            Spacer()
            Image("20x20_avocado")
        }
        Text("Ingredients: Avocado, Almond Butter, Bread, Red Pepper Flakes")
            .font(.caption)
            .lineLimit(1)
    }
}

ZStack

除了 HStack 和 VStack 还有 ZStack, ZStack 的布局方式类似绝对定位, 在 UIKit 中类似 向一个 View 添加不同的 SubView。

VStack {
            Text("Hello")
            Text("World").padding(10);
}

VStack的对齐方式组合自水平和垂直方向。

public struct Alignment : Equatable {

    public var horizontal: HorizontalAlignment

    public var vertical: VerticalAlignment

    @inlinable public init(horizontal: HorizontalAlignment, vertical: VerticalAlignment)

    public static let center: Alignment

    public static let leading: Alignment

    public static let trailing: Alignment

    public static let top: Alignment

    public static let bottom: Alignment

    public static let topLeading: Alignment

    public static let topTrailing: Alignment

    public static let bottomLeading: Alignment

    public static let bottomTrailing: Alignment

    public static func == (a: Alignment, b: Alignment) -> Bool
}

Shape in SwiftUI

在 SwiftUI 中绘图类似之前使用 UIBezierPath 的 API,只需要实现 Shape 的描述信息即可这里就不多赘述了。


struct WedgeShape: Shape {
    var wedge: Ring.Wedge

    func path(in rect: CGRect) -> Path {
        var p = Path()
        let g = WedgeGeometry(wedge, in: rect)
        p.addArc(center: g.cen, radius: g.r0, startAngle: g.a0, endAngle: g.a1, clockwise: false)
        p.addLine(to: g.topRight)
        p.addArc(center: g.cen, radius: g.r1, startAngle: g.a1, endAngle: g.a0, clockwise: true)
        p.closeSubpath()
        return p
    }
    
}

SwiftUI 混合布局

很多现有的 APP 大量的使用 Cocoa 传统的命令式 UI 布局方式,即便不用兼容 iOS 13.x 以下直接引入 SwiftUI,也是不可能全部改造为 SwiftUI,势必会存在较长时间的混合编程。
苹果已经考虑到这点,并且提供了非常简单的方式供混合编程。

UIKit 嵌入 SwiftUI View

这个是最为常见的场景 SwiftUI 提供了 UI/NS/WKHostingController 包装一个 SwiftUI View,并且提供了对于的视图更新时机 setNeedsBodyUpdate updateBodyIfNeeded

系列文章深度解读|SwiftUI 背后那些事儿

SwiftUI View 嵌入 UIKit View

开发者已经封装好了大量可用的视图组件,可以直接嵌入到 SwiftUI 中无缝开发。

系列文章深度解读|SwiftUI 背后那些事儿
系列文章深度解读|SwiftUI 背后那些事儿

这块就不做过多解释了,这部分的代码和时机分成清晰。 Demo 可以参考 Session 231 integrating swiftui

SwiftUI On All Device

在前文 SwiftUI初体验中说到
苹果考虑的跨平台不是像 RN Flutter Weex 的跨手机操作系统平台,而是跨 TV Watch Mac iPad 平台,所以理念也不一致,苹果特意指出针对不同的设备,是没有一种方法,能万能的适配到所有设备上。
如果有势必回抹掉很多平台特有的体验,这虽然对开发者友好,但不见得对用户是一件好事,苹果指出了针对多设备的设计理念,

  1. 针对对应的平台选择对应的合适的设计
  2. 认真分析共享模块的设计
  3. 共享视图是个有风险的行为,要考虑的更加深入
  4. Learn once apply anyware.

在 SwiftUI 中开发者描述的视图层只是数据的定义和描述, SwiftUI 框架内部已经做的足够多,相信学习了 SwiftUI 可以轻易地在其他平台上开始开发。
关于更多细节请参考 Session 240 SwiftUI on all Device 和 [Session 219 SwiftUI on watchOS
](https://developer.apple.com/videos/play/wwdc2019/219/)

accessibility in SwiftUI

今年 SwiftUI 也充分考虑了无障碍访问功能,不得不说苹果是非常有人性化的公司,SwiftUI 给无障碍开发减少了很多的工作量。不过作者对这方面了解甚少,就不误导各位读者了,
详细的大家可以参考 Session 238 accessibility in swiftui

参考

  1. SE-0255 omit-return
  2. SE-0242 default-values-memberwise
  3. SE-0228 expressiblebystringinterpolation
  4. SE-0258 property-wrappers
  5. function-builders.md
  6. swift-evolution
  7. Attribute大全
  8. Inside SwiftUI's Declarative Syntax's Compiler Magic
  9. Session402
  10. API Design GuideLine
  11. Session415
  12. 声明式编程
  13. 命令式编程
  14. understanding-property-wrappers-in-swiftui
  15. SwiftUI初体验中
  16. Session 402 What's New in Swift
  17. Session 204 Introducing SwiftUI: Building Your First App
  18. Session 216 Introducing SwiftUI Essentials
  19. Session 226 SwiftUI Essentials
  20. Session 231 Integrating SwiftUI
  21. Session 237 Building Custom Views with SwiftUI
  22. Session 238 accessibility in swiftui
  23. Session 240 SwiftUI on all Device
  24. Session 219 SwiftUI on watchOS
  25. Session 415 Modern Swift API Design
  26. 30 分钟学会 Flex 布局

推荐阅读

  1. 关于苹果官方出的 Combine 框架,几乎就是抄袭 RxSwift ,但是 Combine 已经和 SwiftUI 深度集成,有兴趣的读者可以参考如何转换。
  2. 是不是很多同学好奇 Xcode 的Preview 和 Live Mode 是如何实现的,这里有一篇原理文章分析,值得看看。

淘宝基础平台团队正在举行2019实习生(2020年毕业)和社招招聘,岗位有iOS Android客户端开发工程师、Java研发工程师、C/C++研发工程师、前端开发工程师、算法工程师,欢迎投递简历至junzhan.yzw@taobao.com

上一篇:Java IO:操作系统的IO处理过程以及5种网络IO模型


下一篇:JS实现深拷贝(双越老师)