次の方法で共有


例外マーシャリング

マネージド コードと Objective-C の両方で、ランタイム例外 (try/catch/finally 句) がサポートされています。

ただし、実装は異なります。つまり、ランタイム ライブラリ (MonoVM/CoreCLR ランタイムと Objective-C ランタイム ライブラリ) は、他のランタイムから例外が発生したときに問題が発生します。

この記事では、発生する可能性がある問題と考えられる解決策について説明します。

また、さまざまなシナリオとそのソリューションをテストするために使用できるサンプル プロジェクトである 例外マーシャリングも含まれています。

問題

この問題は、例外がスローされたときに発生し、スタック アンワインド中に、スローされた例外の種類と一致しないフレームが発生します。

この問題の一般的な例は、ネイティブ API が Objective-C 例外をスローし、スタック アンワインド プロセスがマネージド フレームに到達したときに、Objective-C 例外を何らかの方法で処理する必要がある場合です。

以前 (pre-.NET) では、既定のアクションは何も行われませんでした。 上記のサンプルでは、これは、Objective-C ランタイムがマネージド フレームをアンワインドすることを意味します。 Objective-C ランタイムはマネージド フレームをアンワインドする方法を認識していないため、このアクションは問題になります。たとえば、マネージド catch 句や finally 句は実行されないため、バグを見つけるのが印象的に困難になります。

破損したコード

次のコード例について考えてみます。

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 には、ハンドルされない Objective-C 例外を通知する方法があり、それを .NET SDK が利用するためで、その段階で Objective-C 例外をマネージド例外に変換しようとするからです。

シナリオ

シナリオ 1 - マネージド catch ハンドラーを使用して Objective-C 例外をキャッチする

次のシナリオでは、マネージド catch ハンドラーを使用して Objective-C 例外をキャッチできます。

  1. Objective-C 例外がスローされます。
  2. Objective-C ランタイムは、例外を処理できるネイティブ @catch ハンドラーを探して、スタックをウォークします (ただし、アンワインドしません)。
  3. Objective-C ランタイムは、 @catch ハンドラーを見つけず、 NSGetUncaughtExceptionHandlerを呼び出し、.NET SDK によってインストールされたハンドラーを呼び出します。
  4. .NET SDK のハンドラーは、Objective-C の例外をマネージド例外に変換し、それをスローします。 Objective-C ランタイムがスタックをアンワインドせず、スタックをウォークしただけだったため、現在のフレームはObjective-C例外がスローされた場所と同じです。

MonoランタイムはObjective-Cフレームを適切にアンワインドする方法を認識していないため、ここでも問題が発生します。

.NET SDK のキャッチされない Objective-C 例外コールバックが呼び出されると、スタックは次のようになります。

 0 TestApp                  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 TestApp                  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");
    }
}

また、このフレームをアンワインドする Mono ランタイムでは認識されないため、 @finally 句は実行されません。

この違いは、マネージド コードでマネージド例外をスローし、ネイティブ フレームを使用してアンワインドして、最初のマネージド 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: TestApp                 mono_jit_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
 3: TestApp                 do_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
 4: TestApp                 mono_runtime_invoke [inlined] mono_runtime_invoke_checked(method=<unavailable>, obj=<unavailable>, params=<unavailable>, error=0xbff45758)
 5: TestApp                 mono_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>)
 6: TestApp                 xamarin_invoke_trampoline(type=<unavailable>, self=<unavailable>, sel="application:didFinishLaunchingWithOptions:", iterator=<unavailable>), context=<unavailable>)
 7: TestApp                 xamarin_arch_trampoline(state=0xbff45ad4)
 8: TestApp                 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 句は実行されません。

Important

MonoVM ランタイムのみが、マネージド例外処理中のネイティブ フレームのアンワインドをサポートします。 CoreCLR ランタイムは、この状況が発生したときにプロセスを中止するだけです (CoreCLR ランタイムは macOS アプリに使用されます。また、任意のプラットフォームで NativeAOT が有効になっている場合にも使用されます)。

シナリオ 2 - Objective-C 例外をキャッチできない

次のシナリオでは、Objective-C 例外が別の方法で処理されたため、マネージド ハンドラーを使用して Objective-C 例外をキャッチcatch

  1. Objective-C 例外が投げられます。
  2. Objective-C ランタイムは、例外を処理できるネイティブ @catch ハンドラーを探して、スタックをウォークします (ただし、アンワインドしません)。
  3. Objective-C ランタイムは、 @catch ハンドラーを検索し、スタックをアンワインドして、 @catch ハンドラーの実行を開始します。

このシナリオは.NET for iOS アプリでよく見られます。メイン スレッドには通常、次のようなコードがあるためです。

void UIApplicationMain ()
{
    @try {
        while (true) {
            ExecuteRunLoop ();
        }
    } @catch (NSException *ex) {
        NSLog (@"An unhandled exception occured: %@", exc);
        abort ();
    }
}

つまり、メイン スレッドでは実際にはハンドルされない Objective-C 例外が発生しないため、Objective-C 例外をマネージド例外に変換するコールバックは呼び出されません。

これは、デバッガーでほとんどの UI オブジェクトを検査すると、実行中のプラットフォームに存在しないセレクターに対応するプロパティをフェッチしようとするため、macOS の以前のバージョンで macOS アプリをデバッグする場合にも一般的です。 このようなセレクターを呼び出すと、NSInvalidArgumentException ("認識されないセレクターが送信されました") が発生し、最終的にプロセスがクラッシュします。

要約すると、処理するようにプログラムされていない Objective-C ランタイムまたは Mono ランタイム アンワインド フレームがあると、クラッシュ、メモリ リーク、その他の種類の予期しない (誤った) 動作など、未定義の動作が発生する可能性があります。

