En Lenskart, hemos creado varios flujos de trabajo especializados para facilitar la experiencia de navegación y compra de nuestros usuarios.
Naturalmente, esto ha llevado al desarrollo de muchas mini-aplicaciones diferentes dentro de la aplicación principal, desde el flujo de compra típico de comercio electrónico y los módulos de seguimiento de pedidos, hasta módulos para AR, Localizador de tiendas, Eye -checkup, Home trial , y muchos más.
A principios de 2020, nos dimos cuenta de que nuestro código había crecido demasiado, lo que presentaba varios problemas:
- Tomó años compilar.
- El desarrollo paralelo se volvió muy difícil.
- Pasamos demasiado tiempo resolviendo conflictos.
- Cada módulo hizo todo, no hubo separación de preocupaciones.
- Incluso los pequeños cambios terminaron teniendo un impacto en cascada.
En resumen, hacer grandes cambios en el código se convirtió en una pesadilla.
Índice
Hora del cuento: UiUtils
UiUtils
, como sugiere el nombre, era una clase común, que contenía funciones de utilidad relacionadas con la interfaz de usuario. Por ejemplo, manejar la visibilidad de la vista, la visibilidad del teclado, mostrar texto en color personalizado, etc.
John escribió una función genérica showView()
en esta clase, que mostró una vista particular. Después de un tiempo, Doe
agregó una llamada API a la vista, haga clic dentro showView()
y manejó la navegación dentro del resultado de la misma llamada.
Esto no rompió la funcionalidad existente, y también era un código de utilidad relacionado con esa vista. Pero ahora UiUtils
como clase no solo tenía la responsabilidad relacionada con la interfaz de usuario, sino también algunos códigos relacionados con la red y algunos códigos relacionados con la navegación.
Esto provocó un acoplamiento estrecho de la capa de interfaz de usuario con UiUtils
. Esto agregó dependencias no relacionadas con la interfaz de usuario al UiUtils
. Y también desdibujó el límite de separación de preocupaciones entre la capa de interfaz de usuario y la clase de utilidad.
Con app
al ser un solo módulo, este problema se estaba produciendo en muchos lugares. Se necesitaba una solución clara, con separación de preocupaciones.
¿Arquitectura modular?
Decidimos dividir nuestro código en capas. Estas capas se apilarían una encima de la otra. Cada capa sería un módulo separado.
La capa de la parte superior contendría el código que más cambia, y la capa de la parte inferior contendría el código que menos cambia. Además, cada capa tenía que cumplir las siguientes condiciones:
- Debe ser compilable y comprobable de forma independiente.
- Debe haber una clara responsabilidad definida para cada capa.
- La dependencia sólo debe estar en las capas debajo de ella, no en la capa superior.
Teniendo en cuenta las reglas establecidas anteriormente, decidimos dividir nuestro código de la siguiente manera:
La capa basement
en la parte inferior contendría las clases que no tendrán ninguna dependencia de Android, tendrán usos en todo el código base y serán bastante genéricas.
Este código rara vez estaría sujeto a cambios y, por lo tanto, la única vez que necesitaríamos compilar basement
es decir, cuando ejecutamos una reconstrucción limpia de todo el proyecto.
Esta capa manejaría el procesamiento de solicitudes y el análisis de respuestas. Nuevamente, esta capa rara vez o nunca debería requerir cambios.
Esta capa contendría repositorios, modelos, implementaciones de bases de datos y servicios de programación relacionados con el almacenamiento en caché. Enviar / recibir cualquier tipo de datos a / desde servidores remotos sería responsabilidad de esta capa.
Este módulo contendría código relacionado con la analítica y, por lo general, solo se cambiaría al agregar o modificar eventos de analítica.
Este módulo contendría todos los widgets
, estilos, temas, archivos de cadena, configuraciones relacionadas con toda la aplicación y utilidades comunes de UI
. El manejo de la navegación a través de funciones en el núcleo también sería responsabilidad de baselayer
.
La causa más común de cambios en el código de este módulo estaría relacionada con la configuración de una función o la navegación en caso de una nueva función. Por tanto, este módulo no tendría cambios frecuentes.
Core contendría todos los módulos de funciones. Estos módulos de funciones tendrían todas las actividades, fragmentos y adaptadores relacionados con la interfaz de usuario de esa función.
Core residiría en la parte superior, se cambiaría con mayor frecuencia y se trabajaría en el módulo. Este módulo también podría llamarse Capa de presentación de la aplicación.
Todos estos módulos residirían en repositorios de git separados. La cobertura de la prueba podría centrarse primero en los módulos que se cambian con más frecuencia.
El viaje
Para lograr esta arquitectura en capas, la implementamos una capa a la vez. los datalayer
El módulo ya se construyó como un módulo separado.
Entonces, el primer objetivo fue el basement
. Identificar las clases con cero dependencias de Android, con uso durante todo el proyecto, fue bastante fácil.
Cosas como el analizador de respuestas usando Gson, convertidor de objeto a cadena, etc. Extrajimos estas clases de core
y ponerlos en basement
.
Lo mismo sucedió con el thirdpartyutils
, ya que solo contiene código relacionado con análisis. Dividimos las clases en paquetes nombrados por los diferentes servicios de análisis que usamos. A continuación se muestra lo que parece thirdpartyutils
:
Haciendo baselayer
fue el mayor desafío. El objetivo aquí era mover todo el código común relacionado con la interfaz de usuario a este módulo. Pero, dado que todos los paquetes relacionados con las funciones estaban estrechamente acoplados, esta fue una tarea larga.
La aplicación tiene muchos widgets personalizados. Con el tiempo, estas clases acumularon una gran cantidad de código, que no era específico de los widgets. Cosas como intenciones de navegación, dependencias comerciales de otras clases. Entonces, baselayer
fue construido en dos pasos:
- Saque todo el código y las dependencias irrelevantes de los widgets personalizados. Esto los convirtió en clases genéricas independientes, que se trasladaron a
baselayer
fácilmente. - Siga el mismo procedimiento para las clases de utilidades relacionadas con la IU y las clases de configuración relacionadas con las características.
La eliminación de la interdependencia entre características también significó que no pudimos usar intents basados en clases para la navegación porque eso requería que se iniciara la importación de la actividad.
Esto también significó que tuvimos que repensar nuestra implementación de navegación (esa es una historia para otro momento 😉).
Con esto, cambiamos nuestro manejo de navegación de core
a baselayer
. Esto también nos proporcionó módulos de funciones en core
con cero dependencias entre sí.
Después de todo esto, la salida fue la arquitectura en capas, en la que habíamos pensado originalmente.
Resultado
Con esta arquitectura en capas, logramos un aumento en la productividad en términos de tiempos de construcción más rápidos y desarrollo paralelo. El tiempo de construcción se redujo en más del 30%, con construcciones limpias reducidas a 7 minutos desde más de 12 minutos.
El tiempo de construcción se redujo en más del 30%.
El módulo más modificado – core
– está en la parte superior, y los que no lo hacen con frecuencia están en las capas inferiores. Eso significa que solo el módulo core
se construye la mayor parte del tiempo, y los módulos debajo de él no tienen que construirse con tanta frecuencia.
Tener varios módulos, que se pueden construir individualmente, también significa que varios desarrolladores pueden trabajar en diferentes módulos al mismo tiempo.
Por ejemplo, si se está desarrollando una nueva función, el desarrollo de la interfaz de usuario core
y el desarrollo de la API datalayer
pueden continuar en paralelo.
Desarrollo paralelo y colaborativo
Cada módulo también proporciona una abstracción al módulo anterior. Por ejemplo, la networklayer
implementación se puede cambiar en cualquier momento, sin que otros módulos lo sepan.
Debido a la simplicidad de esta abstracción, pudimos obtener ganancias de rendimiento masivas a través de varias optimizaciones de red y renderizado, ¡pero esa es una publicación para otro momento!
Alcance futuro
El core
módulo se puede dividir en módulos de funciones independientes. Se app
puede crear un módulo por encima de los feature
módulos, con la responsabilidad de manejar los feature
módulos.
El desarrollo de características dinámicas se puede realizar en paralelo. La canalización CI / CD se puede configurar para cada módulo por separado.
Últimos pensamientos
Si se encuentra en las etapas iniciales del desarrollo de su proyecto, no sugeriría seguir esta arquitectura línea por línea. En su lugar, mantenga los archivos en paquetes separados como el anterior. De modo que una vez que el código y el equipo crezcan lo suficiente, se pueda dividir fácilmente en módulos.
Para los desarrolladores, que buscan sugerencias sobre cómo dividir el código en módulos, el punto de partida sería definir responsabilidades claras de cada módulo. Entonces será posible identificar y eliminar las interdependencias del código, mientras avanza a través del código capa por capa.
A fin de cuentas, este es más bien un paso intermedio para lograr una arquitectura modular. Hay mucho más por hacer, incluido el aprovechamiento de funciones dinámicas y aplicaciones instantáneas.
Para cualquier pensamiento / sugerencia, hágamelo saber en la sección de comentarios a continuación.
Añadir comentario