swiftuiviewdecodableswift-concurrencyswift6

Swift 6 concurrency - how to make a UIView Codable?


How do you make a UIView Codable under Swift 6 concurrency?

To see the problem, start with this:

final class MyView: UIView {
    var name: String

    init(name: String) {
        self.name = name
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

So far so good. Now I will make this view Codable by adopting Decodable and Encodable:

final class MyView: UIView, Decodable, Encodable {
    var name: String

    init(name: String) {
        self.name = name
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

The compiler goes wild. We are told three things:

Main actor-isolated property 'name' can not be referenced from a nonisolated context

Protocol requires initializer 'init(from:)' with type 'Decodable'

Cannot automatically synthesize 'init(from:)' because implementation would need to call 'init()', which is not designated

Well, I can grapple with the last two problems by writing my own init(from:), just as I was doing in Swift 5:

final class MyView: UIView, Decodable, Encodable {
    var name: String

    enum CodingKeys: String, CodingKey {
        case name
    }

    init(from decoder: any Decoder) throws {
        let con = try! decoder.container(keyedBy: CodingKeys.self)
        self.name = try! con.decode(String.self, forKey: .name)
        super.init(frame: .zero)
    }

    init(name: String) {
        self.name = name
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

The compiler doesn't like this either. It doesn't like my init(from:):

Main actor-isolated initializer 'init(from:)' cannot be used to satisfy nonisolated requirement from protocol 'Decodable'

Oh, so Decodable says that this is supposed to be nonisolated??? That's not very nice of it. So now what?

The compiler suggests as a fix that I might declare my init(from:) to be nonisolated too. But I can't do that, because if I do, then I am not permitted to call super.init(frame:).

The compiler alternatively suggests that I just throw @preconcurrency at my adoption of Decodable, like this:

final class MyView: UIView, @preconcurrency Decodable, Encodable {
    var name: String

    enum CodingKeys: String, CodingKey {
        case name
    }

    init(from decoder: any Decoder) throws {
        let con = try! decoder.container(keyedBy: CodingKeys.self)
        self.name = try! con.decode(String.self, forKey: .name)
        super.init(frame: .zero)
    }

    init(name: String) {
        self.name = name
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

OK, that silences the complaint about init(from:). But the complaint about name remains. The only thing I can think of to silence that one is to declare it nonisolated(unsafe):

final class MyView: UIView, @preconcurrency Decodable, Encodable {
    nonisolated(unsafe) var name: String

    enum CodingKeys: String, CodingKey {
        case name
    }

    init(from decoder: any Decoder) throws {
        let con = try! decoder.container(keyedBy: CodingKeys.self)
        self.name = try! con.decode(String.self, forKey: .name)
        super.init(frame: .zero)
    }

    init(name: String) {
        self.name = name
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

And now the compiler is happy. But I'm not! I've been forced to say a bunch of stuff I feel I shouldn't be forced to say. Is this really the best that can be done, under the current circumstances?


Note that this is not quite the same as the situation I solved at https://stackoverflow.com/a/69032109/341994 because a UIView is main actor isolated by nature and cannot be otherwise.


Solution

  • At its core, this issue doesn't have to do with UIView or Codable specifically, so let's examine a whittled down case:

    // Stand-in for Encodable
    protocol P {
        func f() // note: mark the protocol requirement 'f()' 'async' to allow actor-isolated conformances
    }
    
    // Stand-in for UIView
    @MainActor
    class C: P {
        func f() {} // error: main actor-isolated instance method 'f()' cannot be used to satisfy nonisolated requirement from protocol 'P'
        // note: add 'nonisolated' to 'f()' to make this instance method not isolated to the actor
    }
    

    What you have here are conflicting requirements:

    1. P is a protocol which isn't isolated to any specific actor (and thus, implicitly nonisolated); it neither makes any guarantees to conforming types about which actor they can expect methods to get called on, nor sets any limitations on callers to invoke the protocol requirements on a specific actor
    2. C is a class which is @MainActor-isolated, meaning that unless otherwise noted, it can only be safely accessed from the main actor (and attempting to access it from any other actor requires an async access)
    3. C.f() here attempts to satisfy both C's @MainActor requirement (implicitly) and P's nonisolated requirement — which isn't possible

    This is what you see when you first try to implement init(from:) yourself: because MyView is isolated to the main actor, its init(from:) is implicitly also isolated to the main actor, hence the error.

    Depending on the actual requirements of P, it could be enough to actually mark the implementation as nonisolated:

    @MainActor
    class C: P {
        nonisolated func f() {}
    }
    

    With this annotation, we're stating that it's safe to call f() from any actor isolation (or no actor isolation), hence meeting the requirements specified by P.

    Unfortunately, as you note, f() now can't access any state which is isolated, because then its implementation would be violating isolation rules:

    @MainActor
    class C: P {
        var s: String = "Hello"
    
        nonisolated func f() {
            print(s) // error: main actor-isolated property 's' can not be referenced from a nonisolated context
        }
    }
    

    There's a fundamental impasse here: as long as there are conflicting isolated and nonisolated requirements, there fundamentally can't be a solution; something has to give.

    1. Annotating the conformance to P with @preconcurrency just tells the compiler "this protocol was written before Swift Concurrency, so I know what I'm doing". This is useful when the protocol isn't marked as, e.g., @MainActor, but you know that its requirements will only ever be accessed from the main actor. This may silence the warning, but if you actually end up accessing isolated state from off the main actor, you'll run into undefined behavior (or crash)
    2. Marking all of the state that was implicitly being isolated as nonisolated will allow you to access it from non-isolated contexts, but also... gets rid of the isolation, so it'll be up to you to then ensure coordination of access from multiple isolations simultaneously

    At the moment, the best you can do is find a way to untangle these conflicting requirements. On thing you can do is have your isolated type vend another type with a snapshot of state that isn't isolated — e.g., have your UIView expose a struct State: Codable interface which contains the view's data and can be used to store and re-hydrate a view.

    In the future (well, the upcoming Swift 6.2), what you need here is an isolated conformance, a pitched feature which would allow an isolated type to conform to a non-isolated protocol by exposing its requirements only in the context of the original isolation. i.e., a @MainActor-isolated view could conform to Codable by having init(from:) and encode(to:) only available to call on the main actor. At the time of writing, this functionality isn't yet available, though.