2

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.

7
  • 2
    Sorry but I have to ask. What would the purpose be for making a view Codable? As you know, you typically encode/decode data. A view is just there to display data to a user. iOS provides a mechanism for view state restoration that doesn't require the view to be Codable. The practicality aside, your question makes for an interesting challenge. Commented May 20 at 23:14
  • @HangarRash All that gets encoded is the data (the view's name in my reduced example), so it's not really any different from "encode/decode data". In a future rewrite, sure, I might completely redo this architecture, one where the view's properties don't constitute a source of truth as they seem to at the moment. So I agree with you 100%. But this app goes back a long way, and for right now, I'm just trying to tweak it far enough to make it iOS 18 / Swift 6 / strict concurrency so I can fix a couple of bugs and put out an update. The major rewrite will take a long time. Commented May 20 at 23:48
  • Full answer below, but tl;dr: you want the upcoming isolated conformances feature for this, if it can wait until Swift 6.2 Commented May 21 at 0:53
  • Create own MainActoredDecodable with @MainActor init(from decoder: Decoder) throws Commented May 21 at 9:29
  • @Cy-4AH Writing your own protocol means that you're not compatible with any existing code that requires the old protocol. If you write MainActorDecodable, you still won't be able to use it with any existing Decoder (because those still require Decodable conformance), which defeats the purpose in this case Commented May 21 at 12:15

1 Answer 1

5

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.

Sign up to request clarification or add additional context in comments.

2 Comments

isolated conformance is silly garbage improvement. Swift community should reject it. If you need isolated Equatable then create new protocol with isolated methods. Don't need to mark Equatable as @MainActor
@Cy-4AH I'm not sure why such a strong reaction. Instead of having to create a new incompatible version of every existing protocol for every single actor out there, isolated conformances allow you to use existing protocols in a scoped way. The protocols themselves aren't changing, but this finally allows isolated types to conform to them. In any case, this feature has already been pitched, accepted, and implemented.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.