Xamarin.iOS および Xamarin.Mac での例外マーシャリング
マネージド コードと Objective-C の両方でランタイム例外 (try/catch/finally 句) がサポートされています。
ただし、実装は異なります。つまり、ランタイム ライブラリ (Mono ランタイムまたは CoreCLR と Objective-C ランタイム ライブラリ) では、例外を処理してから他の言語で記述されたコードを実行する必要があるときに問題が発生します。
このドキュメントでは、発生する可能性がある問題と考えられる解決策について説明します。
また、さまざまなシナリオとその解決策をテストするために使用できるサンプル プロジェクトである例外マーシャリングも記載されています。
問題
この問題は、例外がスローされたときに発生し、スタック アンワインド中に、スローされた例外の種類と一致しないフレームが検出されます。
この問題の一般的な例としては、ネイティブ API によって Objective-C 例外がスローされ、スタック アンワインド プロセスがマネージド フレームに到達したときに何らかの方法でその Objective-C 例外を処理する必要がある場合があります。
レガシ Xamarin プロジェクト (.NET より前) の場合、既定のアクションでは何も行われません。
上記のサンプルでは、これは Objective-C ランタイムでマネージド フレームをアンワインドさせることを意味します。 Objective-C ランタイムではマネージド フレームをアンワインドする方法を認識していなため、このアクションは問題になります。たとえば、そのフレーム内の句 finally
と catch
は実行されません。
破損したコード
次のコード例について考えてみます。
var dict = new NSMutableDictionary ();
dict.LowlevelSetObject (IntPtr.Zero, IntPtr.Zero);
このコードによって、ネイティブ コードで Objective-C NSInvalidArgumentException がスローされます。
NSInvalidArgumentException *** setObjectForKey: key cannot be nil
スタック トレースは次のようになります。
0 CoreFoundation __exceptionPreprocess + 194
1 libobjc.A.dylib objc_exception_throw + 52
2 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
3 libobjc.A.dylib objc_msgSend + 102
4 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
5 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr)
6 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException ()
フレーム 0 から 3 はネイティブ フレームであり、Objective-C ランタイムのスタック アンワインダーではそれらのフレームをアンワインドできます。 特に、任意の Objective-C@catch
または @finally
句を実行します。
ただし、Objective-C スタック アンワインダーではマネージド フレーム (フレーム 4 から 6) を適切にアンワインドできません。Objective-C スタック アンワインダーによってマネージド フレームがアンワインドされますが、マネージド例外ロジック (catch
や finally 句 など) は実行されません。
これは、通常、次の方法でこれらの例外をキャッチできないことを意味します。
try {
var dict = new NSMutableDictionary ();
dict.LowLevelSetObject (IntPtr.Zero, IntPtr.Zero);
} catch (Exception ex) {
Console.WriteLine (ex);
} finally {
Console.WriteLine ("finally");
}
これは、Objective-C スタック アンワインダーではマネージド catch
句について認識せず、finally
句も実行されないためです。
上記のコード サンプルが有効な場合、Objective-C では、Xamarin.iOS と Xamarin.Mac によって使用されるハンドルされない Objective-C 例外である NSSetUncaughtExceptionHandler
の通知を受け取る方法があり、その時点で Objective-C 例外をマネージド例外に変換しようとするからです。
シナリオ
シナリオ 1 - マネージド キャッチ ハンドラーを使用して Objective-C 例外をキャッチする
次のシナリオでは、マネージド catch
ハンドラーを使用して Objective-C 例外をキャッチできます。
- Objective-C 例外がスローされます。
- Objective-C ランタイムではスタックを調べて (ただし、アンワインドしません)、例外を処理できるネイティブ
@catch
ハンドラーを探します。 - Objective-C ランタイムでは
@catch
ハンドラーを検索せず、NSGetUncaughtExceptionHandler
を呼び出し、Xamarin.iOS/Xamarin.Mac によってインストールされたハンドラーを呼び出します。 - Xamarin.iOS/Xamarin.Mac のハンドラーでは、Objective-C 例外をマネージド例外に変換してスローします。 Objective-C ランタイムではスタックをアンワインドしなかった (検索しただけである) ため、現在のフレームは、Objective-C 例外がスローされた場所と同じです。
Mono ランタイムでは Objective-C フレームを適切にアンワインドする方法を認識していないため、ここで別の問題が発生します。
Xamarin.iOS のキャッチされない Objective-C 例外コールバックが呼び出されると、スタックは次のようになります。
0 libxamarin-debug.dylib exception_handler(exc=name: "NSInvalidArgumentException" - reason: "*** setObjectForKey: key cannot be nil")
1 CoreFoundation __handleUncaughtException + 809
2 libobjc.A.dylib _objc_terminate() + 100
3 libc++abi.dylib std::__terminate(void (*)()) + 14
4 libc++abi.dylib __cxa_throw + 122
5 libobjc.A.dylib objc_exception_throw + 337
6 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
7 libxamarin-debug.dylib xamarin_dyn_objc_msgSend + 102
8 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
9 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr) [0x00000]
10 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException () [0x00013]
ここでは、マネージド フレームはフレーム 8 から 10 のみですが、マネージド例外はフレーム 0 でスローされます。 つまり、Mono ランタイムではネイティブ フレーム 0 から 7 をアンワインドする必要があります。これが原因で、上で説明した問題と同等の問題が発生します。Mono ランタイムではネイティブ フレームをアンワインドしますが、どの Objective-C@catch
や @finally
句も実行しません。
コード例:
-(id) setObject: (id) object forKey: (id) key
{
@try {
if (key == nil)
[NSException raise: @"NSInvalidArgumentException"];
} @finally {
NSLog (@"This won't be executed");
}
}
また、@finally
句は実行されません。このフレームをアンワインドする Mono ランタイムではこれについて認識しないためです。
この違いは、マネージド コードでマネージド例外をスローしてから、ネイティブ フレームを最初から最後までアンワインドして最初のマネージド catch
句に到達することです。
class AppDelegate : UIApplicationDelegate {
public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
{
throw new Exception ("An exception");
}
static void Main (string [] args)
{
try {
UIApplication.Main (args, null, typeof (AppDelegate));
} catch (Exception ex) {
Console.WriteLine ("Managed exception caught.");
}
}
}
マネージド UIApplication:Main
メソッドでネイティブ UIApplicationMain
メソッドを呼び出し、次に iOS では多くのネイティブ コードを実行してから最終的にマネージド AppDelegate:FinishedLaunching
メソッドを呼び出します。マネージド例外がスローされたときに、多数のネイティブ フレームがスタック上に残ったままになります。
0: TestApp ExceptionMarshaling.IOS.AppDelegate:FinishedLaunching (UIKit.UIApplication,Foundation.NSDictionary)
1: TestApp (wrapper runtime-invoke) <Module>:runtime_invoke_bool__this___object_object (object,intptr,intptr,intptr)
2: libmonosgen-2.0.dylib mono_jit_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
3: libmonosgen-2.0.dylib do_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
4: libmonosgen-2.0.dylib mono_runtime_invoke [inlined] mono_runtime_invoke_checked(method=<unavailable>, obj=<unavailable>, params=<unavailable>, error=0xbff45758)
5: libmonosgen-2.0.dylib mono_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>)
6: libxamarin-debug.dylib xamarin_invoke_trampoline(type=<unavailable>, self=<unavailable>, sel="application:didFinishLaunchingWithOptions:", iterator=<unavailable>), context=<unavailable>)
7: libxamarin-debug.dylib xamarin_arch_trampoline(state=0xbff45ad4)
8: libxamarin-debug.dylib xamarin_i386_common_trampoline
9: UIKit -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:]
10: UIKit -[UIApplication _callInitializationDelegatesForMainScene:transitionContext:]
11: UIKit -[UIApplication _runWithMainScene:transitionContext:completion:]
12: UIKit __84-[UIApplication _handleApplicationActivationWithScene:transitionContext:completion:]_block_invoke.3124
13: UIKit -[UIApplication workspaceDidEndTransaction:]
14: FrontBoardServices __37-[FBSWorkspace clientEndTransaction:]_block_invoke_2
15: FrontBoardServices __40-[FBSWorkspace _performDelegateCallOut:]_block_invoke
16: FrontBoardServices __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__
17: FrontBoardServices -[FBSSerialQueue _performNext]
18: FrontBoardServices -[FBSSerialQueue _performNextFromRunLoopSource]
19: FrontBoardServices FBSSerialQueueRunLoopSourceHandler
20: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
21: CoreFoundation __CFRunLoopDoSources0
22: CoreFoundation __CFRunLoopRun
23: CoreFoundation CFRunLoopRunSpecific
24: CoreFoundation CFRunLoopRunInMode
25: UIKit -[UIApplication _run]
26: UIKit UIApplicationMain
27: TestApp (wrapper managed-to-native) UIKit.UIApplication:UIApplicationMain (int,string[],intptr,intptr)
28: TestApp UIKit.UIApplication:Main (string[],intptr,intptr)
29: TestApp UIKit.UIApplication:Main (string[],string,string)
30: TestApp ExceptionMarshaling.IOS.Application:Main (string[])
フレーム 0 から 1 と 27 から 30 はマネージドですが、その間のすべてのフレームはネイティブです。
Mono でこれらのフレームを最初から最後までアンワインドした場合、Objective-C@catch
も @finally
句も実行されません。
シナリオ 2 - Objective-C 例外をキャッチできない
次のシナリオでは、Objective-C 例外が別の方法で処理されたため、マネージド catch
ハンドラーを使用して Objective-C 例外をキャッチすることはできません。
- Objective-C 例外がスローされます。
- Objective-C ランタイムではスタックを調べて (ただし、アンワインドしません)、例外を処理できるネイティブ
@catch
ハンドラーを探します。 - Objective-C ランタイムでは
@catch
ハンドラーを検索し、スタックをアンワインドして、@catch
ハンドラーの実行を開始します。
このシナリオは Xamarin.iOS アプリでよく見られます。メイン スレッドには通常、次のようなコードがあるためです。
void UIApplicationMain ()
{
@try {
while (true) {
ExecuteRunLoop ();
}
} @catch (NSException *ex) {
NSLog (@"An unhandled exception occured: %@", exc);
abort ();
}
}
つまり、メイン スレッドではハンドルされない Objective-C 例外は実際には発生しないため、Objective-C 例外をマネージド例外に変換するコールバックは呼び出されません。
これは、Xamarin.Mac でサポートされる以前のバージョンの macOS で Xamarin.Mac アプリをデバッグする場合にも一般的です。デバッガーでほとんどの UI オブジェクトを検査すると、実行中のプラットフォームに存在しないセレクターに対応するプロパティがフェッチされます (Xamarin.Mac には以降のバージョンの macOS のサポートが含まれているため)。 このようなセレクターを呼び出すと NSInvalidArgumentException
("認識できないセレクターが .... に送信されます") がスローされ、最終的にプロセスがクラッシュします。
要約すると、処理するようにプログラムされていない Objective-C ランタイムまたは Mono ランタイムのアンワインド フレームがあると、クラッシュ、メモリ リーク、その他の種類の予期しない (誤った) 動作などの未定義の動作につながる可能性があります。
解決策
Xamarin.iOS 10 と Xamarin.Mac 2.10 では、マネージドネイティブ境界でマネージドと Objective-C 例外の両方をキャッチし、その例外を他の種類に変換するためのサポートが追加されました。
擬似コードでは、次のようになります。
[DllImport (Constants.ObjectiveCLibrary)]
static extern void objc_msgSend (IntPtr handle, IntPtr selector);
static void DoSomething (NSObject obj)
{
objc_msgSend (obj.Handle, Selector.GetHandle ("doSomething"));
}
objc_msgSend への P/Invoke がインターセプトされ、代わりに次のコードが呼び出されます。
void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
@try {
objc_msgSend (obj, sel);
} @catch (NSException *ex) {
convert_to_and_throw_managed_exception (ex);
}
}
逆のケースでも同様のことが行われます (マネージド例外を Objective-C 例外にマーシャリングします)。
マネージドネイティブ境界での例外のキャッチはコストの負担がないわけではないため、レガシ Xamarin プロジェクト (.NET より前) では、既定では常に有効になっているとは限りません。
- Xamarin.iOS/tvOS: シミュレーターでは Objective-C 例外のインターセプトが有効になっています。
- Xamarin.watchOS: Objective-C ランタイムでマネージド フレームをアンワインドさせると、ガベージ コレクターで混乱が生じ、ハングまたはクラッシュするため、すべてのケースでインターセプトが適用されます。
- Xamarin.Mac: デバッグ ビルドで Objective-C 例外のインターセプトが有効になっています。
.NET では、マネージド例外を Objective-C 例外にマーシャリングすることは常に既定で有効になっています。
[ビルド時フラグ] セクションでは、インターセプトが既定で有効になっていない場合に有効にする方法 (または、既定でインターセプトが有効になっている場合は無効にする) 方法について説明します。
イベント
例外がインターセプトされた後に発生するイベントには、Runtime.MarshalManagedException
と Runtime.MarshalObjectiveCException
の 2 つがあります。
どちらのイベントにも、スローされた元の例外 (Exception
プロパティ) を含む EventArgs
オブジェクトと、例外のマーシャリング方法を定義する ExceptionMode
プロパティが渡されます。
ハンドラーで実行されるカスタム処理に従って動作を変更するために、イベント ハンドラーで ExceptionMode
プロパティを変更できます。 1 つの例として、特定の例外が発生した場合にプロセスを中止します。
ExceptionMode
プロパティの変更は単一のイベントに適用され、将来インターセプトされる例外には影響しません。
マネージド例外をネイティブ コードにマーシャリングする場合は、次のモードを使用できます。
Default
: 既定値はプラットフォームによって異なります。 これは常に .NET ではThrowObjectiveCException
です。 レガシ Xamarin プロジェクトでは、GC が協調モード (watchOS) の場合はThrowObjectiveCException
で、それ以外 (iOS/watchOS/macOS) の場合はUnwindNativeCode
です。 既定値は、今後変更される可能性があります。UnwindNativeCode
: これは以前の (未定義の) 動作です。 これは、協調モードで GC を使用する場合 (watchOS では唯一のオプションであるため、watchOS では有効なオプションではありません) も、CoreCLR を使用する場合も使用できませんが、レガシ Xamarin プロジェクトの他のすべてのプラットフォームでは既定のオプションです。ThrowObjectiveCException
: マネージド例外を Objective-C 例外に変換し、Objective-C 例外をスローします。 これは、.NET と レガシ Xamarin プロジェクトの watchOS では既定値です。Abort
: プロセスを中止します。Disable
: 例外インターセプトを無効にするため、イベント ハンドラーでこの値を設定しても意味がありませんが、イベントの発生後は、無効にするには遅すぎます。 いずれの場合も、設定されているときは、UnwindNativeCode
として動作します。
マネージド コードに Objective-C 例外をマーシャリングする場合は、次のモードを使用できます。
Default
: 既定値はプラットフォームによって異なります。 これは常に .NET ではThrowManagedException
です。 レガシ Xamarin プロジェクトでは、GC が協調モード (watchOS) の場合はThrowManagedException
で、それ以外 (iOS/tvOS/macOS) の場合はUnwindManagedCode
です。 既定値は、今後変更される可能性があります。UnwindManagedCode
: これは以前の (未定義の) 動作です。 これは、協調モードで GC を使用する場合 (watchOS では唯一の GC モードであるため、これは watchOS では有効なオプションではありません) も、CoreCLR を使用する場合も使用できませんが、レガシ Xamarin プロジェクトの他のすべてのプラットフォームでは既定値です。ThrowManagedException
: Objective-C 例外をマネージド例外に変換し、マネージド例外をスローします。 これは、.NET と レガシ Xamarin プロジェクトの watchOS では既定値です。Abort
: プロセスを中止します。Disable
: 例外インターセプトを無効にするため、イベント ハンドラーでこの値を設定しても意味がありませんが、イベントの発生後は、無効にするには遅すぎます。 いずれの場合も、設定するとプロセスが中止されます。
そのため、例外がマーシャリングされるたびに確認するには、次を行うことができます。
Runtime.MarshalManagedException += (object sender, MarshalManagedExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling managed exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
Runtime.MarshalObjectiveCException += (object sender, MarshalObjectiveCExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling Objective-C exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
ビルド時フラグ
mtouch (Xamarin.iOS アプリの場合) と mmp (Xamarin.Mac アプリの場合) に次のオプションを渡すことができます。これは、例外インターセプトが有効かどうかを判断し、発生する必要がある既定のアクションを設定します。
--marshal-managed-exceptions=
default
unwindnativecode
throwobjectivecexception
abort
disable
--marshal-objectivec-exceptions=
default
unwindmanagedcode
throwmanagedexception
abort
disable
disable
を除き、これらの値は、MarshalManagedException
および MarshalObjectiveCException
イベントに渡される ExceptionMode
値と同じです。
この disable
オプションでは "ほとんどの場合"、インターセプトを無効にします。ただし、実行オーバーヘッドが発生しない場合でも例外をインターセプトします。 マーシャリング イベントは引き続きこれらの例外に対して発生し、既定のモードは実行中のプラットフォームの既定のモードです。
制限事項
Objective-C 例外をキャッチしようとすると、関数の objc_msgSend
ファミリに対する P/Invoke のみをインターセプトします。 つまり、別の C 関数に対する P/Invoke では、Objective-C 例外がスローされ、前の未定義の動作が引き続き発生します (これは今後改善される可能性があります)。