在UIKit框架中嵌入SwiftUI页面

772 阅读8分钟

介绍文章之前,先来看看第一个用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 或者逻辑,从而使状态管理愈发复杂,最后甚至不可维护而导致项目失败。

声明式的界面开发方式

近年来,随着编程技术和思想的进步,使用声明式或者函数式的方式来进行界面开发,已经越来越被接受并逐渐成为主流。大家比较熟悉的ReactFlutter 就是采用这种方式,这一点上 SwiftUI 也几乎与它们一致。总结起来,这些 UI 框架都遵循以下步骤和原则:

  1. 使用各自的 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")
     }
    
  2. 接下来,框架内部读取这些 view 的声明,负责将它们以合适的方式绘制渲染。

    这些 view 的声明只是纯数据结构的描述,而不是实际显示出来的视图,因此这些结构的创建和差分对比并不会带来太多性能损耗。相对来说,将描述性的语言进行渲染绘制的部分是最慢的,这部分工作将交由框架以黑盒的方式为我们完成。

  3. 如果 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)
    }
}