Coroutine Flow + Retrofit (+ Dagger Hilt) で 安全なAPIコールを実現する

8 min read読了の目安(約8000字

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 を参照してください。

脚注
  1. DroidKaigiのLoadStateを参考にさせてもらいました。 ↩︎