今年は夏ごろから去年iOS版をリリースしたFamm年賀状アプリのAndroid版を開発し、無事リリースすることができました。
半年ほどで新規アプリをリリースまで持っていくのは大変でしたが、外部のメンバーと協業し、吸収できたポイントがたくさんありました。
その時に役立った知見/考え方を共有できればと思います!
どんなアプリ?
Fammで使っている写真を加工して使ったり、好きな文字を印字して、簡単に年賀状が注文できるアプリです。 マイデザイン機能や宛先登録機能で、再注文もしやすい設計になっています。
ぜひ!今年は年賀状を! 😙
さまざまなテンプレートがあり、写真の穴のパターンも膨大にあるので、開発者側から見るととても画像の加工周りが1番の壁になりそうだなという印象で、実際にそうでした。
まず、設計的な部分
馴染みのあるGoogle の推奨 MVVM
+ UseCaseを取り入れたClean Architecture 風
のスタイルを選択しました。
雑ですがこんなフローになります。
Repository
(データ操作)UseCase
(ビジネスロジック)ViewModel
(状態管理)View
(UI)
画面遷移
当初はSingle ActivityのNavigation
で作っていたのですが、画面数も多く、データもFeatureごとのViewModelに管理できていた方がわかりやすく、工数的にもそちらの方がいくらか楽だよねという話になり、1Featureごとに1ActivityでNavigationしていくスタイルに落ち着きました。
Navigation Graphで各Featureごとの画面構成がパッと見てわかるので新規参入メンバーなどにとってもわかりやすいです。
DI
Dagger Hiltを選択しました。
UseCase / Repository / サードパーティのクライアント/ Coroutine Dispatcher
は全てHiltで注入します。
Daggerには苦手意識があったのですが、Hiltはとてもシンプルで書きやすくスムーズに導入できました。(感動)
画像ローダ
最初はGlideを使っていたのですが、どうもCoroutinesの中で使うとなると冗長になってしまう&しんどい場面が多く、悩みました。
非同期で画像をゴリゴリ加工していくアプリなので、Kotlin Coroutines/Flowに親和性が高そうという理由で初期段階でCoilに切り替える事にしました。
UXも損ねず、かなり少ないコード量でかけるし、Flow/Coroutine Scopeの中で自然に扱えるのでとてもいい選択だったかなと思っています。 画像認証などは認証用のHttpClientを自前で作っておかないといけないので、その辺はGlideより大変でした。
状態管理
LoadStateViewModel
という、ベースの状態管理用のViewModelを作り、必要に応じて継承していくスタイルにしました。
ベースのViewModelを持つことは議論になりましたが、状態管理に統一性が生まれ、役割を限定することで肥大化も防げそうなのでこちらもやりやすい選択だったと思います。
以下のLoadStateにData(UI Model)をラップしてFlowで返します。
sealed class LoadState<out T> { data class Success<out T>(val value: T) : LoadState<T>() data class Error(val error: Throwable) : LoadState<Nothing>() object Loading : LoadState<Nothing>() }
/** * データのロード状態/エラーを表現するViewModel */ abstract class LoadStateViewModel : ViewModel() { private val _progressLiveData: MutableLiveData<Boolean> = MutableLiveData() val progressLiveData: LiveData<Boolean> = _progressLiveData private val _throwableLiveEvent: MutableLiveData<LiveEvent<Throwable>> = MutableLiveData() val throwableLiveEvent: LiveData<LiveEvent<Throwable>> = _throwableLiveEvent protected fun <T> handleLoadState( result: LoadState<T>, success: ((T) -> Unit)? = null ) { when (result) { is LoadState.Success -> { _progressLiveData.value = false success?.invoke(result.value) } is LoadState.Loading -> { _progressLiveData.value = true } is LoadState.Error -> { val throwable = result.error Timber.e(throwable) _throwableLiveEvent.value = LiveEvent(e) } } } }
非同期処理
もちろんKotlin Coroutines
ですが、応用が効きやすいので、基本的にKotlin Flow
を使っていく形に落ち着きました。
設計を整えていく段階で、CoroutineDispactherの注入場所が開発の最中に何度か変遷しました。
当初はViewModelで処理によって切り替えていたのですが、応用が効きづらく内部の処理に依存するので少しわかりづらい感じになっていました。
最終的に、ビジネスロジックがまとまっているUseCase
層にDispathcers.Default (計算処理に最適化)
、IO処理を担当するRepositoryにDispathcers.IO (I/O処理に最適化)
を注入する形に落ち着きました。
計算量が高くなるビジネスロジックはUseCaseにまとまり、ViewModelはDipathcerを持たないのでかなりスッキリしました。
以下、Dagger Moduleです。 Moduleごとに統一されているかもパッとわかるので良いです。
// RepositoryModule.kt @Singleton @Provides fun provideTopContentsRepository( @IoDispatcher ioDispatcher: CoroutineDispatcher, publicApiService: PublicApiService ) = TopContentsRepository(ioDispatcher, publicApiService)
// UseCaseModule.kt @Provides fun provideTopContentsUseCase( @ComputeDispatcher computeDispatcher: CoroutineDispatcher, topContentsRepo: TopContentsRepository ): TopContentsUseCase = TopContentsUseCase(computeDispatcher, topContentsRepo)
Repository
Repositoryは、Retrofitからsuspendで受け取ったデータをFlowで返すのみです。
初期はRepositroyで状態のラップやモデルの加工などもやっていたのですが、ここでFixしてしまうとViewModelやUseCaseで取り回しずらくなってしまったので、Repositoryではデータを返すのみの構造としました。
class TopContentsRepository constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val publicApiService: PublicApiService ) { fun getTopContents() = flow { emit(requireNotNull(publicApiService.getTopContents().body())) }.flowOn(ioDispatcher) }
Model
API/Local Dataから取得した値を集約する、独立したParcelable
なUI Modelを作ります。
このクラスは先述したLoadState
にラップされ、UseCase
から返ります。以降必要に応じて各画面に受け渡されていきます。
チームメンバーからアドバイスをもらいこの形式にしたのですが、APIやDataSourceから受け取ったEntityをViewModel/Viewは知らない状態になり、最適化された状態のModelが全て画面間で受け渡し可能になるので、こちらもいい選択でした。
@Parcelize data class TemplateData( val templateId: Int, val groupId: Int?, val regDate: Long?, val imageData: ImageData, val maskData: List<MaskData>, val priceData: PriceData ) : Parcelable { //... }
UseCase
ビジネスロジックを詰め込み、取得したDataをUI Modelに集約するクラスです。
ここが一番複雑化しますが、複雑な処理は全てここにまとまるので全体としてはコードも追いやすいと思っています。
runCaching
内で単一/複数の値を同期的に取得し、先述のLoadStateにラップしてViewModelに渡します。
class TopContentsUseCase( private val computeDispatcher: CoroutineDispatcher, private val topContentsRepository: TopContentsRepository ) { fun getTopContentsData() = flow { emit(LoadState.Loading) runCatching { val topContents = topContentsRepository.getTopContents().single() TopContentsData( topContents.contents.banners, getSectionData( topContents.contents.sections ) ) }.onSuccess { emit(LoadState.Success(it)) }.onFailure { emit(LoadState.Error(it)) } }.flowOn(computeDispatcher) //.. }
ViewModel
親のViewModelがLoading/Errorを管理してくれるので、UseCaseで加工したUIモデルをLiveDataに突っ込むだけです。
画面によってはSavedStateHandleでユーザーデータを保持していたりもします。
@HiltViewModel class TopContentsViewModel @Inject constructor( private val useCase: TopContentsUseCase ) : LoadStateViewModel() { private val _topContentsLiveData = MutableLiveData<TopContentsData>() val topContentsLiveData: LiveData<TopContentsData> = _topContentsViewData fun getTopContents() { viewModelScope.launch { useCase.fetchTopContentsData().collectLatest { handleLoadState(it) { model -> _topContentsLiveData.value = model } } } } }
View/UI
UIではLiveDataのコールバックを受け取って、状態の反映 or 受け取った値を各Componentに値を渡していくだけです。
@AndroidEntryPoint class TopContentsFragment : Fragment(R.layout.fragment_top_contents) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentTopContentsBinding.bind(view) viewModel.topContentsLiveData.observe(viewLifecycleOwner) { //成功時 }) viewModel.progressLiveData.observe(viewLifecycleOwner) { //ローディング } viewModel.throwableLiveEvent.observe(viewLifecycleOwner) { //エラー } } }
まとめ
時間がない中、新規でAndroidアプリを作る時こそチーム内でのルールや設計が整っていた方が開発がスムーズに進むと肌で感じました。
何よりチームメンバーと定期的に会話して現状のアプリに対する考え方、懸念を足並み揃えていくアクションが大切だなと思います。
新規は学ぶことも多く、負債のない状況なので吸収できることが多い反面、やることが多いので破綻しないように厳格に設計やルールを言語化していかないといけないですね 🍵
積極採用中!!
子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings