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

Leveraging KMM in Tapple (JP)

Leveraging KMM in Tapple (JP)

※現在ではKMPが推奨単語となっており、KMMは非推奨となっておりますが、登壇時点ではKMM(Kotlin Multiplatform for Mobile)という単語が一般的に用いられていたので、資料ではKMMが用いられています

===

プロダクトにおけるKMM導入事例について紹介しています。
タップルがKMMを採用した背景や全体設計、KMM化を進めるために整備したドキュメントやフロー、工夫した点と今後の展望などについて紹介しています。

また、後半ではiOS側のKMMに関連する設計や、iOSエンジニア目線でのKMM化を進める上での工夫した点、KMM導入におけるメリデメについても自分たちの意見を紹介しています。

===

* Although now KMP is a preferred word for multi-platform technology for mobile as well, at the time of this presentation, KMM was the common terminology at that time that refers to multi-platform technology that shares logics for both iOS and Android.

At Flutter × Kotlin Multiplatform by CyberAgent #8, Ryohei Uno and I talked about how we, Tapple, Inc. leverage KMM in our product. In detail, we talked about..
- How we managed our documentation to let new members easily join our development with KMM
- The architecture from iOS engineer perspective
- Pros and cons of KMM from our perspecitves

Video
- https://www.youtube.com/watch?v=xupjjhXwwP0

Shohei Kawano

August 12, 2023
Tweet

More Decks by Shohei Kawano

Other Decks in Technology

