🧑‍💻

URBAN HACKS iOSプロジェクトで活用しているSwift Macroについて

に公開

こんにちは、モバイルエンジニアの藤野です。
以前は東急カードプラスを担当していて、現在は東急線アプリのiOSを担当しています。

今回はURBAN HACKS内でのSwift Macroの運用について紹介したいと思います。

URBAN HACKS共通マクロの運用

現在URBAN HACKS内の東急線アプリや東急カードプラスのiOSプロジェクトでは、独自のSwift Macroを導入しています。
その中でもプロジェクトを横断して使える共通マクロについては、GitHubのprivate repositoryにを用意して、各プロジェクトがSPM経由で取り込んで利用することができるようになっています。

共通化した経緯としては、元々東急カードプラスで作成して運用していたマクロを他のプロジェクトで利用したい場面があったため、一つのSwift Packageにまとめて、プロジェクトから分離することにしました。
マクロを使うような場面では、プロジェクトによらず同じようなボイラープレートが発生するケースが多く、共通化することで各プロジェクトの開発効率を上げることができます。

共通マクロの実装にあたり、OSSを活用することも検討しました。
しかし、Sourceryなど既存のメタプログラミングツールのテンプレートと同じく、社内の利用ケースに応じて微調整が必要な場面が多いため、OSSでは対応が難しいと考えて、独自の実装をすることにしました。

利用しているマクロの紹介

マクロのパッケージ(UHMacroパッケージ)については、Xcodeで作成できる標準のテンプレートを踏襲して、以下のような構成になっています。

UHMacroパッケージ

UHMacro内で各マクロの宣言をして、実装はUHMacroImplements内で記述しています。
実行用ターゲットのUHMacroClientは、元のテンプレートに付随していたもので必要ではありませんが、開発時の動作確認用に残してあります。

一部のマクロの利用ケースと、実装内容を簡単に解説していきます。

URLマクロ

こちらは、最もシンプルなExpressionMacroになっています。
swift-syntaxのExampleにもある、URLのstring literalをコンパイル時にvalidateして、有効な場合はforce unwrapでOptional無しのURLとして返すマクロです。

https://github.com/swiftlang/swift-syntax/blob/fb65fcb05c15498d8dbbc14123c5fb7e0136a0af/Examples/Sources/MacroExamples/Implementation/Expression/URLMacro.swift#L19-L38

事前に変換が成功することがわかっている固定値のURLに対しても、URL.init(string:)を利用するとURL?になってしまうのはかなり扱いづらく、どこかでOptionalを外す処理を書かないといけません。
force unwrapを利用する場合には、必ずURLへの変換が成功することを確認する必要がありますが、static assertの考え方で、マクロを介してコンパイル時に検証する事ができれば、事故を防ぐ事ができます。
URBAN HACKS内でもプロジェクトを問わず、WebViewや外部ブラウザで表示する固定値のURLをコード内で扱うことが多いため、利用できるシチュエーションは多いです。

マクロの処理としては、swift-syntaxの例と同様に、expansionの中で実際に引数のstring literalをURL.init(string:)に渡して、変換に失敗した際はエラーを投げるようにしています。

マクロ展開前
let url = #URL("https://10q89s.jp/")
マクロ展開後
let url = URL(string: "https://10q89s.jp/")!

AutoInitマクロ

こちらは、classstructのmemberwise initializerを生成するMemberMacroで、そのクラスのアクセスレベルを引き継ぐことができます。

URBAN HACKSではSwift Packageを利用してパッケージ分割をしているため、パッケージを跨ぐpublicクラスの定義でmemberwise initializerを書かなければいけないことが多く、利用する機会は多いです。
特に、手書きでDIを行っている部分では、巨大なボイラープレートが発生しやすく、大きくコードを削減する事ができました。

マクロの処理としては、クラスの中からstored propertyのみを抜き出して、上から順番にinitのパラメータリストを生成しています。
その他の機能については、現状はシンプルにアクセスレベルの変更のみ実装していますが、必要に応じてinitializerに含めないパラメータの指定や、デフォルト引数の指定などをサブのマクロで実現する拡張が考えられます。

マクロ展開前
@AutoInit
public struct Hoge {
   static let const = 42
   private var id: Int
   private var name: String
   var comment: String?
   var isChecked = false
   var closure: () -> Void

   var displayName: String {
       name.uppercased()
   }

   var isFocused: Bool = false {
       didSet {
           print(isFocused)
       }
   }
}
マクロ展開後
public struct Hoge {
    static let const = 42
    private var id: Int
    private var name: String
    var comment: String?
    var isChecked = false
    var closure: () -> Void

    var displayName: String {
        name.uppercased()
    }

    var isFocused: Bool = false {
        didSet {
            print(isFocused)
        }
    }

    public init(
        id: Int,
        name: String,
        comment: String? = nil,
        isChecked: Bool = false,
        closure: @escaping () -> Void,
        isFocused: Bool = false
    ) {
        self.id = id
        self.name = name
        self.comment = comment
        self.isChecked = isChecked
        self.closure = closure
        self.isFocused = isFocused
    }
}

EnumIdentifierマクロ

こちらは、associated valueを持つenumcase名から、Stringの識別子を生成するMemberMacroです。

URBAN HACKSのプロジェクトでは、Firebaseなどに送るログのイベントをenumで定義することが多く、各パラメータはassociated valueで表現する方式をとっています。
そのため、イベント名を記述する際にrawValueを使うことができず、毎回computed propertyで自明なコードを書く必要がありました。

イメージ
enum ActionEvent {
    case detailButtonTap(itemId: Int)
    case usageButtonTap
    ...

    var name: String {
        switch self {
        case .detailButtonTap:
            "detailButtonTap"
        case .usageButtonTap:
            "usageButtonTap"
        ...
        }
    }

このような巨大なボイラープレートを解消するために、一定のルールに従っているものについては、マクロを利用して、イベント名のcomputed propertyを自動生成するようにしました。
既存コードの適用可否については、シェルスクリプトを利用して、case名とイベント名が全て対応規則に沿っているかを確認して判断しています。
現状全てのログイベントに対応規則があるわけではないので、利用できない部分もありましたが、今後リファクタリングをしながら適用できる箇所を増やしていきたいと思います。

マクロの処理については、enumの中のcase名を抜き出して、それをStringとして返すcomputed propertyを生成します。
こちらのcomputed propertyについては、各プロジェクトの利用ケースに合わせて、プロパティ名を変更できるようにしています。

マクロ展開前
@EnumIdentifier(name: "name")
public enum TestEnum {
    case foo, foo2
    case bar(id: Int)
    case yoo(String)
}
マクロ展開後
@EnumIdentifier(name: "name")
public enum TestEnum {
    case foo, foo2
    case bar(id: Int)
    case yoo(String)

    public var name: String {
        switch self {
        case .foo:
            "foo"
        case .foo2:
            "foo2"
        case .bar:
            "bar"
        case .yoo:
            "yoo"
        }
    }
}

おわりに

今回はSwift Macroの運用の話でしたが、内製開発のメリットの一つとして、共通モジュールを利用することで、プロジェクトを横断した開発効率の向上が可能なことが挙げられます。
コードベースの課題に限らず、プロジェクト間のシナジーを活かした業務の効率化に、今後も取り組んでいきたいと思います。

https://apps.apple.com/jp/app/東急カードプラス/id684161442

https://apps.apple.com/jp/app/東急線アプリ-東急電鉄-東急バス公式の時刻表-運行情報/id604757991

東急URBAN HACKS

Discussion