Swift 在手淘商品评价的技术重构与实践
作者:王浙剑(柘剑)
手淘新版商品评价列表在经历一个半月的技术重构,几个月的迭代和放量,最终在 2021 年的双十一上,以 100% 的流量稳定的跑完了整个过程。我们不仅在业务上有了比较明确的提升,同时还沉淀了不少技术探索,比如沉淀基于 DinamicX + 事件链编排的轻模式研发框架、推动原生语言升级成 Swift/Kotlin,最终使得整体研发效率和稳定性有一个比较大的提升。(注:DinamicX 为内部自研动态化 UI 框架)
这篇文章,我会重点谈论关于 Swift 的部分。如果你想了解关于 Swift 如何提升研发效率/质量、现有项目/模块是否需要 Swift 作为原生语言如何选型、在商品评价落地 Swift 过程中我们遇到了哪些问题以及最后有哪些收益和结论的一些问题,希望这篇文章可以给你带来一些帮助。
首先是,我为什么会选择学习 Swift?
技术变革,未来已来
因为,我内心十分坚定,相比较于 OC,Swift 更能承载未来。
坚强后盾
最主要的原因就是它有一个坚强的后盾,Swift 作为 Apple 未来最重要的开发语言,光对外输出的 WWDC 内容就已经高达 73 个,包括但不限于语法、设计、性能、开发工具链等,具体内容如图所示:
回过头来看 Swift 这几年的发展,从 2014 年开始正式对外发布,到现在已经经历了 7 个年头了,在整个过程中,Apple 投入了大量精力建设 Swift,尤其是 Swift Only 框架的出现,也意味着 Apple 正在积极倡导各位投入到 Swift 开发中来。
三大优势
其次,Swift 有三个比较明确的优势: 更快、更安全且更具备表达性。
更快 是指 Swift 在执行效率上做了很多优化。比如,Swift 系统库本身就采用了很多不需要引用计数的基础类型,无论是内存分配大小、引用计数损耗、方法派发静态分析等方面的问题都得到了一个有效的提升。具体细节这里就不展开分析,感兴趣的可以移步 Understanding Swift Performance 了解细节。
所谓的 安全 不等于不发生 Crash,而是指任何的输入都有一个比较明确的表现定义。Swift 设计初衷是希望开发者无需任何不安全的数据结构就能编写代码,因此 Swift 拥有一个十分健壮的类型系统,开发者几乎不需要考虑指针的问题,就能完成所有的开发工作。同时还提供了一系列前缀为 Unsafe 的类型或函数,用于与不安全语言(例如 C 语言)的高性能交互、操作原始内存等相对不安全的操作,一方面以 Unsafe 警惕开发者使用这些 API ,另外一方面是区分类型以保证大部分开发场景使用的都是安全的类型。
这里可以分享一个数据,我之前参与的一个 App 项目,是用 Pure Swift 编写的(99%+),我们的线上 crash 率常年持续在十万分之 8 左右,这对于一个小型团队(单端 4 人)来说,是一个十分可观的结果。我们几乎不使用 Unsafe 的 API,使得我们的大部分问题都能在编译期间避免,可选类型及可选绑定的设计强制开发者需要去思考如何处理值为空的场景,使得在软件发布之前就把开发者的错误扼杀在萌芽之中。
更具备表达性 简单点说就是用更少的代码来表达一段完整的逻辑。在 Swift Evolution 项目中已经有 330 个提案来增强 Swift 的表达性,也得益于这些特性,使得 Swift 的代码量比 OC 少了大概 30% - 50% 左右。我们举几个实际例子
Builder Pattern
当我们定义了一个有很多属性的复杂 model 时,我们不希望这个 model 的属性在初始化完成后可以被变更。我们就需要通过 builder 模式来解决,代码如下:
但是 Swift 的 Struct 支持属性默认值和初始化构造器,使得 builder pattern 意义并不是很大,代码如下:
State Pattern
当一个函数的执行结果可能存在多种不同的状态时,我们通常会采用状态模式来解决问题。
例如我们定义一个函数执行结果可能存在 finish\failure\none 三种状态,由于存在一些关联值,我们不能使用枚举来解决。需要定义三个不同的具体类型,具体代码如下所示:
但是如果我们使用 Swift 的 enum 特性,代码就会变的简洁很多很多:
Facade Pattern
当我们定义一个入参需要符合多个协议类型时,我们通常会使用 Facade Pattern 来解决问题。
例如我们有四个协议 JSONDecodable、JSONEncodable、XMLDecodable、XMLEncodable 以及一带有两个入参的方法,入参 1 为 json 要求同时满足 JSONDecodable、JSONEncodable 两个协议,入参 2 为 xml 同时满足 XMLDecodable、XMLEncodable。当我们使用 OC 来解决问题时通常会这么写:
额外定义了两个协议 JSONCodable、XMLCodable 来解决这个问题。但是在 Swift 中我们可以使用 & 来解决这个问题,不再需要定义额外的类型,代码如下:
以上是 Swift 在 更具备表达性 方面的一些内容,当然优势也远不止这些,但是篇幅有限这里不再展开。
总而言之,得益于 Swift 的高表达性,使得开发者可以通过更少的代码可以表达一段完整的逻辑,在一定程度上减少了开发成本,同时也降低了问题的产生。
势不可挡
Swift 除了有一个坚强的后盾以及三大优势以外,这几年的发展趋势也比较好。
首先根据 Githut 显示,Swift 语言在 Github 的活跃度(Pull request) 已经超越了 OC 了,如下图所示:(数据截止至 2021/10/25)
同时,国内 Top 100 的 Swift 混编应用也有明显增加,从 19 年的 22% 已经上升到了 59%:(数据截止至 2021/04/22)
这里的提升,一方面是国内许多一线互联网公司都开始布局,另外一方面是 WidgetKit 等 Swift Only 的框架出现也在促使大家开始建设 Swift 基础设施。
当然,国外数据更加亮眼,已经达到了 91%,几乎可以说是全部都已经用上了,为什么这么说呢?因为美版前 100 中 Google 系有 8 个应用都没有使用上 Swift。
这里再和大家分享一个数据,在业余时间组织《WWDC 内参》作者招募的时候,我们收集了作者的技术栈和兴趣点,最终发现有超过一半的作者有比较丰富的 Swift 开发经验,还有 2/3 的人对 Swift 这个专题的内容比较感兴趣(总共 180 人样本)。可以看得出社区对于 Swift 的热情还是十分高的,长远角度看,是否使用 Swift 进行开发也会成为大家选择工作的原因之一。
为什么选择商品评价列表?
也许很多人在看到第一部分之后,会有一种我得马上在我们项目中用上 Swift 的冲动。为了避免你为你的“冲动”买单,下面我分享一下「手淘商品评价列表」选择 Swift 的心路历程。
先简单讲下自己来手淘的经历,起初我加入的是手淘基础架构组,主要工作职责之一就是建设 Swift 基础设施,但是后来因为组织需要,我加入到了一个新的业务架构组,工作重心也由原来的从 Swift 基础升级驱动业务,转变成业务试点驱动基础技术升级。在这个过程中,我们主要经历了三次技术决策:
一. 团队最开始接手的项目:手淘订单协议升级为新奥创
二. 基于对业务研发的领域理解,团队提出新的事件链编排能力,并与 DX 共建
三. 商品评价重构,包括评价列表、交互等
每个阶段我都有思考过我是否要使用 Swift,但最终前两次我都放弃了使用我自己比较擅长的 Swift,主要出于下面几点考虑:
需要具备使用 Swift 的前提
订单新奥创项目之所以没有采用 Swift 为主要开发语言,最大的问题就是当时的基本基础设施还不够完备。依赖的大部分模块几乎都不支持 Module,如果要硬上 Swift 几乎是不可能的事情,会增加很多的工作量,对于一个工期较赶的项目来说,不是一个明智之举,权衡之下,暂时放弃了使用 Swift 的念头。
什么样的业务更适合使用 Swift 重构
在基本条件都很完备的情况下,对于一个业务重构项目来说,Swift 会是一个更好的选择。无论是大环境的趋势,还是 Swift 独有的优势来说,已经不太适合继续使用 OC 去重构一个业务模块了。
对于想尝试 Swift 的大型项目来说,建议可以优先考虑包袱小、牵连小的业务做试点。当时我们在订单新奥创项目放弃使用 Swift 的另外一个重要原因就是因为奥创整体架构较为复杂,搭建和数据混合在一起、局部改动成本过高会导致牵一发而动全身的问题,整体对端侧新技术交互的开放包容有限。但是手淘商品评价就没有这类问题,可以选择的空间比较多,因此我们就比较坚定的选择了 Swift 作为端侧主要开发语言。
既要因地制宜、又要获取支持
当项目具备使用 Swift 的条件之后,一定要结合自身团队现状进行综合考虑。
首先,团队需要提前培养或者配备一位有 Swift 开发经验的人,来保证复杂问题的攻坚以及代码质量的把控。尤其是代码质量,大部分最初从 OC 接触 Swift 的人,都会经历一段“不适”期,在这段时期,很容易写出「OC 味」的 Swift 代码,所以特别需要一位有热情、有相关经验和技术能力的人来实践并表率。
同时,我们还需要获得主管的支持,这点很关键,光有技术热爱很难把一件事件持续做下去。需要结合项目情况持续与主管保持沟通,并且在交流过程中不断升级自己对一个技术的思考,让主管从最初的质疑到最后的支持,也是一个十分有趣的过程。
需要有一定技术基础支撑
首先,在基础设施完备性上,我们做了一次大范围的 Module 适配工作,解决了混编的核心问题。同时升级了 DevOps,将包管理工具 tpod 升级到了 1.9.1 支持了源码级别的静态库版本 framework 工程,同时还提供了 tpodedit 模式解决头文件依赖问题,以及在发布链路新增了一些核心卡口检查防止工程劣化。
其次,我们基于手淘已有的技术方案,权衡性能与效率之类的问题之后,最终我们结合对业务研发的痛点理解,开展基于事件链编排的研发模式升级探索,并从成本上考虑初期在 DX 内部共建、并输出到新奥创,整体架构如下所示:
在 UI 层,我们使用 XML 作为 DSL 保证双端一致性的同时降低了双端的开发成本。
在逻辑编排上,我们设计了事件链技术方案尽可能的原子化每一个端侧基础能力,从而保证端侧能力开发者可以聚焦在能力的开发上。
基于上述框架支持下,开发者可以自行决定单个基础能力所使用的开发语言, 对于新手使用 Swift 的上手成本,可以下降一个档次,不再需要和复杂的环境做斗争。
遇到了哪些问题?
坦率说,虽然我们在技术决策的时候做了深度思考,但当真的执行起来的时候,依旧遇到了不少问题。
基础库 API 并未适配 Swift
虽然 Xcode 提供了 “自动” 生成桥接文件的能力,但由于 OC 和 Swift 语法差异过大,大部分自动生成的 Swift API 并不遵循 “API Design Guidelines”,这会导致目前接入的 Swift 业务库写出很多可读性差且不好维护的代码。
同时,由于 Swift 的可选值设计,使得 OC SDK 提供给 Swift 使用时需要梳理清楚每一个对外的 API 入参和出参的可选设定。商品评价重度依赖的一个基础 SDK 就没有很好的做到这一点,以至于我们遇到了不少问题。
错误推导导致的不必要兼容
我们先看下,下面这段代码:
由于 DemoConfig 这个类并没有注明初始化方法返回值是否可选,以至于 Xcode 默认推导的 API 变成了。
开发者就不得不去思考如何解决初始化为空的场景,这显然是多余的。
除了 SDK 做可选语义适配以外,我们也可以新增一个分类,提供一个返回值不为空的 OC 方法,代码如下:
不安全 API
没有写清楚可选设定的 OC API 被桥接到 Swift 本质上都是不安全的。为什么这么说呢?
我们拿一个线上 Crash 真实案例来举例,堆栈如下:
客户端的实现代码如下:
导致 Crash 的原因是 context?.demoCtx.engine.value 这段代码。
本质原因是 demoCtx 未注明可选语义,导致 OC 桥接到 Swift 的时候默认使用了隐式解包。在读取过程中,如果值并没有值,会由于强制解包而直接产生 Unexpectedly found nil while implicitly unwrapping an Optional value 的 Crash。
要解决这个问题,除了 SDK 做可选语义适配以外,我们还可以可以把调用代码都改成可选调用避免强制解包的问题:
破坏性继承
在使用上面这个基础 SDK 遇到最大的问题就是 DemoArray 的破坏性继承。
DemoArray 继承自 NSArray 并且重写了不少方法,其中就有 objectAtIndex: 这个方法。
在 NSArray 头文件中清楚的定义了
objectAtIndex: 这个方法的返回值一定不为空,但是 SDK 在 DemoArray 这个子类实现 objectAtIndex: 这个方法时居然返回了 nil,代码如下所示:
这使得使用 Swift 开发 SDK 自定义 EventHandler 压根无法进行。
核心原因是实现一个 SDK 自定义 EventHandler 首先要符合 DemoEventHandler 协议,符合协议必须实现 - (void)handleEvent:(DemoEvent *)event args:(NSArray *)args context:(DemoContext *)context; 这个方法,由于协议上约定的是 NSArray 类型,因此转换成 Swift API args 就变成了 [Any] 类型,如下图所示:
但是 SDK 传给 DemoEventHandler 的类型本质上是一个 DemoArray 类型:
倘若 DemoArray 里面存在 [Null null] 对象,就会导致 attempt to insert nil object from objects[0] 的 Crash,如下图所示:
具体原因是在调用 handleEvent(_:args:context:) 时候,Swift 内部会调用 static Array.unconditionallyBridgeFromObjectiveC(:) 把 args 入参由 NSArray 转变成 Swift 的 Array,而在调用 bridge 函数的时候,会先对原数组进行一次 copy 操作,而在 NSArray Copy 的时候会调用 -[__NSPlaceholderArray initWithObjects:count:] ,由于 DemoArray 的 NSNull 被转变成了 nil,初始化会失败,直接 Crash。
要避免这个问题,让 SDK 修改 DemoArray 显然是不现实的,由于调用方实在是过多,无论是影响面还是回归测试成本短期内都无法评估。所以只能增加一个中间层来解决这个问题。我们首先设计了一个 OC 的类叫 DemoEventHandlerBox 用于包装和桥接,代码如下:
DemoEventHandlerBox 中有个类型为 SwiftyEventHandler 类用于逻辑处理,代码如下:
SwiftyEventHandler 暴露给 OC 的方法设置为 final 同时做好将 DemoArray 转回 NSArray 的逻辑兼容。最后 Swift 这边的所有 EventHandler 实现类都继承自 SwiftyEventHandler 并重写 handle(event:args:context) 方法。这样就可以完美避免由于破坏性继承导致的问题了。
Clang Module 构建错误
第二大类问题主要和依赖有关,虽然前文有提到,目前的基本基础设施已经完备,但依旧存在一些问题。
依赖更新不及时
很多人刚开始写 Swift 的时候,经常会遇到一个问题 Could not build Objective-C module,一般情况下的原因是因为你所依赖的模块并没有适配 Module,但由于手淘基础设施基本已经完备,大部分库都已经完成 Module 化适配,所以你可能只需要更新一下模块依赖就可以很好的解决这类问题。
例如 STD 这个库,手淘目前依赖的版本是 1.6.3.2,但当你的 Swift 模块需要依赖 STD 的时候,使用 1.6.3.2 会导致无法编译通过。这时候你的 Swift 模块可能需要升级到 1.6.3.3 才能解决这个问题。本质上 1.6.3.3 和 1.6.3.2 的区别就是模块化适配,因此你也不用担心会产生什么副作用。
混编导致的依赖问题
前文提到的 Module 适配虽然解决了大部分问题,但是还是存在一些异常 case,这里展开说下。
我们在商品评价重构的过程中,为了保证项目可以循序渐进的放量,我们做了代码的物理隔离,新创建了一个模块叫 SwiftyRateKit 是一个 Swift 模块。但是评价列表的入口类都在一个叫 TBRatedisplay 的 OC 模块。因此为了做切流,TBRatedisplay 需要依赖 SwiftyRateKit 的一些实现。但当我们将 TBRatedisplay 依赖了 SwiftyRateKit 开始编译之后,就遇到了下面这么一个问题:
Xcode 将暴露给 OC 的 Swift 类 ExpandableFastTextViewWidgetNode 的头文件声明写到了 SwiftyRateKit-Swift.h 中,ExpandableFastTextViewWidgetNode 是继承自 TBDinamic 的类 DXFastTextWidgetNode 的。
因为当时 TBRatedisplay 并没有开启 Clang Module 开关(CLANG_ENABLE_MODULES),导致 SwiftyRateKit-Swift.h 的下面这么一段宏定义并没有生效,因此就不知道 ExpandableFastTextViewWidgetNode 这个类是在哪里定义的了:
但当我们开启 TBRatedisplay 的 Clang Module 开关之后,更恐怖的事情发生了。由于 TBDinamic 没有开启 Clang Module 开关,导致 @import TBDinamic 无法编译通过,进入了一个“死循环”,最后不得不临时移除了所有没有支持 Clang Module 开关的 OC 模块导出。
这里概念比较抽象,我用一张图来表示一下依赖关系:
首先,对于一个 Swift 模块来说,只要模块开启了 DEFINES_MODULE = YES 且提供了 Umbrella Header 就可以通过 import TBDinamic 的方式导入依赖。因此 SwiftyRateKit 可以在 TBDinamic 没有开启 Clang Module 开关的时候就显示依赖,并可以编译通过。
但对于一个 OC 模块来说,导入另外一个模块分两种情况。
第一种是开启了 DEFINES_MODULE = YES 的模块,我们可以通过 #import <TBDinamic/TBDinamic_Umbrella.h> 导入。
第二种是开启了 Clang Module 开关的时候,我们可以通过 @import TBDinamic 导入
由于 TBRatedisplay 依赖了 SwiftyRateKit,Xcode 自动生成的 SwiftyRateKit-Swift.h 头文件采用的是 @import TBDinamic 的方式来导入模块的,因此就造成了上面的问题。
所以我个人建议现阶段要尽量避免或者减少将一个 Swift 模块的 API 提供给 OC 使用,不然就会导致这个 Swift 对外 API 需要依赖的 OC 模块都需要开启 Clang Module,同时依赖了这个 Swift 模块的 OC 模块也需要开启 Clang Module。而且,由于 Swift 和 OC 语法不对等,会让 Swift 开发出来的接口层能力非常受限,从而导致 Swift 对外的 API 变得相当不协调。
类名与 Module 同名
理论上 Swift 模块之间互相调用是不会存在问题的。但由于手淘模块众多,历史包袱过重,我们在做商品评价改造的时候遇到了一个「类名与 Module 同名」的苦逼问题。
我们个 SDK 叫 STDPop,这个 SDK 的 Module 名也叫 STDPop,同时还有一个工具类也叫 STDPop。这会导致什么问题呢?所有依赖 STDPop 的 Swift 模块,都无法被另外一个 Swift 模块所使用的,会报一个神奇的错误:'XXX' is not a member type of class 'STDPop.STDPop' 主要原因是因为依赖 STDPop 的 Swift 模块生成的 .swiftinterface 文件时会给每个 STDPop 的类加一个 STDPop 的前缀。例如 PopManager 会变成 STDPop.PopManager 但由于 STDPop 本身就一个类叫 STDPop 会就导致编译器无法理解 STDPop 到底是 Module 名还是类名。
而能解决这个问题的唯一办法就是需要 STDPop 这个模块移除或者修改 STDPop 这个类名。
具体有哪些方面的收益?
我们在一次深思熟虑之后,踏上了披荆斩棘的 Swift 落地之路,虽然在整个过程中遇到了很多前所未有的挑战,但现在回过来看,我们当初的技术选型还是比较正确的。主要体现在下面几个方面:
代码量减少,Coding 效率提高
得益于 Swift 的强表达性,我们可以用更少的代码去实现一个原本用 OC 实现的逻辑,如下图所示,我们不再需要写过多的防御性编程的代码,就可以清晰的表达出我们要实现的逻辑。
同时,我们对原有 13 个用 OC 实现的表达式,用 Swift 重新写了一遍,整体代码量的变化如下:
代码量的变少意味着需要投入开发的时间变少了,同时产生 bug 的机会也就变少了。
大幅降低交叉 Review 的成本
OC 奇特的语法使得大部分其他开发压根无法看懂具体的逻辑,从而导致 iOS 和 Android 双端交叉 Review 的成本相当之高,也会使得很多库经常存在双端逻辑不一致性。
当初在做订单迁移新奥创时,面对较多双端 API 不一致,且部分代码逻辑的味道较复杂,项目上发生过多起临时问题排查影响节奏的事情。
因此,我们另辟蹊径,采用 Swift & Kotlin 的模式进行开发,由于 Swift 和 Kotlin 的语法极度相似,使得我们交叉 Review 毫无压力。
同时,得益于商品评价使用的脚手架,后续需求迭代也大幅下降。我们以「评价 Item 新增分享按钮」为例:
如果采用 OC & Java 模式,因为双端代码都看不懂。所以需求评审都双端需要各派 1 名,加上讨论等各种事宜大概需要 0.5 人日。然后双端讨论方案后一个人进行模板开发,需要 1 人日左右。最后双端各自实现原生分享原子能力,需要各 2 人日左右(其中有 1 人日需要调研如何接入分享 SDK),总计 2 * 0.5 + 1 + 2 * 2 = 6 人日。
但是如果采用 Swift & Kotlin 的模式,我们只需要有 1 人取参加需求 Review,0.5 人日。单人完成技术调研、模板开发 3 人日左右。最后再把写好的代码给另外一端看,另外一端可以直接 copy 代码并根据自己端的特点进行适配 1 人日左右。总计 0.5 + 3 + 1 = 4.5 人日左右。大约节省 25% 的时间。
项目稳定性有所提高
因为没有比较好的量化指标,只能谈谈感受。
首先,由于编码问题导致的提测问题明显下降,基本上的异常分支流得益于 Swift 的可选值设计,都已经在开发阶段考虑清楚了,总体提测问题明显比使用 OC 时少了很多。
其次,线上问题也明显下降,除了上文提到的 Crash 问题。商品评价重构项目基本上没有发生线上问题。
优先享受技术红利
无论是 WidgetKit 还是 DocC,可以很明显的看得出来,苹果内部对于新特性以及开发工具链的升级一定是 Swift 优先于 OC,因此所有使用 Swift 的同学都能很快速的使用上所有苹果新开发的特性和工具。
同时,也得益于 Swift 的开源,我们不仅可以通过源码去学习一些不错的设计模式,还可以定位一些疑难杂症,不再需要和生涩难懂的汇编代码作斗争。
总结与展望
以上算是我们在手淘探索 Swift 业务落地的一个总结,希望可以给大家在技术选型或者探索避坑的时候给到一点帮助,当然,这只是一个开始,还有很多事情值得去做。首先,我们需要一起去完善和规范 Swift 的编码规范,甚至沉淀一系列最佳实践去引导大家更低成本的从 OC 转型到 Swift;其次,我们也需要针对前文提到的混编问题,推动基础 SDK 做 Swift Layer 建设以及继续优化现有 Swift 工具链;最后,我们还需要引入一些优秀的开源库避免重复造轮子,以及利用好 Apple 提供的能力(例如 DocC),并最终找到一个 Swift 在手淘的最佳实践。
最后,如果你对我们做的事情也比较感兴趣,欢迎加入我们一起共建 Swift/Kotlin 生态,我的联系方式是:zhejian.wzj@alibaba-inc.com,期待你的加入。
【文献参考】
https://mp.weixin.qq.com/s/pQiLyl572fSgMX1Fq3RDhw
https://github.com/apple/swift-evolution
https://mp.weixin.qq.com/s/5SXAozM2c6Ivyzl7B9IfQQ
https://www.swift.org/documentation/api-design-guidelines/
https://www.jianshu.com/p/0d3db4422954
https://developer.apple.com/videos/play/wwdc2020/10648
https://madnight.github.io/githut/
https://mp.weixin.qq.com/s/O5sVO5gVLzDCHGGNcjQ1ag
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践 &干货给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/9379f313ecbb51a777aa6bbe5】。文章转载请联系作者。
评论