关于SwiftUI的内部技术分享

什么是 SwiftUI?[1]

官方的定义非常明确:

SwiftUI is a user interface toolkit that lets us design apps in a declarative way.
SwiftUI 就是⼀种描述式的构建 UI 的⽅式。

简介[2]

苹果在 2019 WWDC 推出新一代声明式布局框架-SwiftUI ,该框架可用于 watchOS、tvOS、macOS、iOS 等,苹果的任意平台都可以使用,达到跨平台的实现。

在 SwiftUI 出现之前,苹果不同的设备之间的开发框架并不互通,macOS 开发用的 AppKit,iOS 开发用的 UIKit,WatchOS 开发用的堆叠,每个都不一样,不能达到互通互用,可复用性差。

之前

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
}

现在

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(ModelData())
    }
}

分成两个部分:

  • struct ContentView 定义的是视图结构。
  • struct ContentView_Previews 是预览视图声明。
    我们主要关注第一部分:struct ContentView

关键字 some ,其实就是一个opaque(不透明)类型,在返回类型前面添加这个关键字,代表你和编译器都确定这个函数总会返回一个特定的具体类型-只是你不知道是哪一种

SwiftUI 的编辑器是双向交互的:

  • 左边代码编辑器的改动会立即反应到右边的预览视图。
  • 右边的预览视图的编辑也会同步到左边的代码视图。

优点

  • 使用 SwiftUI,系统会默认支持白天和黑夜模式的自动切换
  • 实时刷新预览
  • 各种尺寸的屏幕间自动适配
  • 高效:更少的代码,更快的交付

SwiftUI 1.0 基本没有公司敢用在正式上线的APP 上,API 在 Beta 版本之间各种废弃,UI 样式经常不兼容,大列表性能差

缺点

  • iOS 14 才可放心的使用,
  • 要解决的是如何部署到低版本操作系统上?

SwiftUI的基本组件[3]

名称 含义
Text 用来显示文本的组件,类似UIKit中的UILabel
Image 用来展示图片的组件,类似UIKit中的UIImageView
Button 用来展示图片的组件,类似UIKit中的UIButton
List 用来展示列表的组件,类似UIKit中的UITableView
ScrollView 用来支持滑动的组件,类似UIKit中的UIScrollView
Spacer 一个灵活的空间,用来填充空白的组件
Divider 一条分割线,用来划分区域的组件
VStack 将子视图按“竖直方向”排列布局。(Vertical stack)
HStack 将子视图按“水平方向”排列布局。(Horizontal stack)
ZStack 将子视图按“两轴方向均对齐”布局(居中,有重叠效果)
基本组件:
  • Text:用来显示文本的组件
Text("Hello, we are QiShare!").foregroundColor(.blue).font(.system(size: 32.0))
  • Image:用来展示图片的组件
Image.init(systemName: "star.fill").foregroundColor(.yellow)
  • Button:用于可点击的按钮组件
Button(action: { self.showingProfile.toggle() }) {
    Image(systemName: "paperplane.fill")
        .imageScale(.large)
        .accessibility(label: Text("Right"))
        .padding()
}
  • List:用来展示列表的组件
List(0..<5){_ in
        NavigationLink.init(destination: VStack(alignment:.center){
            Image.init(systemName: "\(item+1).square.fill").foregroundColor(.green)
            Text("详情界面\(item + 1)").font(.system(size: 16))
    }) {
          //ListRow
       }
布局组件:

VStack、HStack、ZStack

功能组件:
  • NavigationView:负责App中导航功能的组件,类似UIKit中的UINavigationView
  • NavigationLink:负责App页面 跳转 的组件,类似于UINavigationView中的 push与pop 功能
NavigationView {
    List(0..<5){_ in
        NavigationLink.init(destination: VStack(alignment:.center){
            Image.init(systemName: "\(item+1).square.fill").foregroundColor(.green)
            Text("详情界面\(item + 1)").font(.system(size: 16))
    }) {
          //ListRow
       }
}
.navigationBarTitle("导航\(item)",displayMode: .inline)
  • TabView:负责App中的标签页功能的组件,类似UIKit中的UITabBarController
TabView {
    Text("The First Tab")
        .tabItem {
            Image(systemName: "1.square.fill")
            Text("First")
        }
    Text("Another Tab")
        .tabItem {
            Image(systemName: "2.square.fill")
            Text("Second")
        }
    Text("The Last Tab")
        .tabItem {
            Image(systemName: "3.square.fill")
            Text("Third")
        }
}
.font(.headline)

UI布局的基本法则

在SwiftUI中,不能给子视图强制规定一个尺寸

  1. 父view为子view提供一个建议的size
  2. 子view根据自身的特性,返回一个size
  3. 父view根据子view返回的size为其进行布局

举个例子:
UI布局的基本法则
struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .border(Color.green)
    }
}
  1. ContentView是Text的父view,为Text提供一个建议的size(全屏尺寸)
  2. 然后Text根据自身的特性,返回了它实际需要的size
    (注意:Text的特性是尽可能的只使用必要的空间,也就是说能够刚好展示完整文本的空间)
  3. 然后ContentView根据Text返回的size,在其内部对Text进行布局,在SwiftUI中,容器默认的布局方式为居中对齐。

