Slide 1

Slide 1 text

2023/09/13 田中幸一 タイミーiOSアプリへの Swift Concurrency導入までの軌跡 @_tanakoo

Slide 2

Slide 2 text

田中 幸一(@_tanakoo) ● 株式会社タイミー ● プロダクト本部 ● ワーキングリレーションチーム ● iOSエンジニア イチオシのiOSDCのトーク 「複雑さに立ち向かうためのコードリーディ ング入門」by shiz 自己紹介

Slide 3

Slide 3 text

目次 ● タイミーについて ● Swift Concurrencyのおさらい ● Swift Concurrencyの導入理由 ● 開発とリリースの方針 ● 起きた問題と対策 ● まとめ

Slide 4

Slide 4 text

1 タイミーについて

Slide 5

Slide 5 text

5

Slide 6

Slide 6 text

6

Slide 7

Slide 7 text

募集人数の推移 7 ※1:2022年4Qと2021年4Qの比較 コロナ禍においても、 過去に例を見ない程の 加速的高成長を実現。

Slide 8

Slide 8 text

タイミーの実績 スキマ バイト No.1 ※2022年8月時点 ※1 [調査方法]デスクリサーチ及びヒアリング調査 [調査期間]2021年2月8日~22日 [調査概要]スキマバイトアプリ サービスの実態調査 [調査対象]2020年12月までにサービスを開始しているスキマバイトアプリ10サービス [調査実施]株式会社ショッ パーズアイ ※2 [出典]AppStoreライフスタイルカテゴリーランキング(2021年5月時点) 8 累計求人案件数 ・ダウンロード数 ※1 ※2 導入事業者数 46,000企業 ワーカー数 500万人

Slide 9

Slide 9 text

2 Swift Concurrencyの おさらい

Slide 10

Slide 10 text

Swift Concurrencyとは Swift 5.5から導入された非同期処理や並行処理を安全かつ簡潔に書ける仕組み ● async/await ● Actor ● Structured Concurrency ● etc.. ここではasync/awaitとActorだけを簡単におさらい

Slide 11

Slide 11 text

async/awaitとは ● 非同期処理を同期処理と同じ見た目で簡潔に書ける仕組み ● これによりコールバック地獄を回避できたり様々な利点がある

Slide 12

Slide 12 text

従来の非同期処理の例 func downloadData(from url: URL, completion: @escaping (Result) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(error)) return } if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { completion(.failure(ResponseError(statusCode: response.statusCode))) return } } completion(.success(data!)) }.resume() }

Slide 13

Slide 13 text

従来の非同期処理の例 func downloadData(from url: URL, completion: @escaping (Result) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(error)) return } if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { completion(.failure(ResponseError(statusCode: response.statusCode))) return } } completion(.success(data!)) }.resume() } 処理のたびにネストが増える 完了処理を呼び忘れる可能性がある

Slide 14

Slide 14 text

async/awaitで書き直すと func downloadData(from url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { throw ResponseError(statusCode: response.statusCode) } } return data }

Slide 15

Slide 15 text

async/awaitで書き直すと func downloadData(from url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { throw ResponseError(statusCode: response.statusCode) } } return data } asyncキーワードで非同期関数であること を明示

Slide 16

Slide 16 text

async/awaitで書き直すと func downloadData(from url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { throw ResponseError(statusCode: response.statusCode) } } return data } エラーを返す場合 throwsを付与 成功結果は単純に返り値になる

Slide 17

Slide 17 text

async/awaitで書き直すと func downloadData(from url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let response = response as? HTTPURLResponse { guard response.statusCode == 200 else { throw ResponseError(statusCode: response.statusCode) } } return data } potential suspension pointsであり、 関数が停止する可能性がある。 停止した場合、スレッドは開放され、 別の仕事を行うことができる。

Slide 18

Slide 18 text

