10

A站 的 Swift 实践 —— 下篇

 2 years ago
source link: https://mp.weixin.qq.com/s/EIPHLdxBMb5MiRDDfxzJtA
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

A站 的 Swift 实践 —— 下篇

Original 快手大前端技术 快手大前端技术 4 days ago

640?wx_fmt=png

小编导读

经过不断迭代,Swift如今已成iOS乃至苹果全平台首选开发语言,A站也已经完全投入到Swift浪潮中,享受到Swift语言带来的舒适和高效开发体验。《A站的Swift实践——上篇》介绍了Swift的技术背景、Swift的架构演进过程以及对最新框架SwiftUI和Combine等技术的选型。有兴趣的同学请点击链接阅读全文:A站的 Swift 实践——上篇。作为下篇文章,本文会继续介绍Swift的混编和动态性。

640?wx_fmt=gif

编辑 / 贞霓



640?wx_fmt=png

#

背景介绍

内容已在本系列上篇中推送,请点击以上链接阅读。

#

框架选择

内容已在本系列上篇中推送,请点击以上链接阅读。

#

如何混编

使用Cocoapods该如何设置

为什么用Module

Name Mangling

为什么Swift代码可以调OC接口?

#

动态性

值类型的方法替换

类的方法替换

ClassContextDescriptorBuilder

Mach_override

如何混编

640?wx_fmt=svg

昨天刚刚结束的Google I/O让人想起了Kotlin在三年前曾经上过一次热搜,Google I/O官宣Kotlin替代Java,正式成为Android开发的首选语言。正所谓演进的力量,这一切都要归功于苹果公司在2014年推出的Swift替代了Objective-C,成为iOS乃至苹果全平台首选的开发语言,从而提高了iOS开发者的热情。上篇介绍了Swift的技术背景以及如何选择开发框架。下篇的内容会介绍大多数以OC为主体的工程如何与Swift共舞,以及如何利用Swift动态性解决工程难题。

640?wx_fmt=png
如果你的工程是OC开发的,要用上Swift就需要进行OC和Swift的混编开发。
然而,混编开发应该怎么开始呢?有没有什么前置条件?

前置条件

混编本质上就是把OC语法的声明通过编译工具生成Swift语法的声明,这样Swift就可以通过生成的声明直接调用OC接口。反之,OC调用Swift接口也可以通过相同的方法,把Swift语法的声明生成OC语法的头文件。这些转换生成的编译工具都集成在开发工具Xcode里。

Xcode其实就是执行多命令行的工具,比如Clang、ld等等。Xcode、Project文件里包含了这些命令的参数和它们执行的顺序,也有所有待编译文件和它们的依赖关系。llbuild[1]是低等级构建系统,根据Xcode Project里的配置按顺序执行命令。命令行工具的参数配置是在Xcode的Build Settings里进行设置的。如果是在同一个Project里混编,首先需要将Build Settings里Always Embed Swift Standard Libraries设置为YES,然后在桥接文件,也就是ProductName-Bridging-Header.h里导入需要暴露给Swift的OC类。如果Swift要调用的OC在不同Project里,则需要将OC的Project设置为Module,将Defines Module设为YES,再把Module里的头文件导入到OC Modulemap文件里的Umbrella Header里。

如何设置CocoaPods

Swift Pod的Podspec需要写明对OC Pod的依赖。在工程Podfile中,OC Pod后面要写 :modular_headers => true。开启Modular Header就是把Pod转换为Module。那CocoaPods究竟做了什么?执行  Pod Install -- Verbose就可以看到,在生成Pod Targets时,CocoaPods会生成Module Map File和Umbrella Header。每个工程设置的情况千奇百怪,而CocoaPods主要是通过自己的dsl配置来完成这些编译参数的设置,所以就需要先了解些混编设置的编译参数和概念:
  • 前面提到的Defines Module,需要设置为YES。

  • Module Map File表示 Module Map的路径。

  • Header Search Paths代表Module Map定义的OC头文件路径。

  • Product Module Name的默认设置和Target Name一样。

  • Framework Search Paths是设置依赖Framework的搜索路径。

  • Other C Flags可以用来配置依赖其它Module文件路径。

  • Other Swift Flags可以配置其Module Map文件路径。

