
Badoo desarrolla varias aplicaciones, y cada una de ellas es un producto separado con sus propias características, equipos de gestión, producto e ingeniería. Pero todos trabajamos juntos en la misma oficina y resolvemos problemas similares.
El desarrollo de cada proyecto tuvo lugar a su manera. La base del código fue influenciada no solo por diferentes marcos de tiempo y soluciones de productos, sino también por la visión de los desarrolladores. Al final, notamos que los proyectos tienen la misma funcionalidad, que es fundamentalmente diferente en la implementación.
Luego decidimos llegar a una estructura que nos daría la oportunidad de reutilizar funciones entre aplicaciones. Ahora, en lugar de desarrollar funcionalidades en proyectos individuales, creamos componentes comunes que se integran en todos los productos. Si está interesado en cómo llegamos a esto, bienvenido a cat.
Pero primero, detengámonos en los problemas, cuya solución condujo a la creación de componentes comunes. Hubo varios de ellos:
- copiar y pegar entre aplicaciones;
- procesos que insertan palos en las ruedas;
- arquitectura diferente de proyectos.
Este artículo es una versión de texto de mi informe con AppsConf 2019 , que se puede ver aquí .Problema: copiar y pegar
Hace algún tiempo, cuando los árboles estaban más difusos, el césped era más verde y yo era un año más joven, a menudo teníamos la siguiente situación.
Hay un desarrollador, llamémosle Lesha. Crea un módulo genial para su tarea, se lo cuenta a sus colegas y lo coloca en el repositorio de su aplicación, donde lo utiliza.
El problema es que todas nuestras aplicaciones están en repositorios diferentes.

El desarrollador Andrey en este momento solo está trabajando en otra aplicación en un repositorio diferente. Quiere usar este módulo en su tarea, que es sospechosamente similar a la que Lesha estaba involucrada. Pero hay un problema: el proceso de reutilización del código está completamente depurado.
En esta situación, Andrei escribirá su decisión (que ocurre en el 80% de los casos) o copiará y pegará la solución de Lyosha y cambiará todo en ella para que se ajuste a su aplicación, tarea o estado de ánimo.

Después de eso, Lesha puede actualizar su módulo agregando cambios a su código para su tarea. No conoce otra versión y solo actualizará su repositorio.
Esta situación trae varios problemas.
En primer lugar, tenemos varias aplicaciones, cada una con su propio historial de desarrollo. Al trabajar en cada aplicación, el equipo del producto a menudo creaba soluciones que son difíciles de llevar a una sola estructura.
En segundo lugar, equipos independientes participan en proyectos, que se comunican mal entre sí y, por lo tanto, rara vez se informan entre sí sobre actualizaciones / reutilización de uno u otro módulo.
En tercer lugar, la arquitectura de la aplicación es muy diferente: de MVP a MVI, de actividad divina a actividad individual.
Bueno, lo más destacado del programa: las aplicaciones están en diferentes repositorios, cada uno con sus propios procesos.
Al comienzo de la lucha contra estos problemas, fijamos el objetivo final: reutilizar nuestras mejores prácticas (tanto lógicas como de interfaz de usuario) entre todas las aplicaciones.
Decisiones: establecemos procesos
De los problemas anteriores, dos están relacionados con los procesos:
- Dos repositorios que comparten proyectos con un muro impenetrable.
- Equipos separados sin comunicación establecida y requisitos diferentes de los equipos de aplicación de productos.
Comencemos con el primero: estamos tratando con dos repositorios con la misma versión del módulo. Teóricamente, podríamos usar git-subtree o soluciones similares y colocar módulos de proyecto comunes en repositorios separados.

