Slide 1

Slide 1 text

巨大モノリシックアプリ モダン化大作戦 Ryomm / Ryoko Matsusaka 2026.09.12 iOSDC 2026

Slide 2

Slide 2 text

Ryomm / Ryoko Matsusaka KINTOテクノロジーズ株式会社 iOS App Developer

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

140 画面 2,400 ファイル 234,000 行 巨大モノリシックアプリ

Slide 6

Slide 6 text

使える状態を維持したまま モダンに整理していく

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

Overview カオスができるまで 心がざわざわしてきたら… #iosdc #b カオス爆誕 カオス収束 エピローグ をつけてXでポスト!

Slide 9

Slide 9 text

カオス紀元前

Slide 10

Slide 10 text

初期アーキテクチャ View Storyboard) ViewController View ViewModel Model

Slide 11

Slide 11 text

Storyboardのメンテ大変すぎる問題 〜 ね か 開 全然 ぎ す 重 ぎ す く に し 作 操 こいつTa bleViewか 囲 範 差分わか 響 影 らん!! ? ! い な ど よ

Slide 12

Slide 12 text

StoryboardからSwiftUIへの移行 UIHostingController

Slide 13

Slide 13 text

StoryboardからSwiftUIへの移行 View A Storyboard) View B SwiftUI ViewController A ViewController B ナビゲーション( UIKit )

Slide 14

Slide 14 text

StoryboardからSwiftUIへの移行 View ObservedObject HostingController ViewController View ViewModel

Slide 15

Slide 15 text

