写点什么

Qunar SwiftUI 的实践、评测与思考

发布于: 2021 年 07 月 27 日

赵龙,2020 年加入 Qunar,担任大前端 iOS 开发,OC、 SWIFT、 C++、Dart 等技能丰富,喜欢优化开发流程,研究增加效率的代码和开发方式。


林书辉,2018 年加入 Qunar,iOS、RN 开发工程师。目前负责大客户端公共产品首页、用户中心等功能的开发和维护。持续关注学习前沿的大前端技术,推崇技术创新带来的效率优化和性能提升。现致力于 Native+DSL 动态化组件方案的开发与推广。



一. 前言


SwiftUI 出现已经 2 年,至今尚未大规模推广落地,它局限在 iOS 生态内,暂时闭源的 UI 库,需要 iOS13 版本来适配,这些因素阻碍了更多人使用,但是其实它相对于其他 UI 框架具有非常高的开发效率与运行效率,相对于 Objective-C+UIKit 更是一个全面的框架升级。写这篇文章是为了让大家熟悉 SwiftUI ,让客户端同学在技术选型的时候有切实的数据和特性来参考,也希望推进大客户端的 Swift 基础设施建设。



二. SwiftUI 代码库


iOS 中 SwiftUI 由 2 个底层框架驱动 SwiftUI.framework 与 Combine.framework 其中 SwiftUI.framework 负责界面搭建,简洁的 DSL 相比 OC 让开发效率提升不少,例如我们要实现在屏幕中心实现一个带文本按钮, OC 中我们一般这样写:

- (void)viewDidLoad {    CGRect screen = [[UIScreen mainScreen] bounds];    UIButton * centerBtn = [UIButton buttonWithType:UIButtonTypeCustom];    [centerBtn setFrame:CGRectMake(screen.size.width / 2 - 30, screen.size.height / 2 - 15, 60, 30)];    [centerBtn setTitle:@"测试按钮" forState:UIControlStateNormal];    [self.view addSubview:centerBtn]; }
复制代码


而在 SwiftUI 中我们这样写:

var body: some View {    Button(){       Text("测试按钮")         .frame(width: 60, height: 30, alignment: .center)    }}
复制代码


Combine.framework 负责生成数据流绑定操作与界面,基于观察者模式,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。只不过,这些框架对这个模式进行了一点扩充,在被观察者与观察者之间引入了可选的转换操作(操作符:Operators)。



三. 渲染流程


以我们去哪儿小组件一个页面左下角文本的渲染为例。


Text(self.title)        .font(Font.system(size:14))        .fontWeight(.semibold)        .foregroundColor(.white)        .frame(alignment: .center)    .offset(x: -3.5, y: 2)
复制代码

进入页面时每个元素(例子中的 Text )由代码底层开始,每个单元(每个.开头的一行)进行链式组装返回一个 view ,之后通知它的上级单元(代码中的上面一行),把下面单元生成的 view 当做参数,生成新的 view ,递归形成整个渲染树,进入流水线进行渲染。当状态发生变化时,首先会对比前后 View 声明的变化, SwiftUI 只会向流水线提交声明中不同的部分。



四. 生命周期


WWDC 2020 期间,苹果公布 SwiftUI 获得了新的的应用程序生命周期,以摆脱 UIKit 的 AppDelegate 和 SceneDelegate 的旧模式。基于此,iOS 14 现在提供了一个 App 协议、一个 SceneBuilder、 scenePhase 枚举器和一个新的属性包装器 UIApplicationDelegateAdaptor 。


遵循 APP 协议,我们可以用新的方式构建 UI,其中必须实现 SceneBuilder ,它本质上是一个用于构建一个或多个场景的函数构建器,使用 scenePhase 来获取场景当前状态,UIApplicationDelegateAdaptor 用来连接老的 app 生命周期方法。


新的生命周期代码执行顺序为:

