Wantedly VisitにおけるKotlin Multiplatformの導入と実装
Photo by Abraham Barrera on Unsplash
Kotlin Advent Calendar 2020の12月25日の記事です。空いていたため飛び込みで参加させていただきました 🙇♂️
はじめましての方ははじめまして。Wantedlyのモバイルエンジニアの久保出です。
Wantedly VisitではプロダクションへKotlin Multiplatformを導入しましたが、その導入までの経緯と設計から実装までの道のりをコードを交えて振り返ってみたいと思います。
執筆時点で使用しているKotlinは1.4.20です。
Kotlin Multiplatformとは
Kotlin Multiplatformとは、Kotlinで書かれた単一のコードを複数のプラットフォームで動作させる仕組みです。Kotlin MPPやKMPとよく呼ばれます。
Kotlin Multiplatformは次のような特徴を持ちます。
- JVM、JS、Nativeといったプラットフォームを主にサポートしています。NativeはLinux、iOS、macOS、Windowsなど各種OSをサポートしています。
- Multiplatformプロジェクトではサポートするプラットフォームを開発者が自由に選択できます。
- commonソースディレクトリ内でKotlinのコードを記述すると、各プラットフォームで動作するようにトランスパイルされたコードが生成されます。たとえばKotlin/JSではJavaScript、iOSではObjective-Cにトランスパイルされます。
- プラットフォーム固有のコードも固有のソースセット内で記述できます。例えばObjective−C互換性をサポートするコードをiOSのソースセットに記述することで、iOSにのみ提供されるコードになります。
- expect/actualという仕組みによりインターフェースとプラットフォームごとの実装を分ける記述が可能です。
Kotlin Multiplatform Mobile
Kotlin Multiplatformはすべてのプラットフォームをサポートするものですが、そのうちAndroid/iOSのモバイルプラットフォームに特化したユースケースはKotlin Multiplatform Mobile(KMM)と呼ばれます。
モバイルにおいてクロスプラットフォーム技術はすでにいくつか存在しますが、どれもUIのコードも共通化するものが多いです。
UIの共通化というのはかなりの茨の道であり、実はWebViewで実現されていたり、iOSの方がサポートが進んでいてAndroidでは実現しづらいUIが出てきたり、結局プラットフォームによる条件分岐が発生したりと課題が多いです。
KMMではビジネスロジックのみを共通化し、UIはプラットフォームごとに実装することを主眼においています。
そのため、UIはプラットフォームでのきれいでパフォーマンスのよい実装ができます。
SDKのような形で導入できるため、既存のプロジェクトに徐々に導入しやすいことも大きなメリットです。
ビジネスロジックのみを共通化すると書きましたが、共通化するレイヤーは開発者が自由に決めることができます。
なので、APIのみを共通化する、ViewModelなどのレイヤーまで共通化するといった課題に合わせた幅広い選択ができます。
導入の経緯
前回の記事で書いたため詳細は省きます。
要約すると
- 一部の画面で導入されていたReact Nativeがメンテナー不在となりビルド時間にも影響を与えるなど完全な負債と化していた。
- ビジネスロジックにもAPIスキーマの微妙な違いなどがあったり、iOSのキャッシュ機構が貧弱で体験に差があるなど、ビジネスロジックの実装の違いを課題に感じていた。
- 技術的な挑戦としてのモチベーションや将来的な生産性を上げられる期待が高かった。
といった理由で検証を進めることにしました。
導入検証
検証はAndroidで先行して進める形にしました。
理由としては、Kotlin MultiplatformをAndroidに導入するのは特に問題がないことがわかっていたことと、目的はReact Nativeのリプレースであり、リプレースもリソースの都合上AndroidをまずはやりきってからiOSをやることにしていたためです。
なので検証の初期段階では、iOSは後々の検証で難しいと判断したらすべてSwiftで実装するという方針でいました。
JetBrainsの中の人の話では、後述するようにiOSにおける課題のほうが多いため、iOSを先行させて検証したほうがよいとのことでした。
設計と実装
Gitリポジトリをどうするか
最初にvisit-app-shared
というリポジトリを作るところからはじめました。
公式に紹介されているサンプルでは、Android/iOS/Kotlin Multiplatformすべてを1つのリポジトリに入れる構成が多いですが、既存のAndroid/iOSプロジェクトへ段階的に導入するつもりだったため、既に別れているAndroid/iOSのリポジトリを統合するにはワークフローの大きな変更やCIの改修などのコストが高いといったデメリットの方が多く、Kotlin Multiplatformのリポジトリも分ける構成を取りました。
この時点ではiOSへの導入可否もわかっていないため、最悪Androidへ統合することも視野に入れていました。
フルスクラッチでKotlin Multiplatformを選択するのならば、公式のリファレンスにしたがって単一リポジトリで進めるとよいと思います。
アーキテクチャ
ここからはアーキテクチャについて、具体的なコードを交えながら説明していきます。
全体的なKotlin Multiplatformのレイヤーは次のような形にしました。
比較的オーソドックスなレイヤー設計かと思いますが、Native UI以外はKotlin Multiplatformによる実装になるように設計しました。
API
APIのライブラリはKtorを使用し、デシリアライズにはKotlinX Serializationを使用しています。
Database
Databaseは、Single source of truthとして使っています。ライブラリはSQLDelightを使用しています。
ほとんどのAPIからフェッチされたデータはデータベースに格納され、変更があればFlow
ストリームによって最終的にUIまで通知される仕組みです。
Repository
Repositoryは、モデルごとにフェッチする関数やFlow
ストリームを返す関数を提供し、内部でAPIの結果をデータベースに保存したり、データベースのストリームを返すようになっています。
UseCase
UseCaseは、基本的にRepositoryの各関数ごとに1つ切り出すような形でRepository層を隠蔽します。
単一のoperator fun invoke()
のみを持ち、ただの関数オブジェクトとして機能します。
Reactor
話はそれますが、Wantedly Visitでは2018年にiOSアプリをリニューアルしています。
リニューアルの際にはアーキテクチャを全面的に刷新しており、コアなライブラリとしてReactorKitを採用しました。
ReactorKitは単方向データフローアーキテクチャの一つです。詳しく書くと長くなるため、ReactorKitのドキュメントを参照してください。
その後のAndroidの段階的なリニューアルでも、ReactorKitと同じインターフェースを持ちつつAndroidXのViewModel
に乗っかったアーキテクチャを採用し、ビジネスロジックの違いを極力減らそうとしていました。
このような経緯から、既存のAndroid/iOSとの統合をしやすく、アーキテクチャ面での学習コストも下げるために、Kotlin MultiplatformでのアーキテクチャもReactorKitライクな実装にしました。
既存の設計などを気にしないのであれば、今ならMVIKotlinなどのKotlin Multiplatformのライブラリを選択すると良いでしょう。
Reactor
のデータフローは、図にすると次のようになります。
- UIからタップなどのトリガーで`Action`が送られる。
- `Action`は`mutate()`によってAPI通信などの副作用を経て`Mutation`に変換される。
- `Mutation`は`reduce()`によって現在の`Stateを変化させる。
- UIは`State`のストリームを監視してUIを変化させる。
といった単方向のデータフローを持ちます。
event
とerror
はReactorKitにはない概念です。
event
はワンタイムなイベントが流れてくるストリームです。Reactor内からpublish()
することでストリームにイベントが流れます。
例えば、登録フローのように登録API成功後に画面遷移を伴う場合、Reactorから完了のイベントを放出してFragmentやUIViewControllerで画面遷移を行うときなどに使います。
段階的な導入をする上で、ナビゲーションは共通ロジックでは所持しない方針にしたため、UI側へイベントを投げて遷移させるためにこのようなストリームを作りました。
error
はその名の通りReactor内部でハンドリングされたエラーが流れてくるストリームです。Reactor内からerror()
することでストリームにエラーが流れます。
通信エラーのようにハンドリング可能なエラーを放出してSnackbarやUIAlertViewControllerでエラーを表示するときなどに使います。
最終的なReactor
のインターフェースは次のようになりました。
interface Reactor<ActionT : Any, StateT : Any, EventT : Any, ErrorT : Exception> {
val initialState: StateT
val currentState: StateT
val state: Flow<StateT>
val event: Flow<EventT>
val error: Flow<ErrorT>
fun send(action: ActionT)
fun destroy()
}
AbstractReactor
フレームワークとして機能させるために、Reactor
に対する抽象クラスを実装します。 具象的なReactor
はこのAbstractReactor
を継承する形になります。
コードは次のような形です。簡略にしているので実際のコードからは削っているところがあります。
abstract class AbstractReactor<ActionT : Any, MutationT : Any, StateT : Any, EventT : Any, ErrorT : Exception>(
final override val initialState: StateT,
) : Reactor<ActionT, StateT, EventT, ErrorT> {
private val _actions: Channel<ActionT> = Channel(UNLIMITED)
// `protected: for some use cases such as incremental search in the sample of ReactorKit.
protected val actions: Flow<ActionT> = _actions.receiveAsFlow()
final override val currentState: StateT
get() = _state.value
private val _state: MutableStateFlow<StateT> = MutableStateFlow(initialState)
final override val state: Flow<StateT> = _state
private val _event: BroadcastChannel<EventT> = BroadcastChannel(CONFLATED)
final override val event: Flow<EventT> = _event.openSubscription().receiveAsFlow()
private val _error: BroadcastChannel<ErrorT> = BroadcastChannel(CONFLATED)
final override val error: Flow<ErrorT> = _error.openSubscription().receiveAsFlow()
private val job: Job = SupervisorJob()
internal val reactorScope: CoroutineScope = CoroutineScope(job + Dispatchers.Main)
private val isFlowInitialized: AtomicBoolean = AtomicBoolean(false)
init {
reactorScope.launch {
transformAction(actions)
.flatMapMerge { mutate(it) }
.let { transformMutation(it) }
.collect { _state.value = reduce(currentState, it) }
}
}
final override fun destroy() {
job.cancel()
_actions.close()
onDestroy()
}
/** Subclasses can override this to do something at [destroy] time. */
open fun onDestroy() {}
protected abstract fun mutate(action: ActionT): Flow<MutationT>
protected abstract fun reduce(currentState: StateT, mutation: MutationT): StateT
final override fun send(action: ActionT) {
_actions.offer(action)
}
protected open fun transformAction(action: Flow<ActionT>): Flow<ActionT> = action
protected open fun transformMutation(mutation: Flow<MutationT>): Flow<MutationT> = mutation
protected fun publish(event: EventT) {
_event.offer(event)
}
protected fun error(error: ErrorT) {
_error.offer(error)
}
}
実際のデータフローは次のReactor実装例にて解説します。
ここで登場するtransform
関数は、ReactorKitにもあるReactor
の中の単方向のストリームに途中で介入できる仕組みです。transformMutation()
で、よりスコープの広いグローバルなストリームを監視し、Reactor
という狭いスコープに反映するような使い方をします。 例えば、記事一覧画面があったときに記事詳細画面でLikeしたという状態を一覧画面に反映するときなどに、グローバルなストリームを経由して状態を反映させるといった使い方です。
データベースをSingle source of truthとして使っているため、データベースのストリームの監視を主にtransformMutation()
で行っています。
Reactor実装例
実際の実装とはかなり差がありますが、記事(Post)画面のReactorは次のような形になります。
class PostReactor(
private val postId: PostId,
private val fetchPostUseCase: suspend (PostId) -> Unit,
private val getPostUseCase: suspend (PostId) -> Flow<Post>,
private val likePostUseCase: suspend (PostId) -> Unit,
) : AbstractReactor<Action, Mutation, State, Nothing, Error>(State()) {
sealed class Action {
object Load : Action()
object Like : Action()
}
sealed class Mutation {
data class SetPost(val post: Post) : Mutation()
}
data class State(
val post: Post? = null,
)
sealed class Error(cause: Throwable) : Exception(cause) {
class GeneralError(cause: Throwable) : Error(cause)
}
// 1. UIからsend(Action.Load)される
// 2. mutate(Action.Load)が呼ばれる
override fun mutate(action: Action): Flow<Mutation> = flow {
when (action) {
Action.Load -> {
try {
// 3. fetchPostUseCaseが呼ばれる
fetchPostUseCase()
// 4. fetchPostUseCaseがDBにデータを保存する
} catch (e: UseCaseException) {
error(Error.GeneralError(e))
}
}
Action.Like -> {
try {
likePostUseCase(postId)
} catch (e: UseCaseException) {
error(Error.GeneralError(e))
}
}
}
}
override fun transformMutation(mutation: Flow<Mutation>): Flow<Mutation> = merge(
mutation,
// 5. getPostUseCaseのFlowを監視しているので新しいデータが流れてくる
getPostUseCase(postId)
// 6. 新しいデータがMutation.SetPostに変換される
.map { Mutation.SetPost(it) },
)
// 7. reduce(Mutation.SetPost)が呼ばれる
override fun reduce(currentState: State, mutation: Mutation): State = when (mutation) {
is Mutation.SetPost -> currentState.copy(
// 8. State.postが更新される
post = mutation.post,
)
// 9. 新しいStateがPostReactor.stateに流れる
// 10. UIが新しいStateで更新される
}
}
最初にデータロードする際の流れを説明します。
- UIから`PostReactor.send(Action.Load)`される。
- `Action.Load`は`mutate()`に流れる。
- `fetchPostUseCase`が呼ばれる。
- `fetchPostUseCase`はAPIを呼び、結果をDBに保存する。
- `transformMutation()`で`getPostUseCase`から取得した`Flow`によってDBのストリームを監視しているため、DBに新しい値が流れる。
- 新しい値は`map`によって`Mutation.SetPost`に変換される。
- `reduce()`に`Mutation.SetPost`が流れてくる。
- `State.post`が更新される。
- `PostReactor.state`のストリームに新しい`State`が流れる。
- UI側で`PostReactor.state`を監視していれば通知されるのでUIを更新する。
といった流れになり、単方向のデータフローが実現されます。
Androidへの導入
実際にAndroidへの導入した方法を説明します。
Androidへの依存の追加
公式リファレンスでは単一リポジトリ前提の記述が多いですが、前述のとおり我々はKotlin Multiplatformのリポジトリを分けたため、Androidプロジェクトへ依存を追加する手法を考えなくてはなりません。
AndroidへKotlin Multiplatformのモジュールを導入する方法は次のような2つの案がありました。
- MavenへAARのartifactとしてアップロードし、AndroidのプロジェクトでMaven経由で依存させる。
- git submoduleとして追加し、Gradleのサブモジュール扱いにする。
すでに社内Mavenの仕組みがあったのとgit submoduleは扱いが難しいと感じていたため、Mavenを使った方法にしました。
Kotlin MultiplatformプロジェクトではMavenへの配信はかなり簡単で、次のようにbuild.gradle.kts
へmaven-publish
プラグインを追加する+αの記述を追加するだけで済みます。
plugins {
kotlin("multiplatform")
id("maven-publish")
}
publishing {
repositories {
maven {
//...
}
}
}
Mavenへ配信したら、Android側のbuild.gradle.kts
で次のようにdependencies
を記述すればKotlin Multiplatformへの依存を追加できます。
repositories {
maven {
//...
}
}
dependencies {
implementation("com.wantedly.visit.app.shared:visit-app-shared:1.5.2")
}
これでAndroidプロジェクト上でKotlin Multiplatformを参照できるようになります。
Androidの実装例
Kotlin Multiplatform側で作られたReactor
は、そのままの形ではAndroid側で使っていません。 Android側で次のようなReactorViewModel
でラップしています。
open class ReactorViewModel<ActionT : Any, StateT : Any, EventT : Any, ErrorT : Exception>(
private val reactor: Reactor<ActionT, StateT, EventT, ErrorT>
) : ViewModel(), Reactor<ActionT, StateT, EventT, ErrorT> by reactor {
override fun onCleared() {
reactor.destroy()
super.onCleared()
}
}
これにより、AndroidのViewModel
のライフサイクルに乗せられるようになり、Reactor
のライフサイクル管理を気にしなくてよくなります。
Reactor
ごとにReactorViewModel
を継承した実装を行います。
class PostViewModel(postReactor: PostReactor) : ReactorViewModel<PostReactor.Action, PostReactor.State, Unit, PostReactor.Error>(postReactor) {
init {
postReactor.send(PostReactor.Action.Load)
}
}
この例では必要なさそうに見えますが、実際はDraftJSから複雑なSpannable
を構築してViewModel
のレイヤーでキャッシュするといったことをやっているため、ReactorViewModel
はopen
にしています。
また、ViewModel
を挟むことによって、Reactor
をモックしてFragment
のユニットテストができるというメリットもあります。
Fragment
の実装は、次のようにstate
をcollect
してView
に状態を反映するのと、View
から発生したアクションをReactor
へ送るだけになります。
class PostFragment : Fragment(R.layout.post_fragment) {
@Inject
lateinit var postViewModel: PostViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
PostFragmentBinding.bind(view).setup()
}
private fun PostFragmentBinding.setup() {
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
postViewModel.state.collect { state ->
// Render views with state
}
}
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
postViewModel.error.collect { error ->
// Error handlings
}
}
like.setOnClickListener {
postViewModel.send(PostReactor.Action.Like)
}
}
}
これでAndroidでもKotlin Multiplatformのコードが動作するところまでできました。
iOSへの導入
次はiOSです。
iOSへの依存の追加
iOSへ依存を追加する方法はいくつかあり、それだけで1つの記事にできそうなのですが、Kotlin公式にCocoaPodsへの統合方法が提供されているため、CocoaPodsで依存を追加することにしました。
ただし、この方法では`pod追加時にローカルパスへの参照が必須であるため、リポジトリを分割している我々の方法ではうまくいきません。そのため、git submodule + CocoaPodsという方法を取りました。
まずはKotlin Multiplatform側でbuild.gradle.kts
を編集します。
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
}
kotlin {
cocoapods {
authors = "Wantedly, Inc."
license = "..."
homepage = "..."
summary = "..."
frameworkName = "VisitAppShared"
}
}
kotlin("native.cocoapods")
プラグインによってpodspec
タスクが追加されます。これは実行することによって.podspec
を生成するタスクで、pod
としてKotlin Multiplatformを利用できるようにします。
$ ./gradlew podspec
コマンドでvisit_app_shared.podspec
が生成されるので、これをコミットしておきます。
次にiOSプロジェクトでgit submoduleとしてKotlin Multiplatformプロジェクトを追加します。 そしてiOS側のPodfile
へ.podspec
へのパスをpod
として追加します。
pod 'visit_app_shared', :path => './visit-app-shared/visit-app-shared'
これによってiOSプロジェクト上でKotlin Multiplatformを参照できるようになります。
iOSの実装例
Kotlin MultiplatformはObjective-Cのコードにトランスパイルされます。そのため、associatedtype
がなくてinterface
のジェネリクスが消失する、拡張関数のジェネリクスが消失するといった多くの制約があります。
制約を乗り越えてSwiftでの使いやすさを高めるためには、ある程度のブリッジの実装が必要になります。
制約によってFlow.collect
はSwiftから直接呼び出せないので、Kotlin Multiplatform側で次のようなFlow.collect
の代わりになるような拡張を書きました。
fun <StateT : Any, EventT : Any, ErrorT : Exception> AbstractReactor<*, *, StateT, EventT, ErrorT>.subscribe(
onState: ((StateT) -> Unit)? = null,
onEvent: ((EventT) -> Unit)? = null,
onError: ((ErrorT) -> Unit)? = null,
): Job {
val job = Job()
val scope = reactorScope + job
if (onState != null) {
scope.launch {
state.collect { onState(it) }
}
}
// ... onEvent, onError
return job
}
Swift側では、この拡張を使ってRxSwiftへ変換するNativeReactor
を実装しています。
前述の通り拡張関数のジェネリクスの情報は失われるため、force-castが必要になってきます。
private extension Kotlinx_coroutines_coreJob {
func asDisposable() -> Disposable {
Disposables.create {
self.cancel(cause_: nil)
}
}
}
class NativeReactor<Action: AnyObject, Mutation: AnyObject, State: AnyObject, Event: AnyObject, Error: KotlinException> {
private let reactor: AbstractReactor<Action, Mutation, State, Event, Error>
init(_ reactor: AbstractReactor<Action, Mutation, State, Event, Error>) {
self.reactor = reactor
}
var initialState: State { reactor.initialState }
var currentState: State { reactor.currentState }
var state: Observable<State> {
Observable.create { emitter in
self.reactor
.subscribe(
onState: {
let state = $0 as! State
emitter.onNext(state)
},
onEvent: nil,
onError: nil
)
.asDisposable()
}
}
// ... event, error
func send(_ action: Action) {
reactor.send(action: action)
}
}
ViewController
の実装は次のようになります。
class PostViewController: UIViewController {
private let reactor: NativeReactor<PostReactor.Action, PostReactor.Mutation, PostReactor.State, KotlinUnit, PostReactor.Error>
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
reactor.send(PostReactor.ActionLoad())
reactor.state
.subscribe(onNext: { [weak self] state in
// Render views with state
})
.disposed(by: disposeBag)
reactor.error
.subscribe(onNext: { error in
// Error handlings
})
.disposed(by: disposeBag)
like.rx.tap
.subscribe(onNext: { [weak self] in
self?.reactor.send(PostReactor.Action.Like())
})
.disposed(by: disposeBag)
}
}
ジェネリクスが非常に長いといった課題はありますが、バインディングの箇所は目指していた本家のReactorKitと殆ど変わらない記述になっています。
現状の課題
Kotlin 1.4.20での現状遭遇している課題について書きます。
実際にKotlin 1.3.70あたりから開発を始めていましたが、当初あった課題も1.4.20になるまでにほとんどは解消されています。
なのでここに上げる課題は、Kotlinの将来のバージョンで改善されている可能性は高いです。
Firebase Performanceとの併用ができない
https://youtrack.jetbrains.com/issue/KTOR-642
Kotlin MultiplatformのHTTPクライアントとして利用しているKtorですが、1.4.3時点でiOSではFirebase Performanceと併用するとクラッシュする問題があります。
iOSでFirebase Performanceをヘビーに使っていなかったため、改善されるまでFirebase Performanceを無効化することで回避しています。
Release/Debug以外のConfigurationに対応していない
https://youtrack.jetbrains.com/issue/KT-42023
iOSでCocoaPodsを使う場合、iOSプロジェクト側でDebug/Release
以外のConfiguration
を追加するとKotlin Multiplatformのビルドが失敗する問題があります。
これはpodspec
Gradleタスクの挙動をオーバーライドすることで回避しています。
val podspec by tasks.existing(PodspecTask::class) {
doLast {
val outputFile = outputs.files.singleFile
val text = outputFile.readText()
val newText = text
// Workaround: https://youtrack.jetbrains.com/issue/KT-42023
.replace("spec.pod_target_xcconfig = {",
"""
spec.pod_target_xcconfig = {
'KOTLIN_CONFIGURATION' => 'Release',
'KOTLIN_CONFIGURATION[config=Debug]' => 'Debug',
""".trimIndent()
)
.replace("\$CONFIGURATION", "\$KOTLIN_CONFIGURATION")
outputFile.writeText(newText)
}
}
iOSのビルド時間への影響
CocoaPodsを使う場合、Build Phaseとして.framework
を生成するGradleタスクを実行するという方法でKotlin Multiplatformは動いています。
Kotlin Multiplatformのコードに差分がない場合は問題ないですが、このGradleタスクは同じ規模のSwiftモジュールのビルドよりは時間を食います。
今のところは有効な回避策はないですが、Kotlinコンパイラは日々改善されているので今後に期待しています。
とか書いていたら1.4.30で早速改善されるようです。
まとめ
Wantedly VisitにおけるKotlin Multiplatformの歴史を振り返り、いかにプロダクション投入へ至ったかを書かせていただきました。
Kotlin MultiplatformはJetBrainsとコミュニティによって支えられ、今後も大きく発展していきモバイル開発においても大きな成果につながることが期待されます。
この記事がKotlin Multiplatformを検討する際の参考になれば幸いです。