iOS Swift Macro入门

一、Swift Macro介绍

WWDC2023会上Swift 5.9加入了Swift Macro,它允许我们在编译时生成代码或在编译之前动态地操作项目的 Swift 代码,从而实现在编译时注入额外的功能,使我们的应用程序的代码库更易于阅读且更高效地编码。
Swift Macro没有iOS版本要求,只要使用Swift 5.9及以上都可以。
OC Macro就是简单的代码替换,而 Swift 宏设计的比较抽象,可以通过框架提供的API直接获取到Swift的句法树,获取或添加需要的代码,功能更加强大。
Swift Macro源码地址 注:本文基于510.0.3版本
本文主要目的是记录以下Swift Macro的用法和每种类型的宏的作用,方便快速入门。如果内容有误或理解有误,希望各位大佬指正,谢谢。

二、初探Swift Macro

1.创建Swift Macro工程

在Xcode的File菜单中选择NEW->Package->Swift Macro,如图:

Swift Macro

先看看默认生成的Macro程序,它会默认实现一个stringify Macro。
Macro文件结构

2.文件结构

这里只先简单介绍一下,具体代码实现及作用后文再详细分析。

2.1.SwiftMacroMacro.swift 宏实现

struct StringifyMacro就是stringify macro实现的源码。
structSwiftMacroPlugin类似于注册宏,实现的宏都要添加到providingMacros数组里才会生效,否则使用时会报错。

Macro实现

2.2.SwiftMacro.swift 宏声明

通过macro关键词定义一个stringify宏,具体结构和声明方法类似。
等号右边赋值部分又是另一个宏,这个宏专门用来定义其他宏的,传入需定义宏所在模块和类型(也就是上面的structStringifyMacro)。
@freestanding(expression)官方定义为MacroRole宏角色(如下图MacroRole的定义)。相当于定义这个宏具备什么功能,不同的关键词决定了这个宏具备不同的功能,并且是可以添加多个这种关键词的,具体每个角色等后文讲解各类型宏作用时就自然理解了。

Macro 声明

externalMacro
宏角色
2.3.main.swift 宏运用

这个文件就是宏在项目中的实际运用,可以看到传入了a + b,返回了他们的相加结果a + b = 42,并且还能把"a + b"作为字符串返回。怎么做到的呢?等看完下文分析ExpressionMacro时,自然就理解了。

宏运用代码

我们还可以右键宏,在菜单里点击Expand Macro展开宏,也就是预览这个宏编译后会转换成什么代码,依次来确认宏是否正确的生成我们想要的代码。如果发现点击后没任何效果,就要排查宏是不是那里写错了。
展开宏

预览宏

2.3.SwiftMacroTests.swift 宏测试

除了上面直接使用宏来做测试以外,我们还可以用苹果为我们提供的测试工具来进行测试。这个测试类运行后是可以在宏实现里面断点调试的,2.2宏运用运行起来断点是不起作用的。
等后面我们使用例子分析代码时,就会使用到这个测试类来断点查看变量,追踪问题。


测试

三、Swift Macro分类

Macro Protocol

宏归类

从上图可以看到官方为我们提供了很多种宏,但实际目前分为两类宏。
1.FreestandingMacro:即独立宏,宏声明中使用 @freestanding关键字,宏运用时以#开头。独立宏和OC的宏类似,就是将宏替换宏中预定义的代码。 上一个章节中的#stringify(a + b)就是一个独立宏。
2.AttachedMacro:附加宏,宏声明中使用@attached关键字,宏运用时以@开头。作用是为类、结构体、协议、属性、方法等目标声明附加额外的代码(只能增加,不能修改删除原有代码)
iOS17为我们提供的新的监听框架import Observation中的@Observable就是一个附加宏。
swift macro其实基础入门并不难,比较麻烦的是需要处理各种语法、重复添加代码等特殊情况,想要自己的宏更加完善,就需要兼容这些特殊情况。比如:

// 1.定义变量有多种语法,需要分别处理
var a: Int = 0 // 标准写法,可以拿到变量类型
var a = 0 // 不能从语法树中获取变量类型,需要自己判断
var a = 0, b = 0 // 有多个变量,需要遍历获取

// 2.给成员变量添加didSet访问器,如果外部代码已经手动添加了didSet。
//   宏内就要自己做处理,根据自己的需求选择抛异常或者返回空。

