Udostępnij przez


Przekazywanie wyjątków

Zarówno kod zarządzany, jak i Objective-C mają obsługę wyjątków środowiska uruchomieniowego (klauzul try/catch/finally).

Jednak ich implementacje są różne, co oznacza, że biblioteki środowiska uruchomieniowego (środowiska uruchomieniowe MonoVM/CoreCLR i biblioteki środowiska uruchomieniowego Objective-C) mają problemy, gdy wystąpią wyjątki od innych środowisk uruchomieniowych.

W tym artykule opisano problemy, które mogą wystąpić, oraz możliwe rozwiązania.

Zawiera również przykładowy projekt Marshaling wyjątków, który może służyć do testowania różnych scenariuszy i ich rozwiązań.

Problem

Problem występuje, gdy zgłaszany jest wyjątek, a podczas odwijania stosu występuje ramka, która nie jest zgodna z typem wyjątku, który został zgłoszony.

Typowym przykładem tego problemu jest sytuacja, gdy natywny interfejs API zgłasza wyjątek Objective-C, a następnie ten wyjątek Objective-C musi być w jakiś sposób obsłużony, gdy proces odwijania stosu osiągnie zarządzaną ramkę.

W przeszłości (pre-.NET) domyślną akcją było nic nie robić. W przypadku powyższego przykładu oznaczałoby to, że środowisko uruchomieniowe Objective-C odwinie ramki zarządzane. Ta akcja jest problematyczna, ponieważ środowisko uruchomieniowe Objective-C nie wie, jak odwijać zarządzane ramki; na przykład nie będzie wykonywać żadnych zarządzanych catch ani finally klauzul, co prowadzi do imponująco trudnego znalezienia usterek.

Uszkodzony kod

Rozważmy następujący przykład kodu:

var dict = new NSMutableDictionary ();
dict.LowlevelSetObject (IntPtr.Zero, IntPtr.Zero); 

Ten kod zgłosi błąd Objective-C NSInvalidArgumentException w kodzie natywnym:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

Ślad stosu będzie podobny do następującego:

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 ()

Ramki 0–3 są ramkami natywnymi, a odwijarka stosu w środowisku uruchomieniowym Objective-C może odwinąć te ramki. W szczególności wykona dowolne klauzule Objective-C @catch lub @finally.

Jednak odwijacz stosu Objective-C nie jest w stanie prawidłowo odwinąć zarządzanych ramek (ramki 4–6): odwijacz stosu Objective-C odwinie ramki zarządzane, ale nie wykona żadnej logiki wyjątków zarządzanych (takiej jak klauzule catch lub finally).

Oznacza to, że zazwyczaj nie da się przechwycić tych wyjątków w następujący sposób:

try {
    var dict = new NSMutableDictionary ();
    dict.LowLevelSetObject (IntPtr.Zero, IntPtr.Zero);
} catch (Exception ex) {
    Console.WriteLine (ex);
} finally {
    Console.WriteLine ("finally");
}

Jest to spowodowane tym, że mechanizm zwalniania stosu Objective-C nie zna klauzuli zarządzanej catch, a klauzula finally również nie zostanie wykonana.

Gdy powyższy przykład kodu jest skuteczny, jest to spowodowane tym, że Objective-C ma metodę powiadamiania o nieobsługiwanych wyjątkach Objective-C, NSSetUncaughtExceptionHandlerktórych używają zestawy SDK platformy .NET, i w tym momencie próbuje przekonwertować wszystkie wyjątki Objective-C do wyjątków zarządzanych.

Scenariusze

Scenariusz 1 — przechwytywanie wyjątków Objective-C za pomocą zarządzanego programu obsługi catch

