方案背景

自 Swift2014 年诞生以来,Swift 迎来了 5.5 版本,ABI 也终于稳定了,在苹果的助力下,很多原有应用程序也从 Objective-C 转向了 Swift,业界全面转向 Swift 的呼声也越来越高,为了提升我们的开发效率,紧跟时代和苹果的步伐,于是我们也开始了 UU 跑腿 APP 的混编之路,在混编开始之前,我们也做了很多对 Swift 语言的测试调研工作,以便我们更好的使用和掌握这门语言。

1.1 Swift 发展历史

  • 2010 年 7 月,克里斯(Chris Lattner)开始设计 Swift。完成基础架构后,克里斯带领开发小组陆续完成语法设计、编译器、运行时、框架、IDE 和文档等相关工作。
  • WWDC 2014,经历四年的开发,Swift 发布。
  • WWDC 2015,Swift 2.0,苹果宣布 Swift 开源,包含编译器和标准库。这一阶段发展迅速,变动也非常频繁。因此开发者也都处于尝试或观望状态。
  • 2016 Swift 3.0,是语法和接口变化最大的一个版本,大量直接从 OC 移植过来的方法名被简化了,大量常用 Foundation 的类在 Swift 中改成了去掉 “NS” 前缀的结构体和类,原来 OC 方法名的很多描述性语句变成了 label,一些开发框架中的 C 语法 API(如 GCD、CoreGraphics 等)也统一了风格, Swift 语法风格基本定型。
  • 2019 Swift 5.0,ABI 稳定,并且向后兼容。2019 年 3 月,Swift 5.0 正式发布。目前,Swift 的当前版本包含跨 Apple 平台的应用程序二进制接口(ABI)的稳定版本。这是朝着帮助开发人员在专用操作系统(如 iOS,macOS,tvOS,watchOS 和 iPadOS)上使用 Swift 迈出的一大步。苹果正在构建一个坚实的生态系统,因为现在标准的 Swift 库已包含在 OS 版本中。

1.2 ABI 稳定

ABI(Application Binary Interface),即应用程序二进制接口,描述了应用程序和操作系统之间,一个应用和它的库之间的接口。在 iOS 和 macOS 平台,Swift 编写的二进制程序在运行时通过 ABI 与其他程序库或组件进行交互。程序的编译会产生一个或者多个二进制实体,这些二进制实体必须在一些很底层的细节上达成一致,才能被链接在一起执行。可以说 ABI 就是一个规范,一种协议。它会规定如何调用函数,如何在内存中表示数据,甚至是如何存储和访问 metadata。Xcode 10.2 集成了 Swift 5.0 编译器,只要使用这个版本以上的编译器,编译出来的二进制就是 ABI 稳定的,据苹果官方数据,截止到 2020 年 12 月 15 日,四年内发布的 iPhone 设备中 iOS 13 及以上占比已达 98%,也就意味着,更新版本的Swift被兼容到更多的设备中,稳定性得到了质的提升。

1.3 模块化(Module)更稳定

aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X3BuZy9DZTZiU3FYa2R1eTNlUXBDVmlhVW1mWTFBMDlyZHlXM3RWdlVrS3o1TWlidExKdFhwUlJxTVMzcmZNdlBOWlJMcG5uNnZBaWJqUldIcVU3OG1OcW9YRVZFQS82NDA.png

在 Swift 中系统库和私有仓库的 api 会以一个模块的方式进行输出,对于不同版本不同设备的兼容性是不同的,例如不同版本 xcode 构建的 Swift 模块是不可以在同一个应用程序中一起使用的,在 Swift 5.1 版本中,Swift 支持了 Module Stability,解决模块间编译器版本兼容的问题,这对广发开发者来说是一个福音,也就意味着在 Swift5 中编译的模块可以在未来 Swift7 或者更高的版本运行。

更早版本的错误信息如下:

“Module compiled with Swift 5.1 cannot be imported by the Swift 5.2 compiler”

1.4 社区活跃度

8ceh6hmmu1.png

由 2020 年 TIOBE 网站统计结果看出,Swift 语言已经冲进了前十,而 OC 的社区活跃度已经跌到了 13 名,目前还处于持续下跌的趋势,反观 Swift 上升趋势正猛,github Swift 代码 push 的量已经超过了 OC,Swift 的新活跃开源项目也远超过 OC,Swift 的开源推动了外部贡献者参与 Swift 生态建设,长期看 OC 三方开源库面临年久失修和新功能不支持的风险。

1.5 Swift 现状

  • UU 跑腿 APP 及矩阵产品使用情况
    目前 UU 跑腿全系产品均全面转向 Swift。
  • 国内 APP,目前已知,微信,手机淘宝、京东、百度 APP 等均已转型 Swift,不过可惜的是抖音似乎对 Swift 并不感冒。

2.0 混编形式