// 3.定义了一个给类使用的宏,不添加检查代码的话,外部是可以错误的给协议、结构体等添加的。

四、自定义宏

开始之前,有一些名词需要先解释一下:

  1. declaration声明:指宏添加到的目标对象,可以是类、结构体、属性、方法等。
  2. member成员:即属性、方法。
  3. attribute属性: 指声明前面添加的修饰符,Swift Attributes。比如 @Observable@available(*, deprecated)等,这些都算是属性。

1.独立宏

1.1.ExpressionMacro

在第二章节创建的宏工程默认实现的宏StringifyMacro就是一个ExpressionMacro表达式宏。

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.arguments.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

在宏声明中需要对表达式宏添加@freestanding(expression)来表示这个宏是一个表达式类型的独立宏。

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftMacroMacros", type: "StringifyMacro")

下图就是此宏生成的Abstract Syntax Tree 抽象语法树。可以很清晰的看到我们使用宏是传入的表达式,多个操作符operator就会生成更深层级的嵌套。
此宏主要用于表达式的计算或转换。需要传入一个表达式,根据自身需求可对其中各节点进行修改。
比如传入的操作符+可以改成-,再将修改后的ExprSyntax作为返回参数传递出去,就能将原本的加法改为减法。
以下是宏#stringify(a + b + c)的语法树结构:

抽象语法树

1.2.DeclarationMacro

可以用来定义类、结构体、枚举、方法等,起到声明的作用。如下代码获取外部传入参数.stringSegment(name) = expression.segments.first解析出参数字段name。然后生成了2个结构体struct (name)struct (name)Sub

public struct DeclarationTestMacro: DeclarationMacro {
    public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        guard let expression = node.arguments.first?.expression.as(StringLiteralExprSyntax.self),
              expression.segments.count == 1,
              case let .stringSegment(name) = expression.segments.first else {
            return []
        }
        let syntax1 = DeclSyntax(stringLiteral:
            """
            struct \(name) { 
            var a = 0
            var b = 0
            }
            """)
        
        let syntax2 = DeclSyntax(stringLiteral:
            """
            struct \(name)Sub {
            var a = 0
            var b = 0
            }
            """)
        return [syntax1, syntax2]
    }
}

宏声明如下:
因为这个宏定义了一个结构体,这里需要标记names,否则会报错:
这个参数有4种定义:
1.arbitrary: 即任意名字都行
2.named(name): 必须是指定的名字
3.prefixed(name): 前缀必须是指定字符串(实际测试中疑似对declaration宏不生效,待确认)。
4.suffixed(name): 后缀必须是指定字符串(实际测试中疑似对declaration宏不生效,待确认)。

@freestanding(declaration, names: arbitrary)
public macro declarationStruct(_ value: String) = #externalMacro(module: "SwiftMacroMacros", type: "DeclarationTestMacro")

使用时展开宏即可预览替换后的结果:


宏展开
1.3.CodeItemMacro

此宏还是一个未启用的实验性功能,不能正式使用。但是可以在SwiftMacroTests测试类中调试。(由于是实验性质的功能,这里就只粗略展示一下用法)

CodeItemMacro

在第二章节创建的宏工程的SwiftMacroMacro类实现中添加struct CodeItemTestMacro,实现CodeItemMacro协议。

CodeItemTestMacro宏只是简单的实现了一个判断传入的参数是否等于0
比如将#codeItemTest(a, b)转换为
if a == 0 {
return
}
if b == 0 {
return
}

具体的宏实现代码如下(下文有讲解具体代码含义):

public struct CodeItemTestMacro: CodeItemMacro {
    public static func expansion(of node: some SwiftSyntax.FreestandingMacroExpansionSyntax, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.CodeBlockItemSyntax] {
        let identifiers = try node.arguments.map { argument in
          guard let declReferenceExpr = argument.expression.as(DeclReferenceExprSyntax.self) else {
              throw MacroError.codeItem
          }
            return declReferenceExpr.baseName
        }
        let items = identifiers.compactMap { syntax -> CodeBlockItemSyntax in
            """
            if \(raw: syntax.text) == 0 {
                return
            }
            """
        }
        return items
    }
}

enum MacroError: Error {
    case codeItem
}

