Kotlin Multiplatform y el Principio de Inversión de Dependencias

Desde hace un tiempo estoy echando un ojo a Kotlin Multiplatform, por necesidades que tengo en uno de mis clientes.

En kotlin multiplataforma existe un mecanismo a través del cual puedes tener código Kotlin común, que no depende de ninguna plataforma, y que invoca código específico para cada plataforma escrito también en Kotlin.

Seguro que esto te suena, es el principio de inversión de dependencias y en kotlin multiplataforma se puede utilizar el método tradicional mediante interfaces, pero también un mecanismo propio del lenguaje basado en las palabras clave expect/actual.

Si nos abstraemos de la sintáxis, ambas técnicas se utilizan para un fin similar pero Expect/actual es más potente que las interfaces en algunos aspectos y más limitado en otros.

En cualquier caso, es bueno conocer las herramientas de las que disponemos para poder decidir cual utilizar según el contexto en el que nos encontremos.

Librería multiplataforma

En el modelo multiplataforma de Kotlin, debe existir un módulo de gradle o un sourceset, dependiendo de la versión que utilices, para el código común y otro por cada plataforma.

Desde la versión 1.3 de Kotlin podemos tener un módulo multiplataforma aplicando el nuevo plugin de gradle kotlin-multiplatform, donde existirá un sourceset para cada plataforma y otro para el código común.

El sourceset common va a contener todo aquel código que no es dependiente de una plataforma concreta y por lo tanto consiste es una serie de declaraciones, contratos, abstracciones y lógica exclusivamente dependiente del lenguaje.

Esto encaja bastante bien por ejemplo con el dominio de una aplicación cuando se utiliza Clean Architecture o Hexagonal Architecture, pero eso lo dejamos para otro post.

En un proyecto multiplataforma para compartir código entre una aplicación Android e iOS, configurando correctamente los ficheros gradle, lo normal es tener una estructura similar a esta:
kotlin-multiplatform-structure.

Expect/Actual

Con este mecanismo, un módulo común puede definir declaraciones esperadas y un módulo de plataforma puede proporcionar declaraciones reales correspondientes a las esperadas. Para ver como funciona esto, veamos un ejemplo:

Cuando nos encontramos en el sourceset common, utilizamos una versión distinta de la librería standar de Kotlin, en concreto kotlin-stdlib-common.

En esta versión de la libería no viene definido un tipo para representar la fecha.

Los tipos de fecha suelen tener una implementación concreta en cada plataforma. Por ejemplo en java se suelen utilizar java.util.Date junto con java.util.Calendar o incluso librerías como Joda-Time, en iOS se utiliza NSDate y en Javascript Date.

El ejemplo va a consistir en un tipo fecha muy sencillo que nos devuelve la hora actual en milisegundos utilizando expect/actual.

Por un lado tendríamos la definición del tipo DateTime con el código independiente de plataforma junto con una definiciones experadas:

data class DateTime (
    val timestamp: Long
) : Comparable<DateTime> {
    constructor() : this(currentMillis())

    override fun compareTo(other: DateTime): Int = timestamp.compareTo(other.timestamp)
}

internal expect fun currentMillis(): Long

Como podemos observar la definición expect es una función, pero también podría ser una clase. Al igual que con las interfaces no se define el cuerpo de la función ya que este existirá en la implementación concreta para cada plataforma.

Posteriormente en el sourceset de cada plataforma tendremos la implementación real de la función, debe ir con la palabra clave actual:

Así sería para Android:

internal actual fun currentMillis(): Long = System.currentTimeMillis()

Así sería para iOS:

internal actual fun currentMillis(): Long = memScoped {
    val timeVal = alloc<timeval>()
    gettimeofday(timeVal.ptr, null)
    val sec = timeVal.tv_sec
    val usec = timeVal.tv_usec
    ((sec * 1_000L) + (usec / 1_000L))
}

Dentro de cada sourceset específico a una plataforma se puede acceder a librerías propias de la plataforma ya que existe interoperatividad con Java para JVM y Objetive C, Swift para iOS.

En la fase de compilación se revisa que cada declaración expect tiene su correspondiente actual para cada plataforma, de no ser así la compilación nos dará un error.

Lo último que falta es utilizar la librería multiplataforma desde la aplicación iOS y Android:

Para Android simplemente de añade una dependencia como con cualquier otro módulo java:

dependencies {
    implementation project(':core-multiplatform')
    }

Y ya se puede utilizar:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val now = DateTime()

        helloTextView.text = "The time is: ${now.timestamp}"
    }
}

En iOS es necesario realizar más configuración, sigue este artículo de la documentación oficial de Kotlin: mpp-ios-android.

Cuando lo tengas bien configurado, puedes utilizarlo:

import UIKit
import core_multiplatform

class ViewController: UIViewController {

    @IBOutlet weak var helloLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()        
        
        let now = DateTime()
        
        helloLabel.text = "The time is: \(now.timestamp)"
    
    }   
}

Ventajas y desventajas de except/actual

Hay una sería de ventajas que convierten except/actual en una opción muy interesante:

Espect de un constructor

Al contrario que con interfaces, es posible crear un contrato de como debe ser el constructor mediante expect:

expect class Foo(bar: String) {
    fun frob()
}

Posteriormente en el sourceset de la plataforma:

actual class Foo actual constructor(val bar: String) {
    actual fun frob() {
        println("platform X")
    }
}

Actual con typealias

Si existe una librería de la plataforma que se ajusta a las necesidades esperadas en el código común, no es necesario crear un wrapper o adapter. Mediante typealias es posible marcarlo como el código actual esperado.

expect class AtomicRef<V>(value: V) {
  fun get(): V
  fun set(value: V)
  fun getAndSet(value: V): V
  fun compareAndSet(expect: V, update: V): Boolean
}

Código actual con typealias:

actual typealias AtomicRef<V> = java.util.concurrent.atomic.AtomicReference<V>

Por contra este mecanismo también conlleva desventajas frente al uso de interfaces:

La dependencia estática entre expect y actual:

Al ser la dependiente estática y resuelta en tiempo de compilación no es posible modificar en tiempo de ejecución la dependencia del código común.

En este escenario por ejemplo no es posible utilizar objetos dobles en los test para crear escenarios preparados.

Conclusiones

Hemos visto en este artículo como funciona el mecanismo expect/actual de Kotlin multiplatform junto con las ventajas y desventajas que nos aporta frente al uso tradicional de interfaces.

Como siempre dependiendo del contexto puede ser más interesante utilizar una u otra opción.

Puedes ver alguno de los ejemplos vistos en este artículo en este repositorio:
kotlin-clean-architecture-multiplatform