以下会分为 4 种形式分别介绍混编的应用:

主工程中 Swift 调用 Objective-C

在主工程中混编较为简单,需要通过桥接的形式,将 OC 的头文件暴露给 Swift 使用,如果是在纯 OC 工程中第一次创建 Swift 文件,编译器会给出提示创建桥接文件如下:
image-20211123150441412.png

  • 创建桥接文件(-Bridging-Header.h)
  • 确保 Build Setting 中 SWIFT_OBJC_BRIDGING_HEADER 为该桥接文件的路径,需要在主工程中配置
  • 将需要引入到 Swift 的 ObjC 的头文件添加进去即可完成混编

主工程中 Objective-C 调用 Swift

由于 Swift Module 的原因,引入一个 Swift 文件就需要把该模块下所有 Swift 文件全部引入,故需要引入 ProjectName(工程中配置的 moduleName)-Swift.h,具体步骤如下:

  • 主 Target Build Setting 中 SWIFT_OBJC_INTERFACE_HEADER_NAME 的配置正确
  • 在 ObjC 中引入该模块的 Swift 头文件,#import “XXX-Swift.h”
  • 若需要在 ObjC 的 .h 中引入,则可以通过向前声明的方式,@class XXX

源码组件内混编 Swift 调用 OC

由于组件内是没有桥接文件的,所以在组件内通过桥接文件实现混编的方案是行不通的,只能通过 podspace 进行操作,这时候需要用到 cocoapods 工程化生成的 umbrella-header ,这里的 umbrella-header 文件可以同样理解为工程的混编桥接文件,这样的话 Swift 就可以引用到对应的 OC 类了,如果需要最终生成的组件可以被当做一整个模块进行引入的话设置 DEFINES_MODULE = YES 即可,这样的话导入就会被认为是一个整体的模块,如下:
image-20211123151556701.png

源码组件内混编 OC 调用 Swift

在同一个 .framework 或者 .a 中实现 ObjC 调用 Swift,依然需要通过引入 Swift Module 的 ObjC Interface Header。

  • 确保 Build Setting 中 SWIFT_OBJC_INTERFACE_HEADER_NAME 的配置正确
  • 在 ObjC 中引入该模块的 Swift 头文件,.framework 中为 #import ,.a 中为 #import “XXX-Swift.h”。
  • 若在 ObjC 的 .h 中引入,则可以通过向前声明的方式,@class XXX

注意:上方的 XXX 依然是当前组件的 modulename,如果当前组件是 Swift 组件,需要被暴露的 Swift 接口必须是 public 的类或者属性,暴露给 OC 的属性和类必须加@objc 的标识,@objc 会告诉编译器该属性或者函数能够应用于 Objective-C 代码中。而且标有 @objc 特性的类必须继承自 ObjC 的类。

Swift 编译性问题

image-20211123152701394.png

混编过程中如出现以上问题则说明未配置 Swift 编译环境,不能自动链接 Swift 动态库和静态库,这里有两种形式可以解决:

在当前组件的示例工程 buildsetting–>Library Search Paths 添加如下配置:(swift 环境变量地址)

“(TOOLCHAIN_DIR)/usr/lib/swift/(TOOLCHAIN_DIR)/usr/lib/swift/(TOOLCHAIN_DIR)/usr/lib/swift/(PLATFORM_NAME)”

“(TOOLCHAIN_DIR)/usr/lib/swift−5.4/(TOOLCHAIN_DIR)/usr/lib/swift-5.4/(TOOLCHAIN_DIR)/usr/lib/swift−5.4/(PLATFORM_NAME)”

第二种方式,在示例工程中创建一个 Swift 文件,创建文件后,编译器会自动为你配置 Swift 环境,这里更推荐第二种形式相当简单粗暴。

iOS12 动态库问题

iOS12 以及一下需要配置 /usr/lib/swift 环境变量才可以解决,注意:一定要首行,经验之谈。

3.0 组件化架构

为什么要组件化

传统的 App 架构设计更多强调的是分层,基于设计模式六大原则之一的单一职责原则,将系统划分为基础层,网络层,UI 层等等,以便于维护和扩展。但随着业务的发展,系统变得越来越复杂,只做分层就不够了。App 内各子系统之间耦合严重, 边界越来越模糊,经常发生你中有我我中有你的情况,互相缠绕,这里可以用柳树的分支来比喻我觉得比较形象。这对代码质量,功能扩展,以及开发效率都会造成很大的影响。此时,一般会将各个子系统划分为相对独立的模块,通过中介者模式收敛交互代码,把模块间交互部分进行集中封装, 所有模块间调用均通过中介者来做(图二)。这时架构逻辑会清晰很多,但因为中介者仍然需要反向依赖业务模块,这并没有从根本上解除循坏依赖等问题。时不时发生一个模块进行改动,多个模块受影响编译不过的情况。

架构选型

