异常封送

托管代码和 Objective-C 都支持运行时异常(try/catch/finally 子句)。

但是,它们的实现不同,这意味着运行时库(MonoVM/CoreCLR 运行时和 Objective-C 运行时库)在其他运行时遇到异常时出现问题。

本文介绍可能发生的问题以及可能的解决方案。

它还包括一个示例项目 异常封送处理,可用于测试不同的方案及其解决方案。

问题

当引发异常并在堆栈展开期间遇到与引发的异常类型不匹配的帧时,会出现此问题。

此问题的典型示例是,当本机 API 引发 Objective-C 异常时,当堆栈展开过程到达托管帧时,必须以某种方式处理该 Objective-C 异常。

在过去(.NET 之前),默认操作是不执行任何操作。 对于上面的示例,这意味着让 Objective-C 运行时环境展开托管帧。 此操作有问题,因为 Objective-C 运行时不知道如何展开托管帧;例如,它不会执行任何托管 catchfinally 子句,从而导致非常难以发现的问题。

代码中断

请考虑以下代码示例:

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 堆栈展开器将展开托管帧,但不会执行任何托管异常逻辑(如 catchfinally 子句)。

这意味着通常无法通过以下方式捕获这些异常:

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 异常的通知,NSSetUncaughtExceptionHandler 这是 .NET SDK 利用的方法,并且在这种情况下会尝试将任何 Objective-C 异常转换为托管异常。

情境

方案 1 - 使用托管捕获处理程序捕获 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");
    }
}

@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: 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 子句都不会被执行。

重要

只有 MonoVM 运行时支持在托管异常处理期间展开原生帧。 遇到这种情况时,CoreCLR 运行时只会中止进程(CoreCLR 运行时用于 macOS 应用,以及在任何平台上启用 NativeAOT 时)。

方案 2 - 无法捕获 Objective-C 异常

在以下方案中, 无法使用 托管 catch 处理程序捕获 Objective-C 异常,因为 Objective-C 异常是以另一种方式处理的:

  1. 引发 Objective-C 异常。
  2. Objective-C 运行时会遍查堆栈(但不展开它),查找可以处理异常的本机 @catch 处理程序。
  3. Objective-C 运行时查找处理程序 @catch 、展开堆栈并开始执行 @catch 处理程序。

此方案通常在适用于 iOS 应用的 .NET 中找到,因为在主线程上通常有如下所示的代码:

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

这意味着在主线程上,从来没有真正未经处理的 Objective-C 异常,因此,永远不会调用将 Objective-C 异常转换为托管异常的回调。

在早于最新版本的 macOS 上调试 macOS 应用时也很常见,因为检查调试器中的大多数 UI 对象将尝试提取与执行平台上不存在的选择器对应的属性。 调用此类选择器将引发一个 NSInvalidArgumentException (“无法识别的选择器发送到...”),这最终会导致进程崩溃。

总之,将 Objective-C 运行时或未编程为处理的 Mono 运行时展开帧可能会导致未定义的行为,例如崩溃、内存泄漏和其他类型的不可预知(错误)行为。

小窍门

对于 macOS 和 Mac Catalyst(但不包括 iOS 或 tvOS)应用,可以通过设置应用的属性为NSApplicationCrashOnExceptionstrue,使得用户界面循环不会捕捉所有异常。

var defs = new NSDictionary ((NSString) "NSApplicationCrashOnExceptions", NSValue.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"));
    }
}

拦截 P/Invoke 到objc_msgSend,并调用以下代码:

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

这两个事件都接收一个 EventArgs 对象,该对象包含引发的原始异常( Exception 属性),以及一个属性 ExceptionMode ,用于定义异常应如何封送。

可以在事件处理程序中更改ExceptionMode属性,以便根据处理程序中完成的任何自定义处理来改变行为。 一个示例是,如果发生特定异常,则中止进程。

更改 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] 异常”)。 实现此目的的一种方法是为 Runtime.MarshalObjectiveCException 事件添加事件处理程序,使用遥测记录所有被封送的 Objective-C 异常(对于调试/本地生成,可以同时将异常模式设置为“中止”),以便检测并修复/避免这些异常。

Build-Time 标志

可以设置以下 MSBuild 属性,这将确定是否启用了异常拦截,并设置应发生的默认作:

  • MarshalManagedExceptionMode:“default”、“unwindnativecode”、“throwobjectivecexception”、“abort”、“disable”。
  • MarshalObjectiveCExceptionMode:“default”、“unwindmanagedcode”、“throwmanagedexception”、“abort”、“disable”。

示例:

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

除了disable之外,这些值与ExceptionMode传递给MarshalManagedExceptionMarshalObjectiveCException事件的值相同。

disable 选项主要会禁用拦截,除了在不增加执行开销的情况下,我们仍会拦截异常。 这些异常仍会引发编组事件,默认模式是执行平台的预设模式。

局限性

我们仅在尝试捕获 Objective-C 异常时拦截 objc_msgSend 系列函数的 P/Invoke。 这意味着对另一个 C 函数进行 P/Invoke 调用时,如果该函数引发任何 Objective-C 异常,仍会遇到旧的未定义行为(这一问题可能将在未来得到改善)。

另请参阅