OkHttp+MVVM=Retrofit

Retrofit vs OkHttp, an Architectural Take

People have been asking the question "Retrofit vs OkHttp" for years. Most of the answers would be a feature comparison, pros and cons, then they'll tell you Retrofit saves you so much time.

Now it is time for a different answer.

I'm gonna convince you Retrofit is better than OkHttp when you follow MVVM.

Suppose you are writing a post detail screen in a blog app. All you need to do is call 1 api and display the data.

How would you write the same feature with different ideas applied.

God Activity Architecture

Let's start basic, remember the good old days when we write everything in Activity? Typically, these are what you do

  • create a getPost(id: String) function accept a post id as a parameter
  • concat String to create a url
  • make a network request directly with OkHttp
  • onResponse callback, parse response and display the data
  • onFailure callback, show some sort of failure message
class PostDetailActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_post_details)
        getPost(intent.getStringExtra("POST_ID")!!)
    }

    fun getPost(id: String) {
        val okhttp = OkHttpClient.Builder().build()
        val request = Request.Builder().get().url("https://somewh.ere/api/v1/posts/$id").build()
        okhttp.newCall(request).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                val body = response.body!!.string()
                val post = Gson().fromJson(body, Post::class.java)
                setView(post)
            }

            override fun onFailure(call: Call, e: IOException) {
                Toast.makeText(this@PostDetailActivity, e.message, Toast.LENGTH_SHORT).show()
            }
        })
    }

    fun setView(post: Post) {
        //TODO
    }
}

MVVM

Now we apply Separation of Concern or else you get frowned upon.

  • M-Model, PostApi
    • put implementation details of network call and deserialization here
    • wrap the asynchronous callback with coroutine because it's 2022
    • or you might go with old school callback and declare the function like fun getPost(id: String, callback: Callback<Post>), doesn't really matter
  • VM-ViewModel, PostDetailViewModel
    • accept PostApi as a constructor parameter, easy DI, testable
    • holds the data
  • V-View, MvvmPostDetailActivity
    • UI manipulation
    • observe data from PostDetailViewModel
    • an instance of PostDetailViewModel should be obtained through a DI framework but let's leave it like this for simplicity
class PostApi {
    suspend fun getPost(id: String): Post = suspendCoroutine { continuation ->
        val okhttp = OkHttpClient.Builder().build()
        val request = Request.Builder().get().url("https://somewh.ere/api/v1/posts/$id").build()
        okhttp.newCall(request).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                val body = response.body!!.string()
                val post = Gson().fromJson(body, Post::class.java)
                continuation.resume(post)
            }

            override fun onFailure(call: Call, e: IOException) {
                continuation.resumeWithException(e)
            }
        })
    }
}

class PostDetailViewModel(val postApi: PostApi) : ViewModel() {
    val postLiveData: MutableLiveData<Post> = MutableLiveData()
    val errorLiveData: MutableLiveData<String> = MutableLiveData()
    fun getPost(id: String) {
        viewModelScope.launch {
            try {
                val p = postApi.getPost(id)
                postLiveData.value = p
            } catch (e: Exception) {
                errorLiveData.value = e.message
            }
        }
    }
}

class MvvmPostDetailActivity : AppCompatActivity() {

    val viewModel: PostDetailViewModel = PostDetailViewModel(PostApi())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_post_details)
        viewModel.postLiveData.observe(this) { post: Post ->
            setView(post)
        }
        viewModel.errorLiveData.observe(this) { message: String ->
            Toast.makeText(this@MvvmPostDetailActivity, message, Toast.LENGTH_SHORT).show()
        }
        viewModel.getPost(intent.getStringExtra("POST_ID")!!)
    }

}

Code Against Interfaces, Not Implementations

I have no idea why this principle(?) exits, isn't that just how interface works? Anyway, I'm sure you have been in a situation where you develop frontend/mobile app but the API is not ready, and you have to make due with mock data.

  • convert PostApi to an interface with suspend fun getPost(id: String): Post
  • RealPostApi implements PostApi.getPost() and call the actual API
  • MockPostApi implements PostApi.getPost() but return mock data of your choice
  • you can now swap between real data and mock data with just one line of code (where you create ViewModel)
  • if you have proper DI setup, no code changes in the Activity needed (the change will be in your DI setup instead)
interface PostApi {
    suspend fun getPost(id: String): Post
}

class RealPostApi : PostApi {
    override suspend fun getPost(id: String): Post = suspendCoroutine { continuation ->
        val okhttp = OkHttpClient.Builder().build()
        val request = Request.Builder().get().url("https://somewh.ere/api/v1/posts/$id").build()
        okhttp.newCall(request).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                val body = response.body!!.string()
                val post = Gson().fromJson(body, Post::class.java)
                continuation.resume(post)
            }

            override fun onFailure(call: Call, e: IOException) {
                continuation.resumeWithException(e)
            }
        })
    }
}

class MockPostApi : PostApi {
    override suspend fun getPost(id: String): Post {
        delay(2000)
        if (id == "404") {
            throw Exception("A post with id = $id does not exist")
        } else {
            return Post(id, "Peace Treaty", "Ceres Fauna")
        }
    }
}

class MvvmPostDetailActivity : AppCompatActivity() {

    val viewModel: PostDetailViewModel = PostDetailViewModel(MockPostApi())
//    val viewModel: PostDetailViewModel = PostDetailViewModel(RealPostApi())

}

Retrofit

Man, hand-written http request and deserialization are such tedious tasks, and you have to do this for every single api call.

But, wait a minute... this looks familiar somehow.

Right, we can just annotate our PostApi interface and have a fully functional "Real"PostApi instance created by Retrofit without ever have to write RealPostApi ourselves!

interface PostApi {
    @GET("https://somewh.ere/api/v1/posts/{id}")
    suspend fun getPost(@Path("id") id: String): Post
}

class MvvmPostDetailActivity : AppCompatActivity() {

    val viewModel: PostDetailViewModel =
            PostDetailViewModel(Retrofit.Builder().build().create(PostApi::class.java))
    //    val viewModel: PostDetailViewModel = PostDetailViewModel(MockPostApi())

}

Since PostDetailViewModel still depends on an interface PostApi, the flexibility of swap the mock implementation is still there, still easy to test. And if later on you decide to hand-write RealPostApi yourself, you can still do that without changing any line of code inside PostDetailViewModel.

Yeah, the Retrofit creation code is kind of ugly in the Activity, so you should have a proper DI setup.

Conclusion

Even you started out using OkHttp, when you adopt MVx architecture, write testable code and use interfaces wisely, Retrofit can replace your hand-written API call implementation. I guess this is what it means by Retrofit turns your HTTP API into a Java interface as written in the first line on the website.