Durante los últimos dos años, el equipo de ingeniería de Goodnotes ha estado trabajando en un proyecto para llevar la exitosa app para tomar notas en iPad a otras plataformas. En este caso de éxito, se explica cómo la app del año para iPad de 2022 llegó a la Web, ChromeOS, Android y Windows con tecnología web y WebAssembly reutilizando el mismo código Swift en el que el equipo ha estado trabajando durante más de diez años.
Por qué GoodNotes llegó a la Web, Android y Windows
En 2021, Goodnotes solo estaba disponible como app para iOS y iPad. El equipo de ingeniería de Goodnotes aceptó un gran desafío técnico: crear una nueva versión de Goodnotes, pero para sistemas operativos y plataformas adicionales. El producto debe ser totalmente compatible con la aplicación para iOS y renderizar las mismas notas. Cualquier nota que se tome sobre un PDF o cualquier imagen adjunta debe ser equivalente y mostrar los mismos trazos que muestra la app para iOS. Cualquier trazo que se agregue debe ser equivalente al que los usuarios de iOS pueden crear, independientemente de la herramienta que el usuario esté usando, por ejemplo, pluma, resaltador, pluma estilográfica, formas o borrador.
En función de los requisitos y la experiencia del equipo de ingeniería, el equipo concluyó rápidamente que la reutilización de la base de código de Swift sería la mejor opción, ya que ya estaba escrita y bien probada durante muchos años. Pero, ¿por qué no simplemente portar la aplicación para iOS o iPad existente a otra plataforma o tecnología, como Flutter o Compose multiplataforma? Para pasar a una plataforma nueva, se necesitaría reescribir GoodNotes. De esta manera, es posible que se inicie una carrera de desarrollo entre la aplicación para iOS ya implementada y una nueva aplicación que se compilará desde cero, o que se detenga el desarrollo nuevo en la aplicación existente mientras la nueva base de código se pone al día. Si Goodnotes pudiera reutilizar el código Swift, el equipo podría beneficiarse de las nuevas funciones que implementó el equipo de iOS mientras el equipo multiplataforma trabajaba en los aspectos básicos de la app y alcanzaba la paridad de funciones.
El producto ya había resuelto una serie de desafíos interesantes para iOS para agregar funciones como las siguientes:
- Renderización de notas.
- Sincronización de documentos y notas
- Resolución de conflictos para notas con tipos de datos replicados sin conflictos
- Análisis de datos para la evaluación de modelos de IA
- Búsqueda de contenido y indexación de documentos.
- Animaciones y experiencia de desplazamiento personalizadas
- Implementación del modelo de vista para todas las capas de la IU
Todas serían mucho más fáciles de implementar en otras plataformas si el equipo de ingeniería pudiera hacer que la base de código de iOS ya funcione para las aplicaciones de iOS y iPad, y ejecutarla como parte de un proyecto que Goodnotes podría enviar como aplicaciones web, para Windows o para Android.
Pila tecnológica de Goodnotes
Afortunadamente, había una forma de volver a usar el código Swift existente en la Web: WebAssembly (Wasm). Goodnotes creó un prototipo con Wasm con el proyecto de código abierto y mantenido por la comunidad SwiftWasm. Con SwiftWasm, el equipo de Goodnotes pudo generar un objeto binario Wasm con todo el código Swift que ya se había implementado. Este objeto binario se puede incluir en una página web que se envía como una aplicación web progresiva para Android, Windows, ChromeOS y cualquier otro sistema operativo.
El objetivo era lanzar Goodnotes como una AWP y poder incluirla en la tienda de cada plataforma. Además de Swift, el lenguaje de programación que ya se usa para iOS, y WebAssembly, que se usa para ejecutar código Swift en la Web, el proyecto usó las siguientes tecnologías:
- TypeScript: Es el lenguaje de programación más usado para las tecnologías web.
- React y Webpack: El framework y el empaquetador más populares para la Web.
- AWP y trabajadores del servicio: Son grandes impulsores de este proyecto porque el equipo pudo enviar nuestra app como una aplicación sin conexión que funciona como cualquier otra app para iOS y puedes instalarla desde la tienda o el navegador.
- PWABuilder: Es el proyecto principal que usa Goodnotes para unir la AWP en un objeto binario nativo de Windows para que el equipo pueda distribuir nuestra app desde Microsoft Store.
- Actividades web de confianza: Es la tecnología de Android más importante que usa la empresa para distribuir nuestra AWP como una aplicación nativa en segundo plano.
En la siguiente imagen, se muestra lo que se implementa con TypeScript clásico y React, y lo que se implementa con SwiftWasm y JavaScript, Swift y WebAssembly sin modificaciones. En esta parte del proyecto, se usa JSKit, una biblioteca de interoperabilidad de JavaScript para Swift y WebAssembly que el equipo usa para controlar el DOM en la pantalla del editor desde nuestro código Swift cuando sea necesario o incluso usar algunas APIs específicas del navegador.
¿Por qué usar Wasm y la Web?
Aunque Apple no admite Wasm oficialmente, los siguientes son los motivos por los que el equipo de ingeniería de Goodnotes consideró que este enfoque era la mejor decisión:
- La reutilización de más de 100,000 líneas de código.
- La capacidad de continuar con el desarrollo del producto principal y, al mismo tiempo, contribuir a las apps multiplataforma.
- El poder de llegar a todas las plataformas lo antes posible con un proceso de desarrollo iterativo
- Tener control para renderizar el mismo documento sin duplicar toda la lógica empresarial y, además, introducir diferencias en nuestras implementaciones.
- Beneficiarse de todas las mejoras de rendimiento realizadas en todas las plataformas al mismo tiempo (y de todas las correcciones de errores implementadas en cada plataforma)
La reutilización de más de 100, 000 líneas de código y de la lógica empresarial que implementa nuestra canalización de renderización fue fundamental. Al mismo tiempo, hacer que el código Swift sea compatible con otras cadenas de herramientas les permite reutilizar este código en diferentes plataformas en el futuro si es necesario.
Desarrollo iterativo de productos
El equipo adoptó un enfoque iterativo para entregar algo a los usuarios lo más rápido posible. Goodnotes comenzó con una versión de solo lectura del producto en la que los usuarios podían obtener cualquier documento compartido y leerlo desde cualquier plataforma. Con solo un vínculo, podría acceder y leer las mismas notas que escribió desde su iPad. En la siguiente fase, se agregaron funciones de edición para que las versiones multiplataforma sean equivalentes a la de iOS.
La primera versión del producto de solo lectura tardó seis meses en desarrollarse, y los siguientes nueve meses se dedicaron al primer conjunto de funciones de edición y a la pantalla de la IU en la que puedes consultar todos los documentos que creaste o que alguien compartió contigo. Además, las nuevas funciones de la plataforma de iOS se pudieron portar fácilmente al proyecto multiplataforma gracias a la cadena de herramientas de SwiftWasm. A modo de ejemplo, se creó un nuevo tipo de pluma y se implementó fácilmente en varias plataformas reutilizando miles de líneas de código.
Crear este proyecto fue una experiencia increíble, y Goodnotes aprendió mucho de él. Es por eso que las siguientes secciones se enfocarán en aspectos técnicos interesantes sobre el desarrollo web y el uso de WebAssembly y lenguajes como Swift.
Obstáculos iniciales
Trabajar en este proyecto fue un gran desafío desde muchos puntos de vista diferentes. El primer obstáculo que encontró el equipo se relacionaba con la cadena de herramientas de SwiftWasm. La cadena de herramientas fue un gran facilitador para el equipo, pero no todo el código de iOS era compatible con Wasm. Por ejemplo, el código relacionado con la E/S o la IU, como la implementación de vistas, clientes de API o el acceso a la base de datos, no era reutilizable, por lo que el equipo tuvo que comenzar a refactorizar partes específicas de la app para poder reutilizarlas desde la solución multiplataforma. La mayoría de las PR que creó el equipo fueron refactorizaciones para abstraer dependencias, de modo que el equipo pudiera reemplazarlas más adelante con la inyección de dependencias o con otras estrategias similares. Originalmente, el código de iOS combinaba la lógica empresarial sin procesar que se podía implementar en Wasm con el código responsable de la entrada y salida, y la interfaz de usuario que no se podía implementar en Wasm porque tampoco es compatible. Por lo tanto, el código de E/S y de la IU debía reimplementarse en TypeScript una vez que la lógica empresarial de Swift estuviera lista para reutilizarse entre plataformas.
Se resolvieron los problemas de rendimiento
Una vez que Goodnotes comenzó a trabajar en el editor, el equipo identificó algunos problemas con la experiencia de edición, y se introdujeron restricciones tecnológicas desafiantes en nuestro plan de trabajo. El primer problema se relacionaba con el rendimiento. JavaScript es un lenguaje de un solo subproceso. Esto significa que tiene una pila de llamadas y un montón de memoria. Ejecuta el código en orden y debe terminar de ejecutar un fragmento de código antes de pasar al siguiente. Es síncrona, pero, a veces, puede ser dañina. Por ejemplo, si una función tarda un tiempo en ejecutarse o tiene que esperar algo, inmoviliza todo mientras tanto. Y eso es exactamente lo que los ingenieros tenían que resolver. Evaluar algunas instrucciones específicas en nuestra base de código relacionadas con la capa de renderización o con otros algoritmos complejos fue un problema para el equipo, porque estos algoritmos eran síncronos y ejecutarlos bloqueaba el subproceso principal. El equipo de Goodnotes los reescribió para que fueran más rápidos y refactorizó algunos para que fueran asíncronos. También introdujeron una estrategia de rendimiento para que la app pudiera detener la ejecución del algoritmo y continuarla más tarde, lo que permite que el navegador actualice la IU y evite que se pierdan fotogramas. Esto no fue un problema para la aplicación para iOS, ya que puede usar subprocesos y evaluar estos algoritmos en segundo plano mientras el subproceso principal de iOS actualiza la interfaz de usuario.
Otra solución que el equipo de ingeniería tuvo que resolver fue migrar una IU basada en elementos HTML adjuntos al DOM a una IU de documentos basada en un lienzo de pantalla completa. El proyecto comenzó a mostrar todas las notas y el contenido relacionados con un documento como parte de la estructura del DOM con elementos HTML, como lo haría cualquier otra página web, pero en algún momento migró a un lienzo de pantalla completa para mejorar el rendimiento en dispositivos de gama baja reduciendo el tiempo que el navegador trabaja en las actualizaciones del DOM.
El equipo de ingeniería identificó los siguientes cambios como elementos que podrían haber reducido algunos de los problemas encontrados si se hubieran realizado al comienzo del proyecto.
- Descarga el subproceso principal con más frecuencia usando trabajadores web para algoritmos pesados.
- Usa funciones exportadas y importadas en lugar de la biblioteca de interoperabilidad JS-Swift desde el principio para reducir el impacto en el rendimiento de salir del contexto de Wasm. Esta biblioteca de interoperabilidad de JavaScript es útil para obtener acceso al DOM o al navegador, pero es más lenta que las funciones exportadas de Wasm nativas.
- Asegúrate de que el código permita el uso de
OffscreenCanvas
en segundo plano para que la app pueda descargar el subproceso principal y mover todo el uso de la API de Canvas a un trabajador web que maximice el rendimiento de las aplicaciones cuando se escriben notas. - Traslada toda la ejecución relacionada con Wasm a un trabajador web o incluso a un grupo de trabajadores web para que la app pueda reducir la carga de trabajo del subproceso principal.
El editor de texto
Otro problema interesante se relacionaba con una herramienta específica: el editor de texto.
La implementación de iOS para esta herramienta se basa en NSAttributedString
, un pequeño conjunto de herramientas que usa RTF en su interior. Sin embargo, esta implementación no es compatible con SwiftWasm, por lo que el equipo multiplataforma se vio obligado a crear primero un analizador personalizado basado en la gramática RTF y, luego, implementar la experiencia de edición transformando RTF en HTML y viceversa. Mientras tanto, el equipo de iOS comenzó a trabajar en la nueva implementación para esta herramienta, reemplazando el uso de RTF por un modelo personalizado para que la app pueda representar texto con diseño de una manera amigable para todas las plataformas que comparten el mismo código Swift.
Este desafío fue uno de los puntos más interesantes de la planificación del proyecto porque se resolvió de forma iterativa en función de las necesidades del usuario. Era un problema de ingeniería que se resolvió con un enfoque centrado en el usuario en el que el equipo tuvo que reescribir parte del código para poder renderizar texto, de modo que habilitaron la edición de texto en una segunda versión.
Versiones iterativas
La evolución del proyecto en los últimos dos años ha sido increíble. El equipo comenzó a trabajar en una versión de solo lectura del proyecto y, meses después, envió una versión nueva con muchas capacidades de edición. Para lanzar cambios de código con frecuencia en producción, el equipo decidió usar ampliamente las marcas de función. Para cada lanzamiento, el equipo podría habilitar funciones nuevas y, también, lanzar cambios de código que implementen funciones nuevas que el usuario vería semanas más tarde. Sin embargo, el equipo cree que podría haber mejorado algo. Piensan que implementar un sistema dinámico de marcas de funciones habría ayudado a acelerar el proceso, ya que eliminaría la necesidad de volver a implementar para cambiar los valores de las marcas. Esto le brindaría a Goodnotes más flexibilidad y también aceleraría la implementación de la nueva función, ya que Goodnotes no tendría que vincular la implementación del proyecto al lanzamiento del producto.
Trabajo sin conexión
Una de las funciones principales en las que trabajó el equipo es la compatibilidad sin conexión. Poder editar y modificar tus documentos es una función que esperarías de cualquier aplicación como esta. Sin embargo, esta no es una función simple porque Goodnotes admite la colaboración. Esto significa que todos los cambios que realizan diferentes usuarios en diferentes dispositivos deberían terminar en todos los dispositivos sin pedirles a los usuarios que resuelvan ningún conflicto. Goodnotes resolvió este problema hace mucho tiempo con el uso de CRDT en segundo plano. Gracias a estos tipos de datos replicados sin conflictos, Goodnotes puede combinar todos los cambios que cualquier usuario realizó en cualquier documento y fusionarlos sin ningún conflicto de combinación. El uso de IndexedDB y el almacenamiento disponible para los navegadores web fue un gran facilitador de la experiencia colaborativa sin conexión en la Web.
Además, abrir la app web de Goodnotes genera un costo inicial de descarga de alrededor de 40 MB debido al tamaño del objeto binario de Wasm. En un principio, el equipo de Goodnotes se basó únicamente en la caché del navegador normal para el paquete de la app y la mayoría de los extremos de la API que usan, pero, en retrospectiva, podría haberse beneficiado de la API de caché y los trabajadores del servicio más confiables antes. En un principio, el equipo se mostró reacio a realizar esta tarea debido a su complejidad, pero, al final, se dio cuenta de que Workbox la hacía mucho menos aterradora.
Recomendaciones para usar Swift en la Web
Si tienes una aplicación para iOS con mucho código que deseas reutilizar, prepárate porque estás a punto de comenzar un viaje increíble. Antes de comenzar, te sugerimos que leas estas sugerencias.
- Verifica qué código quieres volver a usar. Si la lógica empresarial de tu app se implementa en el servidor, es probable que te gustaría volver a usar el código de la IU, y Wasm no te ayudará en este caso. El equipo analizó brevemente Tokamak, un framework compatible con SwiftUI para compilar apps de navegador con WebAssembly, pero no estaba lo suficientemente madura para las necesidades de la app. Sin embargo, si tu app tiene una lógica empresarial sólida o algoritmos implementados como parte del código del cliente, Wasm será tu mejor amigo.
- Asegúrate de que tu base de código de Swift esté lista. Los patrones de diseño de software para la capa de IU o las arquitecturas específicas que crean una separación sólida entre la lógica de la IU y la lógica empresarial serán muy útiles, ya que no podrás volver a usar la implementación de la capa de IU. La arquitectura limpia o los principios de la arquitectura hexagonal también serán fundamentales, ya que deberás insertar y proporcionar dependencias para todo el código relacionado con la E/S, y será mucho más fácil hacerlo si sigues estas arquitecturas en las que los detalles de la implementación se definen como abstracciones y se usa mucho el principio de inversión de la dependencia.
- Wasm no proporciona código de IU. Por lo tanto, decide qué framework de IU quieres usar para la Web.
- JSKit te ayudará a integrar tu código Swift con JavaScript, pero ten en cuenta que, si tienes una ruta de acceso directa, cruzar el puente JS-Swift puede ser costoso y deberás reemplazarlo con funciones exportadas. Puedes obtener más información sobre cómo funciona JSKit en la documentación oficial y en la publicación Dynamic Member Lookup in Swift, a hidden gem!.
- La posibilidad de reutilizar tu arquitectura dependerá de la arquitectura que siga tu app y de la biblioteca del mecanismo de ejecución de código asíncrono que uses. Patrones como MVVP o la arquitectura componible te ayudarán a volver a usar tus modelos de vista y parte de la lógica de la IU sin vincular la implementación a dependencias de UIKit que no puedes usar con Wasm. Es posible que RXSwift y otras bibliotecas no sean compatibles con Wasm, así que tenlo en cuenta porque deberás usar OpenCombine, asíncrono/espera y transmisiones en el código Swift de Goodnotes.
- Comprime el binario de Wasm con gzip o brotli. Ten en cuenta que el tamaño del archivo binario será bastante grande para las aplicaciones web clásicas.
- Incluso cuando puedas usar Wasm sin la AWP, asegúrate de incluir al menos un trabajador de servicio, incluso si tu app web no tiene un manifiesto o no quieres que el usuario la instale. El trabajador de servicio guardará y publicará el objeto binario Wasm de forma gratuita y todos los recursos de la app para que el usuario no tenga que descargarlos cada vez que abra tu proyecto.
- Ten en cuenta que el proceso de contratación puede ser más difícil de lo esperado. Es posible que debas contratar desarrolladores web sólidos con algo de experiencia en Swift o desarrolladores de Swift sólidos con algo de experiencia en la Web. Si puedes encontrar ingenieros generalistas con algunos conocimientos en ambas plataformas, sería genial.
Conclusiones
Crear un proyecto web con una pila de tecnología compleja mientras se trabaja en un producto lleno de desafíos es una experiencia increíble. Será difícil, pero valdrá la pena. Goodnotes nunca podría haber lanzado una versión para Windows, Android, ChromeOS y la Web mientras trabajaba en nuevas funciones para la aplicación para iOS sin usar este enfoque. Gracias a esta pila de tecnología y al equipo de ingeniería de Goodnotes, ahora está en todas partes, y el equipo está listo para seguir trabajando en los próximos desafíos. Si quieres obtener más información sobre este proyecto, puedes mirar una charla que dio el equipo de Goodnotes en NSSpain 2023. Asegúrate de probar Goodnotes para la Web.