私の携わるプロダクト「abceed」が拡大期を迎えたとき、人の増加に伴う設計変更の必要性に遭遇したときの話です。 なぜ再設計が必要だったのか、そしてなぜFluxを選択し、同実装したのかを解説します。
拡大期を迎えたプロダクトに起きたこと - Android編2022/04/20@大人気教育アプリ 3社 のモバイルアプリ開発秘話@yashims85
View Slide
皆様はどのようなチームで開発を行っていますか?
私がGlobeeに入社したのはabceedの売上が安定し、規模が拡大していく最中のことです。
拡大期を迎えたことで、様々な事情から、再設計を行う必要が出てきたタイミングでした。
Who is me?● 本名: 屋代昌也● 年齢: 36歳くらい● @ : @yashims85 (やしむす)● ❤ : スノボ、アニメ、バイク、お酒● 2020年末頃にGlobeeに入社し、Android開発をメインで担当
何を作ってるの?どうやって発展してきたの?
作っているもの
abceedとはクイズ、シャドーイング、スラッシュリーディングなど約20種類の豊富なアプリ学習に対応
累計ユーザー数220万人突破おかげさまで
なぜ設計を変える必要が出てきたか
なぜ設計を変える必要があったか● つくった人がずっとコードを追っているので、未知のコードが存在しなかった● 可読性、メンテのしやすさよりも、最短でリリースし収益化することが優先された● ミニマムから始め機能を追加していったために、スーパーなんでもクラスが生まれていた
あとから来た人が沼にハマりやすい環境になっていた。
再設計をするときの注意点
設計を変えるという事が今までの否定になってはいけない● 今あるものは作ったときのリソースの制約や、環境のもとで全力で作った結果● その当時の設計で拡張できる限界。設計寿命に達した● だから我々は再設計を行う
既存コードが悪いわけではなく、プロダクトに必要とされる価値や優先順位が変化してきた
実際に変えた設計の一例をご紹介
Fluxアーキテクチャの採用
Flux● ソフトウェアアーキテクチャの一つ● MVCやMVVM、Reduxなどと並行概念● Redux、Flutter等はFluxの実装系View DispatcherStoreActionsendcommitobservecreate
Flux● コードの制約度が高い● 大規模なイベント駆動型ソフトウェア向け● ボイラープレートが多くなりがちView DispatcherStoreActionsendcommitobservecreate単方向Pub/Sub参照の向き呼び出しの順番
なぜFlux?● iOS, Androidのエンジニアは相互に補助したり、p-rを出したりしながら開発を行っている● そのため何かしらの共通化を行いたい要求がある● 制約性の高いアーキテクチャで設計を共通化させる。○ 制約度が高いと自然と似たようなコードになる○ それぞれのOS設計上で実装されるので、無理無くできる● 多人数大規模開発に向いている● 完全分業ならAndroid標準のMVVMにしたほうが無難な場合も
AndroidでFluxFluxは設計概念なので、実装は無数に存在する。今回は独自で実装したので、それのご紹介です。
AndroidでFlux● FluxのViewはFragmentに相当● Dispatcherは単体だと都合が悪いので、DispatcherとビジネスロジックのUseCaseを分離Fragment(Presenter)DispatcherUseCaseStoreActionsendcommitobservecallcreate
PresenterAndroidでFlux● FragmentがもりもりになるのでPresenterに分離DispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve
PresenterAndroidでFlux● プレゼンテーション層とビジネスロジック層の分離ができた● ビジネスロジックはプレゼンテーションへの参照を持たないDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserveプレゼンテーション ビジネスロジック
実装● Flux Store: reactivex.Observable● Presenter: FragmentとBLのつなぎ処理● Flux Action: メッセージング用data class● Flux Dispatcher: kotlinx.coroutine● UseCase: Dispatcherから叩かれ、Storeを更新する処理
PresenterAndroidでFluxのStoreDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve
Flux Storeclass UserDetailStore {val error = RxProperty()val userInfo: RxProperty = RxProperty()fun commitError(exception: Exception) {this.error.value = exception}fun commitUserInfo(info: UserInfo) {this.userInfo.value = tabList}}AndroidのLiveDataと同じようなものと思って問題ありません。実装の実態はRx.BehaviourSubjectです。
PresenterAndroidでFluのViewDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve
Flux View(Presenter)class UserDetailPresenter(private val store: UserDetailStore,private val dispatcher: FluxDispatcher,) {private val disposables = CompositeDisposable()private var vm: UserDetailViewModel? = null// onViewCreated時などに呼ばれるfun initialize(vm: UserDetailVideoViewModel) {this.vm = vmstore.error.asObservable().subscribe { e: Exception -> vm.postError(e) }.addTo(disposables)store.userInfo.asObservable().subscribe { info: UserInfo -> vm.postUserName(info.name) }.addTo(disposables)}// onStart時などに呼ばれるfun showUserDetail(userId: String) {PrepareUserDetailAction(userId = userId).sendTo(dispatcher)}}
PresenterAndroidでFluxのActionDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve
Flux Actioninterface FluxAction {fun uid(): String = hashCode().toString()fun sendTo(dispatcher: FluxDispatcher) = dispatcher.dispatch(this)}data class PrepareUserDetailAction(val userId: String) : FluxActionFluxAction インターフェースを継承することで、後述のFluxDispatcherにいい感じに送れるようにしています。
PresenterAndroidでFluxのDispatcherDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve
FluxDispatcherclass FluxDispatcher( private val coroutineCxt: CoroutineContext = CoroutineName("FluxDispatcher") + Dispatchers.Default) { private val observerList: MutableList> =mutableListOf() @Suppress("UNCHECKED_CAST") fun register( klass: KClass<T>, cb: suspend (action: T) -> Unit ): ActionObserver<T> = ActionObserver( klass = klass, cb = cb ).also { runBlocking(coroutineCxt) { observerList.add(it as ActionObserver) } } @Suppress("UNCHECKED_CAST") fun registerOnce( klass: KClass<T>, cb: suspend (action: T) -> Unit ): ActionObserver<T> = ActionObserver( klass = klass, cb = cb, dispatchCount = 1 ).also { runBlocking(coroutineCxt) { observerList.add(it as ActionObserver) } } fun unRegister(observer: ActionObserver<out FluxAction>) { runBlocking(coroutineCxt) { observerList.remove(observer) } } fun dispatch(action: T) { runBlocking(coroutineCxt) { val execObserverList = observerList.filter { it.isMyAction(action) } Log.d( "Flux", "Dispatch action: ${action::class.simpleName}#${action.uid()} for ${execObserverList.size} observers." ) execObserverList.forEach { observer: ActionObserver-> CoroutineScope(Dispatchers.Default).launch { observer.observe(action) withContext(coroutineContext) { if (observer.dispatchCount > 0) { observer.dispatchCount-- if (observer.dispatchCount == 0) { unRegister(observer) } } } } } } } companion object { private const val DISPATCH_UNLIMITED: Int = -1 } data class ActionObserver<T : FluxAction>( val klass: KClass<T>, val cb: suspend (action: T) -> Unit, var dispatchCount: Int = DISPATCH_UNLIMITED ) { fun isMyAction(action: T): Boolean = klass.isSuperclassOf(action::class) suspend fun observe(action: T) = cb.invoke(action) }}資料は公開する予定ですので、後ほどご確認ください!
UseCase// data class PrepareUserDetailAction(// val userId: String// ) : FluxActionclass UserDetailUseCase(private val dispatcher: FluxDispatcher,private val userRep: UserRepository,private val store: UserDetailStore,) {private val disposables = CompositeDisposable()init {dispatcher.register(PrepareUserDetailAction::class) {prepareUserDetail(it.userId)}}private fun prepareUserDetail(userId: String) {userRep.readUserInfo(config).subscribe({ userInfo: UserInfo ->store.commitUserInfo(userInfo)}, {e: Throwable ->store.commitError(e as Exception)}).addTo(disposables)}}Dispatcherに登録。callbackは、バックエンドスレッド内で実行されてるNWエラー等が発生してもエラーの正常系として扱える!バックエンドからデータを取得しStoreにcommit
FluxイベントサイクルプレゼンテーションイベントサイクルPresenterAndroidでFluxプレゼンテーションイベントサイクルとFluxのイベントサイクルが分離され、Presenterで接続されているDispatcherUseCaseStoreActionsendcommitobservecallcreateFragmentcallpushViewModelobserve参照の向き呼び出しの順番赤字 非同期
まとめ● プロジェクトや組織のフェーズによって、コードに求められる優先価値は変わってくる● プロダクトは組織や顧客の写し鏡○ ある設計やその設計を元にしたコードには寿命が存在する○ 課題が変化したタイミングが再設計の良いタイミング● Fluxの単方向Pub/Subは多人数開発ではとても具合が良いので、同じような境遇の方はネタの一つに加えてみてはいかがでしょうか?
Eigowakaran!!Globeeでは一緒に働いていただけるエンジニア募集中です!(英語ができなくても大丈夫です!)
おわり