Slide 1

Slide 1 text

CONFIDENCIAL KMM導入事例 ~Tapple編~

Slide 2

Slide 2 text

CONFIDENCIAL 目次 ● TappleのKMM全体像 ○ 概要 ■ KMMとは ■ 変遷 ■ 設計 ○ KMM化のフロー ■ 工夫した点と今後 ● iOSエンジニアがKMM化を進める上での工夫、試行錯誤した点 ○ 設計 ○ iOS現状と今後について ○ KMMのメリット・デメリット ● まとめ

Slide 3

Slide 3 text

CONFIDENCIAL ● タップルにて KMM導入を決めた背景・理由がわかる ● タップルでの KMM導入の進め方、工夫した/している点 がわかる ● iOSチーム、アプリコードへの導入時の工夫した/している点 がわかる この発表のゴール

Slide 4

Slide 4 text

CONFIDENCIAL https://developers.cyberagent.co.jp/blog/archives/37611/ KMMのおさらいと他チーム事例

Slide 5

Slide 5 text

CONFIDENCIAL TappleのKMM全体像 Androidエンジニア ☕と飲んだり、淹れたりするのが 趣味です Shohei Kawano

Slide 6

Slide 6 text

CONFIDENCIAL 前提 ● 2014年サービスリリース → コードベースが大きい ● サービス特性上、仕様が複雑 ○ 男女による分岐 ○ 「自分」と「お相手」のユーザー情報・状態 ○ 特定条件によるイベント発生、etc. ● 様々な場所でOS間アプリ挙動差異 なぜKMM?

Slide 7

Slide 7 text

CONFIDENCIAL 導入による狙い・理想 ● 複雑なロジックを一箇所にまとめて管理する ● OS間アプリ挙動差異を整理・修正する ● 両OS向けアプリエンジニアが協働し設計・挙動差異をなくす →より質の高いユーザー体験をより早く届けられる状態 なぜKMM?

Slide 8

Slide 8 text

CONFIDENCIAL Flutterの選択肢? ● Flutter=UI&ロジック両方を共有可能 ● タップルの要件 ○ UIは既存のものを使いまわし、ロジックのみ共有 なぜKMM? https://kotlinlang.org/lp/mobile/

Slide 9

Slide 9 text

CONFIDENCIAL TappleでのKMM導入変遷

Slide 10

Slide 10 text

CONFIDENCIAL 2020年後半 ● KMM検討・検証開始 2021年前半 ● Androidの既存コードをKMM-Compatibleに ○ network / modelsをcommonMainに移動 ○ RetrofitをKtorに置き換え ○ etc. ● KMMアーキテクチャ策定 ● 計測ロジックなどのKMM化 satoshun KMM導入変遷

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

CONFIDENCIAL 2021年後半 ● KMMチーム発足 ○ Android ■ 専任1名 ■ 兼務1名 ○ iOS ■ 兼務1名 ● 機能のKMM化開始 2022年 ● iOS&AndroidのGitリポジトリをモノレポ化 ● ドキュメント整備 ● 小さい画面から少しずつKMM化 KMM導入変遷 satoshun モノレポ化後、 iOS/Androidのコードの距離が 近くなり、成果物をpublish/取 り込むコストも減り、より KMMの開発がしやすくなりま した

Slide 13

Slide 13 text

CONFIDENCIAL 現在 ● 複数人のモバイルエンジニアがKMMを用いて開発中 ○ 新規機能開発→まずKMMで開発可能かどうか検討し可能な限りKMMで開発 ○ 既存機能の置き換え→徐々に ● 13個のKMM Featureモジュール ● 施策開発・その他技術的な取り組みと並行して少しずつ推進中 KMM導入変遷

Slide 14

Slide 14 text

CONFIDENCIAL TappleのKMM設計

Slide 15

Slide 15 text

CONFIDENCIAL ● ViewModelクラスをKMMで新しく定義することはしない ● ViewModel内のロジック/状態管理をKMMモジュールにて共通化する ● 単一方向のデータフロー アーキテクチャ方針

Slide 16

Slide 16 text

CONFIDENCIAL ● Presenter ○ データ取得、状態管理の役割 ○ iOS/AndroidのViewModel内で利用 ○ 1Screen, 1Presenter ○ Dispatcher ■ Actionを発行する ○ Action ■ Presenterへの命令 ○ UiState ■ UIの状態を表現する View ViewModel Presenter Repository Remote API Local Storage KMMのアーキテクチャ KMMによって共通化

Slide 17

Slide 17 text

CONFIDENCIAL 「いいかも送信履歴」画面 実装例(ざっくり)

Slide 18

Slide 18 text

CONFIDENCIAL いいかも送信履歴 ● 初回読み込み ● 追加読み込み ● 「いいかも」送信の取り消し

Slide 22

Slide 22 text

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 = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState

Slide 23

Slide 23 text

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 = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState 初回読み込み 追加読み込み 「いいかも」取り消し

Slide 24

