Hilfe: Zugriff auf Mutable State von Actors

Diese Seite verwendet Cookies. Durch die Nutzung unserer Seite erklären Sie sich damit einverstanden, dass wir Cookies setzen. Weitere Informationen

  • Hilfe: Zugriff auf Mutable State von Actors

    Hallo Leute!

    Ich brauche unbedingt Eure Hilfe zum Thema Concurrency (speziell Actors und deren mutable state) ... aber der Reihe nach:

    Kontext:

    Ich modernisiere den Unterbau meiner Freemium-Apps (macOS und iOS), um Abhängigkeiten von Receigen / OpenSSL los zu werden, indem ich auf die StoreKit2 API wechsle. Betroffen ist eine dedizierten Klasse StoreManager, die alle StoreKit-Operationen durchführt und von diversen (Objective-C) Klassen aufgerufen wird. Hier wird z. B. das Receipt nach Käufen untersucht oder auf Transaktionen gelauscht und diese muss auf Swift umgestellt werden.

    Problem:

    Swift's strenge Überprüfung drohender "data races" führt aber immer wieder zu Compiler-Fehlern und es beisst sich die Katze in den Schwanz.

    Durch Nutzung der StoreKit2 API erfolgt die Überprüfung der "Entitlements" (nach meinem Verständnis des Receipts) per Transaction.currentEntitlements. Diese ist asynchron und soll entsprechende Produkt-IDs in einem Set speichern. Daher nutze ich einen entsprechenden Actor zur Isolierung:

    Quellcode

    1. private actor entitlementsActor {
    2. var purchasedProductIDs: Set<String> = []
    3. func checkPurchasedProducts() async {
    4. self.purchasedProductIDs.removeAll()
    5. for await verificationResult in Transaction.currentEntitlements {
    6. switch verificationResult {
    7. case .verified(let transaction):
    8. self.purchasedProductIDs.insert(transaction.productID)
    9. case .unverified(let unverifiedTransaction, let verificationError):
    10. self.purchasedProductIDs.insert(unverifiedTransaction.productID)
    11. }
    12. }
    13. }
    14. }
    Alles anzeigen
    Allerdings soll das Set purchasedProductIDs in Properties vom StoreManager genutzt werden, also ausserhalb des Actors und hier scheitere ich:

    Die letzten Tage habe ich zu den Themen Swift Concurrency, Task, Actor, immutable state u.a. recherchiert und fand zum Beispiel diesen Beitrag der WWDC 2021 sehr hilfreich. Aber ich sehe keinen (praktikablen) Weg, an besagtes Set zu kommen:
    • Ein Zugriff per await erfordert entweder eine Task oder asynchrone Funktion: Ich möchte für den StoreManager aber ein Property haben, das unmittelbar den (Kauf-) Status eines Produktes liefert - welcher sporadisch im Hintergrund aktualisiert wird, wenn z. B. Transaktionen geändert wurden.
    • Das Set ist mutable und kann damit nicht als nonisolated definiert werden.
    • Die Funktion checkPurchasedProducts ist aber die einzige, welche das Set purchasedProductIDs verändert.
    Ich verstehe einfach nicht, wie ich ein Property im Hintergrund aktualisieren kann, ohne alle beteiligten Funktionen als async zu erklären (und damit z. B. in den aufrufenden Objective-C Klassen Completion-Handler zu haben). Wie greife ich auf Variablen eines Actors zu - oder könnte ich die Überprüfung der Entitlements anders aufrufen? Oder welche Grundlagen habe ich komplett falsch verstanden?

    Mein aktueller Plan B ist so dirty, dass ich ihn kaum nennen mag: Speichern der purchasedProductIDs in den UserDefaults und in den Properties Zugriff auf diese... ;(

    Bitte helft mir mit Hinweisen auf erklärende Literatur / Videos, einen allgemeinen oder speziellen Lösungsansatz zum StoreKit2 oder auch nur mit den richtigen Stichworten...

    Ich werde noch wahnsinnig, Mattes
    Diese Seite bleibt aus technischen Gründen unbedruckt.
  • Mir ist nicht ganz klar, warum Du hier ein Property verwenden möchtest. Je nachdem wann checkPurchasedProducts aufgerufen wird und seine Arbeit erledigt hat, enthält der Inhalt des Property purchasedProductIDs doch keine korrekten Daten.

    Ich würde hier entweder einen Completion Handler oder eine Notification verwenden, sobald checkPurchasedProducts seiner Arbeit erledigt hat und korrekte purchasedProductIDs zur Verfügung stehen. Erst dann lassen sich diese verwenden.

    Alles andere ergibt für mich überhaupt keine Sinn, oder ich verstehe den Anwendungsfall nicht.
  • MCDan schrieb:

    Ich würde hier entweder einen Completion Handler oder eine Notification verwenden, sobald checkPurchasedProducts seiner Arbeit erledigt hat und korrekte purchasedProductIDs zur Verfügung stehen. Erst dann lassen sich diese verwenden.
    Da würde ich Dir bei einem einmaligen Füllen des Sets zustimmen. Hier ist es aber so, dass im Hintergrund immer wieder Aktualisierungen über den App Store kommen können - z. B. wenn ein "Ask to Buy"-Kauf von den Eltern freigeben wurde. Es gibt also keinen Zeitpunkt x, an dem "die Arbeit erledigt ist": purchasedProductIDs ist immer nur eine Momentaufnahme...

    Letztlich lässt sich die Frage wahrscheinlich darauf reduzieren, wie man ein Property einer Instanz über einen Hintergrund-Prozess aktualisiert, ohne von Swift bzgl. "potential data races" abgemahnt zu werden.
    Diese Seite bleibt aus technischen Gründen unbedruckt.
  • MyMattes schrieb:

    MCDan schrieb:

    Ich würde hier entweder einen Completion Handler oder eine Notification verwenden, sobald checkPurchasedProducts seiner Arbeit erledigt hat und korrekte purchasedProductIDs zur Verfügung stehen. Erst dann lassen sich diese verwenden.
    Da würde ich Dir bei einem einmaligen Füllen des Sets zustimmen. Hier ist es aber so, dass im Hintergrund immer wieder Aktualisierungen über den App Store kommen können - z. B. wenn ein "Ask to Buy"-Kauf von den Eltern freigeben wurde. Es gibt also keinen Zeitpunkt x, an dem "die Arbeit erledigt ist": purchasedProductIDs ist immer nur eine Momentaufnahme...
    Letztlich lässt sich die Frage wahrscheinlich darauf reduzieren, wie man ein Property einer Instanz über einen Hintergrund-Prozess aktualisiert, ohne von Swift bzgl. "potential data races" abgemahnt zu werden.
    Dass sich die purchasedProductIDs laufend verändern können ist mir bewusst. Ich würde jedoch nur darauf reagieren, wenn es auch wirklich eine Änderung an den purchasedProductIDs gibt.

    In diesem Fall halte ich eine Notification immer noch für die sinnvollste Lösung.

    Ich habe mir die StoreKit2 API noch nicht angeschaut, aber ich bin sicher, dass man sich damit für Änderungen registrieren kann und dann durch eine Notification oder einen Callback Handler informiert wird, wenn es Änderungen gibt.

    Selbst wenn man mit der StoreKit2 API permanent auf mögliche Änderungen pollen muss, würde ich einen Wrapper mit einer Notification Lösung bauen.
  • MCDan schrieb:

    Ich habe mir die StoreKit2 API noch nicht angeschaut, aber ich bin sicher, dass man sich damit für Änderungen registrieren kann und dann durch eine Notification oder einen Callback Handler informiert wird, wenn es Änderungen gibt.
    Das ist auch prinzipiell so:
    • In einer Background-Task läuft (asynchron) ein Transaction.updates, das die Änderungen von Transaktionen mitbekommt.
    • Dieses triggert (wieder asynchron) die o. g. Funktion zum Prüfen der Receipts / der Entitlements per Transaction.currentEntitlements
    Es gibt in der App aber diverse Stellen, an denen geprüft werden muss, ob momentan ein bestimmtes Produkt als gekauft gilt. Also keine Aktion, die nach Store-Aktualisierungen per Notification o. ä. ausgeführt werden soll, sondern z. B. beim Aufruf eines Menüpunktes die Entscheidung "hat der User dafür bezahlt?" - und zwar instant. Hier würde ich eigentlich auf eine Status-Variable zurückgreifen, eben besagte purchasedProductIDs.

    Mit Deinem Ansatz hiesse das (nach meinem Verständnis), auf eine Notification zu hören, um dann - nach Beendigung der Überprüfung - das Property zu aktualisieren. Also müsste eine Funktion nach Empfang der Notification ebenfalls Zugriff auf Variablen des Actors und das Property purchasedProductIDs haben. Damit bin ich doch wieder beim Ursprungsproblem. Oder gebe ich das Set bei der Notification als Daten mit (quasi meine Defaults-Krücke in schön) ... klingt nach Code Smell.

    Ich will mich nicht bockig anstellen und danke Dir sehr für Deine Hinweise - hoffe ich doch, so meinen Knoten im Kopf zu lösen. Aber ich verstehe noch immer nicht, wie man eine Variable aus einem Hintergrund-Thread heraus verändert, ohne dass diese nur asynchron abgefragt werden kann.
    Diese Seite bleibt aus technischen Gründen unbedruckt.
  • Ich habe das nun alles noch einmal in den Apple-Foren gepostet ... auch in der Hoffnung, beim Schreiben einen Aha-Effekt zu haben - leider nicht. Wenn in den nächsten Tagen nicht der Groschen fällt, investiere ich wohl ein Ticket an den DTS: Ich habe noch beide frei und das Abo verlängert sich im Februar.

    In der Zwischenzeit bin ich für jeden Schubser dankbar - das Problem sitzt vermutlich vor'm Computer, aber alle Beispiele, die ich gefunden habe, nutzen die Infos immer nur zur Aktualisierung von Views, meist in SwiftUI. Was aber, wenn man eine direkte Aussage braucht, auf Basis einer Art Cache (eben dem besagten Set)?
    Diese Seite bleibt aus technischen Gründen unbedruckt.
  • Ok, ich verstehe das Problem jetzt besser.

    Evtl. hilft es ja, dass Property bzw. das Array vom Property nicht permanent in der Funktion, sondern nur ein mal zu ändern, also z.B. s

    Quellcode

    1. private actor entitlementsActor {
    2. @SynchronizedLock var purchasedProductIDs: Set<String> = []
    3. func checkPurchasedProducts() async {
    4. var newPurchasedProductIDs: Set<String> = []
    5. for await verificationResult in Transaction.currentEntitlements {
    6. switch verificationResult {
    7. case .verified(let transaction):
    8. newPurchasedProductIDs.insert(transaction.productID)
    9. case .unverified(let unverifiedTransaction, let verificationError):
    10. newPurchasedProductIDs.insert(unverifiedTransaction.productID)
    11. }
    12. }
    13. purchasedProductIDs = newPurchasedProductIDs
    14. }
    15. }
    Alles anzeigen
    Über das @SynchronizedLock sollte ein Zugriff aus verschiedenen Threads möglich sein.

    Das Problem, dass die App gerade auf das Property zugreift, bevor checkPurchasedProducts() fertig ist und evtl. ein bestimmtes Produkt neu ins Array eingetragen wurde, besteht natürlich weiterhin. Da wüsste ich jedoch auch nicht, wie sich dies lösen lässt.

    Da man jedoch nicht gleichzeitig ein neues Produkt kaufen und "verwenden" kann, sollte es dieses Problem evtl. gar nicht geben.

    Evtl. könnte man nach dem Kauf eines neuen Produktes checkPurchasedProducts() aufrufen und die App mit einem Progress Indicator "sperren", bis die Funktion fertig ist. Alternativ läuft die Prüfung im Hintergrund und die App zeigt eine lokale User Notification an, sobald der User das Produkt verwenden kann.

    Dieser Beitrag wurde bereits 4 mal editiert, zuletzt von MCDan ()

  • MCDan schrieb:

    Über das @SynchronizedLock sollte ein Zugriff aus verschiedenen Thread möglich sein.
    Ich vermute, Du nutzt dieses Tool als Wrapper zum Serialisieren?

    Werde ich auf jeden Fall probieren, im Moment versuchte ich gerade, ein kleines Beispielprojekt zu erstellen - auch als Vorbereitung des evt. DTS-Tickets. Und "Quinn der Eskimo" hat im Apple-Forum reagiert, vielleicht kommt dort ein Hinweis (er ist immer sehr hilfreich).

    Danke, Mattes
    Diese Seite bleibt aus technischen Gründen unbedruckt.
  • Ich hab's am Laufen :)

    Beim Bauen eines kleinen Beispiel-Projektes war ich maximal verblüfft, weil "es einfach funktionierte": Ich konnte in asynchronen Tasks beliebig auf Instanz-Eigenschaften der entsprechenden Klasse zurückgreifen, auch mit geschachteltem Task() und ohne irgendeinen Actor. Dann versuchte ich, mich immer mehr dem Original-Code anzunähern und es funktionierte noch immer - umgekehrt erhielt ich dort aber den folgenden Fehler, wenn ich versuchte, das Property zu verändern

    Quellcode

    1. Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
    Etwas Googlen brachte mich auf diesen SO-Artikel, bei dem das Problem mittels @MainActor behoben / umgangen wird. Und so funktioniert es bei mir auch - das Konstrukt mit der Isolierung der Receipt-Prüfung als Actor ist nicht erforderlich. Alle asynchronen / längeren Aktivitäten laufen in untergeordneten Tasks, so dass ich auch kein schlechtes Gewissen habe, den StoreManager in die Main-Queue zu zwingen. Ende gut, alles gut.

    Ach ja: Ich vermute (!), dass meinen Beispielprojekt auch ohne @MainActor funktionierte, weil sich alle Funktionen in einem UIViewController befanden, der ja eh auf der Main-Queue läuft ... wie gesagt eine (m. E. plausible) Vermutung.

    Danke für's Mitdenken!
    Diese Seite bleibt aus technischen Gründen unbedruckt.

    Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von MyMattes ()