El problema ocurre durante la modificación. A diferencia de los proyectos de código abierto, que tienen una API estable y se distribuyen a través de fuentes externas, los cambios a menudo ocurren en componentes internos que rompen todo. Cuando se utiliza el subárbol, cada migración se convierte en una molestia.
Mis colegas del equipo de iOS tienen una experiencia similar y resultó que no tuvo mucho éxito, como Anton Schukin habló en
la conferencia de Mobius el año pasado.
Después de estudiar y comprender su experiencia, cambiamos a un único repositorio. Todas las aplicaciones de Android ahora se encuentran en un solo lugar, lo que nos brinda ciertos beneficios:
- puede reutilizar el código de forma segura utilizando los módulos Gradle;
- logramos conectar la cadena de herramientas en CI usando una infraestructura para compilaciones y pruebas;
- Estos cambios eliminaron la barrera física y mental entre los equipos, ya que ahora somos libres de usar los desarrollos y soluciones de los demás.
Por supuesto, esta solución también tiene desventajas. Tenemos un gran proyecto, que a veces no está sujeto a IDE y Gradle. El problema podría resolverse parcialmente mediante los módulos de carga / descarga en Android Studio, pero es difícil usarlos si necesita trabajar simultáneamente en todas las aplicaciones y, a menudo, cambiar.
El segundo problema, la interacción entre equipos, constaba de varias partes:
- equipos separados sin comunicación establecida;
- distribución indistinta de la responsabilidad de los módulos comunes;
- diferentes requisitos de los equipos de productos.
Para resolverlo, formamos equipos que se dedican a la implementación de ciertas funcionalidades en cada aplicación: por ejemplo, chat o registro. Además del desarrollo, también son responsables de integrar estos componentes en la aplicación.
Los equipos de productos ya tienen componentes existentes en sus manos, mejorándolos y personalizándolos a las necesidades de un proyecto particular.
Por lo tanto, ahora la creación de un componente reutilizable es parte del proceso para toda la empresa, desde la etapa de la idea hasta el inicio de la producción.
Soluciones: arquitectura racionalizada
Nuestro siguiente paso hacia la reutilización fue racionalizar la arquitectura. ¿Por qué hicimos esto?
Nuestra base de código lleva el legado histórico de varios años de desarrollo. Junto con el tiempo y las personas, los enfoques cambiaron. Entonces nos encontramos en una situación con todo un zoológico de arquitecturas, lo que resultó en los siguientes problemas:
- La integración de módulos comunes fue casi más lenta que escribir nuevos. Además de las características de lo funcional, era necesario soportar la estructura tanto del componente como de la aplicación.
- Los desarrolladores que tuvieron que cambiar de una aplicación a otra pasaron mucho tiempo dominando nuevos enfoques.
- A menudo, los contenedores se escribieron de un enfoque a otro, lo que equivalía a la mitad del código en la integración del módulo.
Al final, nos decidimos por el enfoque MVI, que estructuramos en nuestra biblioteca MVICore (
GitHub ). Estábamos particularmente interesados en una de sus características: las actualizaciones de estado atómico, que siempre garantizan la validez. Fuimos un poco más allá y combinamos los estados de las capas lógicas y de presentación, reduciendo la fragmentación. Por lo tanto, llegamos a una estructura donde la única entidad es responsable de la lógica, y view solo muestra el modelo creado a partir del estado.

La separación de responsabilidades ocurre a través de la transformación de modelos entre niveles. Gracias a esto, obtenemos una bonificación en forma de reutilización. Conectamos los elementos desde el exterior, es decir, cada uno de ellos no sospecha que el otro existe, simplemente regalan algunos modelos y reaccionan a lo que les llega. Esto le permite extraer componentes y usarlos en otros lugares escribiendo adaptadores para sus modelos.
Veamos un ejemplo de una pantalla simple como se ve en la realidad.