StoryboardからSwiftUIへの移行 View ObservedObject HostingController import SwiftUI struct HogeView: View { var body: some View { Text(”hoge") ViewController View ViewModel } }

Slide 16

Slide 16 text

StoryboardからSwiftUIへの移行 class ViewController: UIViewController { let hostingController: UIHostingController View init() { let view = HogeView() self.hostingController = UIHostingController(rootView: view) ObservedObject } HostingController super.init(nibName: nil, bundle: nil) override func viewDidLoad() { super.viewDidLoad() addChild(hostingController) hostingController.view.translatesAutoresizingMaskIntoConstraints = false ViewController view.addSubview(hostingController.view) NSLayoutConstraint.activate([ hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), View /" viewを全体に広げる ]) ViewModel } } hostingController.didMove(toParent: self)

Slide 17

Slide 17 text

StoryboardからSwiftUIへの移行 View ObservedObject class ViewStateModel: ObservableObject { HostingController @Published var item: Item? @Published var actionEvent: Void = () ViewController View ViewModel }

Slide 18

Slide 18 text

StoryboardからSwiftUIへの移行 View import SwiftUI ObservedObject struct HogeView: View { @ObservedObject var model: ViewStateModel HostingController var body: some View { Button(model.title) { model.actionEvent = () ViewController View ViewModel } } }

Slide 19

Slide 19 text

StoryboardからSwiftUIへの移行 View class HostingController: UIHostingController { private let model: ViewStateModel init(model: ViewStateModel) { ObservedObject self.model = model super.init(rootView: .init(model: model)) } HostingController var actionPublisher: AnyPublisher { model.$actionEvent.dropFirst().eraseToAnyPublisher() } ViewController func update(item: Item?) { View model.item = item } ViewModel }

Slide 20

Slide 20 text

StoryboardからSwiftUIへの移行 View ObservedObject extension ViewController { func bind(hostingController: HostingController) { hostingController.actionPublisher .receive(on: DispatchQueue.main) HostingController .sink { /" Do something } .store(in: &cancellables) ViewController View ViewModel } }

Slide 21

Slide 21 text

StoryboardからSwiftUIへの移行 View Storyboard ObservedObject HostingController ViewController ViewController View View ViewModel ViewModel

Slide 22

Slide 22 text

StoryboardからSwiftUIへの移行 View Storyboard ObservedObject HostingController 本当に同じに なってるか…? ViewController ViewController View View ViewModel ViewModel

Slide 23

Slide 23 text

SnapshotTestを導入する SnapshotTesting

Slide 24

Slide 24 text

SnapshotTestを導入する https://blog.kinto-technologies.com/posts/20240417SnapshotTest/

Slide 25

Slide 25 text

SwiftUI化 めでたしめでたし…? 見た目が同じだけで 良いのか…?

Slide 26

Slide 26 text

一方 その頃────

Slide 27

Slide 27 text

Domainモデルの導入 View @IBOutlet weak var ViewModel typealias Hoge = label: UILabel! Response Response.Hoge func setLabel() { class ViewModel { label.text = let hoge: Hoge viewModel.hoge.p1 APIClient struct Response { let hoge: Hoge } struct Hoge { } let p1: String } }

Slide 28

Slide 28 text

Domainモデルの導入 View @IBOutlet weak var ViewModel typealias Hoge = label: UILabel! Response Response.Hoge func setLabel() { class ViewModel { label.text = let hoge: Hoge viewModel.hoge.p2 APIClient struct Response { let hoge: Hoge } struct Hoge { } let p1: String let p2: String } }

Slide 29

Slide 29 text

Domainモデルの導入 View ViewModel Response @IBOutlet weak var typealias Hoge = struct Response { Response.Hoge let hoge: Hoge label: UILabel! func setLabel() { label.text = viewModel.hoge.p2 } class ViewModel { struct Hoge { let hoge: Hoge let p1: String } let p2: String } } APIClient

Slide 30

Slide 30 text

Domainモデルの導入 View @IBOutlet weak var Response label: UILabel! ViewModel typealias Hoge = Response.Hoge struct Response { func setLabel() { let hoge: Hoge label.text = class ViewModel { viewModel.hoge.p2 struct Hoge { } } let p1: String let p2: String } } let hoge: Hoge APIClient

Slide 31

Slide 31 text

Domainモデルの導入

Slide 32

Slide 32 text

Domainモデルの導入 DomainModel struct Domain { View ViewModel @IBOutlet weak var class ViewModel { label: UILabel! func setLabel() { label.text = let p: String APIClient } let d: Domain Response } struct Response { let hoge: Hoge viewModel.d.p } struct Hoge { let p1: String } }

Slide 33

Slide 33 text

Domainモデルの導入 DomainModel struct Domain { View ViewModel @IBOutlet weak var class ViewModel { label: UILabel! func setLabel() { label.text = let p: String APIClient } let d: Domain Response } struct Response { let hoge: Hoge viewModel.d.p } struct Hoge { let p1: String let p2: String } }

Slide 34

Slide 34 text

Domainモデルの導入 View Response View DomainModel

Slide 35

Slide 35 text

一方 その頃────

Slide 36

Slide 36 text

APIクライアントの移行 (背景説明割愛)

Slide 37

Slide 37 text

APIクライアントの移行 static func getHoge(p: String, completion: @escaping (Result) -> Void) { let parameters = RequestParameters(p: p) AF.request(path, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: APIBase.apiHeader(), requestModifier: { $0.timeoutInterval = timeoutInterval }) .responseDecodable { (response: AFDataResponse) in switch response.result { case .success(let item): if let error = ServerResponseDecoder().decodeMaintenanceError(response) { completion(.failure(error)) } else { completion(.success(item)) } case .failure(let error): completion(.failure(CustomError(serverError: .network, error: error, response: response))) } } } func getHoge(p: String) async throws -> Response { let networkService = NetworkServiceImpl() let response = try await networkService.request( PostHogeTarget( parameters: .init(p: p) ) ) return response } completion handler concurrency

Slide 38

Slide 38 text

APIクライアントの移行 View class ViewModel { let apiService: APIService func transform(_ input: TimetableInput) -> TimetableOutput { let onGetHoge = input.getHoge .flatMap { self.getHoge() } .eraseToAnyPublisher() ObservedObject return .init(onGetHoge: onGetHoge) } HostingController func getHoge() -> Future, Never> { .init { promise in Task { do { let domain = try await apiService.getHoge(p: p) promise(.success(.success(domain))) } catch { promise(.success(.failure(error)) } } } } ViewController View ViewModel }

Slide 39

Slide 39 text

APIクライアントの移行 View class ViewModel { let apiService: APIService func transform(_ input: TimetableInput) -> TimetableOutput { let onGetHoge = input.getHoge .flatMap { self.getHoge() } .eraseToAnyPublisher() ObservedObject return .init(onGetHoge: onGetHoge) } HostingController func getHoge() -> Future, Never> { .init { promise in Task { do { let domain = try await apiService.getHoge(p: p) promise(.success(.success(domain))) } catch { promise(.success(.failure(error)) } } } } ViewController View ViewModel }

Slide 40

Slide 40 text

APIクライアントの移行 View ObservedObject extension ViewController { func bind(hostingController: HostingController) { let output = viewModel.transform( .init( getHoge: getHogeSubject.eraseToAnyPublisher() ) ) output.onGetHoge .receive(on: DispatchQueue.main) .sink { [weak self, weak hostingController] result in guard let self, let hostingController else { return } switch result { case .success(let hoge): updateView(hoge) case .failure(let error): showError(error: error, resumeHandler: { [weak self] in getHogeSubject.send() }) } } .store(in: &cancellables) HostingController ViewController View } ViewModel }

Slide 41

Slide 41 text

カオス爆誕前夜 Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService Domain Mapper Response) APIManager Alamofire Apollo Completion Handler Model Singleton) Moya Concurrency

Slide 42

Slide 42 text

SwiftUI化 めでたしめでたし…? 見た目が同じだけで 良いのか…?

Slide 43

Slide 43 text

SwiftUI化 めでたしめでたし…? 見た目が同じだけで 良いのか…? 生 発 レ デグ

Slide 44

Slide 44 text

SwiftUI化 めでたしめでたし…? や ル ク イ サ フ ライ の 作 操 ザ 見た目が同じだけで ユー れ 漏 良いのか…? 装 実 生 発 レ デグ スクロー ル や 分析ツー キーボー ルの ドの 実装漏れ 挙動の違 い

Slide 45

Slide 45 text

SwiftUI化 めでたしめでたし…? や ル ク イ サ フ ライ の 作 操 ザ 見た目が同じだけで ユー れ 漏 良いのか…? 装 実 生 発 レ デグ スクロー ル や 分析ツー キーボー ルの ドの 実装漏れ 挙動の違 い 化 須 必 A Q

Slide 46

Slide 46 text

SwiftUI化 めでたしめでたし…? や ル ク イ サ フ ライ の 作 操 ザ 見た目が同じだけで ユー れ 漏 良いのか…? 装 実 生 発 レ デグ スクロー ル や 分析ツー キーボー ルの ドの 実装漏れ 挙動の違 い ! 化 須 必 A Q

Slide 47

Slide 47 text

リファクタすること

Slide 48

Slide 48 text

安全にリファクタすること

Slide 49

Slide 49 text

一方 その頃────

Slide 50

Slide 50 text

Swift6に移行したい Swift6 にした い!

Slide 51

Slide 51 text

Swift6に移行したい class ViewModel { @Published var status: LoadingType? var updateStatus: AnyObject? メインスレッド class ViewController { viewModel.setStatus { [weakを更新 self] status in guard let self, let status else { return } switch status { case .loading: break case .finish: updateView() case .error(let error): showError(error: error) } func setStatus( process: @escaping (_ status: LoadingType?) -> Void ) { updateStatus = $status.sink { status in process(status) } } 非メインスレッド で更新 func callAPI() { status = .loading apiService.callAPI { [weak self] status in guard let self = self else { return } switch status { case .loading: self.guestLoginStatus = .loading case .finish: self.guestLoginStatus = .finish case .error(let error): self.guestLoginStatus = .error(error) } } } } } } ら な 6 t f Swi で 点 時 ビルド ! ず は る け 付 気

Slide 52

Slide 52 text

Swift6に移行したい https://www.swift.org/migration/documentation/migrationguide/

Slide 53

Slide 53 text

Swift6に移行したい

Slide 54

Slide 54 text

Swift6に移行したい 全体に共通の問題 ・構造体がSendableじゃない ・ViewModel↔ViewController間のCombine購読 ・APIクライアントの完了ハンドラが非Sendable ・etc… + Upcoming Features 全部を1度のQAで 動作保証してもらうのは 無理…

Slide 55

Slide 55 text

Swift6に移行したい 全部QAするなら 凍結中のリファクタも 一緒に進めたい…

Slide 56

Slide 56 text

2ブランチ作戦

Slide 57

Slide 57 text

2ブランチ作戦 feature develop cherry-pick modern refactor

Slide 58

Slide 58 text

2ブランチ作戦 modern develop develop

Slide 59

Slide 59 text

2ブランチ作戦 feature エグ差分 develop cherry-pick modern refactor

Slide 60

Slide 60 text

カオス爆誕

Slide 61

Slide 61 text

2ブランチ作戦 develop modern みえない運用コスト

Slide 62

Slide 62 text

恐怖!誰も知らない進捗

Slide 63

Slide 63 text

恐怖!誰も知らない進捗 年内に終わる …ということに 無理じゃね? なっている マ?

Slide 64

Slide 64 text

恐怖!誰も知らない進捗 は SwiftUI化 面 画 の ど ? み 済 応 対 が 何 Domainモデル導入 いつ終わる見込み? い な ら 知 も 誰 全体で APIクライアント移行 ? た っ わ 終 が 割 何 どの画面は Strict Concurrency Checking対応 何がで きてない?

Slide 65

Slide 65 text

恐怖!誰も知らない進捗 画面A 画面B SwiftUI化 APIクライアント移行 Domainモデル導入 Strict Concurrency Checking対応 移行単位を定義 画面C

Slide 66

Slide 66 text

一方 その頃────

Slide 67

Slide 67 text

Repository導入? Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService Mapper APIManager Alamofire Apollo Completion Handler Model Singleton) Moya Concurrency

Slide 68

Slide 68 text

Repository導入? Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService Mapper APIManager Alamofire Apollo Completion Handler Model Singleton) Moya Concurrency

Slide 69

Slide 69 text

Repository導入? 長期的な理想像 + 実現可能な計画

Slide 70

Slide 70 text

カオス収束に向けて

Slide 71

Slide 71 text

モダン化大作戦

Slide 72

Slide 72 text

モダン化大作戦 SwiftUI化 Domainモデル導入 APIクライアント移行 Strict Concurrency Checking対応 アーキテクチャ刷新(整理) モダン化

Slide 73

Slide 73 text

モダン化大作戦 「妥協の心」が 大事!

Slide 74

Slide 74 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService Mapper APIManager Alamofire Apollo Completion Handler Model Singleton) Moya Concurrency

Slide 75

Slide 75 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel

Slide 76

Slide 76 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel 端末に依存する ロジック UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya import SwiftUI import UIKit EntityModel が必要なロジック

Slide 77

Slide 77 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController iPhoneでもiPadでも Macでも動くロジック ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… ObservedObject View アプリ内で使う 構造体 SwiftUI DomainModel Moya EntityModel

Slide 78

Slide 78 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController ObservedObject ViewModel何をどこに Mapper Repository APIClient DataStore Singleton, Cache, etc… SwiftUI アプリ外で使う 構造体 保存しているかを知る UseCase View DomainModel Moya EntityModel

Slide 79

Slide 79 text

モダン化大作戦 - アーキテクチャ刷新 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel

Slide 80

Slide 80 text

モダン化大作戦 - 安全な移行 feature ちゃんとQAしてから develop リリースされるように コントロールしたい cherry-pick modern refactor

Slide 81

Slide 81 text

モダン化大作戦 - モジュール化 Swift Package Manager → モジュール単位でSwift6ビルドを有効化できる

Slide 82

Slide 82 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject ここだけ View SwiftUI Swift6ビルド ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 83

Slide 83 text

モダン化大作戦 - モジュール化 var package = Package( /* … */ ) package.targets.forEach { target in target.swiftSettings = (target.swiftSettings ?& []) + [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("InferIsolatedConformances"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), .enableUpcomingFeature("ImmutableWeakCaptures") ] }

Slide 84

Slide 84 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController レ イヤードアーキ テ ObservedObject SPMの相性良す ViewModel 件について UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… View クチャと SwiftUI ぎる アプリ本体 DomainModel Moya SPM1 EntityModel SPM2

Slide 85

Slide 85 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 86

Slide 86 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 87

Slide 87 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 88

Slide 88 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI リソース ViewModel UseCase Mapper Repository APIClient どうせ全部 メインスレッドで 動くし、そのままで… DataStore Singleton, Cache, etc… Moya アプリ本体 DomainModel SPM1 EntityModel SPM2

Slide 89

Slide 89 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel SPM

Slide 90

Slide 90 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel SPM

Slide 91

Slide 91 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel SPM

Slide 92

Slide 92 text

モダン化大作戦 - モジュール化 Storyboard ViewController UIKit これをレビュー HostingController ObservedObject View SwiftUI し続けるのは無理… ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel SPM

Slide 93

Slide 93 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 94

Slide 94 text

モダン化大作戦 - モジュール化 var package = Package( name: "DomainLayer", products: [ .library(name: "UseCase", targets: ["UseCase", "UseCaseImplementation"]), ], ) .library(name: "Domain", targets: ["DomainModule"]) /" ..) var package = Package( name: "DataLayer", products: [ .library(name: "Entity", targets: ["Entity"]), ], ) .library(name: "Repository", targets: ["Repository", "RepositoryImplementation"]) /" ..)

Slide 95

Slide 95 text

一方 その頃────

Slide 96

Slide 96 text

モダン化大作戦 - モジュール化 KMPモジュール 入れるわ Android ロボットは、Google が作成および提供している作品から複製または変更したものであり、クリエイティブ・コモンズ表示 3.0 ライセンスに記載された条件に従って使用しています マ?

Slide 97

Slide 97 text

モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ViewModel UseCase Repository DataStore ObservedObject プロトコルを 独立したモジュール として定義 Mapper APIClientProtocol APIClientImpl KMPAPIClientImpl View SwiftUI アプリ本体 DomainModel SPM1 Moya EntityModel SPM2

Slide 98

Slide 98 text

モダン化大作戦 - モジュール化 人がルールを守る

Slide 99

Slide 99 text

モダン化大作戦 - モジュール化 構造がルールを守らせる

Slide 100

Slide 100 text

モダン化大作戦 開発 QA 画面A モダン化 画面B モダン化 画面A QA

Slide 101

Slide 101 text

モダン化大作戦 開発 QA

Slide 102

Slide 102 text

Tips 1

Slide 103

Slide 103 text

APIトークンリフレッシュ&リトライ HogeRepository TokenRepository TokenAPIClient Keychain

Slide 104

Slide 104 text

APIトークンリフレッシュ&リトライ HogeRepository public protocol HogeRepository { } func getHoge() async throws-+ Hoge TokenRepository TokenAPIClient Keychain public struct HogeRepositoryImpl: HogeRepository { let apiClient: HogeAPIClient let tokenRepository: TokenRepository public func getHoge() async throws -+ Hoge { do { let token = try await tokenRepository.getToken() try await apiClient.getHoge(token: token) } catch where error.serverError =- .tokenInvalid { let token = try await tokenRepository.refreshToken() } } } try await apiClient.getHoge(token: token) 面倒だ…

Slide 105

Slide 105 text

APIトークンリフレッシュ&リトライ Macro

Slide 106

Slide 106 text

APIトークンリフレッシュ&リトライ public struct TokenAuthenticatedMacro: BodyMacro { public static func expansion( of node: AttributeSyntax, providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax, in context: some MacroExpansionContext ) throws -+ [CodeBlockItemSyntax] { /" ..) let statements = body.statements.trimmed return [ """ do { let token = try await tokenRepository.getToken() \(statements) } catch where error.serverError =- .tokenInvalid { let token = try await tokenRepository.refreshToken() } } } ] \(statements) """

Slide 107

Slide 107 text

APIトークンリフレッシュ&リトライ public protocol HogeRepository { } func getHoge() async throws-+ Hoge public struct HogeRepositoryImpl: HogeRepository { let apiClient: HogeAPIClient let tokenRepository: TokenRepository @TokenAuthenticated public func getHoge() async throws -+ Hoge { } } try await apiClient.getHoge(token: token)

Slide 108

Slide 108 text

Tips 2

Slide 109

Slide 109 text

UseCaseパターンがSwiftっぽくない Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel

Slide 110

Slide 110 text

UseCaseパターンがSwiftっぽくない public struct HogeUseCase { private let hogeRepository: any HogeRepository public func getHoge() async throws -+ Domain.Hoge { let response = try await hogeRepository.getHoge() /" ..) } return HogeMapper.map(response) public func register(hoge: Domain.Hoge) async throws { /" ..) } } try await hogeRepository.register(hoge: hoge) 本来UseCaseは ビジネスロジック単位

Slide 111

Slide 111 text

UseCaseパターンがSwiftっぽくない public struct GetHogeUseCase { private let hogeRepository: any HogeRepository public struct HogeUseCase { public func execute() async throws -+ Domain.Hoge { private let hogeRepository: any HogeRepository let response = try await hogeRepository.getHoge() /" ..) public func getHoge() async throws -+ Domain.Hoge { let response = try await hogeRepository.getHoge() /" ..) } return HogeMapper.map(response) public func register(hoge: Domain.Hoge) async throws { /" ..) } } } } return HogeMapper.map(response) public struct RegisterHogeUseCase { private let hogeRepository: any HogeRepository try await hogeRepository.register(hoge: hoge) public func execute(hoge: Domain.Hoge) async throws { /" ..) } } try await hogeRepository.register(hoge: hoge)

Slide 112

Slide 112 text

UseCaseパターンがSwiftっぽくない user.change(phoneNumber: “080-0000-0000”) changeUserPhoneNumberUseCase.execute(“080-0000-0000”) https://www.swift.org/documentation/api-design-guidelines/

Slide 113

Slide 113 text

UseCaseパターンがSwiftっぽくない callAsFunction

Slide 114

Slide 114 text

UseCaseパターンがSwiftっぽくない public protocol ChangePhoneNumberUseCase { func callAsFunction(_ phoneNumber: String) async throws } public struct ChangePhoneNumberUseCaseImpl: ChangePhoneNumberUseCase { private let phoneNumberRepository: any PhoneNumberRepository public func callAsFunction(to newPhoneNumber: String) async throws { try await phoneNumberRepository.update(phoneNumber: newPhoneNumber) } } let changePhoneNumber: ChangePhoneNumberUseCase = ChangePhoneNumberUseCaseImpl() /" ..) try await changePhoneNumber(to: “080-0000-0000“)

Slide 115

Slide 115 text

UseCaseパターンがSwiftっぽくない public protocol ChangePhoneNumberUseCase { func callAsFunction(_ phoneNumber: String) async throws } /" -+ changePhoneNumber(“080-0000-0000”) public protocol ChangePhoneNumberUseCase { func execute(_ phoneNumber: String) async throws } /" -+ changePhoneNumberUseCase.execute(“080-0000-0000”)

Slide 116

Slide 116 text

UseCaseパターンがSwiftっぽくない public protocol ChangePhoneNumberUseCase { func callAsFunction(_ phoneNumber: String) async throws } /" -+ changePhoneNumber(“080-0000-0000”) 違法建築 しやすい public protocol ChangePhoneNumberUseCase { func execute(_ phoneNumber: String) async throws func auth() async throws } /" -+ changePhoneNumberUseCase.execute(“080-0000-0000”)

Slide 117

Slide 117 text

Tips 3

Slide 118

Slide 118 text

テストを維持したい public struct HogeUseCase: HogeUseCase { private let repository: any Repository public init(fuga: String) { self.init( repository: RepositoryImpl(fuga: fuga) ) } init(repository: any Repository) { self.repository = repository } public func getHoge(key: String) async throws -+ Hoge { /" hoge } }

Slide 119

Slide 119 text

テストを維持したい 本体 テストA SPM1 テストB SPM2 テストC

Slide 120

Slide 120 text

テストを維持したい 本体 テストA SPM1 テストB SPM2 テストC

Slide 121

Slide 121 text

テストを維持したい TestPlan

Slide 122

Slide 122 text

テストを維持したい

Slide 123

Slide 123 text

まとめ

Slide 124

Slide 124 text

モダン化作戦で得た大規模リファクタのコツ ・影響範囲を限定し、コントロール可能にする ソフトスキルの 見せ所だぜ! ・理想像の共有 ・「妥協の心」を持つ ! な る れ 忘 を 目的

Slide 125

Slide 125 text

エピローグ

Slide 126

Slide 126 text

未解決問題 1

Slide 127

Slide 127 text

Observationへの移行 Observation https://developer.apple.com/documentation/swiftui/ migrating-from-the-observable-object-protocol-to-the-observable-macro

Slide 128

Slide 128 text

Observationへの移行 /" BEFORE final class ViewStateModel: ObservableObject { @Published var hoge: Hoge? /" AFTER @Observable final class ViewModel { var hoge: Hoge? @Published var submitEvent: String? } func submit(p: String) {} } struct View: View { } struct View: View { @ObservedObject var model: ViewStateModel var viewModel: ViewModel /" ..) /" ..) }

Slide 129

Slide 129 text

Observationへの移行 View ObservedObject HostingController ViewController View Observable ViewModel

Slide 130

Slide 130 text

Observationへの移行 View View ObservedObject HostingController ViewController ViewController View View ViewModel Observable ViewModel

Slide 131

Slide 131 text

Observationへの移行 enum ActionEvent { actionTask = Task { [weak self] in case presentFuga guard let self else { break } case dismiss } @Observable final class HogeViewModel { private let stream: AsyncStream for await action in viewModel.action { switch action { case .presentFuga: presentFugaVC() case .dismiss: CombineもAsyncStreamも dismiss(animated: true) private let continuation: AsyncStream.Continuation nonisolated func makeAsyncIterator() -+ AsyncStream.Iterator { } stream.makeAsyncIterator() func dismiss() { } continuation.yield(.dismiss) func submit() { } } continuation.yield(.presentFuga) コードが辿りにくい } んだよなぁ } }

Slide 132

Slide 132 text

未解決問題 2

Slide 133

Slide 133 text

エラーの構造化 CustomError

Slide 134

Slide 134 text

エラーの構造化 Storyboard UIKit ViewController HostingController ObservedObject View CustomError ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… SwiftUI アプリ本体 DomainModel SPM1 CustomError Moya EntityModel CustomError SPM2

Slide 135

Slide 135 text

エラーの構造化 Storyboard UIKit ViewController HostingController View ObservedObject SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… 出所がわかる エラー Moya DomainModel SPM1 EntityModel NetworkError KeychainError SPM2

Slide 136

Slide 136 text

エラーの構造化 Storyboard UIKit ViewController HostingController View ObservedObject SwiftUI その後の振る舞いが わかるエラー ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… アプリ本体 DomainModel SPM1 LogoutError TicketError Moya EntityModel NetworkError KeychainError SPM2

Slide 137

Slide 137 text

エラーの構造化 Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel SPM1 LogoutError TicketError Moya EntityModel NetworkError KeychainError SPM2

Slide 138

Slide 138 text

未解決問題 3

Slide 139

Slide 139 text

分析ログの構造化 Storyboard UIKit ログ送信… ViewController HostingController ViewModel ログ送信… ObservedObject View APIService Domain Mapper ログ送信… Response) APIManager Alamofire Apollo Model Singleton) ログ送信… SwiftUI Completion Handler ログ送信… Moya Concurrency

Slide 140

Slide 140 text

分析ログの構造化 Storyboard ViewController UIKit 分析ログの送信って HostingController ObservedObject ビジネスロジック? View SwiftUI ViewModel UseCase Repository アプリ本体 ログ送信… Mapper APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2

Slide 141

Slide 141 text

分析ログの構造化 「削除」のログを送る場合 ・「スワイプして削除」「削除ボタンが押された」ことを知りたい? → UI層 ・「削除」という操作がされたことを知りたい? → Domain層

Slide 142

Slide 142 text

分析ログの構造化 操作を 知りたい Storyboard ViewController HostingController ObservedObject View ユーザ行動を 知りたい アプリ本体 ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… データ状況を DomainModel SPM1 知りたい ログ送信… Moya EntityModel SPM2 Shared Package

Slide 143

Slide 143 text

No content