相对 AppDelegate,它使用 WindowGroup 包装在需要监听生命周期的场景之外,可以方便快捷的访问页面生命周期方法,简洁而且可以让页面之间解耦。现有的渲染流程仍然遵循 iOS Cocoa 框架,使用 Core Animation 为渲染核心库。



五. 语言特点


SwiftUI 从根本上不是在构建 UI,而是在描述 UI 。这两者有什么区别?


构建 UI 时,你会明确每一个控件的类型,甚至精确到平台。比如在原生 UI 框架中,你需要一个输入框 ,你就要根据不同的平台,选择具体是使用 UITextFiled(iOS) 还是 NSTextFiled(macOS),而这两个输入框 有着不同的属性、外观和特性。在 Flutter 中,你不需要考虑不同平台的控件类型,但你需要考虑控件的风格,官方提供了符合 Material Design 的 Android 控件和 iOS Style 的 iOS 控件,它们的许多特性也不一致,这一切给 UI 构建带来了更加复杂的逻辑和工作量。


而 SwiftUI 更像是 Web 的模版,它只描述 UI ,而控件具体长什么样子,有什么特性,会根据编译的平台因地制宜的实现,而且是自动的。这就像是给 Web 套上了一个模版,一个主题。接下来展示一个控件 Picker,相同的代码在不同平台上不同的表现。



你会发现,同样的代码,展示出来的 UI 却完全不一样,但 UI 表达的内容确实完全一致的。Picker 控件,在 iOS 上被翻译成了一个选择页面,有滚轮选择效果。而在 macOS 上,Picker 则会被翻译成我们桌面操作系统上常见的下拉列表。它完全符合了平台设计语言和用户使用习惯,同时又极大的降低了开发和适配难度。


从这一点上来看,SwiftUI 和 RN 有着更为相似的思路和技术。只不过 Facebook 无论对 iOS 还是 Android 都几乎没有任何话语权,因此在兼容性、一致性上都跟 SwiftUI 或者将来的谷歌自有跨平台框架有差距。



六. SwiftUI 数据状态和绑定


SwiftUI 分三种方式绑定数据与界面。

1.@State & @Binding

提供 View 内部的状态存储,应该是被标记为 private 的简单值类型,仅在内部使用。

2.ObservableObject & @ObservedObject

针对跨越 View 层级的状态共享,处理更复杂的数据类型,在数据变化时触发界面刷新。

3.@EnvironmentObject

对于 “跳跃式” 跨越多个 View 层级的状态,更方便地使用 ObservableObject,以简化代码。它们在使用时,都是通过在对应的属性前加以上修饰符,使用 Propertywrapper 的包装使得属性成为被观察者,如果它们有变化,就会直接通知对应范围内的 UI 刷新。可以看到,苹果在 SwiftUI 引入了前端开发中渐成主流的响应式编程思想,开发者基于苹果提供的原生 API ,就可以轻松实现视图和逻辑处理的解耦,降低了代码的维护成本,从而让开发者可以将更多精力投入业务的实现。反观目前 Qunar 正在使用的 Objective-C+UIKit 的开发模式,如果开发者想要体验响应式编程,则面临着引入一系列第三方框架(如 ReactiveCocoa)、代码风格受语法限制等一系列问题。从这一点上, SwiftUI 相比于 Objective-C 的优势是十分明显的。



七. SwiftUI 的一些语法特性


1.Opaque Result Type

静态语言中返回对象类型必须在编译时确定,而一些对象在代码中又想隐藏自己的类型,对外使用协议类,对此 SwiftUI 给出了自己的解决方案,不透明返回类型,一句话概括,它就是一个泛型实例化的时候,不依赖调用者指定类型的一种语法特性。看一个例子:

func reverseGeneric() -> some Shape { return Rectangle(...)  }let x = reverseGeneric()// type(of: x) == Rectangle// 并且 x 的类型根据 reverseGeneric 的具体实现决定
复制代码
2.Propertywrapper

这个特性可以在属性 Get、Set 的时候,将部分可复用的代码包装起来,用一个 @符接一个自己定义的属性名称来使用这种能力。