Actorとは ● 並行処理におけるデータ競合やデッドロックを防ぐ仕組み ● Actorはキューのようなものを持っていて処理を順番に実行する ● これにより処理が同時に1つしか実行されないことを保証(Actor Isolation)

Slide 19

Slide 19 text

データ競合の例 final class Counter { private var count: Int = 0 func increment() -> Int { count += 1 return count } }

Slide 20

Slide 20 text

データ競合の例 let counter: Counter = .init() DispatchQueue.global().async { print(counter.increment()) } DispatchQueue.global().async { print(counter.increment()) } 1,2 、 2,1 の結果を期待。 しかし、両方の実行結果が2になりうる

Slide 21

Slide 21 text

データ競合の例 final class Counter { private var count: Int = 0 func increment() -> Int { count += 1 return count } } 1回目の処理でreturnする直前に、 2回目の処理が呼ばれることで両方 2となる

Slide 22

Slide 22 text

従来の排他制御の例 final class Counter { private let queue: DispatchQueue = .init(label: "Counter") private var count: Int = 0 func increment(completion: @escaping (Int) -> Void) { queue.async { [self] in count += 1 completion(count) } } }

Slide 23

Slide 23 text

従来の排他制御の例 final class Counter { private let queue: DispatchQueue = .init(label: "Counter") private var count: Int = 0 func increment(completion: @escaping (Int) -> Void) { queue.async { [self] in count += 1 completion(count) } } } クリティカルセクションを明示的に専用の実 行キューで守る必要があった。

Slide 24

Slide 24 text

Actorで書き直すと actor Counter { private var count: Int = 0 func increment() -> Int { count += 1 return count } }

Slide 25

Slide 25 text

Actorで書き直すと let counter: Counter = .init() Task.detached { print(await counter.increment()) } Task.detached { print(await counter.increment()) } 1,2、あるいは2,1と必ず出力される

Slide 26

Slide 26 text

3 Swift Concurrencyの 導入理由

Slide 27

Slide 27 text

Swift Concurrencyの導入理由 ● トークンのリフレッシュ処理がRxSwiftで実装されており複雑化していた ● トークン取得の排他制御に漏れがありエッジケースで問題が起きていた ● そもそも標準APIで良いところはRxSwift剥がしていきたい

Slide 28

Slide 28 text

4 開発とリリースの方針

Slide 29

Slide 29 text

開発の方針 ● 初手のスコープを小さく保つ ○ 外部ライブラリにAPIリクエストを投げる部分 ○ トークンリフレッシュ部分 ● 新しい技術をペアで開発して知見を広げる ○ 同僚が前半、後半を自分が引き継いで実装

Slide 30

Slide 30 text

リリースの方針 ● 影響を局所化する ○ 1部の通信から徐々に全体に適用 ○ 段階リリースの活用 ○ bugが修正されているXcode14.3.1を使ってリリースする ● ロールバックしやすくする ○ Remote ConfigでFeature Flagを用意

Slide 31

Slide 31 text

● 23.07.11 フィーチャーフラグをOFF ● 23.08.01 一部の通信だけフラグをON ● 23.08.07 全ての通信のフラグをON ● 23.08.15 フィーチャーフラグを削除 ※ 現行アプリは日付でバージョニングしています。 リリースされたバージョンの具体

Slide 32

Slide 32 text

5 起きた問題と対策

Slide 33

Slide 33 text

