@_dynamicReplacementを持つframeworkを動的にロードして実装を差し替える
Swiftは@_dynamicReplacementの仕組みを使い、dynamic修飾子が設定されたメンバの実装を差し替えることができます
この仕組みは、SwiftUIのPreviewなどに活用されているようです
そこで、@_dynamicReplacementの基本的な挙動を確認し、@_dynamicReplacementが設定された実装を含むモジュールがロードされたタイミングで実装が差し替わるという特徴を生かし、frameworkを動的に読み込むことで、動的に挙動を差し替えるのを試してみました
この記事は@_dynamicReplacementの挙動を確認することを目的としており、frameworkを動的にロードする仕組みをプロダクトに導入することを推奨するものではありません
@_dynamicReplacementの基本的な挙動の確認
Libクラスのtext関数の挙動を変えてみます
dynamicを付けて、差し替えられるように宣言します
とりあえず、このまま実行してボタンを押下すると当然、textはDefault
に変わります
public class Lib {
public init() {}
// dynamicを付けて、差し替えられるようにする
public dynamic func text() -> String {
"Default"
}
}
struct ContentView: View {
@State var text: String = "Hello, world!"
var body: some View {
VStack(spacing: 32) {
Text(text)
Button {
text = Lib().text()
} label: {
Text("Update text")
}
}
}
}
挙動を差し替えるために、@_dynamicReplacementを設定した_text
関数を作成します
この状態で実行してボタンを押下すると、textはReplaced
に変わります
public class Lib {
public init() {}
// dynamicを付けて、差し替えられるようにする
public dynamic func text() -> String {
"Default"
}
}
+ extension Lib {
+ @_dynamicReplacement(for: text())
+ public func _text() -> String {
+ "Replaced"
+ }
+ }
frameworkを動的にロードして実装を差し替える
@_dynamicReplacementが設定された実装を含むモジュールがロードされたタイミングで実装が差し替わります
そこで、frameworkを動的に読み込むことで、動的に挙動を差し替えるのを試してみました
やりたいこと
上記のLib
をBaseLib.framework
に入れ、@_dynamicReplacementの実装をReplaceLib
に入れます
通常時はDefault
が表示されますが、Load framework
ボタンを押下してReplaceLib.framework
を読み込むと、Replaced
が表示されるように変わります
+ import BaseLib
struct ContentView: View {
@State var text: String = "Hello, world!"
var body: some View {
VStack(spacing: 32) {
Text(text)
Button {
text = Lib().text()
} label: {
Text("Update text")
}
+ Button {
+ // ReplaceLib.frameworkをロードして、挙動を差し替える
+ let frameworkURL = Bundle.main
+ .url(forResource: "ReplaceLib", withExtension: "framework")!
+ .appending(component: "ReplaceLib")
+ let res = DynamicLinking.open(at: frameworkURL)
+ print("res: \(res)")
+ } label: {
+ Text("Load framework")
+ }
}
}
}
+ enum DynamicLinking {
+ static func open(at url: URL) -> Result<UnsafeMutableRawPointer, NSError> {
+ if let res = dlopen(url.path, RTLD_LAZY) {
+ return .success(res)
+ }
+ let err = dlerror().flatMap { String.init(cString: $0) } ?? "unknown error"
+ return .failure(.init(domain: err, code: -1))
+ }
+ }
動作している様子
最初はLib().text()の結果は、Default
ですが、Load framework
ボタンを押下すると、Replaced
に変化していることがわかります
BaseLib.frameworkの実装
適当なframeworkターゲットを作成し、BaseLibと名付け、Libクラスを実装しました
Libクラスはdynamic修飾子を持ったtext関数を持っています
ビルドし、Product
> Show Build Folder in Finder
からビルドされたBaseLib.frameworkを取得します
public class Lib {
public init() {}
public dynamic func text() -> String {
"Default"
}
}
ReplaceLib.frameworkの実装
BaseLibで実装したLibクラスのtext関数を差し替える@_dynamicReplacementを持つReplaceLib.frameworkを作成します
frameworkターゲットを作成し、ReplaceLibと名付けます
先ほどビルドしたBaseLib.frameworkを追加します
Framework in Frameworkを避けるためにFrameworks and Librariesには入れますが、Do Not Embedを選択しておきます
次に、ReplaceLib.frameworkにLibのextensionを実装します
import BaseLib
extension Lib {
@_dynamicReplacement(for: text())
public func _text() -> String {
"Replaced"
}
}
最後に、動的に読み込むためにteamとbundle identifierを設定し、署名するようにしておきます
BaseLib.frameworkと同様に、ビルドし、Product
> Show Build Folder in Finder
からビルドされたReplaceLib.frameworkを取得します
アプリターゲットの実装
アプリターゲットを作成し、上記でビルドしたBaseLib.framework
とReplaceLib.framework
を追加します
ただし、BaseLib.framework
のみEmbed & Signします
一方で、ReplaceLib.framework
はEmbedせずに、Copy Bundle Resourcesでコピーしておくようにします
最後にdlopenを使ってframeworkをロードします
import SwiftUI
import BaseLib
struct ContentView: View {
@State var text: String = "Hello, world!"
var body: some View {
VStack(spacing: 32) {
Text(text)
Button {
text = Lib().text()
} label: {
Text("Update text")
}
Button {
let frameworkURL = Bundle.main
.url(forResource: "ReplaceLib", withExtension: "framework")!
.appending(component: "ReplaceLib")
let res = DynamicLinking.open(at: frameworkURL)
print("res: \(res)")
} label: {
Text("Load framework")
}
}
.font(.title)
}
}
enum DynamicLinking {
static func open(at url: URL) -> Result<UnsafeMutableRawPointer, NSError> {
if let res = dlopen(url.path, RTLD_LAZY) {
return .success(res)
}
let err = dlerror().flatMap { String.init(cString: $0) } ?? "unknown error"
return .failure(.init(domain: err, code: -1))
}
}
これで、@_dynamicReplacementを使って動的に実装を差し替えることができました
まとめ
@_dynamicReplacementの基本的な挙動を確認し、frameworkを動的に読み込むことで、動的に挙動を差し替えるのを試してみました
動的リンクライブラリを読み込むことで簡単に実装を差し替えることができました
SwiftUIのPreviewがViewを少し操作してもスグに反映される理由が少しわかった気持ちになりました
また、特にテストでのモックなどでも、活用できそうな仕組みだと思いました