前回のTestFlightでNSLogが使いものにならなくなった件の記事では、Swift Macroを使えばログ採取位置をXcodeのコンソールに正しく表示できるのではないか、というところで一旦締め括りました。
今回は、実際にSwift Macroを使ってログ採取処理を実装してみた結果を紹介します。
先に結論を言いますと、Swift Macroを使っても、Xcodeのコンソールにログ採取位置を期待どおりに表示することはできませんでした。
また、マクロを使うことで、ログ採取が不要な場合にはログ出力処理そのものを展開しないようにできるため、パフォーマンス上のメリットもあるのではないかと考えました。しかし、実際に処理時間を計測したところ、今回の条件では明確な差は確認できませんでした。
ただ、今回の試みは「既存のXcodeプロジェクト内でSwift Macroを作って利用する」という、Swift Macroの比較的簡単なチュートリアルにもなっていると思います。
手軽にSwift Macroを試してみたい方の参考になればと思い、今回の記事にまとめました。
今回やろうとしていること
C言語では、ログ採取処理を#defineマクロとして定義し、Releaseビルド時にはマクロの中身を空にすることで、ログ採取処理そのものをなくす方法があります。
これと同じようなことをSwift Macroでやってみよう、というのが今回の目的です。
やりたいこと自体は単純なのですが、Swift Macroは基本的にSwift Package Manager(SwiftPM)のパッケージとして作成する必要があるため、通常のSwiftコードを追加する場合と比べると少し大がかりです。
今回はできるだけ手軽にSwift Macroを作成するため、既存のXcodeプロジェクト内にSwift Macroパッケージを作成する方法を試しました。
既存プロジェクトにSwift Macroパッケージを追加する
まず、既存のプロジェクト(今回の例ではSampleApp)にSwift Macroパッケージを追加します。
プロジェクトを開いて、XcodeのFile - New - Package...メニューを選択すると、次のダイアログが表示されます。
Swift Macroを選択してNextをクリックします。
次にTesting Systemを選択するダイアログが表示されますので、今回はNoneを選択してNextをクリックします。
次のダイアログでは、Save As:欄にパッケージ名として「LogMacro」を指定し、Where欄には既存プロジェクトのフォルダ(.xcodeprojがあるフォルダ)を指定します。
また、Add to欄には既存のプロジェクトを指定します。
Source Control欄は、Where欄に指定したフォルダがすでにGit管理されている場合、「The folder "〜" contains a Git repository.」というメッセージとともに自動的にOFFになっているはずです。
既存プロジェクトがGit管理されているにもかかわらずONになっている場合は、Where欄に正しいフォルダが指定されているか確認してみてください。
Createをクリックすると、次のようにプロジェクトにLogMacroパッケージが追加されます。
続いて、TARGETS - Generalを開き、Frameworks, Libraries, and Embedded Contentの+ボタンをクリックします。
次の画面が表示されるので、LogMacroを選択してAddをクリックします。
次のように表示されたら、LogMacroパッケージの追加は完了です。
次に、今回追加したLogMacroには、サンプルとして#stringifyというマクロが定義されていますので、それが利用できるか試してみます。
既存プロジェクトの任意のコードが実行される場所に、次のコードを追加します。
import LogMacro
print(#stringify(1 + 2))
#stringifyは、引数として指定した式を実行した結果と、その式自体を文字列化したものをタプルとして返すマクロです。
上記のコードを実行して、Xcodeのコンソールに
(3, "1 + 2")
と表示されれば、#stringifyマクロは正常に動作しています。
ログ採取マクロを作成する
では、サンプルの#stringifyマクロの代わりに#logマクロを定義してみましょう。
まず、Package.swiftファイルのplatformsの指定を次のように変更します。
platforms: [.macOS(.v11), .iOS(.v14), .tvOS(.v14), .watchOS(.v7), .macCatalyst(.v14)],
これは、これから使用するLoggerは、macOS 11以上、iOS 14以上での利用が必須になっているためです。
次に、LogMacroフォルダ(LogMacro.swiftと同じ場所)にAppLogger.swiftを作成し、次のコードを書きます。
import OSLog
public struct AppLogger {
public static let shared = Logger()
}
ログ採取のたびにLoggerインスタンスを生成するのではなく、あらかじめインスタンスを作成して再利用できるようにしています。
次に、LogMacro.swiftの内容を次のようにします。
@_exported import OSLog
@freestanding(expression)
public macro log(_ message: String) = #externalMacro(module: "LogMacroMacros", type: "LogMacro")
これで#logマクロが定義されました。
サンプルとして用意されている#stringifyは残しておいても構いませんが、今回はシンプルにするため削除しました。
@_exported import OSLogは、LogMacroをimportした側でもOSLogの宣言を利用できるようにするための指定です。今回のマクロ展開後のコードで必要となるOSLog関連の宣言を、利用側で毎回明示的にimport OSLogしなくても済むようにしています。
次に、LogMacroClientフォルダのmain.swiftには#stringifyマクロを使用するサンプルコードがありますので、それを削除してimport LogMacroのみにします。
今回は既存プロジェクトからマクロを利用するため、このmain.swiftを実行することはありません。
import LogMacro
次に、LogMacroMacrosフォルダのLogMacroMacro.swiftを次のようにします。
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
struct LogMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let message = node.arguments.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}
#if DEBUG
return """
AppLogger.shared.log("\\(\(message) ,privacy: .public)")
"""
#else
return "()"
#endif
}
}
@main
struct LogMacroPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
LogMacro.self,
]
}
これが#logマクロの本体です。
DEBUGが定義されている場合(Debugモードの場合)はAppLogger.shared.log(...)を生成し、ログメッセージにはprivacy: .publicを指定して、ログ内容が<private>にならないようにしています。また、DEBUGが定義されていない場合(Releaseモードの場合)は、 return "()" で何もしないコードを生成してログ採取処理が行われないようにしています。
そして、既存プロジェクトの任意のコードが実行される場所でLogMacroをimportし、#logを呼び出します。
これで#logマクロを使ってログを出力できるようになりました。
実行すると、Xcodeのコンソールに次のように表示されます。
しかし残念ながら、コンソールに表示されるソースコード位置は#logを記述した場所ではなく、マクロ展開に関係する位置になってしまいました。
さらに、コンソール上の→をクリックしてもFailed to open documentというエラーになってしまいます。
この問題については、#sourceLocationを使用する方法など、いろいろ試してみました。しかし、最終的に期待した結果にはなりませんでした。
つまり、
- ログ内容を自動的に
publicとして出力する -
#logを記述したソースコード位置をXcodeのコンソールに正しく表示する
という2つをSwift Macroによって実現する、という当初の目論見は残念ながらうまくいきませんでした。
ただし、ログメッセージの中に#file、#line、#columnで取得した情報を埋め込み、ログを記述した位置を文字列として表示することは可能です。
その場合は、LogMacroMacro.swiftの
AppLogger.shared.log("\\(\(message) ,privacy: .public)")
の部分を、次のように変更します。
AppLogger.shared.log("\\("\\((#file as NSString).lastPathComponent) \\(#line):\\(#column) \\(\(message))" , privacy: .public)")
これにより、ログメッセージ自体にファイル名、行番号、列番号を含めることができます。
パフォーマンス上のメリットはあるのか
もう1つ、マクロを使うことでReleaseビルド時にはログ出力コード自体を展開しないようにできるため、通常のログ採取メソッドを呼び出す方法よりもパフォーマンス上有利なのではないかと考えました。
そこで実際に処理時間を計測してみましたが、今回の計測では、マクロを使用する場合とログ採取メソッドを呼び出す場合で明確な処理時間の差は確認できませんでした。
最適化されたReleaseビルドでは、呼び出したメソッドが実質的に何もしないことをコンパイラが判断できる場合、その呼び出し自体が最適化によって除去される可能性があります。
今回差が確認できなかったのも、そのようなコンパイラ最適化が影響している可能性があります。
したがって、少なくとも今回のような単純なログ出力処理では、「Macroにすれば高速になる」というメリットは確認できませんでした。
まとめ
今回の目的だった「ログ採取位置をXcodeのコンソールに正しく表示する」という用途では、残念ながらSwift Macroを期待どおりに活用することはできませんでした。
一方で、今回試した方法を使えば、
- 既存のXcodeプロジェクト内にSwift Macroパッケージを作成する
- アプリ側から独自のMacroを呼び出す
- Macroによって任意のSwiftコードを展開する
という一連の流れを比較的手軽に試すことができます。
プロジェクト内限定のマクロを簡単に作れるようになれば、ログ採取以外にもいろいろな用途が考えられると思います。
これからSwift Macroを使ってみようという方の参考になれば幸いです。
この記事の画像はZkitchで作成しました。
Zkitchは、画面のスクリーンショットに矢印やテキストなどの説明を追加できるアプリです。
説明入りの画像をよく作成される方は、ぜひお試しください。