ヒント

macOS および Mac Catalyst (ただし、iOS や tvOS ではない) アプリの場合、アプリの NSApplicationCrashOnExceptions プロパティを trueに設定することで、UI ループですべての例外をキャッチしないようにすることができます。

var defs = new NSDictionary ((NSString) "NSApplicationCrashOnExceptions", NSNumber.FromBoolean (true));
NSUserDefaults.StandardUserDefaults.RegisterDefaults (defs);

ただし、このプロパティは Apple によって文書化されていないため、今後動作が変更される可能性があることに注意してください。

ソリューション

マネージドネイティブ境界でマネージド例外と Objective-C 例外の両方をキャッチし、その例外を他の型に変換することがサポートされています。

擬似コードでは、次のようになります。

class MyClass {
    [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 例外にマーシャリングします)。

.NET では、マネージド例外を Objective-C の例外としてマーシャリングする機能は、常にデフォルトで有効になっています。

[ビルド時フラグ] セクションでは、インターセプトが既定の場合に無効にする方法について説明します。

イベント

例外がインターセプトされると発生するイベントは、 Runtime.MarshalManagedExceptionRuntime.MarshalObjectiveCException の 2 つです。

どちらのイベントにも、スローされた元の例外 (EventArgs プロパティ) を含むException オブジェクトと、例外のマーシャリング方法を定義するExceptionMode プロパティが渡されます。

ExceptionMode プロパティは、イベント ハンドラーで実行されるカスタム処理に従って動作を変更するために、イベント ハンドラーで変更できます。 1 つの例として、特定の例外が発生した場合にプロセスを中止します。

ExceptionModeプロパティの変更は単一のイベントに適用され、将来インターセプトされる例外には影響しません。

マネージド例外をネイティブ コードにマーシャリングする場合は、次のモードを使用できます。

  • Default: 現在、常に ThrowObjectiveCException。 既定値は将来変更される可能性があります。
  • UnwindNativeCode: これは、CoreCLR を使用する場合は使用できません (CoreCLR はネイティブ コードのアンワインドをサポートしていません。代わりにプロセスを中止します)。
  • ThrowObjectiveCException: マネージド例外を Objective-C 例外に変換し、Objective-C 例外をスローします。 これは .NET の既定値です。
  • Abort: プロセスを中止します。
  • Disable: 例外インターセプトを無効にします。 イベント ハンドラーでこの値を設定しても意味がありません (イベントが発生すると、例外のインターセプトを無効にするには遅すぎます)。 いずれの場合も、設定されている場合は、 UnwindNativeCodeとして動作します。

マネージド コード Objective-C 例外をマーシャリングする場合は、次のモードを使用できます。

  • Default: 現在、.NET では常に ThrowManagedException 。 既定値は将来変更される可能性があります。
  • UnwindManagedCode: これは以前の (未定義の) 動作です。
  • ThrowManagedException: Objective-C 例外をマネージド例外に変換し、マネージド例外をスローします。 これは .NET の既定値です。
  • Abort: プロセスを中止します。
  • Disable: 例外インターセプトを無効にします。 イベント ハンドラーでこの値を設定しても意味がありません (イベントが発生すると、例外のインターセプトを無効にするには遅すぎます)。 いずれの場合も、設定されている場合は、 UnwindManagedCodeとして動作します。

そのため、例外がマーシャリングされるたびに表示するには、次の操作を行います。

class MyApp {
    static void Main (string args[])
    {
        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);
        };
        /// ...
    }
}

ヒント

理想的には、健全に動作するアプリケーションでは、Objective-C 例外は発生しないはずです(Apple は、マネージド例外よりもはるかに例外的だと考えており、「ユーザーに配布するアプリで [Objective-C] 例外をスローしないようにする」としています)。 これを実現する 1 つの方法は、Runtime.MarshalObjectiveCException イベント用のイベント ハンドラーを追加して、テレメトリを使用してマーシャリングされたすべての Objective-C 例外をログに記録し、これらの例外を検出して修正/回避することです (デバッグ/ローカル ビルドの場合は、例外モードを "Abort" に設定することもできます)。

ビルドタイム フラグ

次の MSBuild プロパティを設定して、例外インターセプトが有効かどうかを判断し、発生する必要がある既定のアクションを設定することができます。

  • MarshalManagedExceptionMode: "default"、"unwindnativecode"、"throwobjectivecexception"、"abort"、"disable"
  • MarshalObjectiveCExceptionMode(マシュールObjective-C例外モード): "default"、"unwindmanagedcode"、"throwmanagedexception"、"abort"、"disable"。

例:

<PropertyGroup>
    <MarshalManagedExceptionMode>throwobjectivecexception</MarshalManagedExceptionMode>
    <MarshalObjectiveCExceptionMode>throwmanagedexception</MarshalObjectiveCExceptionMode>
</PropertyGroup>

disableを除き、これらの値は ExceptionMode イベントと MarshalObjectiveCException イベントに渡される値と同じです。

disable オプションは、ほとんどの場合、インターセプトを無効にします。ただし、実行オーバーヘッドが発生しない場合でも例外をインターセプトします。 マーシャリング イベントは引き続きこれらの例外に対して発生し、既定のモードは実行中のプラットフォームの既定のモードです。

制限事項

Objective-C 例外をキャッチしようとしたときに、P/Invokes を objc_msgSend 関数ファミリにインターセプトするだけです。 つまり、P/Invoke を別の C 関数に呼び出すと、Objective-C 例外がスローされ、古くて未定義の動作が引き続き発生します (これは将来改善される可能性があります)。

こちらも参照ください