Xamarin.iOS 和 Xamarin.Mac 中的异常封送处理

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

但是,它们的实现不同,这意味着运行时库(Mono 运行时或 CoreCLR 和 Objective-C 运行时库)在处理异常时出现问题,然后运行以其他语言编写的代码。

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

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

问题

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

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

对于旧版 Xamarin 项目(pre-.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 堆栈展开器将展开托管帧,但不会执行任何托管异常逻辑(例如 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 异常的通知(NSSetUncaughtExceptionHandler),Xamarin.iOS 和 Xamarin.Mac 会使用该方法,且会在这个时候尝试将任何 Objective-C 异常转换为托管异常。

方案

方案 1 - 使用托管 catch 处理程序捕获 Objective-C 异常

在以下方案中,可以使用托管 Objective-C 处理程序捕获 catch 异常:

  1. 将引发 Objective-C 异常。
  2. Objective-C 运行时会遍查堆栈(但不展开堆栈),查找可以处理异常的本机 @catch 处理程序。
  3. Objective-C 运行时找不到任何 @catch 处理程序、调用 NSGetUncaughtExceptionHandler,调用 Xamarin.iOS/Xamarin.Mac 安装的处理程序。
  4. 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 异常

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

  1. 将引发 Objective-C 异常。
  2. Objective-C 运行时会遍查堆栈(但不展开堆栈),查找可以处理异常的本机 @catch 处理程序。
  3. 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 项目(pre-.NET),默认情况下它并不总是启用:

  • Xamarin.iOS/tvOS:在模拟器中启用 Objective-C 异常截获。
  • Xamarin.watchOS:在所有情况下都强制实施拦截,因为让 Objective-C 运行时展开托管帧会混淆垃圾回收器,并使其挂起或崩溃。
  • Xamarin.Mac:为调试生成启用了 Objective-C 异常截获。

在 .NET 中,默认情况下始终启用对 Objective-C 异常的封送托管异常。

生成时标志部分介绍了如何在默认未启用拦截时启用拦截(或默认启用拦截时禁用)。

事件

截获异常后,将引发两个事件:Runtime.MarshalManagedExceptionRuntime.MarshalObjectiveCException

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

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

更改 ExceptionMode 属性适用于单个事件,不会影响将来截获的任何异常。

将托管异常封送给本机代码时,可以使用以下模式:

  • Default:默认值因平台而异。 在 .NET 中始终是 ThrowObjectiveCException。 对于旧版 Xamarin 项目,如果 GC 处于协作模式 (watchOS),则为 ThrowObjectiveCException,其他情况则为 UnwindNativeCode (iOS/ watchOS / macOS)。 默认值将来可能会更改。
  • 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,其他情况则为 UnwindManagedCode (iOS/ tvOS / macOS)。 默认值将来可能会更改。
  • 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 外,这些值与传递给 MarshalManagedExceptionMarshalObjectiveCException 事件的 ExceptionMode 值相同。

disable 选项主要会禁用截获,但在不添加任何执行开销的情况下,我们仍会截获异常。 这些异常仍会引发封送事件,默认模式是执行平台的默认模式。

限制

我们仅在尝试捕获 Objective-C 异常时截获到函数 objc_msgSend 系列的 P/Invoke。 这意味着对另一个 C 函数的 P/Invoke(随后会引发任何 Objective-C 异常)仍将遇到旧行为和未定义的行为(将来可能会有所改进)。