Transcript

  1. CONFIDENCIAL 目次 • TappleのKMM全体像 ◦ 概要 ▪ KMMとは ▪ 変遷

    ▪ 設計 ◦ KMM化のフロー ▪ 工夫した点と今後 • iOSエンジニアがKMM化を進める上での工夫、試行錯誤した点 ◦ 設計 ◦ iOS現状と今後について ◦ KMMのメリット・デメリット • まとめ
  2. CONFIDENCIAL 前提 • 2014年サービスリリース → コードベースが大きい • サービス特性上、仕様が複雑 ◦ 男女による分岐

    ◦ 「自分」と「お相手」のユーザー情報・状態 ◦ 特定条件によるイベント発生、etc. • 様々な場所でOS間アプリ挙動差異 なぜKMM?
  3. CONFIDENCIAL 2020年後半 • KMM検討・検証開始 2021年前半 • Androidの既存コードをKMM-Compatibleに ◦ network /

    modelsをcommonMainに移動 ◦ RetrofitをKtorに置き換え ◦ etc. • KMMアーキテクチャ策定 • 計測ロジックなどのKMM化 satoshun KMM導入変遷
  4. CONFIDENCIAL 2021年後半 • KMMチーム発足 ◦ Android ▪ 専任1名 ▪ 兼務1名

    ◦ iOS ▪ 兼務1名 • 機能のKMM化開始 2022年 • iOS&AndroidのGitリポジトリをモノレポ化 • ドキュメント整備 • 小さい画面から少しずつKMM化 KMM導入変遷 satoshun
  5. CONFIDENCIAL 2021年後半 • KMMチーム発足 ◦ Android ▪ 専任1名 ▪ 兼務1名

    ◦ iOS ▪ 兼務1名 • 機能のKMM化開始 2022年 • iOS&AndroidのGitリポジトリをモノレポ化 • ドキュメント整備 • 小さい画面から少しずつKMM化 KMM導入変遷 satoshun モノレポ化後、 iOS/Androidのコードの距離が 近くなり、成果物をpublish/取 り込むコストも減り、より KMMの開発がしやすくなりま した
  6. CONFIDENCIAL • Presenter ◦ データ取得、状態管理の役割 ◦ iOS/AndroidのViewModel内で利用 ◦ 1Screen, 1Presenter

    ◦ Dispatcher ▪ Actionを発行する ◦ Action ▪ Presenterへの命令 ◦ UiState ▪ UIの状態を表現する View ViewModel Presenter Repository Remote API Local Storage KMMのアーキテクチャ KMMによって共通化
  7. CONFIDENCIAL abstract class Presenter<S : UiState, A : UiAction>( initialState:

    S, dispatcher: CoroutineDispatcher ) : Dispatcher<A>, PresenterContext { … val state: StateFlow<S> = _state fun watchState() = state.wrap(this) … protected fun setState(reducer: S.() -> S) { scope.launch { stateMutex.withLock { _state.update(reducer) } } } … Presenter.kt
  8. CONFIDENCIAL abstract class Presenter<S : UiState, A : UiAction>( initialState:

    S, dispatcher: CoroutineDispatcher ) : Dispatcher<A>, PresenterContext { … val state: StateFlow<S> = _state fun watchState() = state.wrap(this) … protected fun setState(reducer: S.() -> S) { scope.launch { stateMutex.withLock { _state.update(reducer) } } } … Presenter.kt UI側に公開するstate property setState関数からのみ更新できる
  9. CONFIDENCIAL abstract class Presenter<S : UiState, A : UiAction>( initialState:

    S, dispatcher: CoroutineDispatcher ) : Dispatcher<A>, PresenterContext { … val state: StateFlow<S> = _state fun watchState() = state.wrap(this) … protected fun setState(reducer: S.() -> S) { scope.launch { stateMutex.withLock { _state.update(reducer) } } } … Presenter.kt iOS側からFlowを購読しやすくする ための関数です ※以下URL先の実装と同じ実装です https://github.com/Kotlin/kmm-production-sample/blob/aed57893bab7e88a0df50285196e942be6934bf8/shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/CFlow.kt
  10. CONFIDENCIAL LikeHistoryPresenter.kt sealed interface LikeHistoryUiAction : UiAction { object Initialize

    : LikeHistoryUiAction object LoadMore : LikeHistoryUiAction data class DeleteLike(val userId: Long) : LikeHistoryUiAction … } data class LikeHistoryUiState( val isLoading: Boolean = false, val likeHistoryList: List<LikeHistory> = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState
  11. CONFIDENCIAL LikeHistoryPresenter.kt sealed interface LikeHistoryUiAction : UiAction { object Initialize

    : LikeHistoryUiAction object LoadMore : LikeHistoryUiAction data class DeleteLike(val userId: Long) : LikeHistoryUiAction … } data class LikeHistoryUiState( val isLoading: Boolean = false, val likeHistoryList: List<LikeHistory> = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState 初回読み込み 追加読み込み 「いいかも」取り消し
  12. CONFIDENCIAL LikeHistoryPresenter.kt sealed interface LikeHistoryUiAction : UiAction { object Initialize

    : LikeHistoryUiAction object LoadMore : LikeHistoryUiAction data class DeleteLike(val userId: Long) : LikeHistoryUiAction … } data class LikeHistoryUiState( val isLoading: Boolean = false, val likeHistoryList: List<LikeHistory> = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState UI側に渡す Stateを定義
  13. CONFIDENCIAL class LikeHistoryPresenter internal constructor( private val repository: LikeHistoryRepository, dispatcher:

    CoroutineDispatcher ) : Presenter<LikeHistoryUiState, LikeHistoryUiAction>( initialState = LikeHistoryUiState(), dispatcher = dispatcher ) { … override fun dispatch(action: LikeHistoryUiAction) { when (action) { is Initialize -> initialize() is LoadMore -> loadMore() is DeleteLike -> deleteLike(action.userId) … } } LikeHistoryPresenter.kt
  14. CONFIDENCIAL class LikeHistoryPresenter internal constructor( private val repository: LikeHistoryRepository, dispatcher:

    CoroutineDispatcher ) : Presenter<LikeHistoryUiState, LikeHistoryUiAction>( initialState = LikeHistoryUiState(), dispatcher = dispatcher ) { … override fun dispatch(action: LikeHistoryUiAction) { when (action) { is Initialize -> initialize() is LoadMore -> loadMore() is DeleteLike -> deleteLike(action.userId) … } } LikeHistoryPresenter.kt Actionを発行してPresenter のstate更新を行う
  15. CONFIDENCIAL LikeHistoryViewModel.kt @HiltViewModel internal class LikeHistoryViewModel @Inject constructor( private val

    presenter: LikeHistoryPresenter ) : … { init { presenter.dispatch(LikeHistoryUiAction.Initialize) } val state = presenter.state.asLiveData(viewModelScope.coroutineContext) }
  16. CONFIDENCIAL LikeHistoryViewModel.kt @HiltViewModel internal class LikeHistoryViewModel @Inject constructor( private val

    presenter: LikeHistoryPresenter ) : … { init { presenter.dispatch(LikeHistoryUiAction.Initialize) } val state = presenter.state.asLiveData(viewModelScope.coroutineContext) } ViewModel初期化時にActionを発行する例 ActionはUIからViewModelを介してActionを発行する場合 もあります
  17. CONFIDENCIAL LikeHistoryViewModel.kt @HiltViewModel internal class LikeHistoryViewModel @Inject constructor( private val

    presenter: LikeHistoryPresenter ) : … { init { presenter.dispatch(LikeHistoryUiAction.Initialize) } val state = presenter.state.asLiveData(viewModelScope.coroutineContext) } UI側から購読するための状態を公開
  18. CONFIDENCIAL @MainActor final class LikeHistoryViewModel { typealias Presenter = KmmPresenter…

    ... private let presenter: Presenter init( ... presenter: Presenter ) { ... self.presenter = presenter presenter.uiState.map(\.likeHistoryList) .sink { [weak self] likeHistory in // handling logic } .store(in: &cancellableSet) ... } LikeHistoryViewModel.swift
  19. CONFIDENCIAL @MainActor final class LikeHistoryViewModel { typealias Presenter = KmmPresenter…

    ... private let presenter: Presenter init( ... presenter: Presenter ) { ... self.presenter = presenter presenter.uiState.map(\.likeHistoryList) .sink { [weak self] likeHistory in // handling logic } .store(in: &cancellableSet) ... } LikeHistoryViewModel.swift iOSの置き換えフェーズでは一度 ViewModelで状態を購読するパターンも あります
  20. CONFIDENCIAL LikeHistoryViewModel.swift extension LikeHistoryViewModel { func loadList() { presenter.dispatch(action: LikeHistoryUiActionRefresh())

    } func loadMore() { presenter.dispatch(action: LikeHistoryUiActionLoadMore()) } 拡張関数を定義して既存のViewModelか らpresenterのActionを発行する例
  21. CONFIDENCIAL 工夫した点 • ドキュメントの整備 • KMM化のフロー整備 • Slackチャンネル作成 • ペア作業の推奨

    →困ったときにすぐに参照できるものがある・質問できる状態作り 工夫した点と今後
  22. CONFIDENCIAL 工夫した点 • ドキュメントの整備 • KMM化のフロー整備 • Slackチャンネル作成 • ペア作業の推奨

    →困ったときにすぐに参照できるものがある・質問できる状態作り 工夫した点と今後
  23. CONFIDENCIAL 現状 • 推進できてはいるが、施策開発等と並行しながらやっている状態 • 正直まだまだ手探りな部分も多い • KMM自体まだBetaの状態なので今後変更もありそう 今後 •

    ”うまみ”のあるところから戦略的にKMM化・より推進する • 理想郷「両OS向けアプリを開発できる貴重なエンジニアが増えていく状態」 に 工夫した点と今後
  24. CONFIDENCIAL • MVVM + Fluxを採用 ◦ Action: API処理やStoreの更新 ◦ Store:

    各画面の状態を保持 • Global State: SingletonのFluxで管理(Shared Flux) TappleのiOSの設計
  25. CONFIDENCIAL • KMM側からSwiftへの変換処理の共通化 ◦ 初期化、状態の初期値の設定など ◦ 状態更新を監視する関数(Flow)を Combineへ変換 共通化したい部分 @MainActor

    final class BlockHistoryViewModel { private let presenter: BlockHistoryStatePresenter @Published private var state: BlockHistoryUiState @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: BlockHistoryStatePresenter) { self.presenter = presenter self.state = presenter.currentState self.presenter.watchState() .watch { [weak self] state in self?.state = state } state.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } }
  26. CONFIDENCIAL • KMM側からSwiftへの変換処理の共通化 ◦ 初期化、状態の初期値の設定など ◦ 状態更新を監視する関数(Flow)を Combineへ変換 共通化したい部分 @MainActor

    final class BlockHistoryViewModel { private let presenter: BlockHistoryStatePresenter @Published private var state: BlockHistoryUiState @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: BlockHistoryStatePresenter) { self.presenter = presenter self.state = presenter.currentState self.presenter.watchState() .watch { [weak self] state in self?.state = state } state.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } 初期化、状態の初期値の代入
  27. CONFIDENCIAL • KMM側からSwiftへの変換処理の共通化 ◦ 初期化、状態の初期値の設定など ◦ 状態更新を監視する関数(Flow)を Combineへ変換 共通化したい部分 @MainActor

    final class BlockHistoryViewModel { private let presenter: BlockHistoryStatePresenter @Published private var state: BlockHistoryUiState @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: BlockHistoryStatePresenter) { self.presenter = presenter self.state = presenter.currentState self.presenter.watchState() .watch { [weak self] state in self?.state = state } state.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } 状態の更新を監視する関数からCombineへの変換
  28. CONFIDENCIAL abstract class Presenter<S : UiState, A : UiAction>( initialState:

    S, dispatcher: CoroutineDispatcher ) : Dispatcher<A>, PresenterContext { … val state: StateFlow<S> = _state fun watchState() = state.wrap(this) … protected fun setState(reducer: S.() -> S) { scope.launch { stateMutex.withLock { _state.update(reducer) } } } … Presenter.kt iOS側からFlowを購読しやすくする ための関数です ※以下URL先の実装と同じ実装です https://github.com/Kotlin/kmm-production-sample/blob/aed57893bab7e88a0df50285196e942be6934bf8/shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/CFlow.kt
  29. CONFIDENCIAL @MainActor public final class KmmPresenter<S: Kmm_coreUiState, A: Kmm_coreUiAction, P:

    Kmm_corePresenter<S, A>>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject<S, Never> public init(_ presenter: P) { self.presenter = presenter self.uiState = .init(presenter.currentState) presenter.watchState() .watch { [weak self] uiState in self?.uiState.send(uiState) } } public func dispatch(action: A) { presenter.dispatch(action: action) } deinit { presenter.close() } } KMMPresenter
  30. CONFIDENCIAL @MainActor public final class KmmPresenter<S: Kmm_coreUiState, A: Kmm_coreUiAction, P:

    Kmm_corePresenter<S, A>>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject<S, Never> public init(_ presenter: P) { self.presenter = presenter self.uiState = .init(presenter.currentState) presenter.watchState() .watch { [weak self] uiState in self?.uiState.send(uiState) } } public func dispatch(action: A) { presenter.dispatch(action: action) } deinit { presenter.close() } } State, Action, Presenterを受け取る KMMPresenter
  31. CONFIDENCIAL @MainActor public final class KmmPresenter<S: Kmm_coreUiState, A: Kmm_coreUiAction, P:

    Kmm_corePresenter<S, A>>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject<S, Never> public init(_ presenter: P) { self.presenter = presenter self.uiState = .init(presenter.currentState) presenter.watchState() .watch { [weak self] uiState in self?.uiState.send(uiState) } } public func dispatch(action: A) { presenter.dispatch(action: action) } deinit { presenter.close() } } UIStateの変更を監視し、状態を変更 KMMPresenter
  32. CONFIDENCIAL @MainActor final class BlockHistoryViewModel { private let presenter: BlockHistoryStatePresenter

    @Published private var state: BlockHistoryUiState @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: BlockHistoryStatePresenter) { self.presenter = presenter self.state = presenter.currentState self.presenter.watchState() .watch { [weak self] state in self?.state = state } state.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } コード量の変化
  33. CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter<BlockHistoryUiState,

    BlockHistoryUiAction, BlockHistoryStatePresenter> private let presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } コード量の変化
  34. CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter<BlockHistoryUiState,

    BlockHistoryUiAction, BlockHistoryStatePresenter> private let presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } Presenterの定義 変わったところ
  35. CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter<BlockHistoryUiState,

    BlockHistoryUiAction, BlockHistoryStatePresenter> private let presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } UIStateから@PublishedへVC用にMapping 変わったところ
  36. CONFIDENCIAL KMMを取り入れるために 工夫2 課題 • VMのテストをする時にKMMをMock化出来なかった アプローチ • Mock用のPresenterを定義 • Mockに必要なもの

    ◦ 外から初期値を代入できる ◦ 適切なタイミングで状態を更新できる ◦ Presenterに、dispatchが来たことが分かる
  37. CONFIDENCIAL @MainActor public protocol PresenterProtocol<State, Action> { associatedtype State associatedtype

    Action var uiState: CurrentValueSubject<State, Never> { get } func dispatch(action: Action) async } 手順1 Protocol化で抽象化
  38. CONFIDENCIAL @MainActor final class BlockHistoryViewModel<Presenter: PresenterProtocol<BlockHistoryUiState, BlockHistoryUiAction>> { private let

    presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } 手順2 ViewModelで使う
  39. CONFIDENCIAL @MainActor final class BlockHistoryViewModel<Presenter: PresenterProtocol<BlockHistoryUiState, BlockHistoryUiAction>> { private let

    presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } 手順2 ViewModelで使う Genericsで定義
  40. CONFIDENCIAL @MainActor public final class MockPresenter<S, A>: PresenterProtocol { public

    private(set) var dispatchedAction: CurrentValueSubject<A, Never>? public let uiState: CurrentValueSubject<S, Never> public init(_ initialState: S) { self.uiState = .init(initialState) } public func changeState(state: S) { self.uiState.value = state } public func dispatch(action: A) { self.dispatchedAction?.value = action } } 手順3 MockPresenter作成
  41. CONFIDENCIAL @MainActor public final class MockPresenter<S, A>: PresenterProtocol { public

    private(set) var dispatchedAction: CurrentValueSubject<A, Never>? public let uiState: CurrentValueSubject<S, Never> public init(_ initialState: S) { self.uiState = .init(initialState) } public func changeState(state: S) { self.uiState.value = state } public func dispatch(action: A) { self.dispatchedAction?.value = action } } 初期値を代入する 手順3 MockPresenter作成
  42. CONFIDENCIAL @MainActor public final class MockPresenter<S, A>: PresenterProtocol { public

    private(set) var dispatchedAction: CurrentValueSubject<A, Never>? public let uiState: CurrentValueSubject<S, Never> public init(_ initialState: S) { self.uiState = .init(initialState) } public func changeState(state: S) { self.uiState.value = state } public func dispatch(action: A) { self.dispatchedAction?.value = action } } 外から状態を更新 手順3 MockPresenter作成
  43. CONFIDENCIAL @MainActor public final class MockPresenter<S, A>: PresenterProtocol { public

    private(set) var dispatchedAction: CurrentValueSubject<A, Never>? public let uiState: CurrentValueSubject<S, Never> public init(_ initialState: S) { self.uiState = .init(initialState) } public func changeState(state: S) { self.uiState.value = state } public func dispatch(action: A) { self.dispatchedAction?.value = action } } dispatchが呼ばれたことをテスト 手順3 MockPresenter作成
  44. CONFIDENCIAL final class BlockHistoryTests: XCTestCase { func testChangedUiState() throws {

    let testTarget = dependency.testTarget let presenter = dependency.presenter let exepect1 = try expect(testTarget.$uiState.collect(2).first()) { presenter.changeState(state: .init(blockedRoom: [], isLoading: true, networkError: nil)) } XCTAssertEqual(exepect1.map(\.isLoading), [false, true]) } struct Dependency { let testTarget: BlockHistorySwiftUiViewModel let presenter: MockPresenter<BlockHistoryUiState, BlockHistoryUiAction> init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方
  45. CONFIDENCIAL final class BlockHistoryTests: XCTestCase { func testChangedUiState() throws {

    let testTarget = dependency.testTarget let presenter = dependency.presenter let exepect1 = try expect(testTarget.$uiState.collect(2).first()) { presenter.changeState(state: .init(blockedRoom: [], isLoading: true, networkError: nil)) } XCTAssertEqual(exepect1.map(\.isLoading), [false, true]) } struct Dependency { let testTarget: BlockHistorySwiftUiViewModel let presenter: MockPresenter<BlockHistoryUiState, BlockHistoryUiAction> init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方 初期値の設定
  46. CONFIDENCIAL final class BlockHistoryTests: XCTestCase { func testChangedUiState() throws {

    let testTarget = dependency.testTarget let presenter = dependency.presenter let exepect1 = try expect(testTarget.$uiState.collect(2).first()) { presenter.changeState(state: .init(blockedRoom: [], isLoading: true, networkError: nil)) } XCTAssertEqual(exepect1.map(\.isLoading), [false, true]) } struct Dependency { let testTarget: BlockHistorySwiftUiViewModel let presenter: MockPresenter<BlockHistoryUiState, BlockHistoryUiAction> init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方 状態更新
  47. CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter<BlockHistoryUiState,

    BlockHistoryUiAction, BlockHistoryStatePresenter> private let presenter: Presenter @Published private(set) var blockHistorySectionModels: [BlockHistorySectionModel] = [] init(presenter: Presenter) { self.presenter = presenter presenter.uiState.map(\.blockedRoom) .sink { [weak self] blockedRoom in self?.blockHistorySectionModels = [.init(items: blockedRoom.map(\.user))] } .store(in: &cancellableSet) } } UIStateからVC用にMapping ViewModel
  48. CONFIDENCIAL struct ItemReportView<Presenter: PresenterProtocol<ItemReportUiState, ItemReportUiAction>>: View { @StateObject private var

    presenter: Presenter @State private var selection = 0 init(presenter: Presenter) { self._presenter = StateObject(wrappedValue: presenter) } var body: some View { ItemReportHistoryListView(list: presenter.uiState.value.historyList) { Task { await presenter.dispatch(action: ItemReportUiActionLoadMoreHistory()) } } } } Presenterを持つSwiftUIのView
  49. CONFIDENCIAL struct ItemReportView<Presenter: PresenterProtocol<ItemReportUiState, ItemReportUiAction>>: View { @StateObject private var

    presenter: Presenter @State private var selection = 0 init(presenter: Presenter) { self._presenter = StateObject(wrappedValue: presenter) } var body: some View { ItemReportHistoryListView(list: presenter.uiState.value.historyList) { Task { await presenter.dispatch(action: ItemReportUiActionLoadMoreHistory()) } } } } Presenterを持つSwiftUIのView Genericsで宣言
  50. CONFIDENCIAL struct ItemReportView<Presenter: PresenterProtocol<ItemReportUiState, ItemReportUiAction>>: View { @StateObject private var

    presenter: Presenter @State private var selection = 0 init(presenter: Presenter) { self._presenter = StateObject(wrappedValue: presenter) } var body: some View { ItemReportHistoryListView(list: presenter.uiState.value.historyList) { Task { await presenter.dispatch(action: ItemReportUiActionLoadMoreHistory()) } } } } Presenterを持つSwiftUIのView UIStateからViewにMapping