Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

新規でAndroidアプリを作る際に役立った考え方 #famm年賀状2022 #android #kotlin

こんにちは、Androidエンジニアのtsutouです。

今年は夏ごろから去年iOS版をリリースしたFamm年賀状アプリのAndroid版を開発し、無事リリースすることができました。

play.google.com

半年ほどで新規アプリをリリースまで持っていくのは大変でしたが、外部のメンバーと協業し、吸収できたポイントがたくさんありました。

その時に役立った知見/考え方を共有できればと思います!

どんなアプリ?

Fammで使っている写真を加工して使ったり、好きな文字を印字して、簡単に年賀状が注文できるアプリです。 マイデザイン機能や宛先登録機能で、再注文もしやすい設計になっています。

ぜひ!今年は年賀状を! 😙

さまざまなテンプレートがあり、写真の穴のパターンも膨大にあるので、開発者側から見るととても画像の加工周りが1番の壁になりそうだなという印象で、実際にそうでした。

f:id:tsutoutakehara:20211227174714p:plainf:id:tsutoutakehara:20211227174708p:plainf:id:tsutoutakehara:20211227174703p:plainf:id:tsutoutakehara:20211227174655p:plainf:id:tsutoutakehara:20211227174659p:plain

まず、設計的な部分

馴染みのあるGoogle の推奨 MVVM + UseCaseを取り入れたClean Architecture 風のスタイルを選択しました。

developer.android.com

nrslib.com

雑ですがこんなフローになります。

f:id:tsutoutakehara:20211227153849p:plain

画面遷移

当初はSingle ActivityのNavigationで作っていたのですが、画面数も多く、データもFeatureごとのViewModelに管理できていた方がわかりやすく、工数的にもそちらの方がいくらか楽だよねという話になり、1Featureごとに1ActivityでNavigationしていくスタイルに落ち着きました。

Navigation Graphで各Featureごとの画面構成がパッと見てわかるので新規参入メンバーなどにとってもわかりやすいです。

DI

Dagger Hiltを選択しました。

UseCase / Repository / サードパーティのクライアント/ Coroutine Dispatcherは全てHiltで注入します。

Daggerには苦手意識があったのですが、Hiltはとてもシンプルで書きやすくスムーズに導入できました。(感動)

developer.android.com

画像ローダ

最初はGlideを使っていたのですが、どうもCoroutinesの中で使うとなると冗長になってしまう&しんどい場面が多く、悩みました。

非同期で画像をゴリゴリ加工していくアプリなので、Kotlin Coroutines/Flowに親和性が高そうという理由で初期段階でCoilに切り替える事にしました。

github.com

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を使っていく形に落ち着きました。

developer.android.com

設計を整えていく段階で、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

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる