SwiftUI 学习笔记(一):从 Swift 5.1 的新特性说起

零、前言

2019 年春夏之交的 WWDC19,注定会成为深深烙印在 Apple 开发者们记忆中的一场盛会。自从 2014 年 Apple 发布 Swift 以来,还没有哪一次的 WWDC 能汇集到这么多人的目光。在这个充满新意和激情的舞台中央,被开发者们给予热情欢呼的主角毫无疑问只有一个,那就是 SwiftUI。

SwiftUI 作为 Apple 在自家平台使用 Swift 语言打造的首个重量级系统框架,将为这个平台上用户界面的构建方式带来革命性的转变。它摒弃了从上世纪八十年代开始就一直被使用的指令式(imperative)编程的方式,转而投向声明式(declarative)编程的阵营,这提高了我们解决问题时所需要着手的层级,从而让我们可以将更多的注意力集中到更重要的创意方面。

WWDC20 后,Apple 进一步扩充了 SwiftUI,也大大加速了 Apple 四大平台(iOS/iPadOS、macOS、watchOS、tvOS)间的融合。对于 Apple 开发者来说,学习 SwiftUI 已经刻不容缓。这一系列博文将着重整理本人在学习 SwiftUI 时的路线以及问题,以飨读者。

WWDC20 后,SwiftUI 已经成为了 Apple 应用布局的“一等公民”,横跨自家的所有平台,意味着开发者只要编写一套布局,就能快速应用到其它平台中。

如果你先前没有接触过 iOS开发和 UIKit的有关知识,你可以先从第二篇学习笔记开始读起,先快速实现一些简单的界面来获得一些成就感,以获得继续学习下去的动力。等你对 iOS开发和 Swift语法有一定了解之后再回过头来看本文讨论的 Swift 5.1新特性,可能会更容易理解一些。

一、两个 Swift 5.1 的新特性

在 2019 年 7 月 29 日发布的 Xcode 11 beta 5 中,Swift 5.1 首次和开发者见面了。Swift 5.1 中带来了两个新特性是为 SwiftUI 量身定制的,在 SwiftUI 开发中经常出现,所以在学习 SwiftUI 之前,有必要先学习这两个新特性——不透明的返回类型(Opaque Result Types)属性包装类型(Property Wrapper Types)

二、不透明的返回类型

首先我们来看一段这样的代码:

import UIKit

//Error: Protocol 'Equatable' can only be used as a generic constraint because it has Self or associated type requirements
func test() -> Equatable {
    return 5
}

let num1 = test()
let num2 = test()

if num1 == num2 {
    print("两数相等")
}

其中 Equatable 是 Swift标准库自带的一个协议,表明这个类支持使用“==”运算符来比较两个对象是否相等。

上面代码的 test 函数中的试图返回 Int 这个遵守 Equatable 协议的类型。看似非常美好,但是编译器会提示报错,理由是“Equatable”这个协议只能被用于泛型的约束。在 Swift 5.1 之前,我们可以用以下方法来解决:

import UIKit

//改写成“泛型的约束”的形式
func test<T: Equatable>() -> T {
    return 5 as! T
}

//这里num1和num2必须指明为Int类型
let num1: Int = test()
let num2: Int = test()

if num1 == num2 {
    print("两数相等")
}

但是这种解决方法不尽完美——第 9 行和第 10 行的两个数必须指明为 Int 类型,因为 test 函数中的泛型要求我们传入具体的类型。此时若把 Int 类型去掉了,就报错了,因为返回类型是一个不确定的类型 T。

于是 Swift 5.1 就引入了一个“不透明的返回类型”的特性——只要在第一段代码第 4 行的 Equatable 之前加一个 some 关键字,问题就解决了:

import UIKit

func test() -> some Equatable {
    return 5
}

let num1 = test()
let num2 = test()

if num1 == num2 {
    print("两数相等")
}

这个 some 关键字起了什么作用呢?如果一个协议作为返回值用 some 修饰,返回值的类型就变成不透明的了,在这个值使用的时候,编译器可以通过函数返回值进行类型推断得到,而且 num1 和 num2 定义的时候可以不限制类型。

可以理解为返回值是遵守 Equatable协议的某个类型,只是编译器检查到这里的时候还不知道这个类型是什么,需要查到下面的返回值 5才知道。

但是需要注意的是,当协议使用 some 关键字修饰时,返回的类型只能是确定的一种而不能是多种,例如下面这个例子:

import UIKit

//Error: Function declares an opaque return type, but the return statements in its body do not have matching underlying types
func test() -> some Equatable {
    if Bool.random() {
        return 5
    } else {
        return "5"
    }
}

let num1 = test()
let num2 = test()

if num1 == num2 {
    print("两数相等")
}

在这个例子中,test 函数的返回值既可能是 Int 型 5 也可能是字符串 “5”,违反了 some 关键字的使用规则(返回 some 修饰的协议时返回值类型必须确定),这一点会在 SwiftUI 的使用中频繁的出现。

