STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES 予約 における予約者さま向けアプリリニューアルの裏側 Androidアプリ編

はじめに

STORES 予約 モバイルエンジニアの satoryo です。

この記事は「STORES 予約 における予約者さま向けアプリリニューアルの裏側 Androidアプリ編」になります。

ここからは Coubic by STORES 予約 Android版のリニューアルについてソースコードを交えながら解説させていただきます。

リニューアルの記事について、PM・デザイナー編、iOSアプリ編も公開されていますので、もしよろしければ合わせてご覧いただけたら嬉しいです。

【PM・デザイナー編】 product.st.inc

iOS編】 product.st.inc

アーキテクチャ

Android Developers に記載されているレイヤ構造を参考に下の画像のような MVVM アーキテクチャで構成しました。

UI層では View で画面を描画し ViewModel で状態(State)を管理する役割として実装しています。

ドメイン層ではデータ層から取得したデータをUI層で扱いやすくするための変換処理を行っています。最近だとドメイン層を省略したアーキテクチャも見られますが、 APIから取得する予約情報が多く ViewModel のコード量を抑えるためあえて残しています。

データ層はAPI通信する役割を担っており、必要に応じてドメイン層へデータを渡したり通信しています。

その他モジュール構成などアーキテクチャの詳細に関しては、以前も記事を書いていますのでこちらをご覧ください。

STORES 予約 Androidアプリを Jetpack Compose でフルリニューアルしました

データフロー

現在、予約者さま向けアプリのAndroid版ではローカルDBを持っていません。 そのためデータフローについてはAPIリクエストの結果をドメイン層に渡す役割のみとなっており、それについてどのように実装をしたのかサンプルコードを交えつつ解説します。

API

APIクライアントの実装は OpenAPI Generator しており、エンドポイントやリクエストパラメータ、レスポンスを記述した YAML から自動生成しています。使用ライブラリは OkHttp3 と Retrofit2、Moshi です。

自動生成すると以下のようなソースコードが追加されます。なお今回は Coroutine を有効にしているため、 suspend fun で生成しています。

なお、記事中のソースコードはサンプルとして書き直したため実際のプロダクトのソースコードとは異なります。

interface DefaultApi {
    /**
     * 情報を取得する(サンプル)
     * 
     * Responses:
     *  - 200: OK
     *  - 400: Bad Request
     *
     * @return [InformationResponse]
     */
    @GET("/information")
    suspend fun fetchInformation(): Response<InformationResponse>
}

/**
 * 
 *
 * @param information 
 */

data class InformationResponse (

    @Json(name = "information")
    val information: kotlin.collections.List<kotlin.String>

)

こちらを使用して以下のようにAPIクライアントを実装しました。

またAPIリクエスト時のエラーに対応するため、レスポンスを判定するクラスと拡張関数を用意してハンドリングしています。

class InformationAPIClient @Inject constructor(private val client: DefaultApi) {

    suspend fun fetchInformation(): InformationResponse {
        val response = client.fetchInformation()

        return when (val type = response.status()) {
            is ResponseStatus.Success<*> -> type.body as InformationResponse
            is ResponseStatus.Error -> {  /* Error handling */  }
    }
}

sealed class ResponseStatus<out T> {
    data class Success<T>(val body: T?) : ResponseStatus<T>()
    data class Error(val body: String?) : ResponseStatus<Nothing>()

    companion object {
        fun <T> Response<T>.status(): ResponseStatus<T> =
            when (code()) {
                in 200..299 -> Success(body = body())
                else -> Error(body = "/* Error response */")
            }
    }
}

Repository

Repositoryは主にAPI通信を実行しレスポンスを受け取ります。ドメイン層からのアクセス用に interface を用意し、通信処理を別クラスで実装しています。

レスポンスを Flow 型としているのは、データ層からUI層までデータをやりとりする型を統一する目的と、今後ローカルDBを導入することを前提にしているためです。

interface InformationRepository {

    suspend fun fetchInformation(): Flow<InformationResponse>
}

internal class InformationRepositoryImpl @Inject constructor(
    private val informationAPIClient: InformationAPIClient
) : InformationRepository {

    override suspend fun fetchInformation(): Flow<InformationResponse> =
        flow {
            val response = informationAPIClient.fetchInformation()
            emit(response)
        }
}

UseCase (ドメイン層)

Repository(データ層)から受け取ったレスポンスをUI層に渡す際、データの変換をしています。

サンプルのソースコードでは分かりにくいですが、主にレスポンスパラメータをUIで扱いやすい型に変換したり、テキストを表示用に加工したりすることが多いです。

interface InformationDetailUseCase {

    suspend fun fetchInformation(): Flow<InformationModel>
}

class InformationUseCaseImpl @Inject constructor(
    private val informationRepository: InformationRepository
) : InformationUseCase {

    override suspend fun fetchInformation(): Flow<InformationModel> =
        informationRepository.fetchInformation().map { 
            InformationModel.convert(it) 
        }
}

data class InformationModel(
    val information: List<String>
) {
    companion object {
        fun convert(response: ResponseXXX): InformationModel =
            InformationModel(
                information = response.information
            )
    }
}

UI

ドメイン層から渡された値を画面に表示します。

ViewModelでは StateFlow で InformationModel を扱い、fetchして変更が入った際、値が柔軟に切り替わるようにしています。

@HiltViewModel
class InformationViewModel @Inject constructor(
    private val informationUseCase: InformationUseCase
): ViewModel() {

    private val _information = MutableStateFlow<InformationModel>(InformationModel(emptyList()))
    val information: MutableStateFlow<InformationModel> = _information

    init {
        fetchInformation()
    }

    fun fetchInformation() {
        viewModelScope.launch {
            informationUseCase.fetchInformation()
                .flowOn(Dispatchers.IO)
                .onEach { information ->
                    _information.value = information
                }
                .catch {
                    // エラーハンドリング
                }
                .launchIn(viewModelScope)
        }
    }
}

最後に ViewModel から渡された値をViewに表示して完了です。

@Composable
fun InformationScreen() {
    val viewModel = hiltViewModel<InformationModel>()
    val information = viewModel.information.collectAsState()

    Box(contentAlignment = Alignment.BottomEnd) {
        LazyColumn(
            modifier = Modifier.fillMaxSize()
        ) {
            items(announcements) { announcement ->
                Text(text = announcement.title)
            }
        }

        Button(onClick = { viewModel.fetchInformation() }) {
            Text(text = "更新")
        }
    }
}

おわりに

予約者さま向けアプリ「 Coubic by STORES 予約 」のAndroid版リニューアルについてデータフローを中心に解説しました。

実装からリリースまでが約半年とスピード感を持って開発しており、実装のこだわりと工数との調整に苦労する部分もありましたが、無事リニューアルできて良かったと思っています。

開発面では前回のオーナーさま向けアプリ「 STORES 予約 」のAndroid版リニューアルに引き続き Jetpack Compose を採用しました。今回は省略してしまいましたが特に Navigation 周りが特に苦労した部分だったので、また別の機会に記事を書く予定です。

今後はアプリの使われ方を分析しながらより良いプロダクトになるよう機能改善を行なっていきます。

最後までご覧いただきありがとうございました。