介绍文章之前,先来看看第一个用SwiftUI构建的页面:
1.SWiftUI简介
SwiftUI 于 2019 年度 WWDC 全球开发者大会上发布,它是基于 Swift 建立的声明式框架。该框架可以用于 watchOS、tvOS、macOS、iOS 等平台的应用开发,等于说统一了苹果生态圈的开发工具。
SwiftUI is a user interface toolkit that lets us design apps in a declarative way. 可以理解为 SwiftUI 就是⼀种描述式的构建 UI 的⽅式。
2.为什么需要 SwiftUI
UIKit 面临的挑战
从 iOS SDK 2.0 开始,UIKit 已经伴随广大 iOS 开发者经历了接近十年的风风雨雨。UIKit 的思想继承了成熟的 AppKit 和 MVC,在初出时,为 iOS 开发者提供了良好的学习曲线。
UIKit 提供的是一套符合直觉的,基于控制流的命令式的编程方式。最主要的思想是在确保 View 或者 View Controller 生命周期以及用户交互时,相应的方法 (比如 viewDidLoad 或者某个 target-action 等) 能够被正确调用,从而构建用户界面和逻辑。不过,不管是从使用的便利性还是稳定性来说,UIKit 都面临着巨大的挑战。UIKit 的基本思想要求 View Controller 承担绝大部分职责,它需要协调 model,view 以及用户交互。这带来了巨大的 side effect 以及大量的状态,如果没有妥善安置,它们将在 View Controller 中混杂在一起,同时作用于 view 或者逻辑,从而使状态管理愈发复杂,最后甚至不可维护而导致项目失败。
声明式的界面开发方式
近年来,随着编程技术和思想的进步,使用声明式或者函数式的方式来进行界面开发,已经越来越被接受并逐渐成为主流。大家比较熟悉的React 和 Flutter 就是采用这种方式,这一点上 SwiftUI 也几乎与它们一致。总结起来,这些 UI 框架都遵循以下步骤和原则:
-
使用各自的 DSL 来描述「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 进行差分,之后框架负责对变更的部分进行高效的重新绘制。
SwiftUI 的思想也完全一样,而且实际处理也不外乎这几个步骤。使用描述方式开发,大幅减少了在 app 开发者层面上出现问题的机率。
3.为什么到现才用SwiftUI构建第一个页面
下面是我从其他开发者在使用SwiftUI框架时,遇到的一些问题,在我对Me模块进行开发时候,确实也遇到了一些相同的困境:
1. SwiftUI 中并非所有东西都可用
有许多组件缺失、不完整或过于简单,我将在下面详细介绍其中一些组件,确实组件可以用UIKit组件来弥补,使用UIViewRepresentable UIViewControllerRepresentable允许将 UIKit 视图和控制器嵌入到 SwiftUI 视图层次结构中。UIHostingController允许在 UIKit 中嵌入 SwiftUI 视图。Mac 开发存在相同的三个(NSViewRepresentable等)。这些桥梁是弥补 SwiftUI 缺失功能的一个很好的止损,但它并不总是一种无缝体验。此外,虽然 SwiftUI 的跨平台承诺很棒,但如果某些东西不可用,仍然需要为 iOS 和 Mac 实现两次桥接代码,那么体验也是糟糕的,比如Me模块是SwiftUI页面,嵌入到UIkit构建的Tabbar控制器中:
let v4 = HiBaseNavController(rootViewController: UIHostingController(rootView: HiinternMePage(action: {
//swiftUI 与 UIKit的交互,跳转至在线简历页面
if let rootVc = UIApplication.fastKeyWindow?.rootViewController as? HiBaseNavController, let tavVC = rootVc.viewControllers.first as? HiinternTabBarController , let navVC = tavVC.selectedViewController as? HiBaseNavController{
let vc = HiinternResumeController()
vc.hidesBottomBarWhenPushed = true
navVC.pushViewController(vc, completion: {
})
}
//tabbar rootVc
if let rootVC = UIApplication.fastKeyWindow?.rootViewController as? HiinternTabBarController, let navVC = rootVC.selectedViewController as? HiBaseNavController {
let vc = HiinternResumeController()
vc.hidesBottomBarWhenPushed = true
navVC.pushViewController(vc, completion: {
})
}
}
})))
2. NavigationView 还没有
如果想隐藏导航栏但仍然可以使用滑动手势,SwiftUI是不支持的。
3.文本输入非常有限
TextField,TextEditor现在太简单了,你最终会回到UIKit。我必须为 UITextField 和 UITextView 构建自己的 UIViewRepresentable(具有自动增长支持)。
4. 编译器挣扎
当视图变得更为复杂,并且自己已尽最大努力将其分解时,可以看到编译器已经无法正确的现实我们的SwiftUI预览页面了。
5. 共享扩展中的 SwiftUI
我可能是错的,但 Share Extensions 仍然使用 UIKit。我通过利用 SwiftUI 构建了一个共享扩展,UIHostingController在加载共享扩展时出现了明显的延迟,从而造成了糟糕的用户体验。可以尝试通过动画视图来屏蔽它,但它仍然有大约 500 毫秒的延迟。
6.荣誉奖
无法访问状态栏(无法更改颜色或拦截点击) @UIApplicationDelegateAdaptor需要,因为App仍然缺乏较好的生命周期管理API 没有向后兼容性 UIVisualEffectsView 在系统 < iOS15中导致滚动会有延迟
当我们开始着手我们项目的下一个大迭代时。我知道这个新项目的交互范围超出了 SwiftUI 当前支持的范围。知道 SwiftUI 在某些关键方面存在很多的不足,SwiftUI 会完全匹配 UIKit 吗?如果是这样,我们可能需要 3 到 5 年的时间来移植所有基本的 UIKit API。如果没有,那么你只能通过 UIKit 并用 SwiftUI 包装它。
除了以上这些客观因素之外自身对SWiftUI框架还未到一个100%掌握的状态,所以我认为目前,并不是一个可以完全使用SWiftUI框架去替换项目中的UIkit框架。
4.第一个SwiftUI页面
通过代码来看看SwiftUI的真面目:
import SwiftUI
private let iconNames = ["personal-account-icon","personal-setting-icon","personal-contact-icon"]
private let titleNames = ["Account","Settings","Contact Us"]
public struct HiinternMePage: View {//包装Me模块的根视图
public let editeResumeAction:()->Void//提供给UIKit的回调方法
public init(editeResumeAction:@escaping ()->Void){
self.editeResumeAction = editeResumeAction
}
@State var showSetting:Bool = false//导航栏控制状态
public var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
MePageHeaderItemView(editeAction: editeResumeAction)//头部View
MePageCareerGoalItemView()//目标职位
VStack(alignment: .leading, spacing: 0) {
ForEach(0 ..< 3) { i in
Button {
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "NotificationCenterHideTabbarName") , object: nil)
} label: {
MePageSubListItemView(isLastRow: i == iconNames.count-1, iconImageName: iconNames[i], itemText: titleNames[i])
}
}
}
Spacer()
}
}.background(Color(hex: 0xF6F6F6)).edgesIgnoringSafeArea(.all).navigationBarHidden(true)
}.navigationBarHidden(true)
}
}
struct HiinternMePage_Previews: PreviewProvider {
static var previews: some View {//预览页
HiinternMePage(editeResumeAction: {
})
.previewDevice("iPhone 11")
}
}
struct MePagePublicEditeItemView: View {
let editeAction:()->Void
var body: some View {
Button(action: editeAction) {
HStack {
Image("me-avator-icon", bundle: HiinternMeUtil.meBundle)
.resizable()
.frame(width: 52, height: 52, alignment: .leading)
.cornerRadius(26)
VStack(alignment: .leading, spacing: 6) {
Text("Profile : 51%")
.font(.system(size: 12))
.foregroundColor(Color("themeprimary1", bundle: HiinternMeUtil.meBundle))
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.white)
.cornerRadius(5)
HStack(alignment: .center, spacing: 0) {
Image("me-privacy-unlock", bundle: HiinternMeUtil.meBundle)
Text("Public")
.foregroundColor(.white)
.font(.system(size: 13))
}
}
Spacer()
HStack(alignment: .center, spacing: 4) {
Text("Edit").foregroundColor(.white).font(.system(size: 15))
Image("arrow-right-white", bundle: HiinternMeUtil.meBundle)
}.padding(.trailing,16)
}.padding(.leading,16).padding(.bottom,12)
}
}
}
struct MePageHeaderItemView: View {
let editeAction:()->Void
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 28) {
HStack {
Text("Name")
.font(.system(size: 20)
.weight(.semibold))
.foregroundColor(.white)
.padding(.leading,16)
Spacer()
}.padding(.top,56)
MePagePublicEditeItemView(editeAction: editeAction)
}.background(
ZStack(alignment: .trailing, content: {
RoundedRectangle(cornerRadius: 0)
.fill(
LinearGradient(
gradient: Gradient(colors: [Color(hex: 0x0074FF), Color(hex: 0x99C7FF)]),
startPoint: .leading,
endPoint: .trailing
)
).cornerRadius(15, corners: [.bottomLeft,.bottomRight])
})
)
Image("me-bg-icon", bundle: HiinternMeUtil.meBundle)
}
}
}
struct MePageCareerGoalItemView: View {
var body: some View {
HStack(alignment: .center, spacing: 12) {
Image("me-goals-icon", bundle: HiinternMeUtil.meBundle)
VStack(alignment: .leading, spacing: 0) {
Text("Career Goals").foregroundColor(Color("grey800", bundle: HiinternMeUtil.meBundle)).font(.system(size: 17)).fontWeight(.semibold)
Text("What are you passionate about?")
.foregroundColor(Color("grey600", bundle: HiinternMeUtil.meBundle))
.font(.system(size: 15))
}
Spacer()
Image("arrow-right-gray", bundle: HiinternMeUtil.meBundle)
}
.padding(.horizontal,16)
.padding(.vertical,11).background(Color.white)
}
}
struct MePageSubListItemView: View {
var isLastRow:Bool = false
var iconImageName:String = "personal-account-icon"
var itemText:String = ""
@State var showDetail = false
var body: some View {
NavigationLink(isActive: $showDetail) {
HiinternSettingPage(showSelf: $showDetail)
} label: {
HStack(alignment: .center, spacing: 14) {
Image(iconImageName, bundle: HiinternMeUtil.meBundle).padding(.leading,16)
VStack(alignment: .center, spacing: 0) {
HStack {
Text(itemText).foregroundColor(Color("grey800", bundle: HiinternMeUtil.meBundle)).font(.system(size: 17))
.fontWeight(.semibold).frame(height: 24)
Spacer()
Image("arrow-right-gray", bundle: HiinternMeUtil.meBundle).padding(.trailing,16)
}.padding(.vertical,11)
HiDivider(color: Color(hex: 0xF6F6F6), width: isLastRow ? 0 : 1)
}
}.background(Color.white)
}
}
}
struct HiDivider: View {
var color: Color = .gray
var width: CGFloat = 2
var body: some View {
Rectangle()
.fill(color)
.frame(height: width)
.edgesIgnoringSafeArea(.horizontal)
}
}

