1
/
5

Wantedly VisitにおけるKotlin Multiplatformの導入と実装

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を変化させる。

といった単方向のデータフローを持ちます。

eventerrorは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で更新される
    }
}

最初にデータロードする際の流れを説明します。

  1. UIから`PostReactor.send(Action.Load)`される。
  2. `Action.Load`は`mutate()`に流れる。
  3. `fetchPostUseCase`が呼ばれる。
  4. `fetchPostUseCase`はAPIを呼び、結果をDBに保存する。
  5. `transformMutation()`で`getPostUseCase`から取得した`Flow`によってDBのストリームを監視しているため、DBに新しい値が流れる。
  6. 新しい値は`map`によって`Mutation.SetPost`に変換される。
  7. `reduce()`に`Mutation.SetPost`が流れてくる。
  8. `State.post`が更新される。
  9. `PostReactor.state`のストリームに新しい`State`が流れる。
  10. 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.ktsmaven-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のレイヤーでキャッシュするといったことをやっているため、ReactorViewModelopenにしています。
また、ViewModelを挟むことによって、ReactorをモックしてFragmentのユニットテストができるというメリットもあります。

Fragmentの実装は、次のようにstatecollectして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のビルドが失敗する問題があります。
これはpodspecGradleタスクの挙動をオーバーライドすることで回避しています。

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を検討する際の参考になれば幸いです。

Wantedly, Inc.では一緒に働く仲間を募集しています
23 いいね!
23 いいね!

同じタグの記事

今週のランキング

Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?