Slide 24 text

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 = emptyList(), … val networkError: LikeHistoryUiEffect.NetworkError? = null ) : UiState UI側に渡す Stateを定義

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

CONFIDENCIAL class LikeHistoryPresenter internal constructor( private val repository: LikeHistoryRepository, dispatcher: CoroutineDispatcher ) : Presenter( 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更新を行う

Slide 27

Slide 27 text

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) }

Slide 28

Slide 28 text

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を発行する場合 もあります

Slide 29

Slide 29 text

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側から購読するための状態を公開

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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で状態を購読するパターンも あります

Slide 32

Slide 32 text

CONFIDENCIAL LikeHistoryViewModel.swift extension LikeHistoryViewModel { func loadList() { presenter.dispatch(action: LikeHistoryUiActionRefresh()) } func loadMore() { presenter.dispatch(action: LikeHistoryUiActionLoadMore()) }

Slide 33

Slide 33 text

CONFIDENCIAL LikeHistoryViewModel.swift extension LikeHistoryViewModel { func loadList() { presenter.dispatch(action: LikeHistoryUiActionRefresh()) } func loadMore() { presenter.dispatch(action: LikeHistoryUiActionLoadMore()) } 拡張関数を定義して既存のViewModelか らpresenterのActionを発行する例

Slide 34

Slide 34 text

CONFIDENCIAL KMM化を推進するために 工夫した点と今後

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

CONFIDENCIAL ドキュメントの整備 ● 「まずはここを見る」 「ここを見ればなにかしらある」 ● 困ったこと・トラブルシュート事例を まとめる・貯める

Slide 38

Slide 38 text

CONFIDENCIAL KMM化のフローの整備

Slide 39

Slide 39 text

CONFIDENCIAL KMM化のフローの整備 既存コードの確認

Slide 40

Slide 40 text

CONFIDENCIAL KMM化のフローの整備 新しいモデル定義の方針を決め、 必要に応じてリファクタリング

Slide 41

Slide 41 text

CONFIDENCIAL KMM化のフローの整備 KMM featureモジュールと Presenterの作成

Slide 42

Slide 42 text

CONFIDENCIAL KMM化のフローの整備 作成したモジュールとPresenterを 使って既存コードを置き換え

Slide 43

Slide 43 text

CONFIDENCIAL 現状 ● 推進できてはいるが、施策開発等と並行しながらやっている状態 ● 正直まだまだ手探りな部分も多い ● KMM自体まだBetaの状態なので今後変更もありそう 今後 ● ”うまみ”のあるところから戦略的にKMM化・より推進する ● 理想郷「両OS向けアプリを開発できる貴重なエンジニアが増えていく状態」 に 工夫した点と今後

Slide 44

Slide 44 text

CONFIDENCIAL iOSエンジニア目線のKMM iOSエンジニア 🍻とサウナが趣味です Ryohei Uno

Slide 45

Slide 45 text

CONFIDENCIAL KMMを交えた iOSの設計の話

Slide 46

Slide 46 text

CONFIDENCIAL ● MVVM + Fluxを採用 ○ Action: API処理やStoreの更新 ○ Store: 各画面の状態を保持 ● Global State: SingletonのFluxで管理(Shared Flux) TappleのiOSの設計

Slide 47

Slide 47 text

CONFIDENCIAL ● VMが、Presenterを持つ ● API処理、状態管理をKMMに移動 ● VMは、KMMで解決できないOS差のロジック ○ Shared Fluxを交えた処理 ○ UserDefaults KMMを含めた画面の全体設計

Slide 48

Slide 48 text

CONFIDENCIAL 当初のKMMの設計から ● iOS側でPresenterをより使いやすい形に変更してみた ● 今回は、3つの工夫点を紹介します!

Slide 49

Slide 49 text

CONFIDENCIAL KMMを取り入れる  工夫①

Slide 50

Slide 50 text

CONFIDENCIAL 課題 ● VMで、KMMからCombineへの変換処理を都度書く必要があった アプローチ ● KMMのPresenterをラップしたKMMPresenterクラスを定義 KMMを取り入れるために 工夫1

Slide 51

Slide 51 text

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) } }

Slide 52

Slide 52 text

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) } } 初期化、状態の初期値の代入

Slide 53

Slide 53 text

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への変換

Slide 55

Slide 55 text

CONFIDENCIAL @MainActor public final class KmmPresenter>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject 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

Slide 56

Slide 56 text

CONFIDENCIAL @MainActor public final class KmmPresenter>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject 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

Slide 57

Slide 57 text

CONFIDENCIAL @MainActor public final class KmmPresenter>: PresenterProtocol { private var presenter: P public let uiState: CurrentValueSubject 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

Slide 58

Slide 58 text

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) } } コード量の変化

Slide 59

Slide 59 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter 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) } } コード量の変化

Slide 60

Slide 60 text

CONFIDENCIAL スッキリ 👏

Slide 61

Slide 61 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter 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の定義 変わったところ

