Cómo construir una biblioteca compartida de C ++ para iOS y Android

En SafetyCulture, queremos enviar las cosas rápidamente, para que nuestros clientes puedan mejorar la seguridad y la calidad del lugar de trabajo rápidamente. Para hacerlo, nuestro equipo de ingeniería móvil está buscando nuevas alternativas para desarrollar nuestras aplicaciones sin tener que escribir el mismo código varias veces en diferentes plataformas.

Hay bastantes opciones disponibles con ventajas y desventajas. Sin embargo, no es fácil elegir uno. Cuando validamos esas opciones, tenemos en cuenta las siguientes pautas:

  1. Sin comprometer la experiencia del usuario. Independientemente de la solución que elijamos, nuestros usuarios aún deberían poder usar una aplicación de alta calidad que se sienta natural y fluida.
  2. Poco o ningún compromiso en la experiencia del desarrollador. Definitivamente haremos algo diferente al desarrollo nativo, sin embargo, no queremos sentir que tenemos que hacer un gran compromiso en una nueva pila solo para compartir código. Debe ser lo suficientemente flexible para que podamos movernos a cualquier dirección que queramos en el futuro sin tirar todo el código.

Después de pasar unos días investigando y debatiendo, nos fijamos en Djinni, una solución de código compartido de Dropbox que le permite compartir la lógica comercial principal en C ++ y aún tener la interfaz de usuario hecha a mano de forma nativa para lograr la mejor experiencia de usuario. También puede exponer algunas API específicas de la plataforma a través del puente para que C ++ las use, y exponer nuevamente a la interfaz de usuario si es necesario. Esto es realmente poderoso, ya que podemos escribir una capa de interfaz de usuario limpia y delgada usando el mismo conjunto de API en ambas plataformas.

Cómo configurar Djinni

La configuración es fácil:

Agregue Djinni como submódulo a su proyecto git:

git submodule add https://github.com/dropbox/djinni.git deps / djinni
actualización de submódulo git --init --recursive

Cree su archivo de descripción de interfaz .djinni, por ejemplo:

pregunta = registro {
  id: cadena;
  título: cadena;
  orden: i32;
}
página = registro {
  id: cadena;
  título: cadena;
  orden: i32;
  preguntas: lista ;
}
forma = registro {
  id: cadena;
  nombre: cadena;
  páginas: lista ;
}
shared_core = interfaz + c {
  static create (): shared_core;
  generate_form (número_de_páginas: i32, preguntas_por_página: i32): formulario;
  prefix_string (input: string): string;
}

La descripción de la interfaz es bastante sencilla: tenemos tres registros que consisten en una única estructura de datos anidados: un formulario contiene múltiples páginas de múltiples preguntas.

Shared_core es el nombre de la interfaz que vamos a implementar en C ++. También puede definir una interfaz que se pueda implementar en Objective-C y Java, le mostraremos un ejemplo más adelante en este artículo. Centrémonos en este simple ejemplo por ahora. Vale la pena señalar que definimos un método de creación estático para shared_core que no toma parámetros. Esto tiene más sentido cuando necesita pasar objetos implementados en su plataforma, ya que los necesitará en la mayoría de los casos.

El siguiente paso es crear un script de shell que tome el archivo .djinni anterior como entrada y genere todo el código de puente para nosotros. El archivo .sh tiene este aspecto:

#! / usr / bin / env bash
### Configuración
# Djinni IDL ubicación del archivo
djinni_file = "demo.djinni"
# C ++ espacio de nombres para src generado
espacio de nombres = "demo"
# Objective-C prefijo de nombre de clase para src generado
objc_prefix = "SC"
# Nombre del paquete Java para src generado
java_package = "com.safetyculture.demo"
### Script
# obtener el directorio base
base_dir = $ (cd "` dirname "0" `" && pwd)
# obtener el directorio java del nombre del paquete
java_dir = $ (echo $ java_package | tr. /)
# directorios de salida para src generado
cpp_out = "$ base_dir / generate-src / cpp"
objc_out = "$ dir_base / generado-src / objc"
jni_out = "$ base_dir / generado-src / jni"
java_out = "$ dir_base / generado-src / java / $ java_dir"
# limpiar los directorios src generados
rm -rf $ cpp_out
rm -rf $ jni_out
rm -rf $ objc_out
rm -rf $ java_out
# ejecuta el comando djinni
deps / djinni / src / run \
   --java-out $ java_out \
   --java-package $ java_package \
   --ident-java-field mFooBar \
   --cpp-out $ cpp_out \
   --cpp-namespace $ namespace \
   --jni-out $ jni_out \
   --ident-jni-class NativeFooBar \
   --ident-jni-file NativeFooBar \
   --objc-out $ objc_out \
   --objc-type-prefix $ objc_prefix \
   --objcpp-out $ objc_out \
   --idl $ djinni_file

