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 运行时不知道如何展开托管帧;例如,它不会在该帧中执行任何 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 异常的通知(NSSetUncaughtExceptionHandler
),Xamarin.iOS 和 Xamarin.Mac 会使用该方法,且会在这个时候尝试将任何 Objective-C 异常转换为托管异常。
方案
方案 1 - 使用托管 catch 处理程序捕获 Objective-C 异常
在以下方案中,可以使用托管 Objective-C 处理程序捕获 catch
异常:
- 将引发 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 异常
在以下方案中,无法使用托管 catch
处理程序捕获 Objective-C 异常,因为 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 项目(pre-.NET),默认情况下它并不总是启用:
- Xamarin.iOS/tvOS:在模拟器中启用 Objective-C 异常截获。
- Xamarin.watchOS:在所有情况下都强制实施拦截,因为让 Objective-C 运行时展开托管帧会混淆垃圾回收器,并使其挂起或崩溃。
- Xamarin.Mac:为调试生成启用了 Objective-C 异常截获。
在 .NET 中,默认情况下始终启用对 Objective-C 异常的封送托管异常。
生成时标志部分介绍了如何在默认未启用拦截时启用拦截(或默认启用拦截时禁用)。
事件
截获异常后,将引发两个事件:Runtime.MarshalManagedException
和 Runtime.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
外,这些值与传递给 MarshalManagedException
和 MarshalObjectiveCException
事件的 ExceptionMode
值相同。
disable
选项主要会禁用截获,但在不添加任何执行开销的情况下,我们仍会截获异常。 这些异常仍会引发封送事件,默认模式是执行平台的默认模式。
限制
我们仅在尝试捕获 Objective-C 异常时截获到函数 objc_msgSend
系列的 P/Invoke。 这意味着对另一个 C 函数的 P/Invoke(随后会引发任何 Objective-C 异常)仍将遇到旧行为和未定义的行为(将来可能会有所改进)。