Retrofit 2.6.0 supports defining interfaces with Kotlin suspend functions. This article describes how to create the most comfortable coroutine experience by customizing the Retrofit Call Adapter and Converter.
- Call Adapter: custom request execution logic, including thread switching, etc.
- Converter: Customize the deserialization logic, how to convert the bytes obtained from the request into objects.
Spoiler for the final result.
|
|
This solution was inspired by Jake Wharton’s Making Retrofit Work for You talk. Jake is also the maintainer of Retrofit. In his talk, he recommends taking advantage of the custom deserialization and request execution APIs that Retrofit provides to adapt adapt your own business logic and interfaces.
Background
Suppose our interface returns JSON data like this, with an errcode
field returning 0
on a successful request, and a data
field holding the data.
The exception errcode
is not 0
and the msg
field returns the error message displayed to the user.
Retrofit interface design
Let’s set aside the implementation for a moment and explore how to design Retrofit’s interface to make it more comfortable for callers to use Coroutine.
Get rid of the “envelope”
A comfortable envelope should make it as easy as possible for the caller, the simpler the better. You can see that the data that’s really useful to the business is inside data
, with an “envelope” on the outside. In most cases we just need to take the normal data and continue with the subsequent business logic. It would be very redundant to have to manually check errcode == 0
every time we call it. One of the simplest designs is to simply return the envelope-removed data type.
Exception handling
The design of returning the data type inside the envelope directly works in theory: it’s nice to call it normally, and if an exception occurs you can get specific exception information with try catch. However, by the design of the Kotlin Coroutine, we should call the wrapped suspend function directly in the main thread. If the function throws an exception, it will be thrown in the main thread, causing the application to crash. This is also evident from the function signature: once the User
data type is not returned properly, the runtime can only throw an exception. The caller then has to do a try catch, which is very cumbersome to write. What’s worse is that people can forget about try catch altogether, and are likely to write the wrong
Watch out! The try catch in the above example was written incorrectly. If an exception is thrown inside the Coroutine block, it will still crash, because the wrong try catch is made on the Coroutine builder launch
. The Coroutine builder returns immediately after opening the Coroutine block in the CoroutineScope, and the Coroutine inside the builder is executed concurrently with the code around launch
. The exception logic inside the Coroutine block cannot be caught by the try catch outside of launch
. The correct way to write try catch is inside the Coroutine block.
In addition, the try catch suspend function needs to be careful to re-throw CancellationException
, otherwise the Coroutine block may not be cancelled in time. See JCIP Notes - Interruption and Cancellation for details. -kotlin-coroutines).
💡 A good wrapper design should make the right way to write the simplest , the simplest way to write by default is the right way to write.
To avoid the hassle and potential mistakes of try catch concurrent exceptions, I recommend catching all exceptions in the suspend function internal wrapper and reflecting the exceptions in the function signature.
One option is to return the type nullable. This would take advantage of Kotlin’s null-safe operator ?
, ? :
and !
:.
This seems to be a rather authentic and elegant design, and is recommended. But with nullable we can’t tell the caller what type of exception has occurred. The only possibilities for the caller are success ! = null
or failure == null
. But this distinction is sufficient in many cases.
In addition, exceptions should be handled in a consistent place in the project, for example by showing the user a hint when errcode ! = 0
, report network request exceptions, etc. Doing ad hoc (ad hoc) exception handling everywhere in the business call interface is not robust enough: people can forget to do exception handling at all, or handle it very roughly. Also, exception handling code can create a lot of redundancy and make it hard to see the normal code logic.
Retrofit’s Call Adapter can help us embed custom logic in Retrofit’s execution logic to achieve the goal of uniformly catching and handling all exceptions. A reference implementation will be given later.
As a rule of thumb, you should not be catching exceptions in general Kotlin code. That’s a code smell. Exceptions should be handled by some top-level framework code of your application to alert developers of the bugs in the code and to restart your application or its affected operation.
– Roman Elizarov, Project Lead for Kotlin
As a rule, do not catch exceptions in Kotlin business logic code. Exceptions should be handled consistently in the top-level infrastructure code of the application: for example, by escalating or retrying the steps that went wrong.
Designing ApiResponse types
In order for the caller to get the exception information, it is inevitable to stuff the return value inside a shell that reflects the success/failure result. But instead of deserializing the return format as is, we do some encapsulation. For example, in the normal case of a request, the msg
field is of no use and can be omitted. The result of the request can be divided into roughly three cases.
- Normal response: we can get the data needed by the subsequent business logic from the
data
field. - Business logic exception: the interface request is successful, but the backend returns data telling us that the business logic is abnormal and we need to display the exception information in the UI.
- Other technical exceptions: network request errors, deserialization errors, etc., which we may need to escalate depending on the situation.
Implemented into the code, it can be represented by Kotlin Sealed Class.
|
|
Add some null-safe syntactic sugar
It is much safer for us to reflect exceptions in the type system rather than throwing them. But the vast majority of scenarios callers don’t need, and shouldn’t have to do, such detailed exception handling. So we add a pair of extensions that allow the caller to use the syntactic sugar of Kotlin nullable.
|
|
The naming of the functions refers to the Kotlin standard library conventions like get
getOrNull
, first
firstOrNull
: the first class throws an exception, the second returns the nullable type. Considering that client-side exception throwing is very dangerous, we name get
getOrThrow
to emphasize it in the method name. (Actually, you can also consider a version without the exception throwing, which nobody in the project probably uses.)
Borrow from Swift’s guard
keyword
getOrNull
is often used followed by a ? let
only handles success cases: if the request is successful, use this data it
to update the UI, otherwise nothing happens. If the failure case requires some action, you can use if / else or when to determine the type.
If … else if too much nesting will make the code less readable, use the early exit style, we first deal with the failure case and exit the current block, so that the successful case all the way down, more simple and clear
But some people think that the early exit style is not robust enough, because there is a risk of forgetting to write an early exit return, causing logical errors.
Swift loves early exit so much that it added the keyword guard
. guard is like if, but with an extra layer of assurance: the compiler makes sure that the else block returns or throws and exits the current block, making early exit just as robust as if … else.
In Kotlin, we can achieve a similar effect with the inline extensions. The key is that the block returns Nothing
.
|
|
Implementation: Retrofit Call Adapter
In order for Retrofit to catch all exceptions, we write a CatchingCallAdapterFactory
that inherits from Retrofit’s CallAdapter.Factory
. This CatchingCallAdapterFactory
exposes an ErrorHandler
for configuring the global exception handling logic.
|
|
CatchingCallAdapterFactory
Reference implementation.
|
|
Implementation: Retrofit Converter
For different cases of ApiResponse
, we need to configure custom JSON deserialization parsing logic. Retrofit can be adapted to different deserialization libraries by injecting custom type converters (not necessarily just JSON data formats, but also XML, Protocol Buffers, etc.) via addConverterFactory
.
JSON deserialization library selection
Currently, the Kotlin project recommends using Moshi, which has much better support for Kotlin than Gson. For example, the following example.
Gson creates a User
type object through reflection, but Gson does not distinguish between Kotlin nullable/non-nullable types and directly returns an object with null properties, causing us to throw a null pointer exception when we subsequently use this “broken” object. Our CatchingCallAdapter
is supposed to catch all exceptions, including deserialization, but Gson’s behavior escapes our exception catching logic and intrudes into the business logic code.
Moshi does not have this problem and throws a uniform JsonDataException
when it gets data that it cannot parse. OtherErrorwhen caught by
CatchingCallAdapter`.
The advantages of Moshi over Gson can be found at the following link.
Please don’t use Gson. 2 out of 3 maintainers agree: it’s deprecated. Use Moshi, Jackson, or kotlinx.serialization which all understand Kotlin’s nullability. Gson does not and will do dumb things, and it won’t be fixed. Please abandon it.
– Signed, a Gson maintainer.
The above quote is from Jake Wharton. Moshi is recommended as a priority for new projects, and migration is risky for projects already using Gson, so caution is advised.
With Moshi, there are currently several options.
- use reflection like Gson, but with an indirect dependency on the 2.5 MiB size
kotlin-reflect
. - use annotation processors to generate
JsonAdapter
for all classes marked@JsonClass(generateAdapter = true)
. - same as 2 code generation, but instead of annotation processors, use Kotlin Symbol Processing.
- similar to 1, but using kotlinx-metadata, which is more lightweight than kotlin-reflect.
3 and 4 are in the MoshiX project and seem to be slightly experimental; also note that code generation has the benefit of higher performance, but the generated code is not small and requires explicitly configuring the appropriate JsonAdapter
for all the classes that need to be deserialized, which is a bit invasive for existing projects.
kotlinx.serialization is the official Kotlin serialization/deserialization scheme, and is also an annotated markup, code generation scheme. However, code generation is integrated into the compiler (similar to @Parcelize
and KSP), and the development experience is probably better, with richer Kotlin feature support, and should be the preferred choice on Kotlin solution. However, streaming parsing is not supported at the moment, see this issue.
On balance, it seems that we can use Moshi for now, and search for replacement annotations for migration when kotlinx.serialization is mature.
Moshi Implementation
Here is the reference implementation of Moshi’s custom parsing ApiResponse
, Gson is much the same
|
|
Use.
|
|
One More Thing: Using Result as a Return Value
The example at the beginning of the article uses the runCatching
method provided by the Kotlin standard library for try catch. The return value of the runCatching
method is Result, which provides a number of useful methods.
|
|
Previously, Kotlin did not allow Result to be the return value of a function. This restriction has been removed in Kotlin 1.5. This allows us to consider using Result as the return type of a Retrofit interface method.
With Result, the caller gets the exception information, but cannot distinguish BizError
from OtherError
at the outermost level. In practice, however, few callers need to make this distinction, and it seems like a good tradeoff to make this rarely used case a bit of a pain.
Even more promising is Kotlin’s plan to make the null-safe operator also available to Result, so we can write it like this
|
|
Thanks to the null-safe operator that works directly on the Result type, we don’t need to define extensions to convert to nullable types, and the calls are more streamlined.
The Call Adapter for the suspend function and the Result return value can be referenced or used directly from this library: yujinyan/retrofit-suspend-result-adapter.
If the interface in your project is wrapped in an “envelope” like the example in this article, you can write a Converter using your own deserialization library. this test case provides a reference implementation of Moshi.