CocoaPods的主要组件有解析命令的CLAide[2]用来解析Pod描述文件,比如Podfile、Podfile.lock和PodSpec文件的Cocoapods-core[3]拉仓库代码和资源的Cocoapods-downloader[4]分析依赖的Molinillo[5]以及创建和编辑Xcode的.xcodeproj和.xcworkspace文件的Xcodeproj[6]。在执行了Pod Install以后,组件调用流程以及配置Module所处流程位置,如下图所示:
640?wx_fmt=png

按照上图的逻辑,Integrates这一步主要是用来配置Module的。先检查Targets,主要是对于包括Swift版本和Module依赖等问题的检查,然后再使用Xcodeproj组件做Module的工程配置。

完成以上工作后,如果我们想要在Swift里使用OC开发的库FMDB,就可以直接使用Import来导入,代码如下:

import UIKitimport FMDBclass SwiftTestClass: NSObject {    var db:FMDB.FMDatabase?    override init() {        super.init()        self.db = FMDB.FMDatabase(path: "dbname")        print("init ok")    }}

可以看到,Import FMDB将FMDB的Module倒入进来后,接口依然能够直接使用Swift语法调用。

这里需要注意的是,Module依赖的Pod也需要是Module。因此改造时需要从底向上地改造成Module。另外,开启Module后,如果某个头文件在Umbrella Header里,那么其它包含这个头文件的Pod也需要打开Module。

为什么要用Module?

在Module被使用之前,开发者们需要对要导入的C语言编译器处理方式类头文件进行预处理,查找头文件里还导入了哪些头文件,递归直到找到全部头文件。但是,预处理的方式会遇到许多问题。其一,编译的复杂度高且耗时长,这是因为每个可编译的文件都会单独编译进行预处理,所以在预处理过程中递归查找导入头文件的工作会重复很多次,尤其是当包含关系很深的头文件被很多.m所导入的时候;其二,会出现宏定义冲突时需要重新排序以及和解依赖的问题等。

Module相对来说更加简易,它的头文件只需要解析一次,所以编译的复杂度会指数级降低,且编译器对Module的处理方式和C语言的预处理方式是完全不同的。编译器会将要编译的文件导入的头文件生成二进制格式,存储在Module Cache中,编译时如果碰到需要导入模块时,会先检查Module Cache,有对应的二进制文件就直接加载,没有才会解析,以此来保证Module解析只有一次。重新解析编译Module只会发生在头文件包含的任何头文件有变动,或者依赖另外一个模块有更新的时候。比如下面的代码:

#import <FMDB/FMDatabase.h>

Clang会先从FMDB.framework的Headers目录里查找FMDatabase.h,再去FMDB.framework的Modules目录里查找module.modulemap文件,分析module.modulemap来判断FMDatabase.h是否是模块的一部分。Module Map用来定义Module和头文件之间的关系。FMDB.framework的module.modulemap的内容如下:

framework module FMDB {  umbrella header "FMDB-umbrella.h"  export *  module * { export * }}

想要确定FMDatabase.h是否是Module的一部分就要看module.modulemap里的Umbrella Header文件,即FMDB-umbrella.h目录里是否包含了FMDatabase.h。在Headers目录里查看FMDB-umbrella.h文件,内容如下:

#ifdef __OBJC__#import <UIKit/UIKit.h>#else#ifndef FOUNDATION_EXPORT#if defined(__cplusplus)#define FOUNDATION_EXPORT extern "C"#else#define FOUNDATION_EXPORT extern#endif#endif#endif#import "FMDatabase.h"#import "FMDatabaseAdditions.h"#import "FMDatabasePool.h"#import "FMDatabaseQueue.h"#import "FMDB.h"#import "FMResultSet.h"FOUNDATION_EXPORT double FMDBVersionNumber;FOUNDATION_EXPORT const unsigned char FMDBVersionString[];