トークンリフレッシュ中にトークンのreadが走った際に古いトークンを取得してしまうこと があった。 原因 swiftのActor内でawaitを呼んだ際のreentrancy(再入可能性)を考慮できていなかっ た。(writeは実行中のtaskを待機していたがreadの考慮が漏れていた) 開発中に起きた問題1 public actor AuthManager { func token() async -> Token? { // awaitでサスペンドされるとresume前にここが呼ばれる可能性がある } func refreshToken() async -> Token? { // この処理の中で awaitがあった }

Slide 34

Slide 34 text

対策 private var refreshTask: Task? func token() async -> Token? { if let refreshTask { return await refreshTask.value } ... } トークンのread時も実行中のタスクの結果を使うように変更した。

Slide 35

Slide 35 text

トークンのリフレッシュ中にタブを切り替えるとセッションが切れ る問題が起きていた。 原因 API通信クラスがActorを保持しており、アプリはタブ毎にAPI通 信クラスを生成している。そのため、タブの数だけActorが生ま れており、Actor1のリフレッシュ中にActor2(Actor1とは異なる インスタンス)が古い値を見ていた。 開発中に起きた問題2

Slide 36

Slide 36 text

Actorをシングルトンにして対処 余談 当初はGlobalActorで実装していたが、コードレビューで意図の理解しづらさなどの指摘 を受けてシンプルにシングルトン化した。(@MainActorのようにattributeも利用していな かったので今考えても使い方を間違っていたと感じています) 対策

Slide 37

Slide 37 text

一部の通信をSwift Concurrencyを使うようにした。 この段階では問題は起きなかった。 初回リリース

Slide 38

Slide 38 text

一部の画面でクラッシュが起きた。 原因 await後にView側でUIを操作するが、ワーカースレッドからの操作になっていて、スレッ ド違反でクラッシュしていた。 全体リリースの開発を始めたところ..

Slide 39

Slide 39 text

原因 func request(_ request: T) -> Single where T: RequestDefinition { return Single.create { single in let task = Task { do { let result = try await self._requestAsync(request) single(.success(result)) } catch { single(.failure(error)) } } return Disposables.create { task.cancel() } } }

Slide 40

Slide 40 text

原因 func request(_ request: T) -> Single where T: RequestDefinition { return Single.create { single in let task = Task { do { let result = try await self._requestAsync(request) single(.success(result)) } catch { single(.failure(error)) } } return Disposables.create { task.cancel() } } } ここまではメインスレッドだったが ..

Slide 41

Slide 41 text

原因 func request(_ request: T) -> Single where T: RequestDefinition { return Single.create { single in let task = Task { do { let result = try await self._requestAsync(request) single(.success(result)) } catch { single(.failure(error)) } } return Disposables.create { task.cancel() } } } Taskのclosure内の先頭でワーカースレッドに なっていた。(Actor Contextの引き継ぎは静的 にコンパイル時に解決されるため、明示的な @MainActorなどの付与がないと引き継がれな い)

Slide 42

Slide 42 text

callbackを @MainActor にいれることにした。 1API毎Actor hoppingが発生するがオーバーヘッドは小さいと判断して、MainActorで ラップした。 対策

Slide 43

Slide 43 text

しかしクラッシュが発生してしまった ● クラッシュ検知15分後にはFeature FlagをOFF & 段階リリースを中止 ● 同じようなrequest関数が他にも存在、MainActorでのラップが漏れていた ● 最終的にクラッシュは修正、フィーチャーフラグも削除済み 全体リリース!

Slide 44

Slide 44 text

6 まとめ

Slide 45

Slide 45 text

Swift Concurrency導入で起きた良いこと ● トークン管理がより安全になった ● RxSwiftのコードが一部なくなった ● 通信処理の見直しによりリトライの過不足がなくなった ● チームに新しい技術の知見が広がった

Slide 46

Slide 46 text

残っている課題 ● Swift Concurrencyという新たな複雑性が導入された ○ チームでの学習が引き続き必要 ● Domainモジュール側にRxが残っている ● アプリケーション全体にSwift Concurrencyを適用する ○ 通信の表層ではまだRxSwiftのSingleを返している ● Strict Concurrency Checkingの有効化 ● まだまだリファクタの余地がある

Slide 47

Slide 47 text

まとめ ● 新しい技術は小さくロールバックできるようにリリースしよう ● 新しい技術はみんなでPRを作る or ペアプロで知見を共有しよう ● Actorの中でawaitする場合のreentrancyな性質に注意 ● Actor Contextの引き継ぎは静的(コンパイル時)に解決される