Upgrade to Pro — share decks privately, control downloads, hide ads and more …

巨大モノリシックアプリ モダン化大作戦

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.
Avatar for Ryomm Ryomm
September 12, 2026

巨大モノリシックアプリ モダン化大作戦

2026.09.12 iOSDC Japan 2026

Avatar for Ryomm

Ryomm

September 12, 2026

More Decks by Ryomm

Other Decks in Programming

Transcript

  1. Storyboardのメンテ大変すぎる問題 〜 ね か 開 全然 ぎ す 重 ぎ

    す く に し 作 操 こいつTa bleViewか 囲 範 差分わか 響 影 らん!! ? ! い な ど よ
  2. StoryboardからSwiftUIへの移行 class ViewController: UIViewController { let hostingController: UIHostingController<HogeView> 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)
  3. 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 } } }
  4. StoryboardからSwiftUIへの移行 View class HostingController: UIHostingController<HogeView> { private let model: ViewStateModel

    init(model: ViewStateModel) { ObservedObject self.model = model super.init(rootView: .init(model: model)) } HostingController var actionPublisher: AnyPublisher<Void, Never> { model.$actionEvent.dropFirst().eraseToAnyPublisher() } ViewController func update(item: Item?) { View model.item = item } ViewModel }
  5. 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 } }
  6. 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 } }
  7. 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 } }
  8. 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
  9. 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
  10. 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 } }
  11. 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 } }
  12. APIクライアントの移行 static func getHoge(p: String, completion: @escaping (Result<Response, Error>) ->

    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<Response>) 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
  13. 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<Result<Domain, Error>, 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 }
  14. 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<Result<Domain, Error>, 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 }
  15. 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 }
  16. カオス爆誕前夜 Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService

    Domain Mapper Response) APIManager Alamofire Apollo Completion Handler Model Singleton) Moya Concurrency
  17. SwiftUI化 めでたしめでたし…? や ル ク イ サ フ ライ の

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

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

    作 操 ザ 見た目が同じだけで ユー れ 漏 良いのか…? 装 実 生 発 レ デグ スクロー ル や 分析ツー キーボー ルの ドの 実装漏れ 挙動の違 い ! 化 須 必 A Q
  20. 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 で 点 時 ビルド ! ず は る け 付 気
  21. 恐怖!誰も知らない進捗 は SwiftUI化 面 画 の ど ? み 済

    応 対 が 何 Domainモデル導入 いつ終わる見込み? い な ら 知 も 誰 全体で APIクライアント移行 ? た っ わ 終 が 割 何 どの画面は Strict Concurrency Checking対応 何がで きてない?
  22. Repository導入? Storyboard UIKit ViewController HostingController ViewModel ObservedObject View SwiftUI APIService

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

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

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

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

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

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

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

    ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel
  30. モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ObservedObject ここだけ View

    SwiftUI Swift6ビルド ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2
  31. モダン化大作戦 - モジュール化 var package = Package( /* … */

    ) package.targets.forEach { target in target.swiftSettings = (target.swiftSettings ?& []) + [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("InferIsolatedConformances"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), .enableUpcomingFeature("ImmutableWeakCaptures") ] }
  32. モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController レ イヤードアーキ テ

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

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

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

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

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

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

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

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

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

    ViewModel アプリ本体 UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya SPM1 EntityModel SPM2
  42. モダン化大作戦 - モジュール化 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"]) /" ..)
  43. モダン化大作戦 - モジュール化 Storyboard UIKit ViewController HostingController ViewModel UseCase Repository

    DataStore ObservedObject プロトコルを 独立したモジュール として定義 Mapper APIClientProtocol APIClientImpl KMPAPIClientImpl View SwiftUI アプリ本体 DomainModel SPM1 Moya EntityModel SPM2
  44. 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) 面倒だ…
  45. 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) """
  46. 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)
  47. UseCaseパターンがSwiftっぽくない Storyboard UIKit ViewController HostingController ObservedObject View SwiftUI ViewModel UseCase

    Mapper Repository APIClient DataStore Singleton, Cache, etc… DomainModel Moya EntityModel
  48. 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は ビジネスロジック単位
  49. 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)
  50. 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“)
  51. 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”)
  52. 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”)
  53. テストを維持したい 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 } }
  54. 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 /" ..) /" ..) }
  55. 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<ActionEvent> for await action in viewModel.action { switch action { case .presentFuga: presentFugaVC() case .dismiss: CombineもAsyncStreamも dismiss(animated: true) private let continuation: AsyncStream<ActionEvent>.Continuation nonisolated func makeAsyncIterator() -+ AsyncStream<ActionEvent>.Iterator { } stream.makeAsyncIterator() func dismiss() { } continuation.yield(.dismiss) func submit() { } } continuation.yield(.presentFuga) コードが辿りにくい } んだよなぁ } }
  56. エラーの構造化 Storyboard UIKit ViewController HostingController ObservedObject View CustomError ViewModel UseCase

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

    UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… 出所がわかる エラー Moya DomainModel SPM1 EntityModel NetworkError KeychainError SPM2
  58. エラーの構造化 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
  59. エラーの構造化 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
  60. 分析ログの構造化 Storyboard UIKit ログ送信… ViewController HostingController ViewModel ログ送信… ObservedObject View

    APIService Domain Mapper ログ送信… Response) APIManager Alamofire Apollo Model Singleton) ログ送信… SwiftUI Completion Handler ログ送信… Moya Concurrency
  61. 分析ログの構造化 Storyboard ViewController UIKit 分析ログの送信って HostingController ObservedObject ビジネスロジック? View SwiftUI

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

    アプリ本体 ViewModel UseCase Mapper Repository APIClient DataStore Singleton, Cache, etc… データ状況を DomainModel SPM1 知りたい ログ送信… Moya EntityModel SPM2 Shared Package