当前位置:首页|资讯

Swift Macro 在业务开发中的探索与实践

作者:哔哩哔哩技术发布时间:2024-10-22

简介


Swift Macro 在 Swift 5.9 版本中正式引入,且需配合 Xcode 15 使用。Swift Macro 作为一种新的设计方法,致力于帮开发者降低编写重复代码的繁琐,以更为简洁优雅的方式去实现。

在 OC 中,有大家熟知的宏 #define,但是在 Swift 5.9之前我们无法像使用 OC 一样去定义宏,在 Swift 中没有这种宏的概念,只能通过静态方法去模拟宏从而达到目的。

OC 宏的实现原理是纯文本的替换,在编译之前通过预处理器帮我们对标记了 #define 的表达式进行展开替换,但是纯文本替换会产生一系列问题,比如 命名冲突,类型检查,调试问题等。



和 OC 的不同之处在于 Swift Macro 选择在编译时进行展开以及替换,一方面是可以在编译时进行类型安全检查,另一方面更大程度的提高了宏编译对开发者的反馈力度,宏的调试和错误都可以被开发者所感知。

相对于传统的预编译,编译时 Swift Macro 具有感知上下文的能力,对上下文的理解,从而可以产生更多样化的代码扩展和逻辑处理。

例如通过 Swift Macro 构造一个宏:为某个类自动生成 Int32 类型 的 age 成员并赋值



当我们将 Double 类型的参数 12.0 传入 宏里,在编译展开阶段就会报错,此时编译器会把错误抛出,并视为编译错误



Swift Macro 是在 Swift 语言特性的基础上设计出来的产物,编译时对宏进行有效性的检查,可以让开发者在 Swift 宏使用过程中,更容易的发现错误,更方便的进行调试。


原理


Swift Macro 大部分是外部宏类型 #externalMacro,它不由当前程序执行,而是在沙盒的某个独立应用程序内,交由 Compiler Plug-in 去处理宏的展开和替换。

以 @DefaultAge 为例,宏的声明分为两个部分,角色声明和方法声明。方法声明则固定通过 macro 和 #externalMacro 关键词去修饰,而角色定义可以分为很多种,后续会展开介绍。


因为宏是通过 Package 去管理的,所以这里的 module 也就是包的模块名,而 type 则是当前宏实现的具体类型。

外部宏的展开进程是独立的,在一个安全的沙盒环境下进行,与外界的其它信息进行隔绝。在编译器执行 和 Swift 宏有关的代码时,编译器会调用宏的实现来展开宏。下面从 @DefaultAge 的定义到展开,大概阐述整个过程。

Test 类添加 @DefaultAge



1.编译器读取当前类,并拿到内存中转化的 AST 语法树,当前 AST 仅有一个成员 name


2.将上述AST 传入宏的作用域,发送给编译器插件 Compiler Plug-in (只会传 AST,不包含其它代码)


3.编译器插件通过宏的声明,去宏的模块内找到该宏的实现,获取当前宏返回的AST,且在这个展开过程中,编译器会去检查 age 的有效性,比如类型是否正确等


4.  编译器拿到新增的 AST后,将其添加到原始 AST 中,最终生成新的语法树


5.  编译器插件将新的语法树序列化后插入到源码中,参与后续编译


过程图示:


经历过这样一个简短的过程,可以得出几个结论:


1.  Swift Macro 的运行作用域是封闭的,隔绝外界无关信息,避免双向信息的交流和获取,禁止在宏内部做出一些对外界干扰的行为

2.  Swift Macro 对代码原环境的上下文感知是有限的,只感知和宏有关的 AST,我们无法对原始的 AST 做出修改和删除,从而印证了 Swift Macro 是一个增量的行为

3.  Swift Macro 会在宏的展开阶段对代码进行有效性检查,保证宏的正确性和可预测性


类别


在对宏的有了一个初步的认识后,了解一下宏的各个角色定义有什么不同。Swift 宏分为目前分为两类,独立宏和绑定宏。


独立宏


独立宏以 # 开头,创建一个表达式或者声明。独立宏类似平常开发中的纯函数,这里独立的意思是不需要感知外部环境的上下文,仅仅靠它自己就可以独立运行。

独立宏又细分为表达式宏和声明宏。


1.1 @freestanding(expression)