W poniższym scenariuszu można przechwytywać wyjątki Objective-C przy użyciu programów obsługi zarządzanej catch :

  1. Zgłaszany jest wyjątek Objective-C.
  2. Środowisko uruchomieniowe Objective-C przechodzi przez stos (ale go nie rozwija), szukając natywnej @catch procedury obsługującej, która może poradzić sobie z wyjątkiem.
  3. Środowisko uruchomieniowe Objective-C nie znajduje żadnych @catch obsługiwaczy, wywołuje NSGetUncaughtExceptionHandler metodę i uruchamia procedurę obsługi zainstalowaną przez zestaw SDK platformy .NET.
  4. Procedura obsługi zestawów SDK platformy .NET przekonwertuje wyjątek Objective-C na wyjątek zarządzany i zgłosi go. Ponieważ środowisko uruchomieniowe Objective-C nie odwijało stosu (jedynie go przeglądało), bieżąca ramka jest taka sama jak ta, z której zgłoszono wyjątek Objective-C.

W tym miejscu występuje inny problem, ponieważ środowisko uruchomieniowe Mono nie wie, jak prawidłowo odwinąć ramki Objective-C.

Gdy wywoływane jest wywołanie zwrotne dla nieuchwyconych wyjątków Objective-C w zestawach SDK platformy .NET, stos wygląda następująco:

 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]

W tym miejscu jedynymi ramkami zarządzanymi są ramki 8-10, ale wyjątek zarządzany jest zgłaszany w ramce 0. Oznacza to, że środowisko uruchomieniowe Mono musi odwinąć ramki natywne 0–7, co powoduje problem odpowiadający omówionemu powyżej problemowi: mimo że środowisko uruchomieniowe Mono odwinie ramek natywnych, nie będzie wykonywać żadnych Objective-C @catch ani @finally klauzul.

Przykład kodu:

-(id) setObject: (id) object forKey: (id) key
{
    @try {
        if (key == nil)
            [NSException raise: @"NSInvalidArgumentException"];
    } @finally {
        NSLog (@"This won't be executed");
    }
}

Klauzula @finally nie zostanie wykonana, ponieważ środowisko uruchomieniowe Mono, które odwija tę ramkę, nie wie o tym.

Odmianą tej metody jest zgłoszenie wyjątku zarządzanego w kodzie zarządzanym, a następnie kontynuowanie działania przez natywne ramówki, aby dotrzeć do pierwszej klauzuli zarządzanej 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.");
        }
    }
}

Metoda zarządzana UIApplication:Main wywoła metodę natywną UIApplicationMain, a następnie iOS wykona wiele operacji w kodzie natywnym, zanim ostatecznie wywoła metodę zarządzaną AppDelegate:FinishedLaunching, przy czym na stosie pozostanie wiele ramek natywnych, gdy zgłoszony zostanie wyjątek zarządzany.

 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[])

Ramki 0-1 i 27-30 są zarządzane, podczas gdy wszystkie ramki między nimi są natywne. Jeśli Mono rozwija się przez te ramki, nie zostaną wykonane żadne klauzule Objective-C @catch ani @finally.

Ważne

Tylko środowisko uruchomieniowe MonoVM obsługuje odwijanie ram natywnych podczas obsługi wyjątków zarządzanych. Środowisko uruchomieniowe CoreCLR po prostu przerwa proces podczas napotkania tej sytuacji (środowisko uruchomieniowe CoreCLR jest używane dla aplikacji systemu macOS, a także gdy funkcja NativeAOT jest włączona na dowolnej platformie).

Scenariusz 2 — nie można przechwycić wyjątków Objective-C

W poniższym scenariuszu nie można przechwycić Objective-C wyjątków przy użyciu zarządzanych programów obsługi catch, ponieważ wyjątek Objective-C został obsłużony w inny sposób:

  1. Zgłaszany jest wyjątek Objective-C.
  2. Środowisko uruchomieniowe Objective-C przechodzi przez stos (ale go nie odwija ani nie rozpakowuje), szukając natywnej obsługi @catch, która może obsłużyć wyjątek.
  3. Środowisko uruchomieniowe Objective-C znajduje procedurę @catch obsługi, odwija stos i rozpoczyna wykonywanie @catch procedury obsługi.

