Swift 6の `sending` キーワードについて調べた

Swift 6言語モードに移行を進めるにあたって、引数に sending がついているから Sendable にしようみたいな対応がある。 ただ sending のことをちゃんと説明できる自信がなかったので自分で理解できる範囲で調べた。

sending キーワード

  • 提案自体は SE-0430: sending parameter and result values で行われた
    • SE-0414: Region based Isolation がベースにあって、sending はその拡張。SE-0414の提案で region isolation (領域分離)の概念が入った
      • データが属する isolation の追跡ができるようになり
        • Sendableじゃない型でもactor boundaryを越えて送信可能になったという理解
      • Region based Isolation についてはインターネットにいろいろ書いてある
  • 関数の引数/戻り値に sending という明示的なアノテーションを付けることで、その値がactor boundaryを越えて送信可能( disconnected )であることを保証する挙動を定義できる
    • 別のactorに渡せるという言い方もできる?
  • 要はSendableであることを保証した上で、その値を安全を送信する
    • region が完全に分離されている(disconnected)なら、non-Sendableでも大丈夫ということがコンパイラで判断できる

触ってみないとわからない

Swift 6.0.3で確認した。

// swift --version
swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
Target: arm64-apple-macosx15.0

まず、non-Sendableなclassを用意する。

class NonSendable {
     var value: Int
     init(_ value: Int) {
         self.value = value
     }
 }

NonSendableを sending で受け取る関数を作る。

 actor SomeActor {
     func doSomething(_ nonSendable: sending NonSendable) async {
         print("nonSendable value:", nonSendable.value)
     }
 }

簡単な確認だけど、他参照なしであればnon-Sendableなclassでも引数に渡すことができるし、他の参照がある(regionを超えている)とnon-Sendableを渡すことができない。

struct SendingCheck {
     static func main() async {
         let actor = SomeActor()
 
         // その場で新しいインスタンスを渡す(他参照なし)はOK
         await actor.doSomething(NonSendable(1))
 
         let nonSendable = NonSendable(2)
         await actor.doSomething(nonSendable)
 
         // sendingで渡した後に参照していると `Sending 'nonSendable' risks causing data races` エラーになる
         print("nonSendable value:", nonSendable.value)
     }
 }

戻り値につけることもできる。それはそうかという感じがする。内部でプロパティとして持ちつつ sending をつけたら怒られた。その返り値がどこからもアクセスされないことを意図したい時に使えるということだろう。

actor SomeActor {
     var nonSendable: NonSendable?
     
     func getSending(value: Int) -> sending NonSendable {
         nonSendable = NonSendable(value)
         // Sending 'self.nonSendable' risks causing data races
         return nonSendable!
     }
 }

よく出くわすのは CheckedContinuationfunc resume(returning value: sending T) でProposalでも例としてあげられている。あとは AsyncThrowingStreamfunc yield(_ value: sending Element) 。だいたいはSendableにして渡すようにすれば警告(Swift 5言語モードなら)やエラーは解消するが、なぜそうなるかをわかっていると少しわかった気になれる。

まとめ

ぐっと考えると理解できるような気がするが、完全に理解した上でここでは sending つけたいな〜と思いながらコードを書くのは難しい気がする。そのうち領域展開できそう。

参考

ScrollView内のButtonのハイライト挙動の変化

SwiftUI.ScrollView 内にある SwiftUI.Button のインタラクションの挙動がXcode/OSバージョンによって変わっています。調べた限りXcode 16以降からのiOS 18*1で挙動の違いがありました。

何が起きるか

Xcode 16以降のiOS 18では、SwiftUI.ScrollView 内に Button を配置している場合、タップした瞬間にタッチイベントは発火してもハイライトされません。通常は軽くタップするとボタンが一瞬透明になりますよね?あの状態です。

import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
        }
        .padding()
    }
}

