[iOS]Published attributeが付与されたプロパティをprotocolのrequirementsに含められない

Combineの@Publishedが付与れたプロパティをprotocolのrequirementsに含めたい。
2020.11.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

WWDC2019で発表されたCombineですが、SwiftUIと組み合わせて使用しやすいよう設計されているように感じますが、UIKitを使用しているアプリで使用できないわけではありません。不足しているUIKitのPublisherを提供するCombineCocoaなども提供されています。

Combineを案件で使っていて困ったことを小ネタですが記事にします。

Property xxx declared inside a protocol cannot have a wrapper

DI出来るようにモジュールを設計する時にprotocolで抽象を表現して利用する側はprotoclに依存させようとしました。DIコンテナ経由で初期化したい時に@Publishが付いたプロパティを宣言したくて試しに付けてみたらコンパイルエラーになりました。今回はこれを回避するワークアラウンドを紹介します。

protocol PrinterDiscoverable {
    @Published var token: String? { get set }
    func discover()
}
Property 'token' declared inside a protocol cannot have a wrapper

Publisherについて

Published attribute について知る前にPublisherについて知っておく必要があります。PublisherはCombineの用語でイベントを送信するオブジェクトのことを指します。PublisherをsubscribeしてSubscriberはイベントを受信できます。

public struct Publisher : Publisher {
    public typealias Output = Wrapped
    public typealias Failure = Never
    public let output: Optional<Wrapped>.Publisher.Output?
    public init(_ output: Optional<Wrapped>.Publisher.Output?)
    public func receive<S>(subscriber: S) where Wrapped == S.Input, S : Subscriber, S.Failure == Never
}

Publisherのドキュメントは以下です。

Apple Developer Documentation

SwifftのSequence protocolに生えているpublisherというプロパティを使ってPublisherの動作を見ます。

import Combine

let publisher = [1, 2, 3, 4, 5, 6].publisher
var subscriptions = Set<AnyCancellable>()

publisher.sink(receiveCompletion: { completion in
    print("completed: \(completion)")
}, receiveValue: { value in
    print("received: \(value)")
}).store(in: &subscriptions)

Sequence protocolに準拠しているArrayからPublisherを生成してそれをsubscribeします。Publisherは要素を順番にPublisherして最後にfinished をpublishしています。 結果は以下のようになります。

received: 1
received: 2
received: 3
received: 4
received: 5
received: 6
completed: finished

@Publishedについて

Property Wrappersという機能を使っていて、@Publishedが付いているプロパティをPublisherにすることができます。

@Published`に関するドキュメントは以下です。

Apple Developer Documentation

Property wrappersについては一度記事を書いたことがあります。

腑に落ちるまでProperty wrappers | Developers.IO

これにより普通のプロパティからPublisherを生成できて楽です。Combineの恩恵を感じながら実装を続けていたのですが実装後に抽象化する時にprotocol側で@Publishedを付与したプロパティをprotocolのrequirementsに含めようとすると先程のコンパイルエラーに遭遇しました。

Swift5.3ではProtocolはProperty wrappersをrequirementsに指定できない

Swift5.2、5.3ではプロトコル内でのProperty Wrappersの宣言をサポートしていません。しかし実装はしないといけないので少しコードを修正して同じ要件を満たすようにしました。

利用者側のコードをまず見てみます。

printerDiscovery.token.sink { [weak self] value in
    guard let token = value else { return }
    target = token
    self?.showNext(token: token)
}.store(in: &subscriptions)

@Publishedが付与されたtokenのprojected valueを使ってpublisherをsubscribeしています。つまり今回の場合利用者側で実際に必要なのはPublisherで、@Publishedが付与されたproperty wrapperではないです。

そこでprotocolを以下のように書きかえます。

protocol PrinterDiscoverable {
    var tokenPublisher: Published<String?>.Publisher { get }
    func discover()
}

protocolに準拠する側の実装は以下のようにします。とあるプリンタのプロプライエタリなSDKで規約上実装を公開してはいけないので命名をこちらで変更しています。

struct PrinterDiscovery: NSObject, DiscoveryDelegate, PrinterDiscoverable {
    @Published private(set) var token: String?
    var tokenPublished: Published<String?> { _token }
    var tokenPublisher: Published<String?>.Publisher { $token }

    private var printerList: [DeviceInfo] = []
    private var filterOption = Epos2FilterOption()

    func discover() {
        filterOption.deviceType = TYPE_PRINTER.rawValue
        let result = Discovery.start(filterOption, delegate: self)
    }

    func onDiscovery(_ deviceInfo: Epos2DeviceInfo!) {
        printerList.append(deviceInfo)
        token = printerList.first?.target ?? "取得失敗"
    }
}

利用する側もコードの修正が必要になります。

printerDiscovery.tokenPublisher.sink { [weak self] value in
    guard let token = value else { return }
    target = token
    self?.showNext(token: token)
}.store(in: &subscriptions)

このワークアラウンドを使って@Pubhlishedなproperty wrapperを公開している実装も見かけました。それに合わせるとこのコードの実装は以下のようになります。

// protocol
protocol PrinterDiscoverable {
    var token: String { get }
    var tokenPublisher: Published<String?>.Publisher { get }
    var tokenPublished: Published<String?>. { get }
    func discover()
}

この実装は以下のリンクなどで触れられています。

まとめ

運良く実務でCombineを使うことができているのでハマりながらこのフレームワークに慣れていければと思います。もっと良い解決方法等何かご意見がある場合はコメントかTwitterの方からご連絡いただければと思います。最後まで読んでいただいてありがとうございました。

参考にした記事