Ten scenariusz jest często spotykany na platformie .NET dla aplikacji systemu iOS, ponieważ w głównym wątku zwykle występuje kod podobny do następującego:

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

Oznacza to, że w głównym wątku nigdy faktycznie nie ma nieobsługiwanego wyjątku Objective-C, a zatem nasze wywołanie zwrotne, które zamienia wyjątki Objective-C na zarządzane wyjątki, nigdy nie jest wywołane.

Jest to również typowe w przypadku debugowania aplikacji systemu macOS we wcześniejszej wersji systemu macOS niż najnowsza, ponieważ inspekcja większości obiektów interfejsu użytkownika w debugerze spowoduje próbę pobrania właściwości odpowiadających selektorom, które nie istnieją na platformie wykonawczej. Wywołanie takich selektorów spowoduje zgłoszenie NSInvalidArgumentException ("Nierozpoznany selektor wysłany do ..."), co ostatecznie powoduje awarię procesu.

Podsumowując, gdy środowisko uruchomieniowe Objective-C lub Mono próbują rozwinąć ramki, których nie są zaprogramowane do obsługi, może to prowadzić do niezdefiniowanych zachowań, takich jak awarie, wycieki pamięci oraz inne typy nieprzewidywalnych (błędnych) zachowań.

Wskazówka

W przypadku aplikacji dla systemów macOS i Mac Catalyst (ale nie dla systemu iOS lub tvOS) możliwe jest, aby pętla interfejsu użytkownika nie przechwyciła wszystkich wyjątków, ustawiając NSApplicationCrashOnExceptions właściwość dla aplikacji na true:

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

Należy jednak pamiętać, że ta właściwość nie jest udokumentowana przez firmę Apple, więc zachowanie może ulec zmianie w przyszłości.

Rozwiązanie

Obsługujemy przechwytywanie zarówno wyjątków zarządzanych, jak i Objective-C na dowolnej granicy zarządzania i natywności oraz konwersję tego wyjątku na inny typ.

W pseudokodzie wygląda to następująco:

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

Element P/Invoke jest objc_msgSend przechwycony, a ten kod jest wywoływany zamiast tego:

void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
    @try {
        objc_msgSend (obj, sel);
    } @catch (NSException *ex) {
        convert_to_and_throw_managed_exception (ex);
    }
}

Podobna procedura jest stosowana w przypadku odwrotnym (przenoszenie zarządzanych wyjątków na wyjątki Objective-C).

Na platformie .NET przeprowadzanie marshalingu wyjątków zarządzanych do Objective-C wyjątków jest zawsze domyślnie włączone.

W sekcji Flagi czasu kompilacji wyjaśniono, jak wyłączyć przechwytywanie, gdy jest to ustawienie domyślne.

Zdarzenia

Istnieją dwa zdarzenia zgłaszane po przechwyceniu wyjątku: Runtime.MarshalManagedException i Runtime.MarshalObjectiveCException.

Oba zdarzenia są przekazywane jako EventArgs obiektowi, który zawiera oryginalny wyjątek, który został zgłoszony (Exception właściwość) i ExceptionMode właściwość, który definiuje sposób obsługi wyjątku.

Właściwość ExceptionMode można zmienić w procedurze obsługi zdarzeń, aby zmienić zachowanie zgodnie z dowolnym niestandardowym przetwarzaniem wykonanym w procedurze obsługi. Przykładem może być przerwanie procesu w przypadku wystąpienia określonego wyjątku.

ExceptionMode Zmiana właściwości dotyczy pojedynczego zdarzenia, ale nie ma wpływu na żadne wyjątki przechwycone w przyszłości.