3. FunctionBuilder

这个特性是一种语法糖,可以用方法参数接受隐式闭包构建复合视图,换句话说就是可以在组件构建参数里直接写我们要构建的内容,而不需要各种语义控制符,例如冒号,逗号,声明参数类型,闭包类型等。看一个例子:

VStack(alignment: .leading) {    Text("Hello, World")    Text("Longzhao.zhao")}
复制代码

合理利用这些特性,在去哪儿 iOS 小组件中,用大约 600 行的代码就实现了登录,酒店,机票,火车票的状态展示。



八. SwiftUI 比较其他声明式 UI 框架


声明式 UI 框架,带给了开发者优秀体验,相比于使用类似 iOS 中的 UIKit 等命令式 UI 框架搭建 UI 界面,声明式 UI 框架让开发者更像在使用自然语言描述自己的需求。从代码维护的角度来看,一个复杂的 UI 结构,如果使用命令式 UI 框架,很难直观的让开发者一眼看出某段代码想要实现一个什么样的 UI 效果,而规范的声明式的代码甚至会给人一种“所见即所得”之感。总结一下,目前业内普遍认为声明式 UI 较命令式 UI 理念上更为先进的原因,主要表现在如下几点:


1.适合做一次开发,多种不同的设备类型自适应。

2.显示 UI 和控制逻辑通过响应式思想与数据进行绑定,实现解耦。

3.更易于实现 UI 控件的局部刷新机制。


我们挑选了目前移动端比较主流的另外两个声明式 UI 框架 – Flutter、RN,与 SwiftUI 进行比较,希望可以给大家一些启发。比较分四个维度:

关于语法:

SwiftUI 得益于 Swift5.1 加入的 Function Builder 与 Opaque return types(非透明返回类型)特性,增强了构建内置 DSL 的能力,使代码变得简洁有效。配合 FuctionBuilder、尾随闭包(Trailing closure)、省略 return(当函数体中只有单独一个表达式,就会自动添加一个 return,返回这个表达式的值)等 Swift 语言特性, SwiftUI 实现了直观的代码风格,效果如下:

struct TripCardContentView: View {    var cardData: CardData?    var body: some View {        switch cardData?.cardType {              case CARD_TYPE_FLIGHT:                    FlightCard(cardData: self.cardData as? FlightData)              case CARD_TYPE_TRAIN:                    TrainCard(cardData: self.cardData as? TrainData)              case CARD_TYPE_HOTEL:                    HotelCard(cardData: self.cardData as? HotelData)          default:            Spacer()        }    }}
复制代码


最终根据实际业务呈现的效果:



RN 使用 JSX 来模拟 HTML 标签的方式,更符合 web 开发的思维,个人认为各有优势相比之下, Flutter 由于没有如此强大的内部 DSL ,所以在开发体验上会有一些不方便,虽然 Flutter 团队通过编辑器的提示插件、保存时强行格式化等方式进行了一些开发体验优化,但是总体上还是不如前两者


基于这些语言特性我们大大加快了开发速度,小组件机票展示业务从零开始搭建 Swift 环境(包含工程适配,打包平台支持)只花了 7 天就可以上线,酒店和车票展示业务随后开发,代码耗时 2pd 。

关于热加载:

SwiftUI 的实时预览分为静态预览和动态预览。默认情况下展示的是静态预览,它的速度快,而且支持代码和可视化两种编写方式。不过它没有任何响应事件,无法滚动和跳转页面。如果需要动态调试,则需要切换到动态预览。动态预览需要经过一段编译时间,然后可以以完全动态的形式实时响应 UI 的变化,而且可以在真机上实时调试。但动态预览的限制还比较多,无法做到 Flutter 那种只需编译一次,之后整个 App 都能实现动态更新。


