今Androidアプリを作るならこんな感じの構成にするつもり
はじめに
自分のGithubに公開してるコードが古くなってきたので、
現時点でAndroidアプリを作るならどんな構成にするか検討した結果を書いた日記です。
使用した技術はこんな感じです。
- Hilt
- Kotlinx.serialization
- Jetpack Compose
- Retrofit
モジュール構成
usecaseは後から追加したので図には出ていません。。。
RepositoryとViewModelの間にUseCaseのモジュールを置いています。
※gradleの設定的な意味ではappモジュールからrepository
やnetwork
モジュールも参照してますが、本質ではないので省いてます。(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.uiState
ViewModelで用意している状態管理の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するように修正しようと考えています。
参考
ソースコード一式
Discussion