Frame[4]

frame 在UIKit中是一种绝对布局,它的位置是相对于父view左上角的绝对坐标。但SwiftUI中frame的概念却完全不同

在SwiftUI中,frame是一个modifier(修饰符的意思),并不是真的修改了view。实际上会创建一个新的view

举个例子

struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .background(Color.green)
            .frame(width: 200, height: 50)
    }
}
想要的

实际的

在上边的代码中,.background并不会直接去修改原来的Text,而是在Text图层的下方新建了一个新的view

为什么会这样呢?

根据布局的3法则考虑这个问题

在考虑布局的时候,是自下而上的!!!

  1. 我们先考虑ContentVIew,它的父view给他的建议尺寸为整个屏幕的大小
  2. ContentVIew去询问它的child,它的child为下边的那个frame,返回了width200, height50, 因此frame告诉ContentView它需要的size为width200, height50,因此最终ContentView的size为width200, height50
  3. background是个一个透明的view,它的父控件frame,给的建议尺寸是width200, height50。它又去询问其child,text返回的是只需要容纳文本的size,因此text的size并不会是width: 200, height: 50

所以要想达到理想效果,需要修改一下上边的代码,调整frame和background的顺序就能实现

struct ContentView: View {
    var body: some View {
        Text("Hello, world")
            .frame(width: 200, height: 50)
            .background(Color.green)
    }
}

数据处理的基本原则

  • Data Access as a Dependency 数据访问依赖

SwiftUI中的界面是严格数据驱动的:运行时界面的修改,只能通过修改数据来间接完成,而不是直接对界面进行修改操作。不回再像 传统命令式编程 MVC 模式下那样,ViewController 承载各种 UIVew控件,开发者需要手动处理 UIView 和 数据之间的依赖关系。当数据产生变化时,要不停的同步数据和视图之间的状态变化。

SwiftUI是一切皆 View,所以可以把 View 切分成各种细粒度的组件,然后通过组合的方式拼装成最终的界面,这种视图的拼装方式大大提高了界面开发的灵活性和复用性,视图组件化并任意组合的方式是 SwiftUI 官方非常鼓励的做法

  • A Single Source Of Truth 单一数据源

在 SwiftUI 中,不同视图间如果要访问同样的数据,不需要各自持有数据,直接共用一个数据源即可。这样的好处是无需手动处理视图和数据的同步,当数据源发生变化时会自动更新与该数据有依赖关系的视图

swiftUI数据流转规范 - 数据流图

从上图可以看出SwiftUI 的数据流转过程:

  • 用户对界面进行操作,产生一个操作行为 action
  • 该行为触发数据状态的改变
  • 数据状态的变化会触发视图重绘
  • SwiftUI 内部按需更新视图,最终再次呈现给用户,等待下次界面操作

数据流工具[5]

通过它们建立数据和视图的依赖关系

  • Property
  • @State
  • @Binding
  • ObservableObject
  • @EnvironmentObject
  1. Property:
    开发中最常见的,它就是一个简单的属性,没什么特别。ChildView 需要 Parent View 给它传一个字符串,并且 ChildView 不对这个字符串进行修改,所以直接定义一个 Property,在使用的时候,直接让 Parent View 告诉它就好了。
struct ContentView : View {
    var body: some View {
        ChildView(text: "Demo")
    }
}