Utilizamos las interfaces básicas de RxJava para indicar los tipos con los que funciona el elemento. La entrada se indica mediante la interfaz Consumer <T>, salida - ObservableSource <T>.
Usando estas interfaces, podemos expresar View como Consumer <ViewModel> y ObservableSource <Event>. Tenga en cuenta que ViewModel solo contiene el estado de la pantalla y tiene poco que ver con MVVM. Una vez recibido el modelo, podemos mostrar los datos del mismo, y cuando hacemos clic en el botón, enviamos el evento, que se transmite al exterior.
Feature ya implementa ObservableSource y Consumer para nosotros; necesitamos transferir allí el estado inicial (contador igual a 0) e indicar cómo cambiar este estado.
Después de la transferencia de Wish, se llama Reducer, que crea uno nuevo basado en el último estado. Además de Reducer, la lógica puede ser descrita por otros componentes. Puedes aprender más sobre ellos
aquí .
Después de crear los dos elementos, tenemos que conectarlos.

val eventToWish: (Event) -> Wish = { when (it) { is ButtonClick -> Increment } } val stateToModel: (State) -> ViewModel = { ViewModel(text = state.counter.toString()) } Binder().apply { bind(view to feature using eventToWish) bind(feature to view using stateToModel) }
Primero, indicamos cómo transformamos un elemento de un tipo en otro. Entonces, ButtonClick se convierte en Incremento, y el campo de contador de Estado entra en texto.
Ahora podemos crear cada una de las cadenas con la transformación deseada. Para esto usamos Binder. Le permite crear relaciones entre ObservableSource y Consumer, observando el ciclo de vida. Y todo esto con una buena sintaxis. Este tipo de conexión nos lleva a un sistema flexible que nos permite extraer y usar elementos individualmente.
Los elementos MVICore funcionan bastante bien con nuestro "zoológico" de arquitecturas después de escribir envoltorios de ObservableSource y Consumer. Por ejemplo, podemos ajustar los métodos de casos de uso de Clean Architecture en Wish / State y usarlos en la cadena en lugar de Feature.

Componente
Finalmente, pasamos a los componentes. Como son
Considere la pantalla en la aplicación y divídala en partes lógicas.

Se puede distinguir:
- barra de herramientas con logotipo y botones en la parte superior;
- una tarjeta con perfil y logo;
- Sección de Instagram.
Cada una de estas partes es el componente que se puede reutilizar en un contexto completamente diferente. Por lo tanto, la sección de Instagram puede formar parte de la edición de perfiles en otra aplicación.

En el caso general, un componente es una Vista, elementos lógicos y componentes anidados en su interior, unidos por una funcionalidad común. E inmediatamente surge la pregunta: ¿cómo ensamblarlos en una estructura soportada?
El primer problema que encontramos es que MVICore ayuda a crear y vincular elementos, pero no ofrece una estructura común. Al reutilizar elementos de un módulo común, no está claro dónde juntar estas piezas: ¿dentro de la parte común o en el lado de la aplicación?
En el caso general, definitivamente no queremos dar a la aplicación piezas dispersas. Idealmente, buscamos algún tipo de estructura que nos permita obtener dependencias y ensamblar el componente como un todo con el ciclo de vida deseado.
Inicialmente, dividimos los componentes en pantallas. La conexión de los elementos tuvo lugar junto a la creación de contenedores DI para actividad o fragmento. Estos contenedores ya conocen todas las dependencias, tienen acceso a la Vista y al ciclo de vida.
object SomeScopedComponent : ScopedComponent<SomeComponent>() { override fun create(): SomeComponent { return DaggerSomeComponent.builder() .build() } override fun SomeComponent.subscribe(): Array<Disposable> = arrayOf( Binder().apply { bind(feature().news to otherFeature()) bind(feature() to view()) } ) }
Los problemas comenzaron en dos lugares a la vez:
- DI comenzó a trabajar con lógica, lo que condujo a la descripción de todo el componente en una clase.
- Dado que el contenedor está conectado a una Actividad o Fragmento y describe al menos toda la pantalla, hay muchos elementos en dicha pantalla / contenedor, lo que se traduce en una gran cantidad de código para conectar todas las dependencias de esta pantalla.
Para resolver los problemas en orden, comenzamos colocando la lógica en un componente separado. Por lo tanto, podemos recopilar todas las funciones dentro de este componente y comunicarnos con View a través de entradas y salidas. Desde el punto de vista de la interfaz, parece un elemento MVICore normal, pero al mismo tiempo se crea a partir de varios otros.

Una vez resuelto este problema, compartimos la responsabilidad de conectar los elementos. Pero aún compartíamos los componentes en las pantallas, lo que claramente no estaba al alcance de nuestra mano, lo que resultó en una gran cantidad de dependencias en un solo lugar.
@Scope internal class ComponentImpl @Inject constructor( private val params: ScreenParams, news: NewsRelay, @OnDisposeAction onDisposeAction: () -> Unit, globalFeature: GlobalFeature, conversationControlFeature: ConversationControlFeature, messageSyncFeature: MessageSyncFeature, conversationInfoFeature: ConversationInfoFeature, conversationPromoFeature: ConversationPromoFeature, messagesFeature: MessagesFeature, messageActionFeature: MessageActionFeature, initialScreenFeature: InitialScreenFeature, initialScreenExplanationFeature: InitialScreenExplanationFeature?, errorFeature: ErrorFeature, conversationInputFeature: ConversationInputFeature, sendRegularFeature: SendRegularFeature, sendContactForCreditsFeature: SendContactForCreditsFeature, screenEventTrackingFeature: ScreenEventTrackingFeature, messageReadFeature: MessageReadFeature?, messageTimeFeature: MessageTimeFeature?, photoGalleryFeature: PhotoGalleryFeature?, onlineStatusFeature: OnlineStatusFeature?, favouritesFeature: FavouritesFeature?, isTypingFeature: IsTypingFeature?, giftStoreFeature: GiftStoreFeature?, messageSelectionFeature: MessageSelectionFeature?, reportingFeature: ReportingFeature?, takePhotoFeature: TakePhotoFeature?, giphyFeature: GiphyFeature, goodOpenersFeature: GoodOpenersFeature?, matchExpirationFeature: MatchExpirationFeature, private val pushIntegration: PushIntegration ) : AbstractMviComponent<UiEvent, States>(
La solución correcta en esta situación es romper el componente. Como vimos anteriormente, cada pantalla consta de muchos elementos lógicos que podemos dividir en partes independientes.
Después de una pequeña reflexión, llegamos a una estructura de árbol y, ingenuamente construyéndola a partir de componentes existentes, obtuvimos este esquema:

Por supuesto, mantener la sincronización de dos árboles (desde Vista y desde lógica) es casi imposible. Sin embargo, si el componente es responsable de mostrar su Vista, podemos simplificar este esquema. Habiendo estudiado las soluciones ya creadas, repensamos nuestro enfoque, confiando en los RIB de Uber.

Las ideas detrás de este enfoque son muy similares a las bases de MVICore. RIB es un tipo de "recuadro negro", la comunicación con la que se produce a través de una interfaz estrictamente definida de dependencias (a saber, entrada y salida). A pesar de la aparente complejidad de admitir dicha interfaz en un producto iterativo rápido, tenemos grandes oportunidades para reutilizar el código.
Por lo tanto, en comparación con las iteraciones anteriores, obtenemos:
- lógica encapsulada dentro de un componente;
- soporte para anidamiento, que permite dividir pantallas en partes;
- interacción con otros componentes a través de una interfaz estricta de entrada / salida con soporte para MVICore;
- Conexión segura en tiempo de compilación de dependencias de componentes (confiando en Dagger como DI).
Por supuesto, esto está lejos de todo. El repositorio en
GitHub contiene una descripción más detallada y actualizada.
Y aquí tenemos un mundo perfecto. Tiene componentes a partir de los cuales podemos construir un árbol totalmente reutilizable.
Pero vivimos en un mundo imperfecto.
Bienvenido a la realidad!
En un mundo imperfecto, hay un montón de cosas que tenemos que soportar. Nos preocupa lo siguiente:
- diferentes funcionalidades: a pesar de toda la unificación, todavía estamos tratando con productos individuales con diferentes requisitos;
- soporte: ¿cómo sin nuevas funcionalidades bajo pruebas A / B?
- Legado (todo lo que se escribió antes de nuestra nueva arquitectura).
La complejidad de las soluciones aumenta exponencialmente, ya que cada aplicación agrega algo propio a los componentes comunes.
Considere el proceso de registro como un ejemplo de un componente común que se integra en las aplicaciones. En general, el registro es una cadena de pantallas con acciones que afectan todo el flujo. Cada aplicación tiene diferentes pantallas y su propia interfaz de usuario. El objetivo final es crear un componente reutilizable flexible, que también nos ayudará a resolver los problemas de la lista anterior.

Requisitos diversos
Cada aplicación tiene sus propias variaciones de registro únicas, tanto desde el lado lógico como desde el lado de la interfaz de usuario. Por lo tanto, comenzamos a generalizar la funcionalidad en el componente con un mínimo: descargando datos y enrutando todo el flujo.

Dicho contenedor transfiere datos a la aplicación desde el servidor, que se convierte en una pantalla terminada con lógica. El único requisito es que las pantallas que se pasan a dicho contenedor deben satisfacer las dependencias para interactuar con la lógica de todo el flujo.
Después de hacer este truco con un par de aplicaciones, notamos que la lógica de las pantallas es casi la misma. En un mundo ideal, crearíamos una lógica común al personalizar la Vista. La pregunta es cómo personalizarlos.
Como puede recordar de la descripción de MVICore, tanto View como Feature se basan en la interfaz de ObservableSource y Consumer. Utilizándolos como una abstracción, podemos reemplazar la implementación sin cambiar las partes principales.

Entonces reutilizamos la lógica dividiendo la IU. Como resultado, el soporte se vuelve mucho más conveniente.
Apoyo
Considere la prueba A / B para la variación de elementos visuales. En este caso, nuestra lógica no cambia, lo que nos permite sustituir otra implementación de Vista por la interfaz existente de ObservableSource y Consumer.

Por supuesto, a veces los nuevos requisitos contradicen la lógica ya escrita. En este caso, siempre podemos volver al esquema original, donde la aplicación suministra toda la pantalla. Para nosotros es una especie de "caja negra", y no le importa al contenedor lo que le pasan, siempre que se observe su interfaz.
Integración
Como muestra la práctica, la mayoría de las aplicaciones utilizan Activity como las unidades básicas, los medios de comunicación entre los cuales se conocen desde hace mucho tiempo. Todo lo que teníamos que hacer era aprender a ajustar componentes en Activity y pasar datos a través de la entrada y la salida. Al final resultó que, este enfoque funciona bien con fragmentos.
Para aplicaciones de una sola actividad, nada cambia mucho. Casi todos los marcos ofrecen sus elementos básicos en los que los componentes RIB se pueden envolver.
Al final
Una vez superadas estas etapas, hemos aumentado significativamente el porcentaje de reutilización de código entre los proyectos de nuestra empresa. Por el momento, el número de componentes se acerca a 100, y la mayoría de ellos implementa la funcionalidad para varias aplicaciones a la vez.
Nuestra experiencia muestra que:
- a pesar de la mayor complejidad del diseño de componentes comunes, dados los requisitos de diferentes aplicaciones, su soporte es mucho más fácil a largo plazo;
- construyendo componentes aislados unos de otros , simplificamos enormemente su integración en aplicaciones basadas en principios diferentes;
- Las revisiones de procesos, junto con el énfasis en el desarrollo y soporte de componentes, tienen un efecto positivo en la calidad de la funcionalidad general.
Mi colega Zsolt Kocsi escribió anteriormente sobre MVICore y las ideas detrás de él. Recomiendo leer sus artículos, que hemos traducido en nuestro blog (
1 ,
2 ,
3 ).
Sobre las RIB, puede leer el artículo original de
Uber . Y para el conocimiento práctico, recomiendo tomar algunas lecciones
de nosotros (en inglés).