Merpay & Mercoin Tech Fest 2023 は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知ることができるお祭りで、2023年8月22日(火)からの3日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。
この記事は、「Merpay iOSにおけるSwift Concurrency対応の挫折と今後」の書き起こしです。
@takeshi:「Merpay iOSにおけるSwift Concurrency対応の挫折と今後」という話をTakeshi Satoがさせていただきます。
自己紹介です。Takeshi Satoと申します。2019年にメルペイに入社して、支払いタブ画面、E2Eテストの整備、eKYC(本人確認)画面の開発を担当しました。今ではTnS(Trust and Safety)という不正対策チームでメルカリアプリの安全を守っております。「一冊でマスター!Swift Concurrency入門」という本を出しております。
今日私がお話するのは、失敗プロジェクトの共有です。私がリーダーをし、Merpay iOSにSwift Concurrencyを導入しようとしたものの、中断したお話をします。
今振り返って気づいたダメだった点をお伝えし、同じようにConcurrency対応やその他プロジェクトのコードを大きく変更する方の参考になればと思います。
Swift Concurrency対応プロジェクトは、2022年9月頃から進めていました。そのプロジェクトの概要をお伝えします。
次に、ロードマップの方向転換。最初は少しずつ対応してリリースしようと思ったのですが、Swift Concurrencyは一気に全て変更しないといけないことが判明し、そのように方向転換をしました。
次に、並行してメルペイのコードをGitリポジトリに統合するプロジェクトが進んでおりまして、その影響をお話します。最後にプロジェクトを中断した理由と、そこから得られた学びを発表します。
まず、そもそもSwift Concurrencyとはどういうものかを説明します。
Swift Concurrencyは、Swift5.5から登場した、言語レベルの並行処理の機能です。並行処理を簡潔に記述でき、Data raceを防ぐことができます。Data raceは、複数スレッドで同じデータを読み書きしたときに、データが不整合になってしまう状態です。それをコンパイル時にチェックする機能です。
キーワードとしては、async/await/Task/actor/@MainActor/Sendableなどがあります。
現在のSwiftは、リリースがSwift 5.8、ベータ版のXcode15ではSwift5.9が載っています。
Swift 6になると、Concurrencyのチェックが厳密になって、適応してないコードはコンパイルエラーになってしまいます。そのためSwiftコアチームは、今のSwift 5から段階的な適用のため、Swift 5系でも使えるコンパイルオプションを提供しています。
Swift 6になると、Concurrencyに対応してないソースはビルドができなくなるのは困りものです。そのため、早めに準備をしなければなりません。
参照:https://forums.swift.org/t/concurrency-in-swift-5-and-6/49337
具体的なコンパイルオプションはこちらです。
Swift 5.6まではOTHER_SWIFT_FLAGSに-warn-concurrencyと-enable-actor-data-race-checksを指定できました。-warn-concurrencyはSwift 6ではエラーになるコードを、Swift 5系でワーニング・エラーで教えてくれるオプションです。これを使って、Swift 6の準備ができます。-enable-actor-data-race-checksは実行時のデータ競合を診断するオプションです。
-warn-concurrencyは強いオプションなので、Swift 5.7から段階的に指定できるように、新しくSWIFT_STRICT_CONCURRENCYという専用のフラグができました。minimal、targeted、completeの3つが指定できます。
minimalが一番弱いオプションで、Sendableとactor分離を明示的に書いているところで、Concurrencyのチェックをします。Xcode14からはデフォルトのオプションになっています。
targetedはもう少し制約が強くなります。actor分離と、Sendableを明示的に書いているところで、Concurrencyのチェックをします。minimmalとの違いは、actor分離を正しく書くのを強制されているところです。ただしSendableのコードを書いてなかったらチェックはしません。
最後にcompleteが一番強いオプションです。モジュール全体でactor分離とSendableのConcurrencyのチェックをします。適切に書いていなければ、エラーかワーキングが出てしまいます。
completeと-warn-concurrencyは同じ意味です。Swift 5.7でも引き続き-enable-actor-data-race-checksを使えるので、指定します。
それでは、メルペイにおけるSwift Concurrency対応プロジェクトについて説明します。
これはMerpayiOSコードにSwift Concurrencyのビルドオプションを追加するプロジェクトになります。
目的は、Swift 6で必須になるConcurrency対応の事前準備です。現状のコードがSwift 6ではコンパイルエラーになるので、時間のあるときに対応していこうという意図があります。Sendableのエラーが出ないようにしていくのが目標です。
コンパイルチェックで、並行処理の不具合を減らす目的もありました。Swift Concurrencyの本来の目的をメルペイでも取り入れていきたいと思い、このプロジェクトを進めました。
ここで、メルペイコードのモジュールの構成を説明します。メルペイはSDKとしてモジュール化され、メルカリに組み込まれています。QRコードを出すQRモジュールや、クーポンを出すクーポンモジュールなどの各Featureのモジュールに、Sharedモジュールという形で、CoreモジュールやAPIモジュールがあり、それぞれのFeatureが依存しています。Coreは、基本的なプロトコルや、Dependencyを定義するものです。
プロジェクト当初のロードマップを説明します。
まずは、それぞれのFeatureモジュールに対応します。20以上の各モジュールにビルドオプションを渡し、それぞれビルドオプションを渡してビルドエラーを直し、Concurrency起因のワーニングをなるべく修正して、それぞれリリースします。その後コアのモジュールを対応していくという、少しずつリリースしていくロードマップを引きました。
ビルドオプション追加後、どんなエラーが出てどう修正しているのかを具体的に見ていきます。例えば、MainActorが付けられていないメソッドでUIKitのViewのプロパティを変更するものです。
UIViewとUIViewControllerはクラスにMainActorが付けられているので、メソッドの呼び出しやプロパティの更新は、MainActorのメソッドやクラスで行わないとエラーになります。
この例では、UILabelのTextプロパティを変えているのですが、MainActorがないと、コンパイルエラーになってしまいます。
それを直すには、Task@MainActorで囲うか、そもそもメソッドをMainActorにして更新する必要があります。
ワーニングの修正の一部も紹介します。例えば、DispatchQueueのasyncのクロージャーは Sendableのクロージャーなので、変数はSendableになる必要があります。
元のコードでは、asyncを実行する前にvarで変更可能な変数を定義し、クロージャで更新していたのですが、ワーニングが出てしまったので、別途変更したいデータはActorなどで全部定義した後で、クロージャ内で値を変更する必要がありました。
この対応でコードの書き方も変えました。メルペイでは、MVVMアーキテクチャを採用していて、各画面にビューモデルを実装しています。中身は薄いクロージャーでビューのイベントを検知したら、HTTP通信などして結果をクロージャーでビューに伝えます。
メルペイのコードはまだUIViewControllerで実装されているビューと接続するときには、全てMainActorが必要です。ViewModelはViewに近い操作ということで、型ごとViewModelにMainActorを追加しました。
また、Swift Concurrencyのプロトコルには少し厄介な仕様がありました。メルペイではCoreモジュールにプロトコルを定義して、それを各モジュールで準拠しています。例えばInputAppliableがCoreで定義されていて、使う側はそれを読み込んでいたのですが、例えばSubViewがUIViewを継承すると、SubViewがMainActorになります。
そこでInputAppliableのプロトコルのInput applyメソッドを実装すると、ワーニングが出てしまいます。MainActorのapplyメソッドはプロトコルに準拠していないということです。
プロトコルにはMainActorがないのですが、SubVirwのapplyメソッドには暗黙的にMainActorがついてしまうので、ワーニングが出てしまいます。
これが厄介です。各FeatureモジュールはCoreモジュールに依存しているのですが、InputAppliableの他に、Coreモジュールでプロトコルをいくつか定義していました。そのため、コアモジュールがプロトコル@MainActorにするまでは、各依存でワーニングが出てしまいます。
そのため、今回のConcurrency対応プロジェクトで各Featureにワーニングがたくさん増えてしまうという事態に陥りました。
そこで、ロードマップの方向転換を決めました。
各Featureモジュールを対応したらそれぞれリリースするのではなくて、全てのモデル対応が終わったら、リリースすることにしました。
これが、プロジェクト失敗の原因だった思います。一発リリースにすることで、プロジェクトの難易度は上がってしまいました。
そこで、ロードマップの方向転換を決めました。
各Featureモジュールを対応したらそれぞれリリースするのではなくて、全てのモデル対応が終わったら、リリースすることにしました。
これが、プロジェクト失敗の原因だった思います。一発リリースにすることで、プロジェクトの難易度は上がってしまいました。
さらにConcurrency対応と並行してGitリポジトリ統合のプロジェクトも始まりました。メルカリのGU Appのプロジェクト後に、メルペイもリポジトリを統合することになりました。
今まではリポジトリを分けてmerpay-ios-sdkというリポジトリにMercari GU Appを組み込んでいたのが、mercari-groundup-iosという一つのリポジトリにすることになりました。
Concurrencyプロジェクトは2022年9月から進んでいましたが、Gitリポジトリの統合プロジェクトによって、11月・12月はお休みし、2023年1月からConcurrencyプロジェクトが再開しました。
Gitリポジトリ統合プロジェクトが終わると、メルペイ画面をSwiftUIに書き換えるプロジェクトが始まりました。今までの画面はレガシーコードとして保守することになりました。ただ、Swift Concurrency対応のプロジェクトは、レガシーコードが対象でした。
今までのコードはレガシーコードとして扱われ、UIKit・MVVMアーキテクチャでなるべく更新しないようにする方針でした。それをGround UP Appアーキテクチャに変えようというプロジェクトです。
SwiftUIでCombineによるGround UP Appのアーキテクチャで、新規画面はこっちで実装しようという話になりました。
その後Swift Concurrencyの実装が完了しました。ただ、いろいろな問題が発生しています。
一気に書き換えたので、GitHubのファイルチェンジが1250ファイルと更新規模が膨大になってしまいます。
また動作確認したところ、不安定な挙動が頻発しました。例えばメインスレッドで動作すべき処理が別スレッドで動いていたり、別スレッドで動作すべき処理がメインスレッドで動作していたり。QRコードを読み込むカメラの処理で、AVFoundationのセッションをスタートするときに、誤ってメインスレッドで動いてしまうこともありました。
このように、品質を保証するのが難しい状態で、レガシーコードは保守運用チームの方針と矛盾する形になってしまいました。バグを直すかプロジェクトを中止するかの判断が問われる事態になりました。
ただSwift ConcurrencyはSwift 6の準備のために始めたプロジェクトです。Swift 6のリリーススケジュールを把握してないと、こちらの都合でプロジェクトを辞めたとしてもSwift 6がリリースされたらすぐ対応しなければなりません。
しかし調べてみると、Swift 6のリリーススケジュールはまだ発表されておらず、2023年はConcurrencyの他にオーナーシップに取り組む予定だと、ブログに書かれていました。またswift-evolutionでも、Swift 5.9のリリースをアナウンスされていますが、まだSwift 6のリリーススケジュールは出されていません。少なくとも2023年中にSwift 6がリリースされることはなさそうでした。
参照:https://www.swift.org/blog/focus-areas-2023/
Swift 6のリリーススケジュールとメルペイのConcurrency対応の現状を踏まえて、2023年3月にチームで話し合いをしました。
Swift Concurrencyのスレッド間で不具合がなくなるとはいえ、レガシーの積極的な更新をすべきではありません。また、一気に書き換えたSwift Concurrency対応のコードで不具合がたくさん見つかってしまっている状態です。さらに、Swift 6がリリースされても、しばらくはSwift 5モードでコンパイルする手段が提供される見込みであると発表されていました。
もちろんSwift6のどこかのバージョンでこの機能が消される可能性はありますので、最終的には対応すべきですが、Swift 6が出た当初はまだ時間がありそうです。
さらに、Xcode14.1、Swift 5.7ではUIキットWKNavigationDelegateやAVFoundationなどのConcurrency未対応のクラスやフレームワークが多い状態でした。そのため、まだまだSwift Concurrencyの書き換えは時期尚早と思われました。これらを踏まえ、Swift Concurrency対応は、SwiftUI書き換えプロジェクトの後でいいという判断になりました。
引き直した後のロードマップはこちらです。SwiftUI書き換えプロジェクトを2024年いっぱいまで終わらせ、その後にSwift Concurrencyプロジェクトを行う形にしました。
これは私の勝手な予想ですが、Swift 6のリリースはおそらく2024〜2025年の間です。SwiftUI書き換えプロジェクトが終わってからでも、この順番でできそうだと思います。
当てが外れて2024年にSwift 6がリリースされたら、書き換えプロジェクトとSwift Concurrency対応を同時にしなければなりませんが、ひとまずは書き換えの後にSwift Concurrencyの対応を考えています。
メルペイのSwift Concurrencyプロジェクトは、コードを一気に変えるという方向転換とチームとしてコードベースが変わるという影響でプロジェクトは中断されました。
チームの方針の影響はありますが、プロジェクトの方針として、一気に変える方針をとってしまったのも、中断の原因となりました。一気に変えると、影響範囲が見えにくくなって、QAが長引く原因になります。
誤算だったのは、Swift Concurrencyを一気に書き換えないと、ワーニングが増えてしまうことです。今回はワーニングが入ることで各モジュールリリースから全て書き換えのリリースに変えましたが、書き換えのベネフィットとチームの状況を見つつ進める必要がありました。
とはいえ、今思えば細かくリリースして早くコード反映した方が良いと思います。影響範囲が狭くなりますし、QAしやすいし、バグが出ても修正がしやすくなります。今回の出来事を通して、一気に書き換えるプロジェクトの難しさを痛感しました。
ワーニングが出てもチームを説得して細かいリリースをした方が、Concurrency対応を少しでも入れられたと思います。今回難しかったのはSwift Concurrency自体が時期早尚であることと、意外とSwift 6までの猶予期間があることでした。
チームによってコードの状況も変わる中で、プロジェクト中断はそれはそれで良い判断だと思いますけれども、プロジェクトリーダーとしては、不確実性を減らすために、細かいリリースを死守すべきだったと思います。
教訓は、大きな機能も細かなスケジュールを立てようということです。この経験が皆さんの参考になれば幸いです。
ご清聴ありがとうございました。