🦁

macOS NSApplication のメインイベントループを60行以下で実装する

2022/09/01に公開約8,200字

はじめに

筆者は github.com/Code-Hex/vz という macOS で仮想化技術を利用するための Virtualization framework の Go 言語とのバインディングを行えるライブラリを開発しています。

この framework では macOS の仮想化もサポートされているのですが、ご存知の通り GUI をメインとした OS なのでグラフィックを描画するための UI もサポートしなければなりませんでした。これらを実装するにあたって、Objective-C で記述した GUI をメインループで動かすような関数を Go 言語から呼び出せるようにメソッドとして作成したのですが、それを呼び出して以降、実行されたプロセスが Cocoa API (Objective-C) の世界に引きこもり状態になり、以降 Go の後続の処理を呼び出すことができずに困っていました。

解決策として Objective-C から Go の世界へ戻れるように、メインイベントループを再実装しようと試みました。実際にこのアプローチはとても上手くいきました。

本稿はその時に学んだことをまとめることで、後に誰かの役に立てられるといいなと思い作成しました。そこまで分量はないので是非読んでみてください。

NSApplication

macOS のデスクトップアプリケーションを作成するために必ず NSApplication を利用する必要があります。NSApplication はドキュメントにも記載されている通り、アプリのメインイベントループだったり、全オブジェクトが使用するリソースを管理する一番重要なオブジェクトです。

この記事では Objective-C のコードを用いて解説しますが、Swift でも同様のことが可能です。

Xcode で新規アプリケーションを作成するといくつかファイルが自動で作成されます。そのうちの main.m ファイルを覗いてみると以下のようなコードになってるはずです。

main.m
#import <Cocoa/Cocoa.h>

int main(int argc, const char * argv[]) {
    return NSApplicationMain(argc, argv);
}

このコードで気になるのは NSApplicationMain 関数です。これは NSApplication のドキュメントで大まかに何をやっているのか解説されています。

void NSApplicationMain(int argc, char *argv[]) {
    [NSApplication sharedApplication];
    [NSBundle loadNibNamed:@"myMain" owner:NSApp];
    [NSApp run];
}

sharedApplication

NSApplication を利用するアプリ全てが、このオブジェクトのシングルインスタンスを利用してメインイベントループを制御しています。

sharedApplication クラスメソッドを呼び出すとアプリケーションに必要な様々なセットアップを行い、NSApplication のシングルインスタンスを作成します。

このメソッドを実行した後 NSApp グローバル変数経由で作成したインスタンスへアクセスできます。つまり、さまざまな箇所から NSApplication オブジェクトに関連した操作をこの NSApp 変数経由で行うことが可能になります。

注意点として、Special Considerations に、このメソッドをオーバーライドして実装を上書きしてはいけないと記述があります。

loadNibNamed:owner:

ここは重要ではないポイントなので割愛します。

run

このメソッドを実行するとメインイベントループが実行されます。実行されると delegate されたオブジェクトは applicationWillFinishLaunching: もしくは applicationDidFinishLaunching: メソッドを通じて通知されます。

ドキュメントをよく読むとわかることですが、run メソッド内ではイベントループが実行される前に finishLaunching メソッドを呼び出されます。このメソッドの初めで NSApplicationWillFinishLaunchingNotification を通知し、終わりの方で NSApplicationDidFinishLaunchingNotification が通知されます[1]。この通知の間でアプリケーションをアクティブにしたり、実行時に指定されたファイルを開いたりします。

重要なポイントとして、このメソッド内で行われることは全てメインスレッド上で行われます。Adding Behavior to a Cocoa Program を読み解くと User Interface の管理やマウスやキーボード入力などのイベントの送受信をメインスレッドで行うと書かれています。なぜ必ずメインスレッドで行う必要があるのか正確な理由はわかりませんが、ドキュメント内では、いくつかの状況ではプログラムの保守やデバッグのやりやすさを目的としてメインスレッドを用いることがベストなケース[2]があるとされているので、おそらくこれらも理由に含まれてるのでしょう。

ここも注意点として、Special Considerations に、このメソッドをオーバーライドした場合、自身で @autorelease ブロックで囲んであげる必要があると記述されています。

メインループの実装

では、メインイベントループがどのように実行されていくのか理解できたと思うので実際にコードを書いてみましょう。NSApplicationMain に従って独自実装の MyApplicationMain 関数を実装していきます。

まずは main.m からです。ここでは単純にこれから実装する MyApplicationMain 関数を呼び出すだけとします。(5 行)

