[iOS18 対応] 段階的にSwift6対応する - Swift5.10編
はじめに
どうもtattsunです 🙌
今回は、クラシルリワードにおける Swift6 対応として、どんな変更が必要なのか、どういう意思決定をしたかをご紹介できればと思います!
8月から準備を進め、執筆時点で Swift5.10 まで対応が完了しています 🏃
前提
- iOSプロジェクトの構成はSwift Package Manager + Cocoapodsで管理
- 対応する前は Swift5.9
何をする必要があるか
ひとことにSwift6対応と言っても具体的に何をするかが掴めないと思うのでどうなったら対応に完了したと言えるのかをあらかじめ整理しておきたいと思います。一度に、Swift6に対応するのではなく、Swift5.10に対応してからSwift6へ段階的に対応する必要があると考え、現状の設計や実装から事前に修正が必要な箇所やチームでの合意が必要な項目の洗い出しから始めました。大まかな内容やロードマップは以下の通りです。
この記事は青枠の部分
事前準備
まずは、Swift 6への移行における全体像を把握しておく必要があります。
Upcoming Feature の洗い出し
アップデートするとどのような変更が入るのかを事前に確認しておく必要があります。どのバージョンで何が有効化されるかは Upcoming Feature を確認します。
実際にはこちらのフラグを利用して、Swift5.9 の状態のまま Swift6 以降で有効になる変更を適用することができます。
-
enableUpcomingFeature(::)
- swift-tools-version 5.8から利用可能
- 次のメジャーバージョンで導入が決定されている機能を使用可能
Swift5.10 で利用できる Upcoming Feature 一覧
Swift5.10 の変更を適用したときの影響調査
変更内容がわかったところでクラシルリワードに適用した時にどのような影響が出るか、影響範囲はどのくらいになるかを可視化しました。どれがどこに作用するかで移行の戦略も変わってくるため、既存コードの影響からどのように改修するかをまとめチームで議論し、合意を取った上で進めました。
Concurrency
Curncurrency の利用方法について、チーム内でばらつきが見られる状況でした。そのため、レビューなどの観点から書き方を統一しようという意思決定になり、クラシルリワードにおける 「Swift Concurrency ガイドライン」 を作成することにしました。誰が実装・レビューしても同等の品質を保てるようにすると同時に Concurrency の知識を深めてもらう目的があります。
用語の説明やこういう書き方をしましょうという具体例を提示して、チーム内で認識のずれが起きないようにしています。
(一部抜粋)
## **1.2. データレースの安全性に関する基本概念**
具体的なガイドラインに入る前に、Swift Concurrencyを使ってdata raceのないコードを実現するための基本概念について学んでおきましょう。
従来はLockやQueueを使ってdata raceを防ぐのはプログラマーの責任でしたが、正確性を維持するのが困難でした。Swift 6ではコンパイル時にdata raceを防ぐ仕組みが導入され、並行処理に関わるリスクが大幅に軽減されました。
### 1.2.1. data race(データ競合)
data race(データ競合)とは、複数スレッド間で共有する変数に対して、同時に同じメモリ領域にアクセスが行われる事象を指します。
データ競合(data race)が生じると、メモリの一貫性が保証されず、予測不可能なバグや振る舞いを引き起こす可能性があります。
...省略...
### **2.1.2. 使用上の注意**
非同期関数を実行する際には一時的に処理が中断(Suspend)され、非同期的に結果が得られてから続きが再開(Resume)されます。
awaitが呼ばれる場所はSuspension Pointとなって若干処理が遅延されてしまいます。
そのため、関数の中身が同期処理の場合は、同期関数(`async`を外した状態)で定義するようにしましょう。
// NG ❌
func process() async {
// 同期処理
}
// OK ✅
func process() {
// 同期処理
}
// OK ✅
func process() async {
// 非同期処理
}
Swift5.10 の Upcoming Feature を有効にする
ここまで整理出来たら、実際にコードを変更する段階に移っていきます。
モジュール単位での適用できるようにする
既存コードの整理や移行に必要な実装を加え、Swift5.10 対応の土台を作ります。影響範囲を考慮して、モジュールごとに Upcoming Feature を有効・無効にする仕組みを実装しておきます。
Upcoming Feature
enum UpcomingFeature: String, CaseIterable {
/// Swift5.10
case conciseMagicFile = "ConciseMagicFile"
case forwardTrailingClosures = "ForwardTrailingClosures"
case bareSlashRegexLiterals = "BareSlashRegexLiterals"
case deprecateApplicationMain = "DeprecateApplicationMain"
case importObjcForwardDeclarations = "ImportObjcForwardDeclarations"
case disableOutwardActorInference = "DisableOutwardActorInference"
case isolatedDefaultValues = "IsolatedDefaultValues"
case globalConcurrency = "GlobalConcurrency"
case existentialAny = "ExistentialAny"
/// Swift6
/// バージョンの指定等が必要になるので実際に対応するときにコメントを外す
/// https://developer.apple.com/documentation/packagedescription/swiftsetting/swiftlanguagemode(_:_:)
case inferSendableFromCaptures = "InferSendableFromCaptures"
case implicitOpenExistentials = "ImplicitOpenExistentials"
case regionBasedIsolation = "RegionBasedIsolation"
case dynamicActorIsolation = "DynamicActorIsolation"
case globalActorIsolatedTypesUsability = "GlobalActorIsolatedTypesUsability"
case nonfrozenEnumExhaustivity = "NonfrozenEnumExhaustivity"
case internalImportsByDefault = "InternalImportsByDefault"
var enableUpcomingFeature: SwiftSetting {
.enableUpcomingFeature(rawValue, .when(configuration: .debug))
}
/// 全てのモジュールに適用させたい`UpcomingFeature`を`true`にする
var forceEnabled: Bool {
switch self {
case .conciseMagicFile, .deprecateApplicationMain, .importObjcForwardDeclarations,
.forwardTrailingClosures, .disableOutwardActorInference, .isolatedDefaultValues,
.bareSlashRegexLiterals, .implicitOpenExistentials, .regionBasedIsolation, .dynamicActorIsolation,
.globalActorIsolatedTypesUsability, .nonfrozenEnumExhaustivity:
true
default:
false
}
}
}
SwiftSetting+Extension
extension SwiftSetting {
/// `keys`で指定された`UpcomingFeature`を有効化する
static func enableUpcomingFeatures(_ keys: [UpcomingFeature] = []) -> [SwiftSetting] {
/// 全てのモジュールで適用された`UpcomingFeature`と重複しないようにする
keys.filter { !$0.forceEnabled }
.map(\.enableUpcomingFeature)
}
/// 全てのモジュールで有効化する`UpcomingFeature`を返す
static func enableUpcomingFeatureForAll() -> [SwiftSetting] {
UpcomingFeature.allCases
.filter(\.forceEnabled)
.map(\.enableUpcomingFeature)
}
}
Upcoming Feature の有効化
ここまで来たらあとは Upcoming Feature を有効化して、エラーを潰していくだけになります。順調に進んでいたかと思いきや、問題が発生しました。クラシルリワードでは、画像や文字列を SwiftGen で管理しており、SwiftGen で自動生成されたファイルがコンパイルエラーを起こしていました。
ここでいくつかの対応方針が考えられます 🤔
- SwiftGen のアップデートを待つ
→ これが一番ベスト、SwiftGen自体に不満があるわけではないので利用を続けたい - SwiftGen の代替手段を考える
→ 影響範囲が大きいため、なるべき取りたくない手段 - 自動生成されたコードがコンパイルエラーを回避できるような仕組みを考える
→ 自動生成されたコードに手を加えるのは、管理が大変になるので避けたい
とはいえ、移行期間なので Upcoming Feature を無効にすることでコンパイルエラーは一時的に避けられることと影響範囲を考慮して、今は様子を見るという選択を取りました。Upcoming Feature をモジュール単位で有効・無効を指定できるため、SwiftGen を利用していないモジュールでは有効にし、利用しているモジュールでは無効にするという方針に倒しました。
Upcoming Feature を利用することで柔軟に変更できるのは運用の観点からも非常に便利だなと感じました。SwiftGen 側の対応があればアップデート後に有効化するだけですし、影響範囲の大きい意思決定をいますぐする必要がないのもリスク軽減になるので嬉しいです 👏
まとめ
綿密に移行計画を立てて、実施することで Swift6 対応の全容を理解することが出来ました。実際に運用するプロダクトでは、エンジニアとして最新技術に追従するということは重要な一方でユーザー影響やチームでの認識を揃えるためにどうすべきかを考える必要があります。
今後も Swift6 対応以外でもこういった要件は発生すると思いますし、やることよりもどうやってやるかの部分がしっかり考えられる組織を作ることが重要になってくると思います。属人性が高くならないように今回記事にしたような内容をちゃんと書き記しておくことも大事だなと改めて思いました。
次回は、リワードアプリの設計の整理と MainActor の隔離境界について記事を執筆予定ですのでご期待ください👍
Discussion