Xcodeの開発で、ファイルを保存するだけで動的に中身が書き換えられるようにしてみた


Xcodeの開発で、通常であれば、ファイルを保存し、ビルドし、プログラムを起動するという手順をとる。プログラムを起動した状態で、終了させずに、その中身を書き換えるということは、通常はできない。だが、injectionという仕組みを使うと、できるようになる。

1. 「InjectionIII」を使ってみる

John Holdsworth氏により、「InjectionIII」というアプリが公開されている。これを使うと、Injectionを簡単に実行できるようになっている。
https://github.com/johnno1962/InjectionIII
以下、使ってみる。

1.1. アプリをインストール

InjectionIIIアプリをインストールする。
https://apps.apple.com/jp/app/injectioniii/id1380446739?mt=12

インストールされたら、実行する→Status menuにInjectionIIIアプリのアイコンが表示されたことを確認する→「Help/README」を選ぶと、以下のページに接続される。
https://github.com/johnno1962/InjectionIII

1.2. サンプルをダウンロードする

以下、Injection IIIのホームページである。
http://johnholdsworth.com/injection.html

以下より、サンプルプログラムをダウンロードできる。
http://johnholdsworth.com/GettingStarted.zip
解凍し、「~/dev/GettingStarted/」となるようにする。

1.3. Xcodeからサンプルを開く

GettingStarted.xcodeprojをダブルクリックして起動。→Open

1.4. InjectionIIIアプリから、GettingStartedを接続する

Status menuのInjectionIIIから「Open project」を選択→「~/dev/GettingStarted/」を選択→「Select Project Directory」

1.5. 実行する

Cmd-Rで実行→シミュレーターに「Master」と表示される→「+」を押すと、現在時刻が表示される。それをクリックする→現在時刻と「CHANGEME」が表示される。

Xcodeに戻る→Cmd-1→DetailViewController.swiftを選択→" CHANGEME"の個所を、たとえば" CHANGED!"に変更する→Cmd-Sでセーブとすると、即座に画面上の「CHANGEME」だったところが、「CHANGED!」に変わる。これがインジェクションである。

2. 独自のプログラムからインジェクションしてみる

2.1. なにかアプリを作る

まずは、なにかシンプルなアプリを開発する。文字が表示されるものが良い。
Xcodeを起動→Create a new Xcode Project→iOS→「Single View App」→Next→Product Name:「InjectionTest」、User Interface: Storyboard→Next→「~/dev」を指定→Create
ViewController.swiftに、以下のようにshow()を追加。viewDidLoad()から呼ばれるようにする。

ViewController.swift
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        show()
    }
    func show() {
        let button = UIButton(frame: CGRect(x: 40, y: 100, width: 200, height: 100))
        button.backgroundColor = .cyan
        button.setTitle("Hello, world!", for: .normal)
        button.setTitleColor(.black, for: .normal)
        view.addSubview(button)
    }
}

まずはこの段階で実行してみる。Cmd-R→ビルドされ、実行される。Simulatorが起動して、「Hello, world!」が表示される。

ViewController.swiftに戻り、試しに"Hello, world!""Hello, Japan!"に修正してCmd-Sで保存しても、反映されない。当然である。

再度Cmd-Rすると、一旦シミュレーター上のアプリが終了し、再度立ち上げられ、今度は"Hello, Japan!"に修正されている。3秒程度で立ち上がる。通常は、このようにアプリを終了し、再読み込みするという手順で開発する。ビルドが早いので、3秒程度でこのサイクルは実行できる。実用上はあまり不満は持たれないかもしれない。

2.2. Linker Flagsを設定する

Xcodeに戻る。Cmd-1→InjectionTestのプロジェクトを選択→PROJECT: InjectionTest→Build Settings→Linking→Other Linker Flags→ここにカーソルを乗せると左に三角が表示される→クリックする→Debugの右の「+」をおす→Debug→Any Architecture | Any SDK:「-Xlinker -interposable」→リターンを押すと確定する

2.3. Bundleを追加

AppDelegate.swiftにBundleを追加する。

AppDelegate.swift
        #if DEBUG
        Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")?.load()
        #endif

参考までに、AppDelegate.swiftの該当するメソッド全体である。

AppDelegate.swift
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        #if DEBUG
        Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")?.load()
        #endif
        return true
    }

2.4. injected()を追加

ViewController.swiftに、injectedというメソッドを追加する。

ViewController.swift
    @objc func injected() {
        show()
    }

参考までに、ViewController classの全体である。

ViewController.swift
class ViewController: UIViewController {
    @objc func injected() {
        show()
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        show()
    }
    func show() {
        let button = UIButton(frame: CGRect(x: 40, y: 100, width: 200, height: 100))
        button.backgroundColor = .cyan
        button.setTitle("Hello, world!", for: .normal)
        button.setTitleColor(.black, for: .normal)
        view.addSubview(button)
    }
}

2.5. InjectionIIIにProjectを指定する

Status menuのInjectionIIIから「Open project」を選択→「~/dev/InjectionTest」を選択→「Select Project Directory」

2.6. 起動する

Cmd-R→Simulatorが起動して、「Hello, world!」が表示される。以下のように出力される。

💉 Injection connected 👍
💉 Watching /Users/eto/dev/InjectionTest/**

2.7. 編集する

ViewController.swiftで、該当する行を以下のように編集してみる。

ViewController.swift
        button.setTitle("Hello, Japan!", for: .normal)

Cmd-Sで、ファイルをセーブする。そうすると、シミュレーター上の「Hello, world!」が、即座に(1秒以内くらいで)「Hello, Japan!」に変わる。以下のように出力される。

💉 *** Compiling /Users/eto/dev/InjectionTest/InjectionTest/ViewController.swift ***
💉 Loading .dylib ...
objc[31231]: Class _TtC13InjectionTest14ViewController is implemented in both /Users/eto/Library/Developer/CoreSimulator/Devices/97670822-70F9-46B8-87F7-5545DF54E516/data/Containers/Bundle/Application/82DDD3CB-9924-4E5C-BCCC-1AE2A8A9E3AD/InjectionTest.app/InjectionTest (0x107b53b40) and /var/folders/94/shwk5bk14l5fx43cggr_n04m0000gn/T/com.johnholdsworth.InjectionIII/eval106.dylib (0x110d9c280). One of the two will be used. Which one is undefined.
💉 Loaded .dylib - Ignore any duplicate class warning ^
💉 Injected 'ViewController'
💉 Replacing InjectionTest.ViewController.__allocating_init(coder: __C.NSCoder) -> Swift.Optional<InjectionTest.ViewController>
💉 Replacing InjectionTest.ViewController.__allocating_init(nibName: Swift.Optional<Swift.String>, bundle: Swift.Optional<__C.NSBundle>) -> InjectionTest.ViewController
💉 Replacing InjectionTest.ViewController.viewDidLoad() -> ()
💉 Replacing InjectionTest.ViewController.show() -> ()
💉 Replacing InjectionTest.ViewController.injected() -> ()
💉 Class ViewController has an @objc injected() method. Injection will attempt a "sweep" of all live instances to determine which objects to message. If this crashes, subscribe to the global notification "INJECTION_BUNDLE_NOTIFICATION" to detect injections instead.

内部で起こっていることを説明すると、ViewController.swiftを常にウォッチし、編集されたのを検知したら、即座にそれをCompileし、動的にロードし、メソッドを置き換えている。その後、injected()が呼ばれ、表示が切り替わる。このようにすると、プログラムを実行している間に、動的にメソッドを書き換えできるため、プログラム開発効率が高まると考えられる。

ここまでのファイルを、以下に置く。このまま実行できるはずである。
https://github.com/eto/InjectionTest

done!