macOS NSApplication のメインイベントループを60行以下で実装する
はじめに
筆者は 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
ファイルを覗いてみると以下のようなコードになってるはずです。
#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 行)
#import "MyApplication.h"
int main(int argc, const char * argv[]) {
return MyApplicationMain(argc, argv);
}
次に main.m で MyApplicationMain
関数をインポートするためにヘッダファイルを作成しましょう。ここで MyApplication
クラスも宣言します。(8 行)
#import <Cocoa/Cocoa.h>
int MyApplicationMain(int argc, const char **argv);
@interface MyApplication : NSApplication {
bool shouldKeepRunning;
}
@end
最後に MyApplicationMain
関数とその中で実行されるメインイベントループを作成します。(41 行)
#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 つのメソッドを実行します。
-
MyApplicationMain
のクラスメソッドとしてsharedApplication
を実行しています。こうすることでグローバル変数である NSApp はMyApplication
クラスとして利用可能になります。 - メインとなる nib ファイルをロードします。
-
run
メソッドはメインスレッド上で実行する必要があるため、直接呼び出す代わりにperformSelectorOnMainThread:withObject:waitUntilDone:
メソッドを経由して実行します。
MyApplication クラス
冒頭で紹介した NSApplication
クラスの run
メソッドの実装と同様に、初めで finishLaunching
を呼び出してあげることによって、アプリケーションがアクティブな状態になったことを NSApplicationDelegate
プロトコルを実装したデリゲートされたオブジェクトへ通知します。
肝心のメインイベントループですが、What Happens in the main Function でどのように動いているか記述されています。
上の図はドキュメントから引用してきたものです。非常にわかりやすい図となっています。
OS ではさまざまなイベントがイベントキューへキューイングされます。これらのイベントは nextEventMatchingMask:untilDate:inMode:dequeue:
メソッドを利用し、アプリケーション側でイベントを受け取ることができます。受け取ると、それらをアプリケーションが管理するウィンドウへ sendEvent:
メソッドを利用して送信します。その後、updateWindows
メソッドを通じでスクリーン上で見えている全ウィンドウへ更新したことを通知します。メインイベントループではこれらの処理が繰り返されます。
アプリケーションを終了させると terminate:
メソッドが呼び出されます。このクラスでの実装ではイベントループを終了するようにしています。
終わりに
実際にこの記事で紹介した MyApplication
でやってることは NSApplication
クラスで実行されるメインループの処理全てを網羅しているわけではありません。しかし、メインイベントループではどのような処理が行われているのかを大まかにイメージできるようになったのではないのでしょうか。
この自作メインイベントループを実装するにあたって Demystifying NSApplication by recreating it という記事がとても良いヒントになりました。非常に感謝しています。
今回記述したコードの全体を Xcode のプロジェクトとして GitHub 上で公開しています。
-
https://developer.apple.com/documentation/appkit/nsapplicationdidfinishlaunchingnotification ↩︎
-
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