测试用例中测试代码如下:

    func testCodeItemMacro() throws {
        #if canImport(SwiftMacroMacros)
        assertMacroExpansion(
            """
            #codeItemTest(a, b)
            """,
            expandedSource: """
            if a == 0 {
                return
            }
            if b == 0 {
                return
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }

//测试结果,可以看到测试通过:
Test Suite 'Selected tests' started at 2024-08-21 11:18:01.012.
Test Suite 'SwiftMacroTests.xctest' started at 2024-08-21 11:18:01.012.
Test Suite 'SwiftMacroTests' started at 2024-08-21 11:18:01.012.
Test Case '-[SwiftMacroTests.SwiftMacroTests testCodeItemMacro]' started.
Test Case '-[SwiftMacroTests.SwiftMacroTests testCodeItemMacro]' passed (0.008 seconds).
Test Suite 'SwiftMacroTests' passed at 2024-08-21 11:18:01.020.
     Executed 1 test, with 0 failures (0 unexpected) in 0.008 (0.008) seconds
Test Suite 'SwiftMacroTests.xctest' passed at 2024-08-21 11:18:01.021.
     Executed 1 test, with 0 failures (0 unexpected) in 0.008 (0.009) seconds
Test Suite 'Selected tests' passed at 2024-08-21 11:18:01.021.
     Executed 1 test, with 0 failures (0 unexpected) in 0.008 (0.009) seconds
Program ended with exit code: 0

打断点追踪协议方法都提供了哪些属性可以让我们获取。从下图可以看到node有个arguments数组,里面包含了传入的参数信息,所以这个宏实现我们就是去获取baseName,然后将baseName转换为上面的if-else代码。


断点

node包含的数据

2.附加宏

2.1.AccessorMacro

这个宏是给成员变量附加访问器的(比如didSet、willSet、getter、setter)。需要注意的是,如果本身已经实现了didSet,宏又去添加一个didSet,不做处理的话会重复添加导致报错,一般需要在宏实现中自己做判断,下面的例子只是简单的展示一下用法,不会做过多的容错处理。

public struct AccessorTestMacro: AccessorMacro {
    public static func expansion(of node: SwiftSyntax.AttributeSyntax,
                                 providingAccessorsOf declaration: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
        guard let property = declaration.as(VariableDeclSyntax.self) else {
            return []
        }
        guard let pattern = property.bindings.first?.pattern.as(IdentifierPatternSyntax.self) else {
            return []
        }
        let identifier = pattern.identifier.trimmed
        if case .argumentList(let list) = property.attributes.first?.as(AttributeSyntax.self)?.arguments,
           let first = list.first,
           let statements = first.expression.as(ClosureExprSyntax.self)?.statements {
            let didSet: AccessorDeclSyntax =
              """
              didSet {
              print("宏内部添加的代码")
              \(statements.trimmed)
              }
              """
            return [didSet]
        } else {
            let didSet: AccessorDeclSyntax =
              """
              didSet {
              print(\(identifier))
              }
              """
            return [didSet]
        }
    }
}

宏声明如下:
这里加了一个入参block,是为了演示外部可以通过一定的方法将要执行的代码块添加到宏生成的访问器里(这种实现有一定的局限性,比如不能随意访问self及其他成员变量),这里只做演示用。
@attached(accessor, names: named(didSet))由于AccessorMacro是一个附加宏,所以不能像上一节的独立宏那样使用freestanding来修饰了。然后指定只生成didSet访问器。

/// 实验传代码块
@attached(accessor, names: named(didSet))
public macro accessorTest<T>(_ block:((T)-> Void)? = nil) = #externalMacro(module: "SwiftMacroMacros", type: "AccessorTestMacro")
/// 常规用法
@attached(accessor, names: named(didSet))
public macro accessorTest() = #externalMacro(module: "SwiftMacroMacros", type: "AccessorTestMacro")

在下面的使用代码中右键菜单展开宏可以预览到最终代码会是什么样的。


宏运用及结果

生成didSet访问器很简单,可以直接用字符串定义。然后是从下图中打印的语法树可以找到传入的block,然后一步步提取其中的代码块,然后插入到didSet中(再次提醒此例子代码比较简单,许多容错都没有处理)。
实际上@accessorTest<Int>({ a in print()}) 这里也是取巧并非真的从宏里面将a变量传递出来了,只是借用同名a让代码块附加到didSet后能找到a这个变量。

declaration语法树

2.2.ExtensionMacro

此宏用于创建Extension,下面的代码创建了一个Extension并遵守Equatable,很简单。

public struct ExtensionTestMacro: ExtensionMacro {
    public static func expansion(of node: AttributeSyntax, 
                                 attachedTo declaration: some DeclGroupSyntax,
                                 providingExtensionsOf type: some TypeSyntaxProtocol,
                                 conformingTo protocols: [TypeSyntax], 
                                 in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        let syntax: ExtensionDeclSyntax? = 
        try? .init(.init(stringLiteral:
          """
          extension \(type.trimmed): Equatable {}
          """
        ))
        
        guard let syntax else {
            return []
        }
        return [syntax]
    }
}
@attached(extension, conformances: Equatable)
public macro extensionTest() = #externalMacro(module: "SwiftMacroMacros", type: "ExtensionTestMacro")
宏展开
2.3.MemberAttributeMacro

为声明中的成员添加属性。声明、成员、属性指什么,可以看文章第四节开头。
这里我们就可以用此宏给声明的成员附加我们上面2.1实现的accessorTest宏。不做判断处理的话,默认是会给所有成员附加属性,我们也可以根据自己需要添加逻辑过滤不想附加属性的成员。

public struct MemberAttributeTestMacro: MemberAttributeMacro {
    public static func expansion(of node: AttributeSyntax,
                                 attachedTo declaration: some DeclGroupSyntax,
                                 providingAttributesFor member: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext
    ) throws -> [AttributeSyntax] {
        return [
            AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("accessorTest"))),
        ]
    }
}
@attached(memberAttribute)
public macro memberAttributeTest() = #externalMacro(module: "SwiftMacroMacros", type: "MemberAttributeTestMacro")
宏展开
2.4.MemberMacro

为声明添加额外的成员。
如下例子中:给声明(即实现中为结构体)添加了一个flag属性(成员)和一个memberAttributePrint方法(成员)。该方法中打印了声明的属性和flag的值。

public struct MemberTestMacro: MemberMacro {
    public static func expansion(
      of node: AttributeSyntax,
      providingMembersOf declaration: some DeclGroupSyntax,
      conformingTo protocols: [TypeSyntax],
      in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        
        let syntax1: DeclSyntax =
        """
        var flag: Bool = false
        
        func memberAttributePrint() {
            print("\(node.attributeName.trimmed)-\\(flag)")
        }
        """
        return [syntax1]
    }
}
@attached(member, names: arbitrary)
public macro memberTest() = #externalMacro(module: "SwiftMacroMacros", type: "MemberTestMacro")
宏展开
2.5.PeerMacro

根据成员附加一个新成员。
下面的例子中,我们根据属性生成了一个新的属性var (a)Peer: Int = 0,新的属性的类型、初始化与目标属性保持一致,并且还附带了@accessorTest宏,为新属性添加didSet访问器。

public struct PeerTestMacro: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let property = declaration.as(VariableDeclSyntax.self) else {
            return []
        }
        guard let identifier = property.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.trimmed,
              let type = property.bindings.first?.typeAnnotation?.type.trimmed,
              let initializer = property.bindings.first?.initializer else {
            return []
        }
        
        let syntax: DeclSyntax =
        """
        @accessorTest
        var \(identifier)Peer: \(type) \(raw: initializer)
        """
        
        return [syntax]
    }
}
@attached(peer, names: arbitrary)
public macro peerTest() = #externalMacro(module: "SwiftMacroMacros", type: "PeerTestMacro")

下面是宏展开的效果:
这里出Bug了,@accessorTest宏无法展开,但通过运行可以看出附加的访问器是生效了的,打印了属性的值5


宏展开
2.6.聚合使用

我们可以一个宏实现里实现多个宏协议,然后声明宏时使用多个@attached修饰。

@attached(accessor, names: named(didSet))
@attached(peer, names: arbitrary)
public macro ObservableTracked() = #externalMacro(module: "ObservationMacros", type: "ObservableTrackedMacro")

五、框架中常见的类(待补充)

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,635评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,543评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,083评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,640评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,640评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,262评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,833评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,736评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,280评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,369评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,503评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,870评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,340评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,460评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,909评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,512评论 2 359

推荐阅读更多精彩内容