ButtonStyle を明示的に指定して値の変化を監視すると何が起きているかわかりやすいです。厳密には ButtonStyleConfiguration.isPressed が軽くタップするだけでは反映されない状態なので、ボタンの opacity などが変化しません。タップする時間が0.5秒くらいあれば isPressed が反映されます。自前の ButtonStyle を作ってスケールエフェクトなどをつけている場合、この挙動はユーザー体験を悪くしてしまう可能性があります。

import SwiftUI

struct TestButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) {
                print("configuration.isPressed: \(configuration.isPressed)") // 軽くタップするだけでは変化しない
            }
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
            .buttonStyle(TestButtonStyle())
        }
        .padding()
    }
}

iOS 18での対応策

UIScrollView には delaysContentTouches というプロパティがあります。

developer.apple.com

この delaysContentTouches を無効にしてあげると従来の挙動に戻すことができることがわかっています。

SwiftUI.ScrollView の場合、この挙動を変更する術がないため、 swiftui-introspectを使って制御する必要があります。

import SwiftUI
import SwiftUIIntrospect

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
        }
        .introspect(.scrollView, on: .iOS(.v18)) { scrollView in
            scrollView.delaysContentTouches = false
        }
        .padding()
    }
}

各地の ScrollView につけてまわる必要があるので素敵な解決策ではないですね...。他にもいい方法があれば教えてください。

Xcode/OSバージョンごとやUIButtonとの違い

XcodeやOSバージョンごとに違うのはもちろんそうですが、 UIButton かどうかでも違いがあります。

UIButton SwiftUI.Button ScrollView内のUIButton ScrollView内のSwiftUI.Button
Xcode 15/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 15/iOS 18 ⚪︎ ⚪︎ × ⚪︎
Xcode 16/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 16/iOS 18 ⚪︎ ⚪︎ × ×
Xcode 16/iOS 26 ⚪︎ ⚪︎ × ⚪︎
Xcode 26/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 26/iOS 18 ⚪︎ ⚪︎ × ×
Xcode 26/iOS 26 ⚪︎ ⚪︎ × ⚪︎

UIButtonの場合は一貫してハイライトが効かないので、Xcode 16/iOS 18で挙動を合わせたのかな〜と思ったのですが、iOS 26で元の挙動に戻っているため不具合だったのかもしれません。ちなみにこの挙動をFeedback(FB16848138)済みでした。

検証コード

適当にコピペして試してみてください。

import SwiftUI

struct TestButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .frame(maxWidth: .infinity, minHeight: 44)
            .background(configuration.isPressed ? .blue.opacity(0.5) : .blue)
            .cornerRadius(10)
            .onChange(of: configuration.isPressed) {
                print("configuration.isPressed: \(configuration.isPressed)")
            }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            switftUIButton("SwiftUI/Outside")
            uikitButton("UIKit/Outside")

            ScrollView {
                switftUIButton("SwiftUI/Inside")
                uikitButton("UIKit/Inside")
            }
        }
        .padding()
    }

    private func switftUIButton(_ title: String) -> some View {
        Button(title) {
            print("action")
        }
        .buttonStyle(TestButtonStyle())
    }

    private func uikitButton(_ title: String) -> some View {
        UIKitButton(title: title) {
            print("action")
        }
        .frame(height: 44)
    }
}

struct UIKitButton: UIViewRepresentable {
    let title: String
    let action: (() -> Void)?

    init(title: String, action: (() -> Void)? = nil) {
        self.title = title
        self.action = action
    }

    func makeUIView(context: Context) -> some UIButton {
        _UIKitButton(title: title, action: action)
    }

    func updateUIView(_ uiView: UIViewType, context: Context) { }
}


final class _UIKitButton: UIButton {
    init(title: String, action: (() -> Void)?) {
        super.init(frame: .null)
        translatesAutoresizingMaskIntoConstraints = false

        setTitle(title, for: .normal)
        setTitleColor(.white, for: .normal)

        backgroundColor = .systemBlue
        layer.cornerRadius = 10

        addAction(.init { _ in action?() }, for: .touchUpInside)
    }

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

    override var isHighlighted: Bool {
        didSet {
            print("isHighlighted: \(isHighlighted)")
            backgroundColor = isHighlighted ? .systemBlue.withAlphaComponent(0.5) : .systemBlue
        }
    }
}

*1:正確には18.1 ~ 18.5で発生します。18.0は体感ほとんど発生しない

iOS16 から使える SwiftUI の機能をおさらいする

はてなのマンガアプリチームで GigaViewer for Apps を作っています、 id:fxwx23 です。

タイトルを見て今更?と思ったかもしれませんが、iOSのアップデートで追加される機能の多くはバックポートされていないため、アプリ側のサポートする下限のOSバージョンが追いついて初めて利用可能になります。その結果、WWDCで発表された機能が実際の日々のプロダクト開発に取り入れられるまでには、2〜3年かかることがあります。

2022年に発表された iOS16 で SwiftUI に追加された機能は、ユーザー体験の向上やコードをシンプルにするために役立つものが多くあります。このタイミングであらためて見直すことで鮮度を保ったまま実践投入することができるのではと思い、今回はその中から個人的におさらいしておきたい機能をピックアップして紹介します。

lineLimit(_:)

View の中で Text が占有できる最大行数を設定するモディファイアです。iOS 15までは Int? を受け取る形のみでしたが、細かい進化を遂げています。

範囲型のサポート

iOS16 で TextField に複数行入力サポートが入ったため、範囲型を受け付けるようになっています。指定した範囲よりもスペースが必要になるとスクローラブルになってくれます。チャットUIの入力部分などがイメージしやすいと思います。

Form {
    TextField("Bio", text: $bio, axis: .vertical)
        .lineLimit(3...)
}

テキストフィールドの高さも自動で追従してくれます

余白空間を保持

指定した行数に満たなくても想定されるスペース分を確保してくれるようになっています。Grid レイアウトで各要素に表示する説明文は最大行数は指定したいけど、要素自体の高さは均一にしたい時などに使えますね。地味に嬉しい...

developer.apple.com

Text("description here")
        .font(.subheadline)
        .lineLimit(2, reservesSpace: true)

contentTransition(_:)

View のコンテンツの変更をアニメーション化させることができるモディファイアです。また一部をアニメーションさせないということも可能です。

developer.apple.com

例えば、数値のカウントアップやカウントダウンの演出のためににいい感じのアニメーションをつけることができます。コンテンツのカウント数が上限するタイミングでアニメーションがあるとおしゃれですね。

VStack {
    Text("\(count)")
        .contentTransition(.numericText())
    Button("Count Up") {
        withAnimation {
            count += 1
        }
    }
}

numericText(coundDown: false)

defersSystemGestures()

OS独自の組み込みジェスチャー(コントロールセンターや通知センター)よりもジェスチャーが優先されるように要求できるモディファイアです。これはユーザーが頻繁にスワイプする可能性のあるゲームや、画面の端に独自のジェスチャーを置く場合などに良さそうです。例えば Edge.Set.vertical で有効にすると Home Indicator がグレーアウトします。

developer.apple.com

ちなみに完全に非表示にしたりできる persistentSystemOverlays(_:)) も iOS16+ です。UIKit でできたことも SwiftUI で完結するようになってきています。

ViewThatFits

ViewThatsFits は複数の View の中から親 View のレイアウト条件に最も適した View を選択して表示してくれます。レスポンシブなデザインや、画面サイズやレイアウトの制約に応じた View の選択を簡素化するのに役立ちますね。

developer.apple.com

例えば、 View 内に収まるテキスト量なら Text で、収まらない場合はスクローラブルに表示したいというシーンを考えます。ViewThatsFits を使うと振り分けたい要素を優先順位順に書いておけばOKです。楽ちん!

struct ContentView: View {
    let text = String(repeating: "Long text ...", count: 100)

    var body: some View {
        ViewThatFits(in: .vertical) {
            // 収まる場合はText
            Text(text)
            // 収まらない場合はScrollView
            ScrollView {
                Text(text)
            }
        }
        .padding()
    }
}

デフォルトでは ViewThatFits にテキストの水平軸と垂直軸の両方を気にするようになっているため、常に ScrollView が選択されてしまいます。 この例の場合は垂直軸のみを測定するように制限する必要があるので注意が必要です。

収まる場合は Text

収まらない場合は ScrollView

AnyLayout

最後は AnyLayout です。AnyLayout は異なるレイアウト( HStackVStack など)を動的に切り替えるための型消去ラッパーです。SwiftUI では通常レイアウトを静的に決定しますが、AnyLayout を使用すると条件に応じてレイアウトを動的に変更することができます。

developer.apple.com

horizontalSizeClass や、ダイナミックフォントに応じてレイアウトを切り替えたい時に便利です。AnyLayout なら subview の状態を破壊せずにレイアウトコンテナの型を動的に変更できるのでいいですね。 if 分岐がなくなるのも良い。

struct ContentView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        let layout = horizontalSizeClass == .compact
        ? AnyLayout(VStackLayout())
        : AnyLayout(HStackLayout())

        VStack {
            layout {
                Rectangle()
                    .fill(.red)
                    .frame(maxWidth: .infinity)
                    .frame(height: 200)

                VStack(spacing: 0) {
                    Rectangle()
                        .fill(.blue)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)

                    Rectangle()
                        .fill(.green)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)
                        .background(.blue)
                }
            }

            Spacer()
        }
        .padding()
    }
}

horizontalSizeClass == .compact

horizontalSizeClass != .compact

Layout プロトコルに準拠したものなら利用できるので、自前のカスタムレイアウトと組み合わせることもできます。早く使いたいですね!


ここまでいくつかの機能を紹介しましたが、もちろんこれがすべてではありません!まだまだ活用しきれていない面白い機能がたくさんあるのでぜひ調べて活用してみてください。iOS 16 以降の機能を再発見する良いきっかけになれば幸いです。

この記事は はてなエンジニア Advent Calendar 2024 の23日目の記事でした。明日は id:masawada さんです!

SwiftPM 6 は同時ビルド/テストを回避するために Package の .build ディレクトリをロックする

Swift version

swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx14.0

挙動について

SwiftPM 6 (release/6.0 以降)は、同時ビルド/テストを回避するために Package の .build ディレクトリをロックをするようです。残念ながら、このロックする挙動は一部のケースでプラグイン実行やテストの動作を止めてしまう可能性があります。 その影響を受けた場合以下のような warning が発生します。

Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', waiting until that process has finished execution...

プロセスが終了するまでロックされ続けるので、CIなどの実行で処理が完了せずにタイムアウトするようになってしまうなどの影響があります。

どのようなパッケージや実装が対象になるかというと、実行プロセスの中でさらに dump-packagebuild コマンドを実行しているケースです。厳密には try swiftCommandState.getActiveWorkspace() の箇所で起こりうるので、他のコマンドを同プロセスの中で実行していれば起きると思います。

Swift の VSCode Extension ではパッケージの依存解決をよしなにやってくれますが、従来の挙動だとユーザーがビルドしている最中にも依存解決が実行されたりしてリポジトリを破損されることが起きていたので同時にビルドが起きないようにこの対応が入ったようです。(という理解)

対応

escape-hatch として従来の挙動に戻せるコマンドオプション --ignore-lock が同時に追加されています。プロセスの中で実行している SPM コマンドに Swift 6 以上での実行かをチェックした上で --ignore-lock オプションを追加することで対処が可能です。

// swift --version などで Swift 6 以上での実行かを判断する(Regex)
if swift6Plus {
  let output = try sh("swift package dump-package --ignore-lock")
  ...
} else {
  ...
}

問題なくオプションを渡せていれば Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', but this will be ignored since --ignore-lock has been passed というwarningと共に処理がロックされずに後続に進むようになります。

参考

iOSDC Japan 2024 に行ってきた

8/22、23 に iOSDC Japan 2024 に参加してきました。24日のDay2は残念ながら都合が悪く参加できませんでしたが、実際に聞いたり&話したりできた範囲で感想を書きたいと思います。ちなみに iOSDC は初参加でした。


Inner Source Practice

開発を加速する共有Swift Package実践 by el_metal | トーク | iOSDC Japan 2024 #iosdc - fortee.jp を見て、お恥ずかしながら Inner Source Practice という言葉を知りました。

Inner Source Practice はOSS開発におけるベストプラクティスを活用して、非OSSな開発にもオープンソースの手法を取り入れ品質を高めようねという理解をしました。サイボウズは異なる時期に開発されたアプリ(kintone、サイボウズ Office、Garoon?)で、認証機能などの共通部分がそれぞれ再実装されていたことが問題としてあったそう。プロジェクトごとに実装が分かれているため新しいメンバーへのオンボーディングにコストがかかってしまう課題もあると言っていて、これは複数プロダクトを抱えるとどうしてもある話だなと思いました。これらの課題を共通の Swift Pacakage を実装することで解決していったとのことでした。利用側のアプリ分バグもスケールしてしまうけど、SSoT になったおかげでバグ修正のスピードも早まったとおっしゃっていていい話でした。開発体制は OSS のように Owner 、Contributer などを定めて進めていて、ちゃんとワークさせているのがすごいなと。これはチーム内の理解もかなり必要そう。利用側のパッケージ更新はひとまず main 指定でやっていると言っていて、この辺りは今後の課題なのかな〜?という感想です。自分が所属するチームでも共通 Package を取り組む将来はあり得なくはないので、興味深く聞いていました。

Swift Concurrency

みんな大好き(?) Swift Concurrency。 Swift 6 もきて盛り上がっていますが、まだまだ理解が十分でない部分が多いので、それらを補完するようにトークを聞いてきました。

まず、大前提として Concurrency が防いでくれるのは Data race(競合)で、Race condition(競合状態)じゃないよというのは確かにでごっちゃになるので要注意ですね。個人的に印象的だったのは隔離境界(Isolation Boundary)を越えなければ難しくないという部分でした。所属チームのプロジェクト でも ViewModel は MainActor にしていこうねというところで、MainActor でいいところは怖がらずに MainActor にしていけばいいという話を聞けたのは良かったです。 境界を越えようとすると途端に難しくなるので、そういうシチュエーションになったらこの時のトークを振り返ったりしたいと思います。

Mergeable Library

WWDC23 で発表された Static/Dynamic に続く新たなライブラリの形式「Mergeable Library」のお話。

開発時に嬉しい Dynamic Framework と、 リリース時に嬉しい Static Framework のいいとこ取りしてくれるので最高ではという印象でしたが、ベンチマークはあんまりいい結果になっていない?ようでした(色々考察がある)。リソースバンドルもいい感じに扱ってくれるようなのでうまくいけば絶対嬉しいはずです。ワードは知っていたけど深く終えていなかった部分なので、とても面白かったです。 活用事例は大募集中のようなので、実際のプロダクトでやってみないとわからない部分も多いだろうし機会を作って試してみたいですね。

参加してみて

自分は残念ながらプロポーザルが採択されなかったので登壇することはできませんでしたが、プロポーザルに出した内容は、アフターイベントである iOSDC 2024 後夜祭 - connpass でお話しできる機会をいただいたのでLTで発表してきました。

speakerdeck.com

来年こそはスピーカーとして参加したいですね。いいネタができるようまた一年頑張っていきたいと思います。

FCM Batch send API の v1 API への移行方法

以前書いたブログで、バッチ送信と呼ばれるFCMへの単一のHTTPリクエストに複数の送信リクエストを含めることができるAPIが廃止される件について書きました。 fxwx23.hatenablog.com

