23 – Probando GoMobile

Posted on Jan 11, 2022 – - by Franco
Cover del post

Introducción

Go tiene un paquete llamado Gomobile que me llamó mucho la atención el fin de semana. Tiene como objetivo ser una herramienta con dos fines específicos:

  1. Crear aplicaciones completas tanto para dispositivos iOS como para dispositivos Android.
  2. Crear bibliotecas compatibles para desarrollo en ambas plataformas.

El primer grupo es algo similar a lo que ofrecen otras herramientas para construir aplicaciones híbridas como Flutter, ReactNative o Ionic. El segundo me pareció un poco más interesante. Poder crear bibliotecas que se utilicen tanto para Android como para Iphone y reutilizar ese codigo que podría usarse para implementar la lógica del negocio (BL) en un solo lugar.

Asi, decidí explorar este camino e implementar una simple aplicación que utilice GoMobile para hacer peticiones HTTP a un servidor de prueba usando Go y llamar estas funciones desde una aplicación nativa de iOS hecha en SwiftUI.

N.B.: A lo largo de este tutorial van a haber algunos "gotchas", basicamente cosas raras que tienes que tener en cuenta sino no va a funcionar, cada vez que se presente una la indicare con la palabra "Gotcha":

Para el siguiente blog se usaron las siguientes dependencias:

  • MacOS (v11.4)
  • Editor de Textos - (Visual Studio, vim, EMacs)
  • Xcode (v13.2.1)
  • Go (v1.16)

Setup

Primero, para usar la platafroma tenemos que tener instalado Go (almenos la version 1.16)

1
go version
go version go1.16 darwin/arm64

Una vez instalada las dependencias, crearemos la siguiente estructura:

gomobile-test/
├── framework/
├── go/
└── ios/
  • En framework se encontrará el compilado que gomobile va a generar
  • En ios ubicaremos el proyecto de SwiftUI de ejemplo, y,
  • en go ubicaremos el codigo fuente en Go que usaremos como base para el demo.

Para crear la estructura podemos usar el siguiente comando:

1
mkdir -p gomobile-test/{framework,ios,go}

Una vez hecho esto, navegamos a nuestra carpeta de Go e iniciamos un nuevo módulo

1
2
go mod init wemake.pe/gomobile-test
go mod tidy

Finalmente, necesitamos instalar el paquete de gomobile y su dependencia gobind:

1
2
3
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/cmd/gobind
gomobile init

Crear la biblioteca

A continuación crearemos una biblioteca básica, creamos un archivo main.go y agregamos el siguiente contenido:

1
2
3
4
5
6
7
// main.go

package GMTest

func SayHello(name string) string {
	return "Hello, " + name;
}

GOTCHA Las funciones que se exportan, al igual que en Go normal, tienen que empezar con una letra mayúscula. GOTCHA2 El nombre del Package, determina el nombre de la biblioteca.

Una vez hecho esto, abrimos una línea de comandos en la carpeta donde se encuentra nuestro archivo y vamos a compilarlo para iOS usando la siguiente función:

1
gomobile bind -target ios -o ../framework/GMTest.xcframework -v

GOTCHA para compilar para arquitecturas iOS es necesario correr el comando en una Mac.


Una vez hecho esto, si abrimos nuestra carpeta Framework, veremos que se ha creado una serie de archivos, tómate un tiempo para explorar algunos de los archivos generados por gomobile.

framework
└── GMTest.xcframework/
    ├── Info.plist
    └── ios-arm64/
       └── GMTest.framework/
           ├── Headers -> Versions/Current/Headers/
           ├── Modules -> Versions/Current/Modules/
           ├── MyTest -> Versions/Current/MyTest
           ├── Resources -> Versions/Current/Resources/
...

Una vez que hayamos generado la biblioteca, tenemos que crear un proyecto de XCode en la carpeta iOS, para poder importar la biblioteca, veamos como hacerlo.

Crear el proyecto en SwiftUI

Primero, abrimos Xcode y creamos un nuevo proyecto, lo llamaremos "GMTest-ios", (el nombre en realidad no afecta), lo crearemos con Switf UI pero esto tambien funciona con Swift puro (y obviamente con Obj-c)

Instalar la biblioteca

Ahora, abriremos nuestro explorador de archivos "Finder" y jalaremos la carpeta generada en framework a nuestro proyectos, Aqui podemos des-seleccionar la opcion de "copiar elementos si necesario" para que cada vez que hagamos un build de nuestra biblioteca automáticamente XCode la reconozca y no tengamos que estar agregándola cada vez. Esto es para que XCode "encuentre" la referencia a la biblioteca al momento de hacer nuestro "build".

Una vez importado, abrimos nuestro ContentView.swift y en la parte de arriba importaremos el framework

1
2
3
4
//.. En ContentView.swift
import SwiftUI
import GMTest
//...

Crear una vista simple en SwiftUI

Primero probaremos la función SayHello. Para esto reemplazaremos el "Hello, World" que viene por defecto en Swift por una llamada a nuestra función:

1
2
3
4
5
6
struct ContentView: View {
    var body: some View {
        Text(GMTestSayHello("World!!!!"))
            .padding()
    }
}

Corremos esto y deberiamos ver "Hello, World!!!!" en nuestro dispositivo.

Ahora hagamos esto un poco más interesante: agregaremos un boton, un string de @State y un texto

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import SwiftUI
import GMTest

struct ContentView: View {
    @State var message = "..."
    var body: some View {
        VStack{
            Text(message)
                .padding()
            Button("Decir algo", action: {

            })
        }

    }
}

// ...

Dentro de la acción del botón agregaremos la llamada a nuestra función y asignaremos a la variable message el resultado de ésta.

struct ContentView: View {
    @State var message = "..."
    var body: some View {
        VStack{
            Text(message)
                .padding()
            Button("Decir algo", action: {
+               message = GMTestSayHello("World!!")
            })
        }
    }
}

Manejar el "estado" con la biblioteca

Ahora haremos algo mas interesante, agregarle un sencillo contador a nuestra variable, modificamos nuestro codigo de Go:

package GMTest

+ import (
+	"fmt"
+ )
+
+ var counter = 0

func SayHello(name string) string {
-   return "Hello, " + name;
+   counter++
+   return fmt.Sprintf("Hello, %s, %d", name, counter)
}

Ahora compilamos otra vez nuestra biblioteca:

1
gomobile bind -target ios -o ../framework/GMTest.xcframework -v

Volvemos a correr el build en nuestro XCode, y apretamos varias veces el botón, veremos que el estado también se actualiza constantemente.

"Resultado: Hello World!!, 10"

Ahora vayamos un poco más profundo, haremos una petición web y retornaremos el resultado, aquí nos toparemos con nuestro primer gran desafío.

Hacer peticiones desde go

Usaremos el paquete net/http para poder hacer las peticiones

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package GMTest

import (
	"io/ioutil"
	"net/http"
)

// Obtain some remote API data
func GetExampleAPI() string {
	resp, err := http.Get("https://dog-api.kinduff.com/api/facts")
	if err != nil {
		log.Fatalln(err)
	}
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalln(err)
	}
	sb := string(body)
	return sb
}

Esta función retornará un string con el contenido del Dog Facts API, podemos llamarla en SwiftUI desde nuestro botón de la siguiente manera:

# ContentView.swift
# ...
-               message = GMTestSayHello("World!!")
+               message = GMTestGetExampleAPI()
# ...

Manejar JSON desde Go

Un string es útil pero idealmente quisieramos retornar solo el Fact del perro, no todo lo que regresa el API. Para hacerlo, usaremos el paquete encoding/json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package GMTest

import (
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
)

type DogFactResponse struct {
	Success string   `json:"success"`
	Facts   []string `json:"facts"`
}

// Obtain a dog fact from the Dog Facts API
func GetDogFact() string {
	resp, err := http.Get("https://dog-api.kinduff.com/api/facts")
	if err != nil {
		log.Fatalln(err)
	}
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalln(err)
	}
	var dogFact DogFactResponse
	err = json.Unmarshal(body, &dogFact)

	return dogFact.Facts[0]
}

Y actualizamos el proyecto de Swift UI para llamar a la nueva función

# ContentView.swift
# ...
-               message = GMTestGetExampleAPI()
+               message = GMTestGetDogFact()
# ...

Compilamos la biblioteca y compilamos el proyecto de Swift y obtendremos lo siguiente:

"Resultado: Dog fact!"

Conocemos a nuestro némesis

Ahora, que tal si en vez de retornar un simple string queremos retornar todo el objeto DogFact, y en Swift elegir qué visualizar? Si intentamos hacerlo, nos encontraremos con la desafortunada realidad que no es posible. Esto es debido a la principal limitante de esta biblioteca: solo soporta algunos tipos de dato primitivos. La lista de datos soportados se detallan a continuacion:

Tipos de dato soportados por Gomobile (a traves de Gobind):

  • Signed integer and floating point types.
  • String and boolean types.
  • Byte slice types. Note that byte slices are passed by reference, and support mutation.

Además:

  • Toda funcion cuyos valores de retorno y parametros esten incluidos en la lista anterior. Las funciones deben retornar cero, uno o dos resultados, donde el segundo resultado es de tipo "error"
  • Toda interface, cuyos metodos exportados sean del tipo soportado
  • Todo -struct- cuyos metodos exportados sean funciones compatibles y cuyos campos exportados sean del tipo soportado