Este script depende del submódulo Djinni que agregamos al principio, así que asegúrese de haberlo hecho. Una vez que ejecute este script, verá un nuevo directorio llamado generate-src. Contiene cuatro subdirectorios: cpp, objc, jni, java que tienen todo el código que necesita para su proyecto iOS o Android.

Para compilar el código generado Dinjini para un proyecto iOS:

  1. Cree un directorio llamado plataformas / ios y cree su proyecto de iOS dentro de él.
  2. Agregue archivos dentro de deps / djinni / support-lib / objc, generate-src / objc y generate-src / cpp a su proyecto iOS. NO use la opción de copia.
  3. Cambie el nombre de main.m de su proyecto iOS por main.mm; esto lo convierte en un archivo Objective-C ++.

Hecho todo, deberías poder construir tu proyecto sin error.

Ahora comencemos a escribir algo de código. Cree un directorio llamado shared y cree dos archivos llamados shared_core_impl.hpp y shared_core_impl.cpp. Ahí es donde escribimos nuestro código compartido en C ++. Y no olvide agregar estos dos archivos a su proyecto de iOS también.

El archivo de encabezado shared_core_impl.hpp tiene este aspecto:

#include "shared_core.hpp"
demostración de espacio de nombres {
  class SharedCoreImpl: demo pública :: SharedCore {
  público:
    SharedCoreImpl ();
    Form generate_form (int32_t número_de_páginas, int32_t preguntas_por_página);
    std :: string prefix_string (const std :: string & input);
  };
}

Lo que esto hace es crear una nueva clase llamada SharedCoreImpl que implementa la interfaz SharedCore. No publicaré el código de implementación completo aquí, pero la lógica es bastante simple. El método generate_form toma 2 parámetros enteros, genera y devuelve un objeto Form con el número de páginas y preguntas proporcionadas a través de parámetros. Prefix_string solo pone un saludo delante de cualquier cadena de entrada y devuelve la nueva cadena.

Es realmente fácil usar su código C ++ en un proyecto iOS; Aquí hay un breve ejemplo:

#import "ViewController.h"
#import "SCSharedCore.h"
@interface ViewController ()
@property (no atómico, fuerte) SCSharedCore * coreAPI;
@implementation ViewController
- (nulo) viewDidLoad {
    [super viewDidLoad];
    _coreAPI = [SCSharedCore create];
}
- (nulo) generateForm () {
    // generar un formulario contiene 500,0000 preguntas
    SCForm * form = [_coreAPI generateForm: 500 preguntas por página: 1000];
}
@fin

¡Eso es! No es muy difícil, ¿verdad? El proceso de configuración es un poco diferente en Android, deberá importar tanto el código jni como el código Java generado. Sin embargo, el concepto es similar: importar encabezados generados automáticamente y agregar código de implementación de C ++ a su proyecto. Es por eso que necesitamos poner esos archivos C ++ en un directorio compartido fuera del proyecto iOS o Android.

La arquitectura

Este es el diagrama de arquitectura con el que terminamos usando Djinni.

Tomamos prestado el concepto de Redux y Flux, donde los datos solo fluyen en una dirección. Ver envía acciones y luego representa un nuevo estado. Tendremos gestores específicos escritos en C ++ para cada pantalla o flujo de interfaz de usuario. Cuando se inicia una vista, el controlador de vista es responsable de crear instancias y configurar el objeto administrador. La vista solo necesita preocuparse sobre cuándo enviar acciones y cómo representar el estado. Toda la lógica empresarial se implementa en el administrador y se oculta de la capa de la interfaz de usuario, lo que puede ayudarnos a escribir código de interfaz de usuario realmente limpio.

Esto también facilita la escritura de pruebas unitarias; Dado que la capa de interfaz de usuario ahora procesa y envía acciones por separado, podemos crear un administrador simulado que solo alimenta diferentes estados para ver y probar la lógica de representación. También podemos crear otro administrador simulado para verificar si se envían las acciones correctas cuando el usuario interactúa con la interfaz de usuario.

Interacción de plataforma

En algunos casos, la interfaz de usuario puede necesitar interactuar con las API de la plataforma. Evitamos llamar directamente a las API de la plataforma desde la capa de interfaz de usuario, en su lugar, exponemos las API implementadas de forma nativa al administrador y volvemos a la capa de interfaz de usuario. A este enfoque lo llamamos forma de U.

Aquí hay un buen ejemplo para una interfaz de plataforma:

ui_platform_support = interfaz + o + j {
  post_task_in_background_thread (tarea: tarea);
  post_task_in_main_thread (tarea: tarea);
}

Aquí aprovechamos las API de concurrencia / subprocesamiento existentes de cada plataforma. El siguiente diagrama muestra cómo la interfaz de usuario está interactuando con las API de la plataforma:

Redes

Como se muestra en el primer diagrama de arquitectura, la capa persistente es responsable de decidir dónde deben ir los datos. Consideramos que las API de back-end son similares al almacenamiento en disco, que es solo otra fuente para que podamos leer y escribir datos. Utilizamos gRPC como nuestro protocolo de red para interactuar con el backend. Existen muchos beneficios al usar gRPC para aplicaciones móviles. Tendremos otra publicación de blog para esto en el futuro. Sin embargo, aún es posible pasar la implementación de redes de su plataforma a la capa persistente si no desea escribir todas estas llamadas HTTP usando C ++.

Lo que hemos aprendido

C ++ no da tanto miedo

Si no tiene experiencia previa en C ++ como nosotros, le recomiendo que lo pruebe al menos. Este lenguaje ha evolucionado mucho en los últimos años con muchas características modernas. El puntero inteligente es similar a ARC en iOS, por lo que no tiene que liberar manualmente las asignaciones de memoria, mientras que debe usar una referencia débil para evitar el ciclo de retención.

Tenga en cuenta el rendimiento general

Hay poca o ninguna sobrecarga en el puente para iOS, ya que la estructura de datos de C ++ no es ajena a Objective-C (Objective-C ++). Sin embargo, hay un notable impacto en el rendimiento de Android, ya que Djinni usa el JNI bajo el capó, por lo que la estructura de datos de clasificación y desorganización entre C ++ y Java son operaciones costosas. Si su aplicación necesita enviar grandes datos estructurados a través del puente y la velocidad es importante, tendrá que buscar opciones como el envío de formato de datos binarios multiplataforma (por ejemplo, Flatbuffers) en lugar de datos estructurados.

Hay pasos adicionales para configurar

Si no está familiarizado con Makefile para construir sus bibliotecas de soporte, es probable que inicialmente invierta tiempo extra para tener todo en su lugar. Como parte de su código se escribirá en C ++, algunas de sus bibliotecas de terceros deberán configurarse correctamente para que C ++ pueda usarlas. En nuestro caso, construimos gRPC, ProtoBuf y Flatbuffers en bibliotecas estáticas en diferentes arquitecturas, las combinamos en binario gordo para iOS usando lipo y las agregamos a nuestra aplicación. Sin embargo, todas estas cosas son una inversión única. Una vez que tenga todo en funcionamiento, es poco probable que pase el mismo tiempo una y otra vez.

¡Eso es! Todavía estamos en proceso de adoptar esta nueva arquitectura dentro del equipo móvil. Seguiremos compartiendo nuestra experiencia en el camino.

¿Tienes experiencia en la construcción de aplicaciones móviles multiplataforma? Únase a nuestro equipo de ingeniería y ayúdenos a construir productos que impacten vidas.