Slide 62

Slide 62 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter 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 変わったところ

Slide 63

Slide 63 text

CONFIDENCIAL KMMを取り入れる  工夫②

Slide 64

Slide 64 text

CONFIDENCIAL KMMを取り入れるために 工夫2 課題 ● VMのテストをする時にKMMをMock化出来なかった アプローチ ● Mock用のPresenterを定義 ● Mockに必要なもの ○ 外から初期値を代入できる ○ 適切なタイミングで状態を更新できる ○ Presenterに、dispatchが来たことが分かる

Slide 65

Slide 65 text

CONFIDENCIAL @MainActor public protocol PresenterProtocol { associatedtype State associatedtype Action var uiState: CurrentValueSubject { get } func dispatch(action: Action) async } 手順1 Protocol化で抽象化

Slide 66

Slide 66 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel> { 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で使う

Slide 67

Slide 67 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel> { 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で定義

Slide 72

Slide 72 text

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 init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方

Slide 73

Slide 73 text

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 init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方 初期値の設定

Slide 74

Slide 74 text

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 init() { self.presenter = .init(.init(blockedRoom: [], isLoading: false, networkError: nil)) self.testTarget = .init(presenter: presenter) } } } 手順4 Testの書き方 状態更新

Slide 75

Slide 75 text

CONFIDENCIAL KMMを取り入れる  工夫③

Slide 76

Slide 76 text

CONFIDENCIAL KMMを取り入れるために 工夫3 前提 ● Tappleでは、SwiftUI化を一部進めている ● 宣言的UIは、各状態に対応するViewを書くことで状態の変更を検知してくれる

Slide 77

Slide 77 text

CONFIDENCIAL KMMを取り入れるために 工夫3 課題 ● SwiftUIを使っていて、OS差のない単純な画面では、VMはStateを@Publishedに マッピングのみしている アプローチ ● 直接SwiftUI側でKMMを呼び出す

Slide 78

Slide 78 text

CONFIDENCIAL @MainActor final class BlockHistoryViewModel { typealias Presenter = KmmPresenter 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

Slide 79

Slide 79 text

CONFIDENCIAL struct ItemReportView>: 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

Slide 80

Slide 80 text

CONFIDENCIAL struct ItemReportView>: 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で宣言

Slide 81

Slide 81 text

CONFIDENCIAL struct ItemReportView>: 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

Slide 82

Slide 82 text

CONFIDENCIAL 設計のまとめ 工夫① KMMPresenterを使うことで、ロジックを共通化ができた 工夫② Mockを使って、VMでKMMを交えたテストが書けるようになった 工夫③ SwiftUIを使ってViewの構築以外KMM側にロジックを寄せる画面ができた

Slide 83

Slide 83 text

CONFIDENCIAL iOS現状と今後について

Slide 84

Slide 84 text

CONFIDENCIAL iOS現状と今後について 現状 ● iOSチームの半分の人がKMMを触ったことがある ● 新規施策で、一部KMMを使った施策を行った 今後 ● iOS依存やKMMに寄せづらい画面もKMMの設計に合わせる ● iOSチームでの導入を引き続き進める ● iOSとAndroidで、Global Stateの管理を統一する

Slide 85

Slide 85 text

CONFIDENCIAL KMMのメリット・デメリット (現時点で河野・宇野が感じている点)

Slide 86

Slide 86 text

CONFIDENCIAL デメリット/改善の余地 ● ビルドエラー時の対応コスト(Gradleまわり) ● ビルド時間への影響 ○ KMM導入に寄ってiOSのビルド時間が一部長くなっている ● コミュニケーションコスト ○ 協働するケースが増える分発生 ○ ブランチ戦略など KMMのメリット・デメリット

Slide 87

Slide 87 text

CONFIDENCIAL メリット ● iOS/Androidエンジニア間での実装の共通認識を持ちやすい ● コードレベルでの共通化が出来るので、両OSの挙動を統一させやすい ○ 挙動差異が減る ● iOS/Androidエンジニアが協働するケースが増えた! ○ 設計議論 ○ UIの作り方など KMMのメリット・デメリット

Slide 88

Slide 88 text

CONFIDENCIAL まとめ

Slide 89

Slide 89 text

CONFIDENCIAL ざっくり以下について話をしました ● TappleにおけるKMMの全体像 ● iOSエンジニア目線のKMM ● KMMメリット・デメリット まとめ

Slide 90

Slide 90 text

CONFIDENCIAL TappleではKMMを使って試行錯誤しながら日々開発をしています! まだまだやりたいこと・できていないことがたくさんあります。 今回話せなかったお話もたくさんあります。 TappleのKMMや、弊社自体に興味がある方は、 この後の質問コーナー・イベント後アンケートで、 どしどしご質問・ご連絡お待ちしております! まとめ

Slide 91

Slide 91 text

CONFIDENCIAL KMM導入事例 ~Tapple編~ ご清聴 ありがとうございました!