Let’s illustrate that with a concrete example:

  • we have a service interface using coroutines to make network requests and parse the result:
interface ApiEndPoints {    @GET("/simplePath")
suspend fun simplePath(): SimpleObject
}
  • and a SimpleObject data class that looks like this:
data class SimpleObject(
val stringField: String = "a simple object"
)

After a bit of tinkering, it appeared it is somehow possible to achieve a generic scenario for HttpMocker that could address most of the possibilities, but I reckoned that this option was not the most ideal one: it implies some knowledge of the return type and, most importantly, a way to serialize your data classes to JSON (which is not necessarily the case: you might have used some specific annotations or parsers to convert a JSON stream to an object, but did not include the corresponding code for the reverse operation).

If you have to look into the Retrofit service to create return objects, why bother with serialization just to deserialize them immediately? It made more sense to completely forego HttpMocker in this case (which works at the OkHttp level: I’ll cover that option in another post) and intercept the calls at the Retrofit level instead, by providing your own implementation of the service interface. In other words, we could try to mimic what Retrofit does by writing our own implementation of the service interface using reflection. So let’s see how we could do that.

Since we have an interface to define our service but no concrete implementation (it is provided dynamically by Retrofit), it is quite easy to replace that call with ours. So let’s start simple and remove the suspend keyword in our interface so we don’t have to worry about coroutines yet:

interface ApiEndpoints {    @GET("/simplePath")
fun simplePath(): SimpleObject
}

Our app runs on a JVM compatible platform, which means we can use a Proxy instance of that interface (that’s what Retrofit actually does too). Proxy is a class from the JVM that can dynamically implement any interface. Whenever a method is invoked on that proxy, it will delegate the call to an InvocationHandler which will have to provide the proper result based on the method signature and parameters.

In our case, this handler is rather simple: it retrieves the expected return type and invokes its constructor with no arguments, in order to use the default values for every field.

So a simple implementation could look like this:

inline fun <reified T : Any> createService(): T {
val service = T::class.java
return Proxy.newProxyInstance(
service.classLoader,
arrayOf(service)
) { _: Any, method: Method, _: Array<out Any> ->
method.returnType
val constructor = type.constructors.first { it.parameterCount == 0 }
constructor.newInstance()

} as T
}

So far, so good: invoking that code works and a call to service.simplePath() returns a SimpleObject with stringField valued to "a simple object".

This is quite simple so far, but Retrofit does not provide synchronous interfaces like that. Generally, you need to return a Call<SimpleObject> (which you can call synchronously or asynchronously), a Single<SimpleObject> if you use RxJava or simply declare your method as suspendable if you prefer to use coroutines.

The latter syntax seems to be the easiest to use, since it doesn’t include any additional generic type. Let’s put the suspend keyword back and run our code:

java.lang.ClassCastException: java.lang.Object cannot be cast to SimpleObject

It looks like our code managed to find a return type and call its constructor. But how come our result no longer is a SimpleObject but an Object instead? This is linked to what happens with coroutines under the hood.

On the Kotlin side of things, it doesn’t look like the method signature changed: same name, same parameters, same return type… But on the JVM side, it did: a Continuation parameter has been added, and the return type has changed to Object. Both of these changes are what allow the functions to stop and start at will. The current state of the coroutine is returned when it interrupts, and it is used as a parameter to resume execution.

Because of this compilation magic, parameters and return types can no longer be trusted through Java reflection. It is now time to switch to Kotlin reflection instead by converting the Java Method object to a Kotlin KFunction. Thankfully, a kotlinFunction extension does that for us:

val function: KFunction<*>? = method.kotlinFunction

The Kotlin reflection API is similar to the Java introspection API, but it allows to manipulate your code from a Kotlin point of view, based on all the extra metadata added by the Kotlin compiler.

Reading the return type now returns the type declared in your Kotlin code (as a KType), no matter what the byte code looks like. Also, looking for a constructor with no parameters would no longer work because our class only has one constructor with one (optional) parameter. But instead, you can access the primary constructor directly and not provide any arguments:

inline fun <reified T : Any> createService(): T {
val service = T::class.java
return Proxy.newProxyInstance(
service.classLoader,
arrayOf(service)
) { _: Any, method: Method, _: Array<out Any> ->
method.kotlinFunction?.let {
val type: KClass<*> = it.returnType.jvmErasure
val constructor: KFunction<Any> = type.primaryConstructor ?: error("No primary constructor")
constructor.callBy(emptyMap())

}
} as T
}

To be completely honest though, we could have skipped the constructor resolution altogether by calling createInstance directly without parameters (the same way we could have called newInstance() on the Java implementation instead of looking up constructors), but it seemed worth noting that Kotlin handles constructors (or methods) with optional parameters differently from Java since the concept does not exist in Java. So we could simplify our code a bit more:

inline fun <reified T : Any> createService(): T {
val service = T::class.java
return Proxy.newProxyInstance(
service.classLoader,
arrayOf(service)
) { _: Any, method: Method, _: Array<out Any> ->
method.kotlinFunction
?.returnType
?.jvmErasure
?.createInstance()

} as T
}

As a side note, if we didn’t have the kotlinFunction extension, identifying the correct Kotlin function based on the JVM method signature would still be easy by matching name and parameters, as long as we are aware of a few differences:

  • In Kotlin, KFunctions have an extra first parameter INSTANCE designating the function itself.
  • Because our function is suspendable, the Continuation parameter will be added on the JVM side.

Finally, the bonus to using Kotlin reflection instead of Java reflection is that our code now works both for the synchronous and the asynchronous version of our function.

Reflection can be a clever way to address some problems in a generic manner, and since Java and Kotlin both offer functions to manipulate classes, objects, properties, functions, etc. it is tempting to think that you can easily mix them when your code only runs on a JVM. But the truth is that due to some differences in the way objects and functions work in Kotlin, there are some subtle peculiarities which make it risky to navigate between the two worlds. Adding coroutines to the mix takes it one step further as it reveals the clever byte code manipulation that gave us those powerful features.

On the other hand, Kotlin reflection offers an API that is not only consistent with your Kotlin code, but also exposes the language specificities that convinced us to give up Java in the first place. The price, though, is that this API is not part of the default stdlib and requires you to include an additional package kotlin-reflect.jar in your project, which to date represents an extra 2.7MB to your app, while Java reflection API is natively part of the JVM (smaller alternatives exist though, but with limited functionalities).

The logical conclusion is that if you are trying to limit the disk space used by your application, you might want to think twice before using Kotlin reflection. But if reflection is the unavoidable path, then it will probably be worth the extra weight compared to the old and somewhat inadequate Java reflection.


Source link

Advertisements