综上所述,Swift 5.1 通过引入 some 这个关键字修饰返回值,在语法上隐藏了返回类型,所以叫做不透明的返回类型,这样就可以在别的地方调用时选择具体的返回类型,并且是在程序编译时就确定下来了。这个特性可以在保证程序运行性能的同时,隐藏真实类型,并且允许协议作为返回类型。

下面我们再举一个例子,来加深对这个新特性的理解:

import UIKit

protocol Animal {
    associatedtype Element
    func feed(food: Element)
}

struct Cat: Animal {
    typealias Element = String //关联类型设为String
    func feed(food: String) {
        print("Cat eats \(food)")
    }
}

func makeAnimal() -> some Animal {
    return Cat()
}

let animal = makeAnimal()
print(type(of: animal)) //prints: Cat

这个在 SwiftUI 中有什么用呢?我们知道,SwiftUI 的强大之处在于它的可组合性。通常,我们可以使用堆栈视图和其他容器(如列表)从其他视图组成布局。这些容器都是通用类型,每次添加、删除、移动或替换视图时,它们的具体类型都会更改,例如以下代码定义的简单视图:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text("+")
            Image(systemName: "video")
        }
    }
}

此时 some View 的类型可以被推断为 VStack<TupleView<(Text, Image)>>。如果我们不写 some 关键字,那我们就必须这么写:

import SwiftUI

struct ContentView: View {
    var body: VStack<TupleView<(Text, Image)>> {
        VStack {
            Text("+")
            Image(systemName: "video")
        }
    }
}

如果我们需要在 VStack 中加一个 Text,body 的类型就变成了VStack<TupleView<(Text, Text, Image)>>,body 的类型也要改写:

import SwiftUI

struct ContentView: View {
    var body: VStack<TupleView<(Text, Text, Image)>> {
        VStack {
            Text("666")
            Text("+")
            Image(systemName: "video")
        }
    }
}

这无疑大大增加了编程过程中的复杂度,况且我们有时会嵌套多层容器,我们恐怕不希望把 body 的类型写成形如 “List <Never,TupleView <(HStack <TupleView <(VStack <TupleView <(Text,Text)>>,Text)>>,HStack <TupleView <(VStack <TupleView <(Text,Text)>>,Text)>> )>>” 的形式,于是 Apple 就顺理成章的增加了 some 关键字,让编译器自己去推断 body 的类型,只要这些类型都遵守“View”协议就行。

另外,当协议使用 some 关键字修饰时,返回的类型只能是确定的一种而不能是多种,故以下写法是错误的,因为 body 中包含了两个遵守 View 协议的元素—— VStack 和 Text:

import SwiftUI

struct ContentView: View {
    //Error: Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type
    var body: some View {
        VStack {
            Text("+")
            Image(systemName: "video")
        }
        Text("-")
    }
}

二、属性包装类型

基于 Swift 的 iOS 开发中,越来越多用“@”修饰的关键字出现,例如 @UIApplictionMain、@objc 等。在 SwiftUI 中,这类关键字会越来越多,例如 @State、@Binding、@EnvironmentObject 等。而 @propertyWrapper 作为 Swift 5.1 的新特性,则是本节分析的重点。

关键词 @propertyWrapper 用来修饰一个结构体,而它修饰的结构体可以变成一个新的修饰符并作用在其他代码上,来改变这些代码的行为。用 @propertyWrapper 修饰过的结构体可以使用 @结构体名 来修饰其它属性,将其“包裹”起来,以控制某个属性的行为。

我们来看一下下面这个例子:

import UIKit

@propertyWrapper struct Trimmed {
    
    private var value: String = ""
    
    var wrappedValue: String {
        get { self.value }
        set { self.value = newValue.trimmingCharacters(in: .whitespaces)} //去除字符串前后的空格
    }
    
    init(wrappedValue initialValue: String) {
        self.wrappedValue = initialValue
    }
}

struct Test {
    //使用上面定义的Trimmed来修饰String类型变量
    @Trimmed var title: String
    @Trimmed var body: String
}

//任何字符串无论是初始化期间还是通过后面的属性访问都会删除前后的空格
var test = Test(title: "  233 666   ", body: " okkkk     ")
print(test.title) //233 666
print(test.body) //okkkk

test.title = "  y1s1   "
print(test.title) //y1s1

在上面这个例子中,我们使用自定义的 @Trimmed 来修饰 Test 结构体中的字符串。可以看到无论是 Test 类初始化,还是中途变更 test 中被 @Trimmed 修饰的 String 类型的值,均进行了删除首尾空格的操作。事实上,每次 @Trimmed 的值发生变更,其值都会被传入临时构造的一个 Trimmed 类的构造函数中进行消除空格处理后传回。属性包装类型本质上做的事是和 get 和 set 相同,大大简化了开发的过程。

发表评论