RN 的基于 HMR(Hot Module Replacement 模块热替换机制)实现了 hot reload,体验还是很不错的,但是在实际使用中,经常会出现代码写了一半,因为编辑器自动保存触发了 watchMan 的回调,走到了 hot reload 逻辑,从而造成红屏。


Flutter 是通过将更新后的源代码文件注入正在运行的 Dart 虚拟机(VM)中来实现的热重载。在虚拟机使用新的的字段和函数更新类后,Flutter 框架会自动重新构建 widget 树,以便开发者快速查看更改的效果。但是由于静态字段惰性初始化等 Dart 语言特性,在一些场景下,如:更改全局变量和静态字段的初始值设定项、枚举类型更改为常规类或常规类更改为枚举类型等,热重载后会无效,需要完全重启应用才可以。


总体来讲,三个框架的热重载机制体验差不多,都达到了可以实时预览样式的目的,这提升了搭建 UI 时的工作效率。但是对于一些涉及到逻辑的代码变更,还是完全重载来的更为稳妥。

跨平台方面:

目前 SwiftUI 支持 iOS、watchOS、iOS14 小组件、MacOS 平台的 APP 开发,理论上是可以一套代码编译出多个平台的应用,但是基于用户体验的考虑,苹果官方并不推荐为不同的设备采用一套 UI 设计,他们更加推崇 “Learn once, apply anywhere” 。相比于 RN 和 Flutter 可以横跨 OS、Android(Flutter 最新 release 版还支持 windows、MacOS、web 平台),SwiftUI 更关注于苹果自身各个平台的编码统一性和体验的提升,在这一点上,Swift 的理念和其他两个是有一些区别的。

性能方面:

通过超长列表视图、大批量动画同时播放、大量视图旋转和缩放等测试场景,我们得出了以下结论:在以上场景中,SwiftUI 不出预料确实具有最强的性能。其次就是 Flutter 和 RN。但是如果一定要考虑跨平台的需求,在 CPU 占用率很高的业务中应避免使用 RN,相比之下 Flutter 更加适合这种任务。



九. SwiftUI 在 Qunar 大客户端落地场景的思考


以上我们从不同的维度比较了各种声明式 UI 框架。我们认为所有的技术都有适合它的场景,抛开场景来做选择必然会带来技术方案选型不合适的问题。下面我们也基于 Qunar 大客户端存在的一些具体场景,进行了上面提及的技术的一些落地的思考,总结如下:


1.对一些性能要求较高,同时为了节约效率而倾向于跨平台开发的页面,可以使用 Flutter 进行开发。但是由于目前 Flutter 还没有官方的动态化热更新方案,所以新功能的迭代,还需要依赖发版来解决。关于 Flutter 框架在大客户端的支持,公司的开发同事已经有了很大的进展,非常期待后续 Flutter 技术在公司全面应用。应用场景方面, IM 聊天会话页,对页面的 UI 一致性要求高,同时要保证绘制的效率,会是 Flutter 落地的理想场景.


2.对一些性能要求较低,更倾向于与高效率跨平台开发及快速迭代需要热更新的页面,可以选择 RN 。这也是去哪儿目前比较主流的解决方案了,它的热更新和越来越丰富的 API 为客户端的功能迭代带来了很大的效率提升,但是由于 RN 框架的机制,注定了一些对性能有苛刻要求的页面不得不去考虑其他方案。


3.针对一些对性能有极高要求的页面,而且可以接受 iOS 与安卓分别投入人力进行开发,并且跟版更新,如大客户端首页、登录页等,目前 native 仍然是不可替代的方案。native 端的 UI 实现可以考虑使用 UIKit 和 SwiftUI 框架进行开发,但是由于 SwiftUI 的最低支持 iOS 版本为 iOS13.0,而我们的大客户端目前的最低支持版本为 iOS10.0,所以在未来 iOS13+ 成为绝对占有率很高的系统之后,这些页面可以考虑首选转型为 swiftUI 为框架实现,它迎合了现在首页的几个需求:始终拥有最高帧率表现,能最大化降低首页启动时间,崩溃与卡顿定位最精确,保持 iOS  新特性最快最全面的支持


