关于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中,不能给子视图强制规定一个尺寸
父view为子view提供一个建议的size
子view根据自身的特性,返回一个size
父view根据子view返回的size为其进行布局
struct ContentView: View {
var body: some View {
Text("Hello, world")
.border(Color.green)
}
}
- ContentView是Text的父view,为Text提供一个建议的size(全屏尺寸)
- 然后Text根据自身的特性,返回了它实际需要的size
(注意:Text的特性是尽可能的只使用必要的空间,也就是说能够刚好展示完整文本的空间) - 然后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法则考虑这个问题
在考虑布局的时候,是自下而上的!!!
- 我们先考虑ContentVIew,它的父view给他的建议尺寸为整个屏幕的大小
- ContentVIew去询问它的child,它的child为下边的那个frame,返回了width200, height50, 因此frame告诉ContentView它需要的size为width200, height50,因此最终ContentView的size为width200, height50
- 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
- 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)
}
}
- @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")
}
}
}
}
- @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")
}
}
}
- 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环境直接取出来就行。
- @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 外部的数据:
- 网络或本地存储的数据
- 界面之间互相传递的数据
总结:
- View与View间的公用数据使用@State + @Binding。
- 多个View与Class间的公用数据:对View用@ObservedObject,让Class满足ObservableObject协议。
- 父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
}
}
学习资料
- 斯坦福公开课 CS193P·2020 年春:该课程强推,我当年学习 OC 看的就是它,现在到SwiftUI了还是先看这个,系统且细致,结合案例和编程过程中的小技巧介绍,是很好的入门课程。
- 苹果官方 SwiftUI 课程:打开Xcode,照着官方的教学,从头到尾学着做一遍应用。
- Hacking with swift:这是国外一个程序员用业余时间搭建的分享网站,有大量的文章可以阅读,还有推荐初学者跟着做的「100 Days of SwiftUI」课程。
- 苹果官方文档:虽然很多文档缺乏工程细节,但是文档涉及很多概念性的内容,你可以知道官方是怎么思考的,并且有很多具体的机制参数
- SwiftUI的 View 如何布局?