👏

今Androidアプリを作るならこんな感じの構成にするつもり

2021/09/30に公開

はじめに

自分のGithubに公開してるコードが古くなってきたので、
現時点でAndroidアプリを作るならどんな構成にするか検討した結果を書いた日記です。

使用した技術はこんな感じです。

  • Hilt
  • Kotlinx.serialization
  • Jetpack Compose
  • Retrofit

モジュール構成

usecaseは後から追加したので図には出ていません。。。
RepositoryとViewModelの間にUseCaseのモジュールを置いています。
※gradleの設定的な意味ではappモジュールからrepositorynetworkモジュールも参照してますが、本質ではないので省いてます。(Hiltのために参照してる)

networkモジュール

Retrofit2を使った通信を行うためのモジュールです。

DI

@Module
@InstallIn(SingletonComponent::class)
object ApiModule {

    @Singleton
    @Provides
    fun provideGithubService(retrofit: Retrofit): GithubService =
        retrofit.create(GithubService::class.java)

    @Singleton
    @Provides
    fun provideOkHttp(): OkHttpClient {
        val builder = OkHttpClient.Builder()
        val logging = HttpLoggingInterceptor()

        logging.level = if (true) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
        builder.addInterceptor(logging)

        return builder.build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl("https://api.github.com/")
            .addConverterFactory(
                Json {
                    ignoreUnknownKeys = true
                }.asConverterFactory(
                    "application/json".toMediaType()
                ),
            )
            .build()
}

Retrofitを使って通信を行うGithubServiceを作るためのObjectです。
Kotlinx.serializationを使ってみました。

@Module
@InstallIn(ViewModelComponent::class)
class GithubDataSourceModule {
    @Provides
    fun provideGithubDataSource(githubDataSource: GithubDataSourceImpl): GithubDataSource {
        return githubDataSource
    }
}

実態クラスではなくInterfaceを返却するようにしています。

通信処理など

interface GithubService {
    @GET("users")
    suspend fun getUsers(): Response<List<UsersResponseItem>>

    @GET("users/{user}")
    suspend fun getUserDetail(@Path("user") user: String): Response<UserDetailResponse>
}

ここはコードの通りですが、suspend funで戻りはRetrofitのResponseを使っています。

class GithubDataSourceImpl @Inject constructor(
    private val githubService: GithubService
): GithubDataSource {
    override suspend fun getUsers(): List<UsersResponseItem> {
        val response = githubService.getUsers()
        if (response.isSuccessful) {
            return requireNotNull(response.body())
        }
        throw NetworkException(response.code(), response.errorBody().toString())
    }

    override suspend fun getUserDetail(user: UserName): UserDetailResponse {
        val response = githubService.getUserDetail(user.name)
        if (response.isSuccessful) {
            return requireNotNull(response.body())
        }
        throw NetworkException(response.code(), response.errorBody().toString())
    }
}

GithubServiceを使ってAPIからデータを取得する処理です。
通信が成功したら結果を返し、失敗していれば独自の例外をthrowします。

RetrofitのResponse型は後続には関係ない人なので、ここでお別れです。

repositoryモジュール

今回はリモートからのデータ取得しか用意していませんが、
Roomを含めていろんなところからデータを取得するためのモジュールです。

DI

@Module
@InstallIn(ViewModelComponent::class)
class GithubRepositoryModule {

    @Provides
    fun provideGithubRepository(githubRepository: GithubRepositoryImpl): GithubRepository {
        return githubRepository
    }
}

networkの方と同じで実態クラスをInterfaceに変えてあげる子です。

@Module
@InstallIn(SingletonComponent::class)
class DispatcherModule {
    @Provides
    @Singleton
    fun provideIODispatcher(): CoroutineDispatcher {
        return Dispatchers.IO
    }
}

Repositoryに渡すDispatcherを用意します。

Repository

class GithubRepositoryImpl @Inject constructor(
    private val githubDataSource: GithubDataSource,
    private val coroutineDispatcher: CoroutineDispatcher
) : GithubRepository {
    override fun getUsers(
        onStart: () -> Unit,
        onComplete: () -> Unit,
        onError: (e: NetworkException) -> Unit
    ): Flow<List<User>> = flow {
        emit(githubDataSource.getUsers().toListUser())
    }.setEvent(onStart, onError, onComplete).flowOn(coroutineDispatcher)

    override fun getUSerDetail(
        userName: UserName,
        onStart: () -> Unit,
        onComplete: () -> Unit,
        onError: (e: NetworkException) -> Unit
    ): Flow<UserDetail> = flow {
        emit(githubDataSource.getUserDetail(userName).toUserDetail())
    }.setEvent(onStart, onError, onComplete).flowOn(coroutineDispatcher)
}

fun List<UsersResponseItem>.toListUser(): List<User> {
    return this.map {
        User(
            imageUrl = it.avatar_url ?: "",
            login = it.login
        )
    }
}

fun UserDetailResponse.toUserDetail(): UserDetail {
    return UserDetail(
        imageUrl = this.avatar_url,
        login = this.login,
        name = this.name ?: "",
        followers = this.followers,
        following = this.following
    )
}

Flowで通信結果を返却するようにしてみました。
処理の開始時にonStart()、処理の終了時にonComplete()、エラー時にonErrorを呼び出すようにしています。
今回は上手く使えていませんが、プログレスの表示管理などで使えるかと思ってこの形にしています。
setEventという拡張関数で設定しています。

ここまではAPIとのやり取りで使っていたModelを使っていましたが、ここで別のモデルに詰め替えています。
※APIの仕様変更などで後続に影響を出さない意図があります。

Featureモジュール

画面イメージ

Githubのユーザを上から順番に並べているだけです。

Viewの処理

@Composable
fun UserListView(
    navController: NavController,
    viewModel: UserListViewModel
) {
    val uiState: UserListViewModel.UiState by viewModel.uiState

    when (uiState) {
        is UserListViewModel.UiState.Loading -> {
            LoadingView()
        }
        is UserListViewModel.UiState.Success -> {
            UserListSuccessView(users = uiState.requireUser(), navController = navController)
        }
        is UserListViewModel.UiState.Failure -> {
            FailureView(error = uiState.requireError())
        }
    }
}

@Composable
fun UserListSuccessView(
    navController: NavController,
    users: List<User>
) {
    LazyColumn {
        item {
            users.forEach {
                TextButton(onClick = {
                    navController.navigate("userDetail/${it.login}")
                }) {
                    Text(it.login)
                }
            }
        }
    }
}

private fun UserListViewModel.UiState.requireUser(): List<User> {
    return (this as UserListViewModel.UiState.Success).users
}

private fun UserListViewModel.UiState.requireError(): String{
    return (this as UserListViewModel.UiState.Failure).error
}

val uiState: UserListViewModel.UiState by viewModel.uiStateViewModelで用意している状態管理のseald classで状態管理をしています。

ViewModel

@HiltViewModel
class UserListViewModel @Inject constructor(
    private val getUserListUseCase: GetUserListUseCase
) : ViewModel() {
    val uiState: MutableState<UiState> = mutableStateOf(UiState.Loading)

    init {
        getUserList()
    }

    private fun getUserList() {
        viewModelScope.launch {
            getUserListUseCase(
                onStart = {},
                onComplete = {},
                onError = {
                    uiState.value = UiState.Failure(it.errorBody)
                }
            ).collect {
                uiState.value = UiState.Success(it)
            }
        }
    }

    sealed class UiState {
        object Loading : UiState()
        data class Success(val users: List<User>) : UiState()
        data class Failure(val error: String) : UiState()
    }
}

GetUserListUseCaseからFlowでデータを受け取りUiStateを経由してデータをセットしています。
UiStateが状態を表現しているseald classです。
開いた瞬間=ロード中と言うこともあり、状態はこの3個にしています。

appモジュール

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            Sample2021Theme {
                Surface(color = MaterialTheme.colors.background) {
                    NavHost(navController = navController, startDestination = "userList") {
                        composable("userList") { UserListView(navController, hiltViewModel()) }
                        composable(
                            "userDetail/{userName}",
                            arguments = listOf(
                                navArgument("userName") { type = NavType.StringType }
                            )
                        ) { backStackEntry ->
                            UserDetailView(
                                requireNotNull(
                                    backStackEntry.arguments?.getString("userName")
                                ),
                                hiltViewModel()
                            )
                        }
                    }
                }
            }
        }
    }
}

Activityの全体像です。

NavHost(navController = navController, startDestination = "userList") {
    composable("userList") { UserListView(navController, hiltViewModel()) }
    composable(
        "userDetail/{userName}",
        arguments = listOf(
            navArgument("userName") { type = NavType.StringType }
        )
    ) { backStackEntry ->
        UserDetailView(
            requireNotNull(
                backStackEntry.arguments?.getString("userName")
            ),
            hiltViewModel()
        )
    }
}

Navigationの設定をしています。
userListが初期表示でString型のuserNameを受け取るuserDetailに遷移が可能となっています。

usecaseモジュール

DI

@Module
@InstallIn(ViewModelComponent::class)
class UseCaseModule {
    @Provides
    fun provideGetUserDetailUseCase(
        getUserDetailUseCase: GetUserDetailUseCaseImple
    ): GetUserDetailUseCase {
        return getUserDetailUseCase
    }

    @Provides
    fun provideGetUserListUseCase(
        getUserListUseCase: GetUserListUseCaseImple
    ): GetUserListUseCase {
        return getUserListUseCase
    }
}

UseCaseのInterfaceを渡すためのモジュールです。

処理

class GetUserDetailUseCaseImple @Inject constructor(
    private val repository: GithubRepository
) : GetUserDetailUseCase {
    override suspend operator fun invoke(
        userName: UserName,
        onStart: () -> Unit,
        onComplete: () -> Unit,
        onError: (e: NetworkException) -> Unit
    ): Flow<UserDetail> =
        repository.getUSerDetail(userName, onStart, onComplete, onError)
}

RepositoryからFlowでデータを受け取ります。
本当ならここでモデルを再度詰め替えるべきですが、現時点では対応できてません
参考にしてくれる場合にはモデルを詰め替えてください

その他

@JvmInline
value class UserName(val name: String)

UserDetailのAPIを呼ぶための引数はvalue classを使ってます。

fun <T> Flow<T>.setEvent(
    onStart: () -> Unit,
    onError: (e: NetworkException) -> Unit,
    onComplete: () -> Unit
): Flow<T> =
    onStart {
        onStart
    }.catch {
        if (it is NetworkException) {
            onError(it)
        } else {
            throw it
        }
    }.onCompletion {
        onComplete()
    }

RepositoryのFlowに処理をセットする拡張関数です。
通信ができない時にはonStartでNetworkExceptionをthrowするように修正しようと考えています。

参考

https://www.amazon.co.jp/dp/B09DNV323P/ref=cm_sw_em_r_mt_dp_4V98JV8CH443RT2VPBPA
https://github.com/skydoves/Pokedex
https://github.com/odaridavid/Clean-MVVM-ArchComponents

ソースコード一式

https://github.com/sobaya-0141/Sample2109

Discussion