main.m
#import "MyApplication.h"

int main(int argc, const char * argv[]) {
    return MyApplicationMain(argc, argv);
}

次に main.m で MyApplicationMain 関数をインポートするためにヘッダファイルを作成しましょう。ここで MyApplication クラスも宣言します。(8 行)

MyApplication.h
#import <Cocoa/Cocoa.h>

int MyApplicationMain(int argc, const char **argv);

@interface MyApplication : NSApplication {
    bool shouldKeepRunning;
}
@end

最後に MyApplicationMain 関数とその中で実行されるメインイベントループを作成します。(41 行)

MyApplication.m
#import "MyApplication.h"

@implementation MyApplication

- (void)run {
    @autoreleasepool {
        [self finishLaunching];

        shouldKeepRunning = YES;
        do {
            NSEvent *event = [self
                    nextEventMatchingMask:NSEventMaskAny
                    untilDate:[NSDate distantFuture]
                    inMode:NSDefaultRunLoopMode
                    dequeue:YES];
            [self sendEvent:event];
            [self updateWindows];
        } while (shouldKeepRunning);
    }
}

- (void)terminate:(id)sender {
    shouldKeepRunning = NO;
}
@end

int MyApplicationMain(int argc, const char **argv) {
    @autoreleasepool {
        [MyApplication sharedApplication];
            
        [NSBundle loadNibNamed:@"MainMenu" owner:NSApp];

        if ([NSApp respondsToSelector:@selector(run)]) {
            [NSApp
                performSelectorOnMainThread:@selector(run)
                withObject:nil
                waitUntilDone:YES];
        }
    }
    return 0;
}

それぞれ何をやっているのか簡単に解説していきます。

MyApplicationMain

この関数では冒頭の NSApplicationMain 関数同様 3 つのメソッドを実行します。

  1. MyApplicationMain のクラスメソッドとして sharedApplication を実行しています。こうすることでグローバル変数である NSApp は MyApplication クラスとして利用可能になります。
  2. メインとなる nib ファイルをロードします。
  3. run メソッドはメインスレッド上で実行する必要があるため、直接呼び出す代わりに performSelectorOnMainThread:withObject:waitUntilDone: メソッドを経由して実行します。

MyApplication クラス

冒頭で紹介した NSApplication クラスの run メソッドの実装と同様に、初めで finishLaunching を呼び出してあげることによって、アプリケーションがアクティブな状態になったことを NSApplicationDelegate プロトコルを実装したデリゲートされたオブジェクトへ通知します。

肝心のメインイベントループですが、What Happens in the main Function でどのように動いているか記述されています。

main_event_loop

上の図はドキュメントから引用してきたものです。非常にわかりやすい図となっています。

OS ではさまざまなイベントがイベントキューへキューイングされます。これらのイベントは nextEventMatchingMask:untilDate:inMode:dequeue: メソッドを利用し、アプリケーション側でイベントを受け取ることができます。受け取ると、それらをアプリケーションが管理するウィンドウへ sendEvent: メソッドを利用して送信します。その後、updateWindows メソッドを通じでスクリーン上で見えている全ウィンドウへ更新したことを通知します。メインイベントループではこれらの処理が繰り返されます。

アプリケーションを終了させると terminate: メソッドが呼び出されます。このクラスでの実装ではイベントループを終了するようにしています。

終わりに

実際にこの記事で紹介した MyApplication でやってることは NSApplication クラスで実行されるメインループの処理全てを網羅しているわけではありません。しかし、メインイベントループではどのような処理が行われているのかを大まかにイメージできるようになったのではないのでしょうか。

この自作メインイベントループを実装するにあたって Demystifying NSApplication by recreating it という記事がとても良いヒントになりました。非常に感謝しています。

今回記述したコードの全体を Xcode のプロジェクトとして GitHub 上で公開しています。

https://github.com/Code-Hex/MyApplication
脚注
  1. https://developer.apple.com/documentation/appkit/nsapplicationdidfinishlaunchingnotification ↩︎

  2. Multithreading also requires greater complexity of program design to synchronize access to shared memory among multiple threads. Such designs make a program harder to maintain and debug. Moreover, multithreading designs that overuse locks can actually degrade performance (relative to a single-threaded program) because of the high contention for shared resources. Designing a program for multithreading often involves tradeoffs between performance and protection and requires careful consideration of your data structures and the intended usage pattern for extra threads. Indeed, in some situations the best approach is to avoid multithreading altogether and keep all program execution on the main thread. ↩︎

Discussion

ログインするとコメントできます