この件がなんとか決着をつけられそうなので、どう対応していくかについてまとめます。

Legacy API を引き続き使えるように要請する(Optional)

前提として該当の API は6月20日以降に動作しなくなると言われていました。終了日2日前に "[Reminder] Update your apps to the latest Firebase Cloud Messaging APIs and SDKs" というメールが届いていて、そこにはサービスに影響させたくない場合は延長リクエストを送る事ができますよと書いてあったので、さっそく Firebase Support に連絡します。

サポートの返事には、沢山リクエストがあったのでひとまず7月20日まで伸ばしましたという報告と、さらに延長したい場合はGoogleフォームに必要項目を記載して提出してねと言われました。フォームには最長9月末まで延長できそうな感じだったので9月末を選択、延長理由は公式SDKがHTTP2にまだ対応してないからと記載しました。提出から数日後に延長許可のメールが届いたので無事に受理されたようでした。

ひとまず引き伸ばしたいという方はまず Firebase の Support に連絡してみると良さそうです。

公式SDKを HTTP/2 対応ver に更新する

前回のブログでも対応方針として HTTP/2 対応するしかないと紹介しましたが、公式の Firebase Admin SDK 側でも対応が進みつつあります。 7月30日時点ではまだ NodeJS SDK しか対応されていません。 Go や Java のほうでは issue はありますが、PR などはまだ出てきていない状況です。

実際に対応された NodeJS SDKv12.3.0 に移行してみます。

- "firebase-admin": "12.2.0"
+ "firebase-admin": "12.3.0" 

やることとしては、 sendAllsendMulticastsendEachsendEachForMulticast に入れ替えるだけです。 12.3.0 では基本的に HTTP/2 のクライアントを利用してくれるようなので特別な設定は不要のようです。

実装は feat(fcm): Add HTTP2 support for `sendEach()` and `sendEachForMulticast()` by jonathanedey · Pull Request #2550 · firebase/firebase-admin-node · GitHub で確認することができます。いい感じにリトライ処理もしてくれるし、Token の最大数も 500 のままで大丈夫そうなので安心です。

ただ基本的にすべての送信で HTTP/2 が採用されてしまうことには注意が必要です。12.3.0 に更新して一部条件では延長期限まで sendMulticast を使い、その他では sendEachForMulticast に移行するというケースを例にします。

const messaging = getMessaging(app);

let response: BatchResponse;
const useHttpV1API = true; // 任意の条件
if (useHttpV1API) {
  response = await messaging.sendEachForMulticast(message);
} else {
  response = await messaging.sendMulticast(message);
}

Node.js の Admin SDKsendEachsendEachForMulticast では基本的に HTTP/2 のクライアントが利用されます。場合によって引き続き HTTP/1.1 のクライアントを使いたいケースがあるかもしれません。 その場合は送信処理を実行する前に enableLegacyTransport() メソッドで切り替えることができます。

気になるのはパフォーマンスですが、Legacy API と比較する必要があるのでそれはまた別途やってみて共有できそうならまたブログを書きたいと思います。

Plugin から別ディレクトリにあるネイティブコードを参照する時は Symbolic Link を使う

Swift Package Manager の Plugin から Sources や 親階層の別ディレクトリにあるネイティブコードを参照したい時は、シンボリックリンクを使えばいいようです。 swift-docc-plugin が参考になりました。

github.com

この方法は Plugin 間でコードの共有ができるまでの間の一時的なワークアラウンドとされています。

This is a workaround until SwiftPM has native support for sharing code between plugins.

Sources/YourTool があったとして、 Plugins/YourTool から参照としたいとします。 この時 Plugins/YourTool/Symbolic-Links を作ってそこにシンボリックリンクを作成してあげるだけです。

$ cd Plugins/YourTool/Symbolic-Links
$ ln -s ../../../Sources/YourTool YourTool

とても単純な話ですが、 Command plugin を自作する際にインプットした情報の備忘録として残しておこうと思います。