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

Coroutine FlowとRetrofitを組み合わせて、ランタイムエラーを極力発生させない安全なAPIコールを実装してみました。

完全なサンプルコードは chmod644/coroutine-flow-example を参照してください。

f:id:xterm256color:20210130090809j:plain

目次

アーキテクチャ

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に結果を出力する。※今回の実装ではData sourceレイヤーがAPIコールの例外処理を担う。
Repository DataSouceからFlowを参照し、必要に応じてデータのキャッシュや永続化を行う。
ViewModel RepositoryのFlowから出力された値をLiveDataに保持する。
View ViewModelのLiveDataを監視して画面に反映する。

f:id:xterm256color:20210130085952p:plain

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ビルダ apiFlowAPIをラップすることで、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 を参照してください。


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