Coroutine Flow + Retrofit (+ Dagger Hilt) で 安全なAPIコールを実現する
Coroutine FlowとRetrofitを組み合わせて、安全なAPIコールを実装してみました。
完全なサンプルコードは chmod644/coroutine-flow-example を参照してください。
アーキテクチャ
MediumのAndroid Developersに記載されたアーキテクチャに則っています。
LiveData with Coroutines and Flow — Part III: LiveData and coroutines patterns | by Jose Alcérreca | Android Developers | Medium
レイヤ | 役割 |
---|---|
Data source (API Service) | APIコールしてFlowに結果を出力する。 |
Repository | DataSouceからFlowを参照し、必要に応じてデータのキャッシュや永続化を行う。 |
ViewModel | RepositoryのFlowから出力された値をLiveDataに保持する。 |
View | ViewModelのLiveDataを監視して画面に反映する。 |
DataSource(API)
APIコールのインタフェース
JSONPlaceholder - Free Fake REST API をお借りしました。
interface PostApi {
// Postの一覧を取得
@GET("posts")
suspend fun fetchPosts(): Response<List<Post>>
// Postを1件取得
@GET("posts/{id}")
suspend fun fetchPost(@Path("id") id: Int): Response<Post>
}
Future
: APIコールの返り値
APIコールが実行中であることやAPIコールのエラーも表現できるように、Sealed Classを定義します。[1]
後述するapiFlow
(もしくは apiNullableFlow
)はFuture
型を出力します。
sealed class Future<out T> {
// APIコールが実行中である
object Proceeding : Future<Nothing>()
// APIコールが成功した
data class Success<out T>(val value: T) : Future<T>()
// APIコールが失敗した
data class Error(val error: Throwable) : Future<Nothing>()
}
APIコールをFlowに変換
APIコールをFuture
型で出力するFlowビルダです。
ネットワークエラーやサーバーエラーが発生した場合、Flow#catch()
で捕捉してFuture.Error
として出力します。
これにより、Repository、ViewModel、Viewに例外が伝搬しないようにします。
inline fun <reified T : Any> apiFlow(crossinline call: suspend () -> Response<T>): Flow<Future<T>> =
flow<Future<T>> {
val response = call()
// 成功した場合は`Future.Success`に値をラップして出力
if (response.isSuccessful) emit(Future.Success(value = response.body()!!))
else throw HttpException(response)
}.catch { it: Throwable ->
// エラーが発生した場合は`Future.Error`に例外をラップして出力
emit(Future.Error(it))
}.onStart {
// 起動時に`Future.Proceeding`を出力
emit(Future.Proceeding)
}.flowOn(Dispatchers.IO)
APIコールの返り値がNullableな場合のため、もう一つ用意しておきます。
inline fun <reified T : Any?> apiNullableFlow(crossinline call: suspend () -> Response<T?>): Flow<Future<T?>> =
flow {
val response = call()
if (!response.isSuccessful) throw HttpException(response)
emit(Future.Success(value = response.body()))
}.catch { it: Throwable ->
emit(Future.Error(error = it))
}.onStart {
emit(Future.Proceeding)
}.flowOn(Dispatchers.IO)
Dagger HiltでAPIサービスを注入
Dagger Hilt は Android に特化した依存性注入ライブラリです。公式のドキュメントがわかりやすいので、初めて使う方は参照してください。
Hilt を使用した依存関係の注入 | Android デベロッパー | Android Developers
@Module
@InstallIn(SingletonComponent::class)
object ApiModule {
@Singleton
@Provides
fun providePostApi(retrofit: Retrofit): PostApi = retrofit.create(PostApi::class.java)
@Singleton
@Provides
fun provideGson(): Gson = GsonBuilder().create()
@Singleton
@Provides
fun provideHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(90, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
@Singleton
@Provides
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
Repository
前述のFlowビルダ apiFlow
でAPIをラップすることで、Future
を出力するFlowを返します。
@Singleton
class PostRepository @Inject constructor(private val postApi: PostApi) {
fun getPostsFlow(): Flow<Future<List<Post>>> = apiFlow { postApi.fetchPosts() }
fun getPostFlow(id: Int): Flow<Future<Post>> = apiFlow { postApi.fetchPost(id) }
}
※補足:画面をまたいで状態を保持したい場合や、メモリ上にキャッシュしたい場合は、StateFlow
をRepositoryで保持するのがいいと思います。この記事では省略しています。
ViewModel
PostRepository#getPostsFlow()
が値を出力する度に、LiveDataの値を更新しています。
LiveDataを一度だけの更新する場合は postRepository.getPostsFlow().asLiveData()
という書き方もできます。
class MainViewModel @ViewModelInject constructor(
private val postRepository: PostRepository,
) : ViewModel() {
val postsLiveData = MutableLiveData<Future<List<Post>>>(Future.Proceeding)
init {
refresh()
}
// viewModelScopeでFlowを開始。APIからデータを取得する。
fun refresh() = viewModelScope.launch {
postRepository.getPostsFlow()
.collectLatest { postsLiveData.value = it }
}
}
View(Fragment)
ViewModelの注入
Fragmentに@AndroidEntryPoint
アノテーションをつけることで、Dagger HiltによってViewModelの依存性が解決されるます。
@AndroidEntryPoint
class MainFragment : Fragment() {
// ViewModelの注入
private val viewModel: MainViewModel by viewModels()
private var _binding: MainFragmentBinding? = null
private val binding get() = _binding!!
private val directions = MainFragmentDirections
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = MainFragmentBinding.inflate(layoutInflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// ... 次のセクションを参照 ...
}
// ... Omitted ...
}
LiveDataを監視
LiveDataにはAPIコールの状態が含まれているので、それによってプログレスインジケータの表示など画面描画を切り替える処理を記述します。
@AndroidEntryPoint
class MainFragment : Fragment() {
// ... Omitted ...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.listContainer.layoutManager = LinearLayoutManager(requireContext())
// APIコールの状態を監視
viewModel.postsLiveData.observe(viewLifecycleOwner) {
when (it) {
is Future.Proceeding -> {
// 更新中はインジケータを表示
binding.progressIndicator.show()
}
is Future.Success -> {
// 成功した場合はインジケータを隠してRecycleViewの更新
binding.progressIndicator.hide()
binding.listContainer.removeAllViews()
binding.listContainer.adapter = PostsAdapter(it.value) {
val action = directions.actionMainFragmentToDetailFragment(it.id)
findNavController().navigate(action)
}
}
is Future.Error -> {
// エラーが発生した場合はメッセージを表示
binding.progressIndicator.hide()
Toast.makeText(requireContext(), R.string.error_get_posts, Toast.LENGTH_LONG).show()
}
}
}
// ... Omitted ...
}
まとめ
- Data sourceで定義したFlowでAPIコールの例外を捕捉。Repository、ViewModel、Viewには例外を伝搬させない。
-
Future
型を定義して、APIコールが実行中であることやエラーも状態として表現できるようにする。 - View(Fragment)は
Future
型を保持したLiveDataを監視して、プログレスバーやエラーメッセージを画面に反映する。
完全なサンプルコードは chmod644/coroutine-flow-example を参照してください。
Discussion