Coroutine Flow + Retrofit (+ Dagger Hilt) で 安全なAPIコールを実現する
Coroutine FlowとRetrofitを組み合わせて、ランタイムエラーを極力発生させない安全なAPIコールを実装してみました。
完全なサンプルコードは chmod644/coroutine-flow-example を参照してください。
目次
アーキテクチャ
MediumのAndroid Developersに記載されたアーキテクチャに則っています。
レイヤ | 役割 |
---|---|
Data source (API Service) | APIコールしてFlowに結果を出力する。※今回の実装ではData sourceレイヤーがAPIコールの例外処理を担う。 |
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 { val response = call() val future: Future<T> = if (response.isSuccessful) { Future.Success(value = response.body()!!) } else { Future.Error(HttpException(response)) } emit(future) }.catch { it: Throwable -> // エラーが発生した場合は`Future.Error`にラップする emit(Future.Error(error = it)) }.onStart { 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() val future = if (response.isSuccessful) { Future.Success(data = response.body()) } else { Future.Error(HttpException(response)) } emit(future) }.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(ApplicationComponent::class) object ApiServiceModule { @Singleton @Provides fun providePostApi(retrofit: Retrofit) = 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("https://jsonplaceholder.typicode.com") .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() .onEach { postsLiveData.value = it } .collect() } }
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 を参照してください。