上面代码中可以看到FMDatabase.h已经包含在文件中,因此Clang会将FMDB作为Module导入。Umbrella框架是对框架的一个封装,目的是隐藏各个框架之间的复杂依赖关系。构建完的Module会被存放到 ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/ 这个目录下面。

Clang编译单个OC文件是通过导入头文件方式进行的,而Swift没有头文件,所以Swift编译器Swiftc就需要先查找声明,再来生成接口。除此之外,Swiftc还会在Module Map文件和Umbrella Header文件中暴露的声明里查找OC声明。

如果工程要构建二进制库,需要支持Swift 5.1加的Module Stability和Library Evolution。

Name Mangling

找到OC声明后,Swiftc就需要进行Name Mangling。Name Mangling的作用在一方面是会像C++那样防止命名冲突,另外一方面是会对OC接口命名进行Swift风格化重命名。如果对Name Mangling命名的效果不满意,还可以回到OC源码中用NS_SWIFT_NAME重新定义想要在Swift使用的名字。

Swiftc的Name Mangling相比较于C和C++的Name Mangling会生成更多信息,比如下面的代码:

public func int2string(number: Int) -> String {    return "\(number)"}

Swiftc编译后,使用nm -g查看生成如下的信息:

0000000000003de0 T _$s8demotest10int2string6numberSSSi_tF


如上所示,信息中的$s表示全局,8demotest的demotest是Module名,8是Module名的长度。int2string是函数名,前面的10是类名长度,6number是参数名。SS表示参数类型是Int。Si表示的是String类型,_tF表示前面的Si是返回类型。
接下来对比一下Clang和Swiftc的编译过程,首先是Clang的编译过程,如下图:
640?wx_fmt=png

其次是Swift的编译过程,如下图:

640?wx_fmt=png

从两者的对比中可以看出,Swift编译过程缺少了头文件,因为它通过分组编译模糊了文件的概念,减少了很多重复查找声明的工作,这样不仅仅可以简化代码的编写,还可以给编译器更多的发挥空间。

至于OC怎样调用Swift接口,Swiftc会生成一个头文件,代码中有Public的声明会先按文件生成Swiftmodule,文件链接完会合并Swiftmodule,最后整体生成到一个头文件里。过程如下图所示:

640?wx_fmt=png

为什么可以调OC接口?

Swift代码之所以可以调OC接口,是因为OC的接口会被编译器自动生成为Swift语法接口文件。在Xcode中,在OC头文件中点击左上角的 Related Items,选择Generated Interface,就可以选择查看生成的Swift版本接口文件。自动转换成的Swift接口文件可以直接供Swift调用,在转换过程中,编译器会将NSString这种OC的基础库转换成Swift里对应的String、Date等Swift库。OC的初始化方法也会被转换成Swift的构造器方法。错误处理也会被转换成Swift风格。下面是OC和Swift转换对应的类型:

640?wx_fmt=png

但是,仅仅只依赖于编译器的转换肯定是不够的,为了能让Swift调用得更加舒服,还需要对OC接口做些修改适配,比如将函数改成使用OC泛型,NSArray paths转成Swift是open var paths:[Any];如果使用了泛型,将其改成 NSArray paths,那对应的Swift就是open var paths:[KSPath],这种接口Swift使用起来会更方便有效。

苹果公司也提供了一些宏来帮助生成好用的Swift接口。

众所周知,OC之前一直缺少是非空的类型信息,可以通过 NS_ASSUME_NONNULL_BEGIN和NS_ASSUME_NONNULL_END包起来,这样就不用逐个去指定是非空了。NS_DESIGNATED_INITIALIZER宏可以将初始化设置为Designated,不加这个宏为Convenience。NS_SWIFT_NAME用来重命名Swift中使用的名称,NS_REFINED_FOR_SWIFT可以解决数据不一致的问题。

在iOS开发的过程中不可避免地需要访问 Core Foundation 类型,Core Fundation框架一旦导入到Swift混编环境中,它的类型就会被自动转为Swift类,Swift也会自动管理Annotated Core Foundation对象的内存,而不用像在OC中那样手动调用CFRetain、CFRelease或者CFAutorelease函数。Unannotated的对象会被包装在一个Unmanaged结构里,比如下面的代码:

CFStringRef showTwoString(CFStringRef s1, CFStringRef s2)
转成Swift就是:
func showTwoString(_: CFString!, _: CFString!) -> Unmanaged<CFString>! {    // ...}

如上面代码所示,Core Fundation 类型的名字转换后会去掉后缀Ref,这是因为在Swift中所有类都是引用类型,Ref后缀比较多余。上面的Unmanaged结构有两个方法,一个是takeUnretainedValue(),另一个是takeRetainedValue(),这两个方法都是用来返回对象的原始未封装类型。如果对象之前没有Retain就用takeUnretainedValue(),已经Retain了,就用takeRetainedValue()。

在Swift里用getVaList(_:_:_:) 或withVaList(_:_:) 函数调用C的Variadic函数,比如 vasprintf(_:_:_:)。

调用指针参数的C函数,和Swift映射如下图:

640?wx_fmt=png
Swift也有无法调用的C接口,比如复杂的宏、C风格的Variadic参数,复杂的Array成员等。简单赋值的宏会被转换成Swift里的常量赋值,对于复杂的宏定义,编译器无法自动转换,如果还是想享受宏带来的好处,比如可以避免重复输入大量模板代码和避免类型检查约束,可以通过函数和泛型替换获取同样的好处。Swift写出来的Module也可以给OC来调用。但是这样的调用会有很多限制,因为Swift中有很多类型是没法给OC用的,比如在Swift里定义的枚举、Swift定义的结构体、顶层定义的函数、全局变量、Typealiases、Nested类型,但是如果绕过这些类型,Swift也变得不那么Swift了。即使是实现了混编,开发者们还需要面对许多难题。因为在OC时代的很多问题,例如Hook,无痕埋点等可以在OC运行时很方便地实现,而Swift却缺少天然的支持。下面介绍一下Swift的动态性,在官方完善前,我们应该怎么使用它。

动态性

640?wx_fmt=svg

Swift在处理纯粹的Swift语言时是有自己的运行时的,但是对于“这个运行时是不提供访问的接口”的问题,Swift核心团队不是不做动态特性,而是因为如果想要支持动态特性就需要处理虚函数表(Virtual Method Table)的动态调用对SIL函数优化的影响,比如类没有被Override就会自动优化到静态调用,而这需要大量的时间。现阶段还有优先级更高的事情要做,比如并发模型、系统编程、静态分析支持类型状态等。因此,有人选择自己去实现一套Swift运行时,使得Swift代码具有动态特性。Jordan Rose[7]实现了一个精简版的Swift[8]运行时,更加严谨的运行时实现可以参考Echo[9]Runtime[10]

有人可能会问,SwiftUI的Preview不就是典型的在运行时替换方法的吗?他是怎么做到的呢?其实他使用的是@_dynamicReplacement属性,这是一个可以直接拿着用来进行方法替换的内部使用属性。

@_dynamicReplacement(for: runSomething())static func _replaceRunSomething() -> String {    "replaced"}

如果想要把上面的代码放到一个库中,并且在运行时加载这个库进行运行时方法替换可以通过这样的方式:

runSomething()let file = URL(fileURLWithPath: "/path/of/replaceLib.dylib")guard let handle = dlopen(file.path, RTLD_NOW) else {    fatalError("oops dlopen failed")}runSomething()

除了这个方法以外,还有其他办法可以进行运行时的方法替换吗?

值类型的方法替换

通过 AnyClass和class_getSuperclass方法可以查看Swift对象的继承链,没有继承NSObject的Swift类,会有一个隐含的Super Class,这个类会带有一个生成的带前缀的SwiftObject,比如_TtCs12_SwiftObject。Swift是实现了NSObject的一个objc运行时的类型,这个类型不能和OC交互。但是如果继承了NSObject就可以和OC交互。

如果方法或属性声明了 @objc dynamic,那么就可以在运行时通过动态派发在Swift对象上去调用,方法是:使用AnyObject的Perform方法去执行NSSelectorFromString里传入的方法或属性名。

对于Swift里的值类型,比如Struct、Enum、Array等,可以遵循_ObjectiveCBridgeable协议,经过Type Casting(显示或隐式)转成对应的OC对象类型。举个例子,如果想要查看Array的类继承关系,代码如下:

func classes(of cls: AnyClass) -> [AnyClass] { var clses:[AnyClass] = [] var cls: AnyClass? = cls while let _cls = cls { clses.append(_cls) cls = class_getSuperclass(_cls) } return clses}let arrays = ["jone", "rose", "park"]print(classes(of: object_getClass(arrays)!))// [Swift.__SwiftDeferredNSArray, Swift.__SwiftNativeNSArrayWithContiguousStorage, Swift.__SwiftNativeNSArray, __SwiftNativeNSArrayBase, NSArray, NSObject]

如上面代码所示,Swift的Array最终都是继承自NSObject,其它值类型也类似。可以看出,所有Swift类型都是可兼容objc运行时的。因此可以给这些值类型添加objc运行时方法,代码如下:

// MARK: 为Swift类型提供动态派发的能力struct structWithDynamic {    public var str: String    public func show(_ str: String) -> String {        print("Say \(str)")        return str    }    internal func showDynamic(_ obj: AnyObject, str: String) -> String {        return show(str)    }}let structValue = structWithDynamic(str: "Hi!")// 为 structValue 添加Objc运行时方法let block: @convention(block)(AnyObject, String) -> String = structValue.showDynamiclet imp = imp_implementationWithBlock(unsafeBitCast(block, to: AnyObject.self))let dycls: AnyClass = object_getClass(structValue)!class_addMethod(dycls, NSSelectorFromString("objcShow:"), imp, "@24@0:8@16")// 使用Objc动态派发_ = (structValue as AnyObject).perform(NSSelectorFromString("objcShow:"), with: String("Bye!"))!

如上面代码所示,取出函数闭包可以通过 @convertion(block)转换成C函数Call Convention来调用,C函数也可以直接去执行这个指针。使用 Memory Dump 工具可以查看Swift函数内存结构,以及解析出符号信息DL_Info。Memory Dump工具有Mikeash的memorydumper2[11],源码解读可以参考Swift Memory Dumping[12]。逆向查看内存布局可以参考《初探Swift Runtime:使用Frida实现针对Alamofire的抓包工具》[13]

类的方法替换

在运行时进行类方法的替换时,先将方法的Block以AnyObject类型传入imp_implementationWithBlock方法,返回一个imp,然后使用 class_getInstanceMethod 来获取实例的原方法,再通过 class_replaceMethod 进行方法替换,完整代码可以参看InterposeKit[14],另外还有一个使用libffi的方法替换库,参见SwiftHook[15]

另外,通过获取函数地址来改变函数指向位置的方法在Swift里实现比较困难,这是因为NSInvocation不可用了,因此需要通过C的函数来Hook Swift。在Swift的AnyClass中有类似OC的布局,记录了指向类和类成员函数的数据,这样就可以使用汇编来做函数指针替换的事情。思路是:保存寄存器,调用新函数,然后恢复寄存器,还原函数。具体可以参考项目SwiftTrace[16]



插桩



使用编译插桩的方式也可以实现运行中的方法替换,关键步骤在于编译时,需要使用DYLD_INSERT_LIBRARIES进行拦截,CommandLine.arguments可以得到Swiftc的执行参数,以查找待编译的Swift文件。通过苹果公司的SwiftSyntax[17]源代码解析、生成和转换的工具可以查出所有方法,并插入特定的方法替换逻辑代码。修改完通过-output-file-map来获取mach-o的地址去覆盖先前产物。使用self.originalImplementation(...)调用原始的实现作为闭包传入execute(arguments:originalImpl:)方法。

ClassContextDescriptorBuilder

Swift运行时给每个类型保留了Metadata信息。Metadata是由编译器静态生成的,有了Metadata的调试才能够发现类型的信息。Metadata偏移-1是Witness table 指针,Witness Table 提供分配、复制和销毁类型的值,Witness Table 还记录了类型大小、对齐、Stride等其它属性。Metadata偏移量0的地方是Kind字段,其描述了Metadata所描述的类型的种类,例如Class、Struct、Enum、Optional、Opaque、Tuple、Function、Protocol等类型。这些类型的Metadata具体详述可见Type Metadata 的官方文档[18],代码描述可以在include/swift/ABI/MetadataValues.h[19]里看到。比如在Metadata里类的方法数量会比实际代码里写的方法数量要多,那是因为编译器会自动生成一些方法,这些方法的种类在MethodDescriptorFlags类中Kind里描述了,代码如下:

enum class Kind { Method, Init, Getter, Setter, ModifyCoroutine, ReadCoroutine,};

可以看到,Getter、Setter以及线程相关读写的ModifyCoroutine、ReadCoroutine类型都是自动生成的。
Class的内存结构生成方法可以在/lib/IRGen/GenMeta.cpp [20]里找到:
  • ClassContextDescriptorBuilder这个类是用来生成Class内存结构的,它继承于TypeContextDescriptorBuilderBase。

  • Enum、Struct等类型的内存结构Builder基类都是继承于ContextDescriptorBuilderBase的TypeContextDescriptorBuilderBase。

  • ContextDescriptorBuilderBase 是最基础的基类,Module、Extension、Anonymous、Protocol、Opaque Type、Generic都是继承于它。

  • Struct的Metadata和Enum的Metadata共享内存布局,Struct会多个指向Type Context Descriptor的指针。

内存布局指的是使用一个Struct或者Tuple,根据每个字段的大小和对齐方式决定怎样来安排内存中的字段,在这个过程中,不仅需要描述清楚每个字段的偏移量,还有Struct或Tuple整体的大小和对齐方式。下面就是GenMeta里和Class类型相关的内存方法代码:
// 最底层基类 ContextDescriptorBuilderBase的布局方法void layout() {  asImpl().addFlags();  asImpl().addParent();}// TypeContextDescriptorBuilderBase的布局方法void layout() {  asImpl().computeIdentity();  super::layout();  asImpl().addName();  asImpl().addAccessFunction();  asImpl().addReflectionFieldDescriptor();  asImpl().addLayoutInfo();  asImpl().addGenericSignature();  asImpl().maybeAddResilientSuperclass();  asImpl().maybeAddMetadataInitialization();}// ClassContextDescriptorBuilder的布局方法void layout() {  super::layout();  addVTable();  addOverrideTable();  addObjCResilientClassStubInfo();  maybeAddCanonicalMetadataPrespecializations();}
根据GenMeta可以看到Swift的Class类型内存布局是根据ContextDescriptorBuilderBase、TypeContextDescriptorBuilderBase再到ClassContextDescriptorBuilder继承层层叠加的,因此对应Class类型的Nominal Type Descriptor就可以用如下C结构来描述:
struct SwiftClassInfo {    uint32_t flag;    uint32_t parent;    int32_t name;    int32_t accessFunction;    int32_t reflectionFieldDescriptor;    ...    uint32_t vtable;    uint32_t overrideTable;    ...};
代码中可见,add的前缀就是增加的偏移记录,addFlags后面的addParent就是下一个偏移的记录。FieldDescriptor换成ReflectionFieldDescriptor是苹果公司在5.0版本对Metadata做的改变,官方Mirror反射目前还不完善,有些信息还没法提供,因此在Metadata里增加了一些反射相关信息。
OC动态调用方法会把_cmd作为第一个参数,第二个参数是Self,后面是可变参数列表,动态调度可以在运行时添加类、变量和方法。而在Swift中动态调用方法是基于VTable的,运行时没法对方法进行动态搜索,地址在编译时静态写在了VTable里,运行时不能改,可以用静态地址调用,或dlsym来搜索名称。VTable的地址在TypeContextDescriptor之后,OverrideTable存储位置在VTable之后,有三个字段来描述,第一个是记录哪个类被重写,第二个是被重写的函数,第三个是用来重写的函数相对的地址。因此通过OverrideTable就可以找到重写前和重写后函数指针,这样就有机会在VTable里找到对应函数进行函数指针的替换,达到Hook的效果。要注意,在Swift编译器设置优化时VTable的函数地址可能会清空或使用直接地址调用,这两种情况发生的话就没法通过VTable进行方法替换。那么还有其它思路吗?

Mach_override

使用Wolf Rentzsch[21]写的Mach_override[22]也是一种方法,可以在原始函数的汇编里加个jmp,跳到自定义函数,然后再跳回原始函数。Mach_override_ptr的三个参数分别是,一,要覆盖函数的指针;二,去覆盖函数的指针;三,参数可以设置为原函数的指针地址,待Mach_override_ptr返回成功,就可以调原函数。Mach_override会分配一个虚拟内存页,使其可写可执行。需要注意的是,Mach_override_ptr初始函数和重入函数指针相同,调用后,重入函数将调用替换函数而不是原始函数。在Swift中如何使用Mach_override可参考SwiftOverride[22]

总结

通过上下篇的介绍,想必你已经了解到A站为拥抱Swift都做了哪些事情。基于A站以及快手主站的一些架构师对于Swift的热爱,以及为之付于的实践,A站的开发体验才得以蜕变。
为了让OC开发同学能够掌握Swift,以更“Swift”的方式进行开发,A站组织了十多次Swift组内的培训和分享,并规范了Swift代码风格和静态检查流程。针对开发体验上的痛点,A站在2020年上半年就开始了混编工程的优化、组件化以及二进制化的建设。完成了分层设计,渐进式地将模块解耦下沉到对应的分层,进而可以借助LLVM Module来抹平模块API在语言上的差异,从而代替Swift和Objective-C在主工程的桥接,为10+ A站和中台的基础库进行了Module化问题修复,并基于主站的二进制化方案 (GUNDAM)完善了对Swift以及混编的支持。从Swift ABI Stability进化为Module Stability的XCFramework,WWDC的Session[23]很好的说明XCFramework的原理,同时表示XCFramework格式对Objective-C/C/C++也有很好的支持。目前组件的二进制化率约为80%,约有50%的组件已经完成了LLVM Module化,构建时间提升了60%以上。随着Swift优势的逐渐体现以及团队Swift能力建设的推进,A站更多的工程师开始倾向于使用Swift进行业务开发,而Swift带来的“加速度”,也让技术团队切实地感受到了强烈的“推背感“。当然,A站也曾遇到一些Swift的Bug,比如打包RxSwift5后遇到模块名和类名一样所产生的Bug和Issue[24],RxSwift6通过避免使用Typealias的类型曲线形地解决了这个问题,目前此问题已被官方标记为“解决”,后面的版本可以正常使用。另外还有两个未解决的问题,一个是在Module的接口中出现Ambiguous Type Name Error问题,参考Issue[25]另一个是Import后产生.swiftinterface出现的错误,参见网站Issue[26]。最后想说的是,Swift开发并不容易,不要被Swift简洁的语法所迷惑,各种大小括号组合会让开发者们感到困惑,还有一些特性会让直观理解变得很困难,比如下面的代码:
let str:String! = "Hi"let strCopy = str
根据Swift类型推导的特性,按道理str类型加上感叹符号后,strCopy就会被自动推导为非可选String类型。但实际情况是,按照官方文档[27]的说法,strCopy没有直接指明类型,即隐式可选值时,str类型是String后加上感叹号,这种是属于隐含解包可选值String无法推导出非可选String类型,因此Swift会先将strCopy作为一个普通可选值来用,这样和直观的感觉非常不一样。
本以为5.0的ABI在稳定后,Swift学起来会更容易,但是其实新的SwiftUI和Combine这样重量级的框架需要开发者继续钻研,真是“Write Swift, Learn Every Year”。Swift不断从其它语言中吸取精髓,接下来的async/await,你准备好了吗?要用上,先得看咱家APP系统最低版本是不是能够支持这些新特性。虽说不容易,但为了稳定和效率,终究跟上了时代的步伐。

相关链接

[1]llbuild: https://github.com/apple/swift-llbuild/

[2]CLAide: https://github.com/CocoaPods/CLAide

[3]Cocoapods-core: https://github.com/CocoaPods/Core

[4]Cocoapods-downloader: https://github.com/CocoaPods/cocoapods-downloader

[5]Molinillo: https://github.com/CocoaPods/Molinillo

[6]Xcodeproj: https://github.com/CocoaPods/Xcodeproj

[7]Jordan Rose: https://twitter.com/UINT_MIN

[8]精简版Swift:https://docs.corp.kuaishou.com/d/home/fcABX0EQblY3c6B9XMhx60H20?channel=kim-cloud#

[9]Echo:

https://docs.corp.kuaishou.com/d/home/fcABX0EQblY3c6B9XMhx60H20?channel=kim-cloud#

[10]Runtime: https://github.com/wickwirew/Runtime

[11]memorydumper2: https://github.com/mikeash/memorydumper2

[12]Swift Memory Dumping: https://www.mikeash.com/pyblog/friday-qa-2014-08-29-swift-memory-dumping.html

[13]文章:https://github.com/neil-wu/FridaHookSwiftAlamofire/blob/master/howto.md

[14]InterposeKit: https://github.com/steipete/InterposeKit

[15]SwiftHook: https://github.com/623637646/SwiftHook

[16]SwiftTrace:https://github.com/johnno1962/SwiftTrace

[17]SwiftSyntax: https://github.com/apple/swift-syntax

[18]Type Metadata 的官方文档: https://github.com/apple/swift/blob/main/docs/ABI/TypeMetadata.rst

[19]代码描述:

https://github.com/apple/swift/blob/3ed11125f3e987722c14c10ac9c1c7ec25a86c65/include/swift/ABI/MetadataValues.h

[20]内存结构生成方法:

https://github.com/apple/swift/blob/3ed11125f3e987722c14c10ac9c1c7ec25a86c65/lib/IRGen/GenMeta.cpp

[21]Wolf Rentzsch:https://github.com/rentzsch

[22]mach_override: https://github.com/rentzsch/mach_override

[23]Session: https://developer.apple.com/videos/play/wwdc2019/416/

[24]Issue: https://bugs.swift.org/browse/SR-12647

[25]Issue:https://bugs.swift.org/browse/SR-12646

[26]Issue: https://bugs.swift.org/browse/SR-11422

[27]官方文档:https://docs.swift.org/swift-book/LanguageGuide/TheBasics.html

640?wx_fmt=png

快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。 

在这里你可以获得:

  • 提升架构设计能力和代码质量 

  • 通过大数据解决用户痛点的能力 

  • 持续优化业务架构、挑战高效研发效能 

  • 和行业大牛并肩作战 

我们期待你的加入!请发简历到:

[email protected]


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK