Hello! STORESでレジアプリを開発している@AkkeyLabです!
この記事は「STORES レジにおけるSwift6移行対応」の完結編です。前提となる方針などはこちらの記事をご覧ください。今回は、対応箇所が特に多かったモジュールにフォーカスし、チームで分担して対応する過程をご紹介します。Swift6対応がこれからの方はもちろん、すでに対応済みの方にとっても、中規模から大規模の技術刷新を行う際の参考になるはずです。
チームでタスクを分担
- 並行で進む施策とのコンフリクトの懸念
- 実装キャッチアップの題材として丁度良い
- 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つ目の一例としては、カメラ処理がありました。レジアプリはSwiftUIをベースに構築されていますが、カメラ周りの実装はSwiftUIだけで完結させることが難しく、SwiftUIのみで構築された画面に比べると、設計方針にも多くの違いがありました。そのため、解決の難易度も他と異なることを事前に把握しておく必要がありました。
テストコードのSwift6対応
Swift6対応のノウハウは数多く存在しますし、「STORES レジにおけるSwift6移行対応」でも紹介しています。なので、ここではサードパーティ製ライブラリが絡んだ場合の一例としてQuickを使って書かれたテストコードのSwift6対応方法をご紹介します。ちなみに、ここで紹介する問題は、この記事の執筆段階で未解決のissueとして定義されているものです。
次のようなテストコードを書くと、変数の読み書きの箇所でデータ競合の可能性があるとコンパイラに警告されてしまいます。なぜこのようなことが起きるのでしょうか。まず、QuickSpec
の定義を確認すると、spec
関数は nonisolated
として宣言されています。つまり、spec
内で宣言された変数は特定のアクターに所属していないことになります。一方で、beforeEach
や it
に渡されるクロージャは、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が持つ言語機能の恩恵を感じ始めています。
この記事から少しでも気づきや学びをあなたに提供できたなら幸いです。
最後まで読んでいただき、ありがとうございます。