Inserción manual de dependencias

La arquitectura de apps recomendada de Android te incentiva a dividir el código en clases para beneficiarte de la separación de preocupaciones, un principio en el que cada clase de la jerarquía tiene una responsabilidad única definida. Esto genera más clases pequeñas que deben estar conectadas entre sí para satisfacer las dependencias de cada una.

Por lo general, las apps para Android se componen de varias clases y algunas de ellas dependen unas de otras.
Figura 1: Modelo del gráfico de la aplicación para Android

Las dependencias entre clases se pueden representar como un gráfico, en el que cada clase se conecta con las otras de las que depende. La representación de todas tus clases y sus dependencias conforma el gráfico de la aplicación. En la figura 1, puedes ver una abstracción del gráfico de la aplicación. Cuando la clase A (ViewModel) depende de la clase B (Repository), hay una línea que apunta de A a B que representa esa dependencia.

La inyección de dependencias ayuda a realizar esas conexiones y te permite intercambiar implementaciones para probarlas. Por ejemplo, cuando pruebas un ViewModel que depende de un repositorio, puedes pasar diferentes implementaciones de Repository con simulaciones o simulaciones para probar los diferentes casos.

Conceptos básicos de la inyección de dependencia manual

En esta sección, se explica cómo aplicar la inyección de dependencia manual en una situación real de una app para Android. Se explica en detalle cómo podrías comenzar a usar la inyección de dependencias en tu app. El enfoque mejora hasta que llega a un punto muy similar al que Dagger generaría automáticamente. Para obtener más información sobre Dagger, consulta Conceptos básicos de Dagger.

Considera un flujo como un grupo de pantallas de tu app que corresponden a una función. El acceso, el registro y la confirmación de compras son ejemplos de flujos.

Cuando abarcas un flujo de acceso para una app para Android normal, el LoginActivity depende de LoginViewModel, que a su vez depende de UserRepository. Luego, UserRepository depende de UserLocalDataSource y UserRemoteDataSource, que a su vez depende de un servicio Retrofit.

LoginActivity es el punto de entrada al flujo de acceso, y el usuario interactúa con la actividad. Por lo tanto, LoginActivity necesita crear el LoginViewModel con todas sus dependencias.

Las clases Repository y DataSource del flujo se ven así:

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

En Compose, ComponentActivity es el punto de entrada; el cableado de dependencias se realiza una vez en onCreate, y la IU se describe mediante elementos componibles llamados desde setContent:

class ApiService {
    /* Your API implementation here */
}

class UserRepository(private val apiService: ApiService) {
    /* Your implementation here */
}

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Satisfy the dependencies of LoginViewModel recursively,
        // then pass what the UI needs into setContent.
        val apiService = ApiService()
        val userRepository = UserRepository(apiService)

        setContent {
            LoginScreen(userRepository)
        }
    }
}

@Composable
fun LoginScreen(userRepository: UserRepository) {
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(userRepository)
    )
    // ...
}

Este enfoque tiene problemas:

  1. Las dependencias deben declararse en orden. Tienes que crear una instancia de UserRepository antes de LoginViewModel para poder crearlo.
  2. Es difícil reutilizar objetos. Si quieres volver a usar UserRepository en varias funciones, debes seguir el patrón de singleton. El patrón singleton dificulta las pruebas, porque todas las pruebas comparten la misma instancia de singleton.

Cómo administrar dependencias con un contenedor

Para resolver el problema de reutilizar objetos, puedes crear tu propia clase de contenedor de dependencias que uses para obtener dependencias. Todas las instancias que proporcione el contenedor pueden ser públicas. En el ejemplo, como solo necesitas una instancia de UserRepository, puedes hacer que sus dependencias sean privadas con la opción de hacerlas públicas en el futuro si es necesario proporcionarlas:

// Container of objects shared across the whole app
class AppContainer {

    // apiService and userRepository aren't private and will be exposed
    val apiService = ApiService()
    val userRepository = UserRepository(apiService)
}

Debido a que esas dependencias se usan en toda la aplicación, deben colocarse en un lugar común que todas las actividades pueden usar: la clase Application. Crea una clase Application personalizada que contenga una instancia de AppContainer.

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

Con Compose, el mismo AppContainer se sigue creando en la subclase Application. Puedes acceder a él en la actividad, antes de llamar a setContent, o desde un elemento componible, con LocalContext:

class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer

        setContent {
            LoginScreen(appContainer.userRepository)
        }
    }
}

// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
    val context = LocalContext.current
    val appContainer = (context.applicationContext as MyApplication).appContainer
    val viewModel: LoginViewModel = viewModel(
        factory = LoginViewModelFactory(appContainer.userRepository)
    )
    // ...
}

Te recomendamos que pases las dependencias como parámetros componibles en lugar de acceder a LocalContext desde lo más profundo del árbol. Esto permite que los elementos componibles se puedan probar y que sus entradas sean explícitas. Resuelve el contenedor una vez en la raíz de la pantalla y pasa lo que se necesita hacia abajo.