4. Objective-C 无论开发还是运行效率都已落后,如果可以有最低版本为 iOS13 的场景, SwiftUI 都是更好的选择,老的 OC 框架只有在对接有历史包袱的需求可能有一些优势


5.因为 Swift 与 Objective-C 可以通过桥接互相调用,现阶段让 Swift 兼容老的 OC 、RN、Flutter 代码最经济的方式是使用 Swift 建桥链接必要的 OC API。


6.目前我们已经使用 SwiftUI 框架实现了大客户端 iOS14 小组件,从 20 年 11 月上线到现在,支撑起了 iOS 桌面待出行行程信息展示的高频场景。



十. Objective-C 迁移 Swift 与 SwiftUI


Swift 语言是 iOS 开发的方向,我们也调研了向 Swift 与 SwiftUI 迁移的可行性,大部分 OC 代码都可以直接找到对应 API 翻译成 Swift,有一些特性需要注意,例如 @synchronized,dispatch_once 等在 Swift 中已经移除,需要自己写一个类似功能来实现。


举一个自己实现这些特性的例子:

//swift同步锁func synchronized(_ lock: AnyObject, block: () -> Void) {    objc_sync_enter(lock)    block()    objc_sync_exit(lock)}//swift dispatch_oncepublic extension DispatchQueue {   private static var _onceTokens = String()   public class func once(token: String, block:()->Void) {      objc_sync_enter(self)      defer { objc_sync_exit(self) }      if _onceTokens.contains(token) {         return      }      _onceTokens.append(token)      block()   }}
复制代码

现在有没有将大客户端的代码全部迁移到 Swift 的必要呢?从迁移成本和收益上考虑,没有太大必要,比较可行的方案是新增一部分功能的 Swift 框架,如网络库、appInfo、通用服务等,框架内部通过桥接封装现有的 OC 框架,提供 Swift 版本的 api 供业务线使用。这样后续 native 代码实现的功能,会多一种 Swift 实现的选择,从而实现逐步迁移。



十一. 总结


Swift 做为苹果的战略语言已经发展的越来越壮大,自 2019 年 Swift ABI 稳定后,苹果更是在全力支持 Swift 。我们可以进入苹果源码库 看到, 自 iOS 13 以来 苹果新增了约 10+个纯 Swift 库, 10+个开源 Swift 库, 以及针对 144 个公开 Framework,遵守 Swift 风格 重新设计了 57 个 Framework 的 API。基于以下事实:


第一.从 WWDC2017 后 苹果已经不再使用 Objective-C 做代码演示;

第二.https://developer.apple.com/ 已经不再更新 Objective-C 相关的文档;

第三.WidgetKit 只支持 Swift,没有支持 Objective-C;

第四.App Clips 限制了包大小为 10M, SwiftUI 是最合适的框架;

第五.开源社区中 Objecive-C 比例逐渐降低甚至废弃 如 Lottie。


可以判断,Swift 是未来 Apple 平台的唯一选择,我们如果可以在 Native 开发中尽早甩掉包袱使用 Swift ,就可以在下一步与其他 APP 的开发效率及运行效率的比较中取得优势。Qunar 大客户端开发中我们可以根据以上列举的数据和方案自行做出最适合需求的技术选型,选择了 Swift 语言或者 SwiftUI 框架也可以这篇文章也可以对照这里的实现细节作为参考。去哪儿 iOS 大客户端已经支持 Swift&OC 混编,各业务线可以直接加入 Swift 文件参与本地编译和 CM 打包,以后我们也会积极推进建立一套大客户端的 Swift 基础框架来支持公司向 Swift 和 SwiftUI 方向发展。

发布于: 2021 年 07 月 27 日阅读数: 419
用户头像

还未添加个人签名 2020.11.28 加入

还未添加个人简介

评论

发布
暂无评论
Qunar SwiftUI 的实践、评测与思考