Dostępne są następujące tryby przekazywania wyjątków zarządzanych do kodu natywnego:

  • Default: obecnie jest to zawsze ThrowObjectiveCException. Wartość domyślna może ulec zmianie w przyszłości.
  • UnwindNativeCode: Nie jest to dostępne w przypadku korzystania z coreCLR (CoreCLR nie obsługuje odwijania kodu natywnego, zamiast tego zostanie przerwany proces).
  • ThrowObjectiveCException: Przekonwertuj wyjątek zarządzany na wyjątek Objective-C i zgłoś wyjątek Objective-C. Jest to wartość domyślna na platformie .NET.
  • Abort: przerwać proces.
  • Disable: wyłącza przechwytywanie wyjątków. Nie ma sensu ustawiać tej wartości w procedurze obsługi zdarzeń (po podniesieniu zdarzenia jest za późno, aby wyłączyć przechwytywanie wyjątku). W każdym razie, jeśli zostanie ustawiona, będzie ona zachowywać się jako UnwindNativeCode.

Dla wyjątków Objective-C przekazywanych do kodu zarządzanego dostępne są następujące tryby:

  • Default: Obecnie zawsze jest ThrowManagedException w platformie .NET. Wartość domyślna może ulec zmianie w przyszłości.
  • UnwindManagedCode: To jest poprzednie (niezdefiniowane) zachowanie.
  • ThrowManagedException: Przekonwertuj wyjątek Objective-C na wyjątek zarządzany i zgłoś wyjątek zarządzany. Jest to wartość domyślna na platformie .NET.
  • Abort: przerwać proces.
  • Disable: wyłącza przechwytywanie wyjątków. Nie ma sensu ustawiać tej wartości w procedurze obsługi zdarzeń (po podniesieniu zdarzenia jest za późno, aby wyłączyć przechwytywanie wyjątku). W każdym razie, jeśli zostanie ustawiona, będzie ona zachowywać się jako UnwindManagedCode.

Aby zobaczyć każdy przypadek przekazywania wyjątku, możesz to zrobić:

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);
        };
        /// ...
    }
}

Wskazówka

W idealnym przypadku wyjątki Objective-C nie powinny występować w dobrze działającej aplikacji. Firma Apple uważa je za znacznie bardziej wyjątkowe niż zarządzane wyjątki: "unikaj zgłaszania wyjątków [Objective-C] w aplikacji, którą dostarczasz użytkownikom". Jednym ze sposobów osiągnięcia tego celu byłoby dodanie procedury obsługi zdarzenia dla Runtime.MarshalObjectiveCException, która rejestrowałaby wszystkie marshalowane wyjątki Objective-C przy użyciu telemetrii. W przypadku kompilacji debugowania lub lokalnych można również ustawić tryb działania wyjątku na "Przerwanie", aby wykryć wszystkie takie wyjątki i je naprawić lub im zapobiec.

Build-Time Flagi

Można ustawić następujące właściwości programu MSBuild, które określają, czy włączono przechwytywanie wyjątków, i ustawić domyślną akcję, która powinna wystąpić:

  • MarshalManagedExceptionMode: "default", "unwindnativecode", "throwobjectivecexception", "abort", "disable".
  • MarshalObjectiveCExceptionMode: "domyślny", "unwindmanagedcode", "throwmanagedexception", "zatrzymaj", "wyłącz".

Przykład:

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

Z wyjątkiem parametru disablete wartości są identyczne z ExceptionMode wartościami przekazywanymi do zdarzenia MarshalManagedException i MarshalObjectiveCException .

Opcja disable spowoduje głównie wyłączenie przechwytywania, z wyjątkiem przypadków, gdy nadal będziemy przechwytywać wyjątki, o ile nie wiąże się to z żadnym dodatkowym obciążeniem wykonywania. Zdarzenia marshalingu są nadal wywoływane dla tych wyjątków, a tryb domyślny jest trybem domyślnym dla platformy wykonawczej.

Ograniczenia

Przechwytujemy tylko P/Invokes dla objc_msgSend rodziny funkcji podczas próby przechwycenia wyjątków Objective-C. Oznacza to, że funkcja P/Invoke do innej funkcji języka C, która następnie zgłasza wszelkie wyjątki Objective-C, nadal będzie działać w starym i niezdefiniowanym zachowaniu (może to zostać ulepszone w przyszłości).

Zobacz także