De esta manera, no tienes un singleton UserRepository. En su lugar, tienes un AppContainer compartido entre todas las actividades que contiene objetos del gráfico y crea instancias de esos objetos que pueden consumir otras clases.

Si se necesita LoginViewModel en más lugares de la aplicación, puedes tener un lugar centralizado donde crees instancias de LoginViewModel. Puedes mover la creación de LoginViewModel al contenedor y proporcionar objetos nuevos de ese tipo con una fábrica. El código para un LoginViewModelFactory se ve así:

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Con Compose, la actualización de AppContainer sigue exponiendo la fábrica. Luego, el elemento componible viewModel consume la fábrica para que el ViewModel se limite al ViewModelStoreOwner más cercano (por lo general, la actividad del host o, con Navigation Compose, una entrada de navegación):

// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
    // ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)
    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val appContainer = (application as MyApplication).appContainer
        setContent {
            LoginScreen(appContainer.loginViewModelFactory)
        }
    }
}

@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
    val viewModel: LoginViewModel = viewModel(factory = factory)
    // ...
}

Este enfoque es mejor que el anterior, pero todavía hay algunos desafíos que debes considerar:

  1. Debes administrar el AppContainer por tu cuenta y crear instancias para todas las dependencias de forma manual.

  2. Todavía hay mucho código estándar. Debes crear fábricas o parámetros de forma manual según si deseas reutilizar un objeto o no.

Cómo administrar dependencias en flujos de aplicaciones

AppContainer se complica cuando quieres incluir más funciones en el proyecto. Cuando se expande tu app y comienzas a ingresar diferentes flujos de funciones, surgen aún más problemas:

  1. Si tienes flujos diferentes, es posible que quieras que los objetos solo se encuentren dentro del alcance de ese flujo. Por ejemplo, cuando creas LoginUserData (que puede consistir en el nombre de usuario y la contraseña utilizados únicamente en el flujo de acceso), no quieres conservar los datos de un flujo de acceso anterior de otro usuario. Deseas una instancia nueva para cada flujo nuevo. Para lograrlo, crea objetos FlowContainer dentro de AppContainer, como se muestra en el siguiente ejemplo de código.

  2. La optimización del gráfico de la aplicación y los contenedores de flujo también puede ser difícil. Recuerda borrar las instancias que no necesites, según el flujo en el que te encuentres.

Agreguemos un LoginContainer al código de ejemplo. Quieres crear varias instancias de LoginContainer en la app, por lo que, en lugar de convertirla en un singleton, conviértela en una clase con las dependencias que necesita el flujo de acceso de AppContainer.

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

En Compose, la vida útil del contenedor de flujo está vinculada a la composición, en lugar de al Activity host. No es necesario mutar un AppContainer.loginContainer compartido, ya que los elementos componibles reciben sus dependencias como parámetros o las leen de un ViewModel que se elevó. Tienes dos opciones:

  1. Gráfico anidado de Navigation Compose (preferido para flujos multipantalla). Coloca todas las pantallas del flujo de acceso en un gráfico de navegación anidado y define el alcance del contenedor para el NavBackStackEntry de ese gráfico. El contenedor se crea cuando el usuario ingresa al flujo y se borra cuando se extrae la entrada de la pila de actividades, sin necesidad de llamadas manuales de ciclo de vida. Para obtener más información, consulta Cómo diseñar tu gráfico de navegación.
  2. remember en la raíz de la pantalla (para un flujo de una sola pantalla o cuando no usas Navigation Compose) Construye el contenedor dentro de remember para que se cree una vez por entrada en la composición y se recopile como basura cuando el elemento componible salga:
@Composable
fun LoginFlow(appContainer: AppContainer) {
    val loginContainer = remember(appContainer) {
        LoginContainer(appContainer.userRepository)
    }
    val viewModel: LoginViewModel = viewModel(
        factory = loginContainer.loginViewModelFactory
    )
    // Render the login flow using loginContainer.loginData and viewModel.
}

Conclusión

La inyección de dependencia es una buena técnica a la hora de crear apps para Android escalables que puedan someterse a prueba. Usa contenedores como una manera de compartir instancias de clases en diferentes partes de tu app y como un lugar centralizado para crear instancias de clases con fábricas.

Cuando se expanda tu aplicación, comenzarás a ver que escribes mucho código estándar (como fábricas), que puede ser propenso a errores. También debes administrar el alcance y el ciclo de vida de los contenedores por tu cuenta, optimizando y descartando los contenedores que ya no se necesitan para liberar memoria. Hacer esto de forma incorrecta puede generar errores sutiles y pérdidas de memoria en tu app.

En la sección sobre Dagger, aprenderás a usar Dagger para automatizar este proceso y generar el mismo código que habrías escrito de forma manual.

Recursos adicionales

Mira contenido