0
0

More than 1 year has passed since last update.

@_dynamicReplacementを動的にロードして挙動を差し替える

Posted at

@_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を動的に読み込むことで、動的に挙動を差し替えるのを試してみました

やりたいこと

上記のLibBaseLib.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に変化していることがわかります

dyrep.gif

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を選択しておきます
スクリーンショット 2023-07-29 16.06.11.png

次に、ReplaceLib.frameworkにLibのextensionを実装します

import BaseLib

extension Lib {
    @_dynamicReplacement(for: text())
    public func _text() -> String {
        "Replaced"
    }
}

最後に、動的に読み込むためにteamとbundle identifierを設定し、署名するようにしておきます
スクリーンショット 2023-07-29 16.06.43.png

BaseLib.frameworkと同様に、ビルドし、Product > Show Build Folder in FinderからビルドされたReplaceLib.frameworkを取得します

アプリターゲットの実装

アプリターゲットを作成し、上記でビルドしたBaseLib.frameworkReplaceLib.frameworkを追加します
ただし、BaseLib.frameworkのみEmbed & Signします
スクリーンショット 2023-07-29 16.32.34.png

一方で、ReplaceLib.frameworkはEmbedせずに、Copy Bundle Resourcesでコピーしておくようにします
スクリーンショット 2023-07-29 16.35.13.png

最後に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を少し操作してもスグに反映される理由が少しわかった気持ちになりました
また、特にテストでのモックなどでも、活用できそうな仕組みだと思いました

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0