Que significa

Esto significa que lamentablemente no podemos usar una variedad de tipos de dato, sobretodo Slices (como arreglos en Go) y tipos definidos por el usuario como nuestro Struct de DogFact en sí.

Según la documentacion de gobind esto un Work In Progress:

The set of supported types will eventually be expanded to cover more Go types, but this is a work in progress.

Este mensaje está así desde 2015, ojalá que pronto hayan novedades, pero no podemos esperar que sea asi. De todas formas, hay una forma de superar el inconveniente de los Slices, detallado a continuación.

Superar (algunas de) las limitaciones

Mientras, podemos hacer un pequeño truco para poder saltar este problema, utilizando una interfaz. Vamos a crear un receptor y se lo vamos a pasar como argumento en la función que llamemos desde Swift, veamos como:

Crear la interfaz de receptor

Creamos una intrafaz llamda DogFactReceiver, en Go, cuyo único método será un metodo Add con un parámetro de tipo DogFact:

# main.go
...
+ type DogFactReceiver interface {
+	Add(dogFact string)
+ }
...

N.B.: En este caso, estamos usando un parámetro de tipo string pero también funcionaría con otro tipo, como un struct.

Luego, definimos una función de ayuda (helper) que reciba la función que acabamos de definir, y llamamos a su método Add:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package GMTest

import (
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
)

type DogFact struct {
	Success string   `json:"success"`
	Facts   []string `json:"facts"`
}

type DogFactReceiver interface {
	Add(dogFact string)
}

// Obtain a dog fact from the Dog Facts API
func GetDogFactReceiver(receiver DogFactReceiver) {
	resp, err := http.Get("https://dog-api.kinduff.com/api/facts")
	if err != nil {
		log.Fatalln(err)
	}
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalln(err)
	}
	var dogFact DogFact
	err = json.Unmarshal(body, &dogFact)

	for _, fact := range dogFact.Facts {
		receiver.Add(fact)
	}
}

En Swift, necesitamos crear una clase Receiver que implemente el protocolo que hemos definido en nuestra biblioteca.

1
2
3
4
5
6
class DogFactReceiver: NSObject, GMTestDogFactReceiverProtocol{
    var facts : [String] = []
    func add(_ fact: String?) {
        facts.append(fact!)
    }
}

Luego necesitamos crear una instancia de esta clase, la cual usaremos para pasar nuestros datos cuando llamemos la funcion. Además, agregaremos la llamada a nuestra nueva función y mostraremos sólamente el último mensaje recibido:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct ContentView: View {
    @State var message = "..."
    let receiver = DogFactReceiver()
    var body: some View {
        VStack{
            Text(message)
                .padding()
            Button("Decir algo", action: {
                GMTestGetDogFactReceiver(receiver)
                message = receiver.facts.last!
            })
        }

    }
}

De esta manera, podemos pasar cualquier Slice de Go a un Array en Swift. Incluso, podemos pasar Structs, sin embargo, solo se exportarán los campos del struct que sean compatibles con los tipos de datos soportados.

Conocer las limitaciones insuperables (por el momento)

De todas maneras, existen limitaciones más grandes, que, por el momento no podemos superar, entre ellas:

  1. Usar tipos de datos no compatibles en Structs (sobre todo, punteros)
  2. Tiempo de compilado elevado, esto es dado que se descargan las dependencias cada vez que se ejecuta el bind

Conclusiones

Gomobile definitivamente es una gran herramienta. Nos permite escribir código en Go que puede ser reutilizado en proyectos iOS y Android. El problema principal para poder ser adoptado es entonces el soporte de los tipos de dato. Todo parece indicar que el desarollo del proyecto está atascado. Parece ser posible, sin embargo, poder hacer un Framework basado en gomobile, que supla estos defectos automáticamente, esto ayudaría a su vez a impulsar Golang como herramienta de desarollo móvil.

Cabe destacar que si bien es cierto hay una gran ventaja en reutilizar código entre plataformas, debemos notar que ésta no es una idea nueva. Por ejemplo, Dropbox hacía algo similar usando C y una biblioteca llamada djinni. Al final, Dropbox decidió abandonar el proyecto dado que, entre otras razones, mantener programadores C++ era más complejo que reescribir el código dos veces en ambas plataformas (Android y iOS).

Idealmente, Go es un lenguaje más moderno y más seguro en especail para principantes, y al mismo tiempo bastante poderoso, así que esperamos ansiosos a que se solucionen los problemas de esta herramienta para que en corto plazo podamos estar utilizando en WeMake el mismo lenguaje para generar tanto nuestros servicios como las las biblotecas compatibles para ambas plataformas.