关于组件化架构,市面上有很多方案,比如 URLRouter、CTMediator,ProtocalService 等,但是他们也都有各自的优缺点,基于我们的业务我们采用了 URLRouter+ 服务注册的形式去解决组件化通信以及架构问题,这个架构核心思想是面向接口,所以我们会有一个庞大的 service 中心仓库,去存储各个组件以及服务的接口,这是它的优点也是它的缺点,好处就是我们可以在编译阶段就能发现问题,很多接口和方法在编译阶段就会告诉你需要哪些参数和方法,告别了 URLRouter 带来的硬编码,易于维护,坏处就是当项目变得无比巨大的时候,service 的维护成本提高也随之而来。

具体形式如下:

image-20211123154515694.png

它就像一个中心仓库,每一个分组件都需要依赖当前 service 去操作,例如:

//Goods模块提供的所有对外服务都放在GoodsModuleService中
@protocol UUGoodsInfoModuleService
- (NSArray *)uu_getGoodsList;
- (NSInteger)uu_getGoodsCount;
...
@end
//Goods模块提供实现GoodsModuleService的对象, 
@interface GoodsModule : NSObject<UUGoodsInfoModuleService>
@end
@implementation GoodsModule
+ (void)load {
    //注册当前服务
    [ServiceManager registerService:@protocol(service_protocol) 
                  withModule:self.class]
}
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}
@end

//将UUGoodsInfoModuleService放在某个公共模块中,对所有业务模块可见
//业务模块可以直接调用相关接口拿到对应的数据
...
id<UUGoodsInfoModuleService> module = [ServiceManager objByService:@protocol(UUGoodsInfoModuleService)];  
NSArray *list = [module uu_getGoodsList];  
...

目前,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的这个方案,阿里的 BeeHive 服务注册方式更为刁钻一些,使用的类 Java 注解形式,在编译阶段就进行注册,这里我们不再赘述,详细请看BeeHive,一次 iOS 模块化解耦实践,阿里组件化框架BeeHive解析 – 简书

针对复杂对象传输的处理方案

URLRouter 最头疼的就是复杂对象的传输问题,例如订单详情模型,它包含几十个字段。如果是传字典或传 json, 那么数据提供方和使用方都需要专门理解并实现一下这种模型的各种字段,对开发效率影响很大,而且冗杂的代码也很多,很不利于维护,这里我们想到的方案是通过远程调用的形式去解决,使用方接收到数据后进行一次强转,这种方式虽然比较粗暴,但考虑到两个模块间交互的复杂对象应该不会很多(如果太多则应考虑这两个模块是否划分合适),同时拷贝粘贴操作起来成本可控,所以可以接受。同时这种方法也能达到预期的编译隔离的效果。

组件内如何读取资源文件

因为传统的项目是再一个 bundle 里面,组件化之后就是多个子工程组装而成,自然而然带来了资源引用问题,如果使用 framework,需要注意资源读取的问题。因为传统的资源读取方式无法定位到 framework 内资源,需要通过 bundleForClass: 才行

//传统方式只能定位到指定bundle,比如main bundle中资源
NSURL *path = [[NSBundle mainBundle] URLForResource:@"file_name" withExtension:@"txt"]; 
// framework bundle需要通过bundleForClass获取
NSBundle *bundle = [NSBundle bundleForClass:classA]; //classA为framework中的某各类  
// 读UIStoryboard
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@“sb_name” bundle:bundle];  
// 读UIImage
UIImage *image = [UIImage imageNamed:@"icon_name" inBundle:bundle compatibleWithTraitCollection:nil];  
...

公共资源文件如何处理

这类资源主要包括图片、音视频,数据模型等等。

首先我们排除了无脑放入 Common 的方案。因为下沉入 Common 会破坏各业务模块的完整性,同时也会影响 Common 的质量。经过讨论后,决定把资源分为三类:

  1. 通用功能所用资源,将相关代码整理为功能组件后一起放入 Common.
  2. 业务功能的大部分资源可以通过无损压缩控制体积,体积不大的资源允许一定程度上的重复。
  3. 较大体积的资源放到服务端,App 端动态拉取放在本地缓存中。

目前基于路由 URL + 服务注册的模块间通讯方式对开发效率基本无损后续我们还会继续探索更好更易用的架构以及组件,敬请期待

总结以及思考

移动端从 2010 左右至今已经 10 多年的光阴了,架构也有了日新月异的变化,但从架构和技术方面来说,都不难,没有什么难以攻克的地方,重点是思想,更多的是团队协作的形式和根据具体业务场景诞生的最佳方案,也更贴近类似服务端的分布式和微服务之说,脱离业务谈架构也纯粹是无稽之谈了,结合实际场景需要,设计出最佳方案才是最好的架构,希望通过本文提供的具体案例和思考方式,大家都能找到适合自己应用的业务混编改造以及模块化之路。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注