struct ChildView: View {
    let text: String
    var body: some View {
        Text(text)
    }
}
  1. @State:
  • 基于值类型的状态管理,这些值通常是字符串、数字、布尔等常量值
  • 只能在当前 View 的 body 内修改,所以它的使用场景是只影响当前 View 内部的变化
  • 当被@State包装的属性改变,SwiftUI 内部会自动重新计算和绘制 View的body部分
  • 被@State包装的变量一定要用private修饰,并且这个变量只能在当前view以及其子View的body中使用,不让外部使用。如果想让外部使用,则应该使用@ObservedObject和@EnvironmentObject
struct PlayerView : View {
    @State private var isPlaying: Bool = false
    
    var body: some View {
        VStack {
            
            Button(action: {
                self.isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}
  1. @Binding

传统的命令式编程中最复杂的部分莫过于状态管理,尤其是多数据同步。
一个数据存在于不同的 UI 中,某个数据改变就要同步到不同的UI 中。当这样需要同步的数据变的很多,再加上一些其他的异步的操作和逻辑处理,会使代码变得臃肿、可读性下降,并且伴随着而来的就是各种 Bug,SwiftUI 的解决办法就是使用 @Binding

使用@state包装的属性只在它所属view的内部使用,那么当它的子视图要访问这个属性的时候就要用到@binding了

@Binding主要有下面几个作用

  • 在不持有数据源的情况下,任意读取
  • 从 @State 中获取数据,并保持同步
  • 对包装的值采用传址而不是传值
struct ContentView: View {
    // 用@State修饰需要改变的变量
    @State private var count: Int = 0
    
    var body: some View {
        VStack {
            Text("\(count)").foregroundColor(.orange).font(.largeTitle).padding()
            // $访问传递给另外一个UI
            CountButton(count: $count)
        }
    }
}

struct CountButton : View {
    // 用@State修饰,绑定count的值
    @Binding var count: Int
    
    var body: some View {
        Button(action: {
            // 此处修改数据会同步到上面的UI
            self.count = self.count + 1
            
        }) { Text("改变Count")
        }
    }
}
  1. ObservableObject

它的原理和RxSwift发布者和订阅者的模式类似

  • ObservableObject 是个协议,必须要类去实现该协议,适用于多个 UI 之间的同步数据

  • 在应用开发过程中,很多数据其实并不是在 View 内部产生的,这些数据可能是一些本地存储的数据,也可能是网络请求的模型数据,这些数据默认是与 SwiftUI 没有依赖关系的,要想建立依赖关系就要用 ObservableObject,与之配合的是还有@ObservedObject和@Published两个修饰符

  • @Published 修饰的属性一旦发生了变化,会自动触发 ObservableObject 的objectWillChange 的 send方法,刷新页面。这一步是系统帮我们默认实现的

  • ObservedObject:被观察的对象 ,告诉SwiftUI,这个对象是可以被观察的,里面含有被@Published包装了的属性

  • @ObservedObject包装的对象,必须遵循ObservableObject协议。也就是说必须是class对象,不能是struct。

  • @ObservedObject允许外部进行访问和修改

class UserSettings: ObservableObject {
    // 有可能会有多个视图使用,所以属性未声明为私有
    @Published var score = 123
}

struct ContentView: View {
    @ObservedObject var settings = UserSettings()

    var body: some View {
        VStack {
            Text("人气值: \(settings.score)").font(.title).padding()
            Button(action: {
                self.settings.score += 1
            }) {
                Text("增加人气")
            }
        }
    }
}

有这样一个场景,A->B->C->D->E->F,A界面的数据要传递给F界面,假如使用@ObservedObject包装,需要一层一层传递。再有反向传值的话就更复杂,且容易出错。而使用@EnvironmentObject则不需要,直接在F界面,通过SwiftUI环境直接取出来就行。

  1. @EnvironmentObject 包装的属性是全局的,整个app都可以访问
  • 主要是为了解决跨组件数据传递的问题。
  • 组件层级嵌套太深,就会出现数据逐层传递的问题,@EnvironmentObject可以帮助组件快速访问全局数据,避免不必要的组件数据传递问题。
  • 使用基本与@ObservedObject一样,但@EnvironmentObject突出强调此数据将由某个外部实体提供,所以不需要在具体使用的地方初始化,而是由外部统一提供。
  • 使用@EnvironmentObject,SwiftUI 将立即在环境中搜索正确类型的对象。如果找不到这样的对象,则应用程序将立即崩溃,所以要 慎用
class UserSettings: ObservableObject {
    @Published var score = 123
}

struct ContentView: View {
    
    @EnvironmentObject var settings: UserSettings
    
    var body: some View {        
        NavigationView{            
            VStack {
                // 显示score
                Text("人气值: \(settings.score)").font(.title).padding()
                // 改变score
                Button(action: {
                    self.settings.score += 1
                }) {
                    Text("增加人气")
                }
                // 跳转下一个界面
                NavigationLink(destination: DetailView()) {
                    Text("下一个界面")
                }
            }
        }
    }
}

struct DetailView: View {
    
    @EnvironmentObject var settings: UserSettings
    
    var body: some View {
        VStack {
            Text("人气值: \(settings.score)").font(.title).padding()
            Button(action: {
                self.settings.score += 1
            }) {
                Text("增加人气")
            }
        }
    }
}

// 需要注意此时需要修改SceneDelegate,传入environmentObject
window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(UserSettings()))
  • Property、 @State、 @Binding 一般修饰的都是 View 内部的数据。
  • @ObservedObject、 @EnvironmentObject 一般修饰的都是 View 外部的数据:
  • 网络或本地存储的数据
  • 界面之间互相传递的数据

总结:

  1. View与View间的公用数据使用@State + @Binding。
  2. 多个View与Class间的公用数据:对View用@ObservedObject,让Class满足ObservableObject协议。
  3. 父View与子View对Class间的公用数据:父View用@ObservedObject,子View用@EnvironmentObject,Class满足ObservableObject协议

与UIKit彼此相容

由于SwiftUI 是一个新发布的框架,UI 组件并不齐全,当 SwiftUI 中并没有提供类似的功能时,可以把 UIKit 中已有的部分进行封装后,提供给 SwiftUI 使用。不过需要遵循UIViewRepresentable协议。UIViewRepresentable协议是SwiftUI框架中提供的用于将UIView转换成SwiftUI中View的协议
当然,也可以在已有的项目中,仅用 SwiftUI 制作一部分的 UI 界面。
UIViewRepresentable协议

protocol UIViewRepresentable : View
    associatedtype UIViewType : UIView

   /// 返回想要封装的 UIView 类型 和 实例
    func makeUIView(context: Self.Context) !" Self.UIViewType

    /// UIViewRepresentable 中的某个属性发生变化,SwiftUI 要求更新该 UIKit 部件时被调用
    func updateUIView(
        _ uiView: Self.UIViewType,
        context: Self.Context
    )
}

举个栗子

struct SearchBar : UIViewRepresentable {
    
    @Binding var text : String
    
    class Cordinator : NSObject, UISearchBarDelegate {
        
        @Binding var text : String
        
        init(text : Binding<String>) {
            _text = text
        }
        
        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            text = searchText
        }
    }
    
    func makeCoordinator() -> SearchBar.Cordinator {
        return Cordinator(text: $text)
    }
    
    func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        return searchBar
    }
    
    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }
}

学习资料

  1. 斯坦福公开课 CS193P·2020 年春:该课程强推,我当年学习 OC 看的就是它,现在到SwiftUI了还是先看这个,系统且细致,结合案例和编程过程中的小技巧介绍,是很好的入门课程。
  2. 苹果官方 SwiftUI 课程:打开Xcode,照着官方的教学,从头到尾学着做一遍应用。
  3. Hacking with swift:这是国外一个程序员用业余时间搭建的分享网站,有大量的文章可以阅读,还有推荐初学者跟着做的「100 Days of SwiftUI」课程。
  4. 苹果官方文档:虽然很多文档缺乏工程细节,但是文档涉及很多概念性的内容,你可以知道官方是怎么思考的,并且有很多具体的机制参数
  5. SwiftUI的 View 如何布局?

  1. https://www.zhihu.com/question/327763737

  2. https://sspai.com/post/65567

  3. https://juejin.cn/post/6844903999762595854

  4. https://www.jianshu.com/p/8b05d84fe411

  5. https://www.jianshu.com/p/72fc8a2f530f

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

推荐阅读更多精彩内容