表达式宏:定义一个可以在表达式上下文中使用的宏,通常返回一个表达式或者值,类似系统中的 #function。使用方式一般是以字符串插值嵌入到某个表达式中,生成新的表达式,并作为表达式的一部分参与运算。

打印当前函数信息


1.2 @freestanding(declaration)


声明宏:定义一个可以在声明上下文中任何地方使用的宏,和表达式宏不同的是,它返回的是一个完整的声明,且永远不会产生值。类似系统中的 #warning,它还可以为我们声明整个类,枚举,属性等。

警告声明


生成一个方法


在日常开发中,我们经常会使用到色值转换,通过 ColorWithXXX 获得一个色值


但是从设计稿复制黏贴并不能保证色值的正确性,这就可能会造成运行时的崩溃。那么此时独立宏就有了用武之地,我们可以在宏的实现内部加入色值的校验。

16进制色值正则校验


这样我们就可以在编译时抛出异常,避免因为少了一个字符而引发的cs。


绑定宏


以 @ 开头,和独立宏不同的点在于,它为我们提供了扩展 Swift 代码的能力,基于参数上不同角色的转换为我们创建或者扩展声明。比如可以对一个类,新增方法,新增属性,新增协议等


2.1 @attached(peer)


attached(peer) 是在原方法的作用层级上,对原有方法的增强,比如函数的重载。日常开发中,我们会在某些性能监控场景计算函数耗时,在方法前后记录当前时间戳计算差值。那么 attached(peer) 就可以提供overloaded 的能力,在原有方法的基础上,为我们自动生成一个新的方法。

函数的重载


@NeedAPM 宏代码展开


如此一来,宏会帮我扩展出一个新的方法 func test(_ needApm: Bool),使用场景就由开发者决定。


2.2 @attached(accessor)


@attached(accessor)是对属性访问器的扩展,主要为某个属性扩展 setter,getter,didSet,willSet方法。可以把存储属性变成计算属性,通过 _Property 去接收;还可以通过这种方法去自己管理 Strcut / Class 的 Copy on Write,用来提升内存效率。

@NameMacro 宏代码展开


又或者通过该宏来达到类似 PropertyWrapper 的能力,对某个属性进行 UserDefault 的存取,那么只需在 accessor 的 set,get 中添加 UserDefault 的能力。


@UserDefault 宏代码展开后


2.3 @attached(memberAttribute)


对类 / 结构体 / 枚举等所有成员添加属性扩展

随着 UserDefaultManager 的成员日益增加,UserDefaultManager 就会变得臃肿起来

而 @attached(memberAttribute) 就可以帮我们解决这个问题

,由于宏的特性,在展开的时候是递归展开的。也就意味着,我们对 UserDefaultManager 实现 memberAttribute 宏,就可以让内部的成员实现 @UserDefault 宏。

@UserDefaultDefine 第一层展开后


@UserDefaultDefine 第二层展开后


2.4 @attached(member)


对类 / 结构体 / 枚举等 添加成员或者方法,如开头 @DefaultAge 所示。


2.5 @attached(extension, conformances)


@attached(extension, conformances:xxxProtocol)是以 extension 的形式去遵循某个协议。日常开发中判断2个实例是否相等 ,需要遵守 Equatable 协议,添加成员的判断,conformances 宏可以帮我们省去这些操作。


@EquatableMacro 宏代码展开后


上面简述了一些不同宏的基础用法,但是想要应用在项目内,还需要结合实际场景。不难看出,Swift Macro 的构造相对来说是比较麻烦的,我们需要按照 AST 的结构去拆解,编写代码,包括宏的单元测试。

不过我们需要透过繁琐的过程看到本质,宏的本质就是将繁琐的代码简化,在编译时帮助我们自动去生成或者补齐代码。虽然写宏的过程是比较痛苦的,但是在@出宏的那一刻就会被延迟满足。

Swift宏 应用

 

模块化场景下的应用


目前在大会员中心业务下维护的 番剧影视 和 大会员收银台 都采用了 MVVP的模块化架构,那么模块化的东西势必会产生一些模块化的模版。每次新增模块,就不得不把一些模版化的代码 CV 过来修改,虽然不复杂,但是不想写。

以模块声明为例,我们需要单个模块内绑定模块的视图,模块的逻辑,以及一些模块固定的成员。

