STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES レジがSwift6対応を完了するまで

STORES レジがSwift6対応を完了するまで

Hello! STORESでレジアプリを開発している@AkkeyLabです!

この記事は「STORES レジにおけるSwift6移行対応」の完結編です。前提となる方針などはこちらの記事をご覧ください。今回は、対応箇所が特に多かったモジュールにフォーカスし、チームで分担して対応する過程をご紹介します。Swift6対応がこれからの方はもちろん、すでに対応済みの方にとっても、中規模から大規模の技術刷新を行う際の参考になるはずです。

product.st.inc

チームでタスクを分担

  1. 並行で進む施策とのコンフリクトの懸念
  2. 実装キャッチアップの題材として丁度良い
  3. QAも段階的に実施したかった

レジチームでは、上記のような背景から、Swift6対応をチームメンバーで分担して進めることにしました。チームメンバーのうち、私を含めた2人が直近Joinしたばかりだったことと、QAの複雑さが2番目・3番目の背景に関係しています。例えば、「第10世代のiPad(iPadOS 18.5)でStar Micronics製のレシートプリンターにBluetooth接続した場合のみ、レシートが正常に印刷されない*1」といった、特定の組み合わせに依存する不具合が発生する可能性があります。そのため、QA項目の最適化を行ったとしても、非常に多くのテストパターンを試す必要があります。また、レジの不具合はオーナーさんの売り上げや経営に大きく傷をつけてしまう可能性が高いため、決して手を抜くことはできません。

ワーニングの解析

レジアプリのモジュールはSwiftPMで管理しているため、enableUpcomingFeature(::)を使って段階的にSwift6の機能を有効化していく戦略を取りました。しかし、段階的といっても600を超えるワーニング(Swift6対応が必要な箇所)が出てしまっては、タスク分割はおろか、工夫なしでは中程度の精度で工数を見積もることすら困難です。そこで、どんなワーニングが出ていて、実装のどのあたりに分布しているかを調査してみることにしました。

まずは、Xcode上で表示されているワーニングを文字情報として取得することを考えます。方法はいくつかありますが、今回はWWDC19の「Testing in Xcode」でも紹介されていたResult Bundleを使ってみました。これは、ビルド後に xcresult という拡張子で保存されるもので、ビルドログやテストに関連する様々な情報が含まれたデータ群です。ちなみに、xcresulttool を使ってJSON形式に変換することもできるため、比較的容易にワーニング情報のみを抽出することができます。

Result BundleをJSONに変換するコマンド例を次に示します。ちなみに、JSONに変換するxcresulttool コマンドは初期状態でパスが通っていないので、 xcrun --find xcresulttool で調べた保存場所をもとにパスを通しています。

xcresult ファイルの保存場所は適宜書き換えてください

export PATH="/Applications/Xcode.app/Contents/Developer/usr/bin:$PATH"

xcresulttool get object \
    --legacy \
    --path ~/Library/Developer/Xcode/DerivedData/{project_name}-{random_id}/Logs/Launch/Run-{project_name}-{env}-{date}.xcresult \
    --format json \
    > build_result.json

次に、 jq コマンドを使って必要な情報のみに絞り込みます。今回は、ワーニングの内容と対照箇所が分かれば良いので、次に示すようなコマンドになります。このとき、出力形式をCSV形式にすることで、表計算ツールでソートやグルーピングを簡単に行うことができます。

jq -r '
  .actions._values[] 
  | select(.buildResult.issues.warningSummaries._values != null) 
  | .buildResult.issues.warningSummaries._values[] 
  | select(.issueType._value == "Swift Compiler Warning") 
  | "\(.documentLocationInCreatingWorkspace.url._value),\(.message._value)"
  ' build_result.json \
    > warnings.csv

出力をもとに分析をした結果、次に示す3点が明確になりました。

  1. 複数箇所に影響する共通処理が書かれている場所
  2. 設計が他と異なっている場所
  3. ざっくり、強く依存していそうな処理群

まず、1に該当する箇所は最優先タスクとして、実装を開始する手前の計画段階までに対応を完了させておくことが理想です。こうすることで、「ワーニングの数に比べて一瞬で終わってしまった」「ワーニングを解決したら別のワーニングが出るようになった」といった見積もり精度を下げる要因を効率よく減らすことができます。

次に、2つ目の一例としては、カメラ処理がありました。レジアプリはSwiftUIをベースに構築されていますが、カメラ周りの実装はSwiftUIだけで完結させることが難しく、SwiftUIのみで構築された画面に比べると、設計方針にも多くの違いがありました。そのため、解決の難易度も他と異なることを事前に把握しておく必要がありました。

複数のワーニングメッセージが表示されているスプレッドシート上で、フィルター設定を有効にしている
ワーニングを抽出している様子

テストコードのSwift6対応

Swift6対応のノウハウは数多く存在しますし、「STORES レジにおけるSwift6移行対応」でも紹介しています。なので、ここではサードパーティ製ライブラリが絡んだ場合の一例としてQuickを使って書かれたテストコードのSwift6対応方法をご紹介します。ちなみに、ここで紹介する問題は、この記事の執筆段階で未解決のissueとして定義されているものです。

次のようなテストコードを書くと、変数の読み書きの箇所でデータ競合の可能性があるとコンパイラに警告されてしまいます。なぜこのようなことが起きるのでしょうか。まず、QuickSpec の定義を確認すると、spec 関数は nonisolated として宣言されています。つまり、spec 内で宣言された変数は特定のアクターに所属していないことになります。一方で、beforeEachit に渡されるクロージャは、Quickの仕様により @MainActor コンテキストで実行されます*2

つまり、非アクター(nonisolated)な変数に、@MainActor からアクセスするという構図になってしまい、Swiftはこれを異なる実行コンテキスト間のデータアクセスとして検出し、データ競合のリスクがあると判断しています。さて、みなさんはどのようにこの問題を解決しますか?

final class SimpleSpec: QuickSpec {
    override class func spec() { // nonisolated
        var number: Int!

        beforeEach { // @MainActor () throws -> Void
            number = 39 // Sending 'number' risks causing data races
        }

        describe("a number") {
            it("should be 39") { // @MainActor () throws -> Void
                expect(number).to(equal(39)) // Sending 'number' risks causing data races
            }
        }
    }
}

まず、SimpleSpec クラスは QuickSpec を継承しているため、クラスそのものを actor にすることはできません。Swiftでは actor は他のクラスを継承できないという制限があるためです。さらに、spec 関数についても注意が必要です。先述の通り spec 関数は nonisolated として宣言されているため、オーバーライド側で @MainActor を付けてアクターアイソレーションを変えることはできません。

actor SimpleSpec: QuickSpec { // Actor types do not support inheritance
    @MainActor // Main actor-isolated class method 'spec()' has different actor isolation from nonisolated overridden declaration
    override class func spec() {
        ...
    }
}

では、Swift Concurrency を使わずにロックを使うアプローチはどうでしょうか。たとえば、Appleが提供している OSAllocatedUnfairLock を用いれば、次に示すように排他制御は実現できます。

import os

final class SimpleSpec: QuickSpec {
    override class func spec() {
        let number = OSAllocatedUnfairLock(uncheckedState: 0)

        beforeEach {
            number.withLock { $0 = 39 }
        }

        describe("a number") {
            it("should be 39") {
                expect(number.withLock(\.self)).to(equal(39))
            }
        }
    }
}

この方法は技術的には問題なく動作しますが、「テストのためのコード」としてはやや大げさな印象も否めません。明示的なロックを導入することでコードの複雑さが増し、テストの意図が読み取りにくくなってしまう可能性もあります。

このように、actor@MainActor も使えず、ロックも避けたい場合、もう少し柔らかい方法として「テストデータ管理専用の actor を使う」というアプローチが有力な選択肢となります。次に示すように、対応方法自体はシンプルで、テストに用いるデータ管理を actor 内で行うというものです。ボイラープレートの増加が気になりますが、Quick側で対応が進んでいない状況を加味すると、妥協できるラインだと判断しました。

final class SimpleSpec: QuickSpec {
    override class func spec() {
        actor MutableLocalValues {
            @MainActor var number: Int!
        }
        let values = MutableLocalValues()

        beforeEach { // @MainActor () throws -> Void
            values.number = 39
        }

        describe("a number") {
            it("should be 39") { // @MainActor () throws -> Void
                expect(values.number).to(equal(39))
            }
        }
    }
}

なお、今回紹介した方法がどのプロダクトにおいても最適解になるとは限らないことに注意してください。

最後の仕上げ

Swift6に関連するワーニングがすべて解消されたら、Upcoming Featureの指定を削除し、 swiftLanguageModes[.v6] を指定します。たったこれだけ!のはずだったんです…が、なんとこの変更だけでコンパイラがクラッシュするようになってしまいました。

調査の結果、 SwiftUI.Binding のイニシャライザの第二引数 set@MainActor を指定した関数をセットすることによって発生していることがわかりました。ちなみに、 nonisolated に変更することでクラッシュは発生しなくなります。この現象を再現できるコードは以下の通りで、Swift version 6.1.2 (swiftlang-6.1.2.1.2 clang-1700.0.13.5)でも再現することを確認しています。

@MainActor
struct MainStruct {
    func main() {
        let bindingWithMainActorFunc = Binding(get: { .zero }, set: mainActorFunc) // Crashes
        let function: @isolated(any) @Sendable (Int) -> Void = mainActorFunc
        let bindingWithAnyIsolatedFunc = Binding(get: { .zero }, set: function) // No crash
        let bindingWithNonIsolatedFunc = Binding(get: { .zero }, set: nonIsolatedFunc) // No crash
    }

    @MainActor func mainActorFunc(_ getter: Int) {}
    nonisolated func nonIsolatedFunc(_ getter: Int) {}
}

原因不明の現象ではありますが、解決方法は明確になったため、Swift6対応の最終仕上げは遂行できそうです。ただ、少し気になったので、この問題がSwiftによるものなのか調査をしてみることにしました。 まず、SwiftUI.Binding にはProperty Wrappersという言語機能が使われている点に着目します。そして、ここを疑って SwiftUI.Binding を模した MyBinding を次のように定義してみました。

@MainActor
struct MainStruct {
    func main() {
        let myBindingWithMainActorFunc = MyBinding(get: { .zero }, set: mainActorFunc) // No crash
        let myBindingWithNonIsolatedFunc = MyBinding(get: { .zero }, set: nonIsolatedFunc) // No crash
    }

    @MainActor func mainActorFunc(_ getter: Int) {}
    nonisolated func nonIsolatedFunc(_ getter: Int) {}
}

@propertyWrapper struct MyBinding {
    var wrappedValue: Int {
        get { .zero }
        set {}
    }

    @preconcurrency init(
        get: @escaping @isolated(any) @Sendable () -> Int,
        set: @escaping @isolated(any) @Sendable (Int) -> Void
    ) {}
}

しかし、コンパイラのクラッシュは発生しません。

よって、今回遭遇したクラッシュの原因は SwiftUI.Binding 内部にある可能性が高く、内部実装が公開されていない以上、Appleに不具合を報告する以外の選択肢はなさそうです(報告済み)。少し話の軸からそれましたが、コンパイラを変更するということは、見た目以上に大きな変更が加えられていることを実感できる事例だったため、紹介させていただきました。なので、「Upcoming Featureの指定を消してSwiftバージョンを6にするだけ!楽勝!」と気を抜かず、見積もりと実装に取り組むことが重要です。

さいごに

このように、レジアプリは無事にSwift6対応を完了させることができました。結果として、「稀に発生する不具合」がいくつか解消されていることが実証されるなど、早くもSwift6が持つ言語機能の恩恵を感じ始めています。

この記事から少しでも気づきや学びをあなたに提供できたなら幸いです。
最後まで読んでいただき、ありがとうございます。

*1:本記事内の機種やメーカーの組み合わせはあくまで例示であり、実際にこのような不具合が発生しているわけではありません

*2:本記事内で取り扱うQuickのバージョンは7.6.2です