那么新增一个 TAB 模块,就需要遵循 BiliModule,并实现以下代码:

同上,在逻辑层 Presenter 和 视图层 View 都需要进行模版化的绑定。CV 的工作量并不大,但是需要人眼去纠错,因为很容易漏改某个地方。所以尝试用 Swift Macro 来简化模版代码,让开发重心更倾向于业务。

于是用到了 @attached(extension, conformances):自动遵循 BiliModule 协议@attached(member):自动生成模版属性和方法

最后,得到了下面这样一句话模块声明


当然了并非所有的模版定义都是一层不变的,这需要建立在一套固有的模版准则上,对样板内部的静态代码进行抽离。在大会员收银台的业务场景,可以轻轻敲下 @BiliModuleDefine,快速的构造一个模块声明,来聚焦业务逻辑的开发。为了不写模版,不得不为这个模版新写一个模版。


曝光场景下的应用


曝光在各个平台都有自己的实现方案,在大会员中心你想要曝光某个 ListView,你需要实现曝光 4 要素。

1.  注册曝光容器(Container),该容器视图需要 遵循 BiliExposureContainer 容器协议,以 ColletionView 为例。


2.  曝光对象(Target), 负责控制曝光的属性(曝光区域,是否重复曝光等),需要遵守 BiliExposureTarget 协议

3.  曝光视图(View),真正曝光的区域遵循 BiliExposureRegion 协议

4.  曝光回调(Report ), 表该视图成功曝光,实现 BiliExposureReporter 协议


这是没有嵌套滚动视图下的情况,如果 Cell 内部又嵌套了其它的滚动视图,那么整个曝光流程,在不熟悉的情况下,会让你手忙脚乱。所以想通过宏让刚接触的人可以快速上手,不用过多关心曝光组件,减少认知成本,同时也避免协议满天飞。

整个流程可以精简为3个步骤:


  • 第一步:标记曝光容器

@ExposureContainer:标记曝光容器

@ExposureView: 标记需要被曝光的视图 (非必须,可选)


情况A:该曝光视图是 ListView


@ExposureContainer 宏代码展开后


情况B:该曝光视图是常规 View(视图内多个元素需要曝光),需配合 @ExposureView 使用。其中 collectionView 和 headerView 都需要曝光检测。

@ExposureContainer 宏代码展开后


  • 第二步:设置曝光参数

    @ExposureTarget(_ percent: Int32, _ repeat: Bool ),通过 @attached(member) 生成成员。


@ExposureTarget(100, true) 宏代码展开后


  • 第三步:标记曝光视图

@ExposureRegion:需要曝光的具体视图,接收曝光回调,@ExposureTargetMember:标记需要被返回的曝光的对象实例。

真正需要被曝光的视图在当前场景下是 UICollectionViewCell,而其内部的exposureData 用来提供曝光配置给 Cell。

@ExposureRegion 和 @ExposureTargetMember 需绑定使用


@ExposureRegion 宏代码展开后


其中 @ExposureTargetMember 和 @ExposureView 是通过 @Peer 宏来进行标记,从而确定哪些成员是需要被找到的,Peer 宏内并无任何实现,以 @ExposureView 举例:

那么对上面的代码进行宏简化,在 List VC 就得到了下代码


整个流程翻译成图:


在曝光的接入流程上,使用者只需要明确自己需要标记宏的位置即可,无需感知什么角色需要遵守什么协议,将曝光流程职责化在具体的某个点上,让曝光的接入变得更为轻量化。


小结


以上两个场景应用,模块宏是为了解决模块化通用模版定义的繁琐, 曝光宏是为了降低整个曝光体系接入的复杂度,本质上都是让 Swift Macro 帮助我们减少重复样板代码的编写,提供快速接入的能力。接入 Swift Macro 给我的感受是它可以玩出很多花样,不仅增加了代码的趣味性,也增加的代码的可读性,降低了维护成本。目前对宏的理解和应用或存在瑕疵,后续会对宏继续探索,更大化的增强宏的实用性。有同学对代码实现有更好的建议也可以提出,一起交流进步。


-End-

作者丨 tit



Copyright © 2024 aigcdaily.cn  北京智识时代科技有限公司  版权所有  京ICP备2023006237号-1