$30 off During Our Annual Pro Sale. View Details »

Architecture and State in Jetpack Compose

Architecture and State in Jetpack Compose

Talk I gave for the Kotlin La Paz community in the Compose Camp online event. Information: https://kotlinlapaz.github.io/ComposeCamp/

Jose Flavio Quispe Irrazábal

November 24, 2022
Tweet

More Decks by Jose Flavio Quispe Irrazábal

Other Decks in Programming

Transcript

  1. This work is licensed under the Apache 2.0 License Architecture

    and State
  2. This work is licensed under the Apache 2.0 License Jose

    Flavio Quispe Irrazabal Senior Software Engineer @jflavio11 Sobre mi
  3. Architecture and State…

  4. This work is licensed under the Apache 2.0 License •

    Fases de composición y árbol de UI • Conceptos básicos de arquitectura • Navegación avanzada ¿Qué temas NO vamos a tocar?
  5. This work is licensed under the Apache 2.0 License •

    Qué son “estado” y “evento” en software • Diseñar state holders para nuestra app • Side effects principales • Diseñar la arquitectura a nivel de UI y de otras capas de nuestra app ¿Qué SÍ vamos a abordar?
  6. This work is licensed under the Apache 2.0 License Hablemos

    de “estado”
  7. This work is licensed under the Apache 2.0 License

  8. This work is licensed under the Apache 2.0 License

  9. This work is licensed under the Apache 2.0 License

  10. This work is licensed under the Apache 2.0 License Importancia

    del “estado” en Jetpack Compose
  11. This work is licensed under the Apache 2.0 License El

    estado define a la UI
  12. This work is licensed under the Apache 2.0 License Composables

    dependen de variables que tienen un estado
  13. This work is licensed under the Apache 2.0 License Lo

    dibujado es una representación gráfica del estado de nuestro programa
  14. This work is licensed under the Apache 2.0 License Importancia

    del “estado” en Jetpack Compose • El estado define a la UI. • Composables dependen de variables que tienen un estado. • Lo dibujado es una representación gráfica del estado de nuestro programa.
  15. This work is licensed under the Apache 2.0 License @Composable

    private fun MyLoader(progress: Float) { LinearProgressIndicator( progress = progress, color = Color.Red, backgroundColor = Color.Black, modifier = Modifier.padding(5.dp) ) }
  16. This work is licensed under the Apache 2.0 License @Composable

    private fun MyLoader(progress: Float) { LinearProgressIndicator( progress = progress, color = Color.Red, backgroundColor = Color.Black, modifier = Modifier.padding(5.dp) ) }
  17. This work is licensed under the Apache 2.0 License Eventos

  18. This work is licensed under the Apache 2.0 License

  19. This work is licensed under the Apache 2.0 License Eventos

    Pueden cambiar el “estado” en nuestra app.
  20. This work is licensed under the Apache 2.0 License UDF

    Unidirectional Data Flow
  21. • Desacopla UI de quien maneja los estados • Fácil

    de escribir tests • Única fuente de la verdad State UI Event State
  22. This work is licensed under the Apache 2.0 License UI

    State • Signed Out • In Progress • Error • Sign In
  23. This work is licensed under the Apache 2.0 License sealed

    class UiState { object SignedOut : UiState() object InProgress : UiState() object Error : UiState() object SignIn : UiState() }
  24. This work is licensed under the Apache 2.0 License UiState

    = Signed Out @Composable fun MainScreenUi(state: UiState) { Header() if(state == SignedOut) { LoginFormUi() } if(state == InProgress) { LoginProgressBar() } }
  25. This work is licensed under the Apache 2.0 License UiState

    = In Progress @Composable fun MainScreenUi(state: UiState) { Header() if(state == SignedOut) { LoginFormUi() } if(state == InProgress) { LoginProgressBar() } }
  26. This work is licensed under the Apache 2.0 License UiState

    = In Progress @Composable fun MainScreenUi(state: UiState) { Header() if(state == SignedOut) { LoginFormUi() } if(state == InProgress) { LoginProgressBar() } }
  27. This work is licensed under the Apache 2.0 License UiState

    = In Progress @Composable fun MainScreenUi(state: UiState) { Header() if(state == SignedOut) { LoginFormUi() if(state == InProgress) { LoginProgressBar() } } }
  28. This work is licensed under the Apache 2.0 License UiState

    = SignedOut @Composable fun MainScreenUi(state: UiState) { Header() if(state == SignedOut) { LoginFormUi() if(state == InProgress) { LoginProgressBar() } } }
  29. This work is licensed under the Apache 2.0 License ¿Cómo

    manejamos estados dependientes de diferentes fuentes?
  30. This work is licensed under the Apache 2.0 License Screen

    UI State UI element state
  31. This work is licensed under the Apache 2.0 License Screen

    UI State UI element state* LoginUiState LoginFormState ErrorInfo LoggedIn EmailValue PasswordValue *not strictly necessary
  32. This work is licensed under the Apache 2.0 License Screen

    UI State - Lo que quieres mostrar en la pantalla, encapsula el estado de los datos - Independiente del lifecycle de la vista - Preferentemente, referenciarlo en la clase ViewModel de Jetpack - Recibe datos de otras capas
  33. This work is licensed under the Apache 2.0 License Repository

    Interactor | Use Case Data Layer Domain Layer Screen UI State Composable Presentation Layer
  34. This work is licensed under the Apache 2.0 License Screen

    UI State data class LoginUiState( val errorInfo: LoginError? = null, val loggedIn: Boolean = false )
  35. This work is licensed under the Apache 2.0 License class

    LoginViewModel( private val loginUseCase: LoginUseCase, . . . ) : ViewModel() { var loginUiState by mutableStateOf(LoginUiState()) private set fun onLogin(username: String, password: String) { viewModelScope.launch { loginUseCase.execute(username, password) loginUiState = loginUiState.copy(loggedIn = true, errorInfo = null) } } }
  36. This work is licensed under the Apache 2.0 License //

    LoginActivity.kt val initialRoute = if(loginViewModel.loginUiState.loggedIn) { Routes.Home } else { Routes.Login } NavHost(navController = navController, startDestination = initialRoute) { . . . }
  37. This work is licensed under the Apache 2.0 License LoginUiState

    Logged in = false Error info = null LoginUiState Logged in = true Error info = null
  38. This work is licensed under the Apache 2.0 License

  39. This work is licensed under the Apache 2.0 License @Composable

    private fun LoginForm( loginErrorInfo: LoginError? = null, ... ) { Column(modifier = Modifier.fillMaxWidth()) { Text(text = "Welcome to Tuiter") OutlinedTextField(...) OutlinedTextField(...) if (loginErrorInfo != null) { Text( text = loginErrorInfo.message, style = MaterialTheme.typography.caption.copy( color = MaterialTheme.colors.error ) ) } ... } }
  40. This work is licensed under the Apache 2.0 License @Composable

    private fun LoginForm( loginErrorInfo: LoginError? = null, ... ) { Column(modifier = Modifier.fillMaxWidth()) { Text(text = "Welcome to Tuiter") OutlinedTextField(...) OutlinedTextField(...) if (loginErrorInfo != null) { Text( text = loginErrorInfo.message, style = MaterialTheme.typography.caption.copy( color = MaterialTheme.colors.error ) ) } ... } }
  41. This work is licensed under the Apache 2.0 License LoginUiState

    Logged in = false Error info = null LoginUiState Logged in = false Error info = LoginError(444, “Incorrect password”)
  42. This work is licensed under the Apache 2.0 License

  43. This work is licensed under the Apache 2.0 License Inmutabilidad

  44. This work is licensed under the Apache 2.0 License data

    class LoginUiState( val errorInfo: LoginError? = null, val loggedIn: Boolean = false ) class LoginViewModel(. . .) : ViewModel() { . . . fun onLogin(username: String, password: String) { . . . loginUiState = loginUiState.copy(loggedIn = true, errorInfo = null) . . . loginUiState = loginUiState.copy(loggedIn = false, errorInfo = ErrorInfo(. . .)) } }
  45. This work is licensed under the Apache 2.0 License data

    class LoginUiState( val errorInfo: LoginError? = null, val loggedIn: Boolean = false ) class LoginViewModel(. . .) : ViewModel() { . . . fun onLogin(username: String, password: String) { . . . loginUiState = loginUiState.copy(loggedIn = true, errorInfo = null) . . . loginUiState = loginUiState.copy(loggedIn = false, errorInfo = ErrorInfo(. . .)) } }
  46. This work is licensed under the Apache 2.0 License data

    class LoginUiState( val errorInfo: LoginError? = null, val loggedIn: Boolean = false ) class LoginViewModel(. . .) : ViewModel() { . . . fun onLogin(username: String, password: String) { . . . loginUiState = loginUiState.copy(loggedIn = true, errorInfo = null) . . . loginUiState = loginUiState.copy(loggedIn = false, errorInfo = ErrorInfo(. . .)) } }
  47. This work is licensed under the Apache 2.0 License UI

    Element State - Lógica de los elementos de nuestra interfaz gráfica - No sobrevive a los cambios de configuración* - Si la vista es compleja, se puede encapsular en una clase que actúe como state holder * A menos que implementes rememberSaveable
  48. This work is licensed under the Apache 2.0 License class

    LoginFormState( email: String, password: String, . . . ) { var email by mutableStateOf(email) var password by mutableStateOf(password) . . . }
  49. This work is licensed under the Apache 2.0 License class

    LoginFormState( email: String, password: String ) { var email by mutableStateOf(email) var password by mutableStateOf(password) … } var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var isEmailValid by derivedStateOf { /* logic for validation */ }
  50. This work is licensed under the Apache 2.0 License @Composable

    fun rememberLoginFormState( email: String = "", password: String = "" ) = rememberSaveable(saver = LoginFormState.Saver) { LoginFormState(email, password) } . . . val formUiState: LoginFormState = rememberLoginFormState()
  51. This work is licensed under the Apache 2.0 License OutlinedTextField(

    value = formUiState.email, onValueChange = { formUiState.email = it }, isError = !formUiState.isValidEmail, label = { if (!formUiState.isValidEmail) { Text(text = "Please, write a valid email.") } else { Text(text = "Email address") } }, . . . ) val formUiState: LoginFormState = rememberLoginFormState()
 . . .
  52. This work is licensed under the Apache 2.0 License Screen

    Ui State UI Element State - Estado de los datos - Independiente del lifecycle - Conectado con otras capas - Lógica de UI - Referencia a otros componentes de UI (como resources, navigation controller, etc) - Puede ser reusable
  53. This work is licensed under the Apache 2.0 License Side

    Effects
  54. This work is licensed under the Apache 2.0 License Launched

    Effect
  55. This work is licensed under the Apache 2.0 License @Composable

    private fun HiScreen(hiViewModel: HiViewModel = viewModel()) { hiViewModel.getNamesFromServer() Text(text = "Hola, hola! ${hiViewModel.names}") // other views that causes recomposition }
  56. This work is licensed under the Apache 2.0 License @Composable

    private fun HiScreen(hiViewModel: HiViewModel = viewModel()) { LaunchedEffect(key1 = Unit, block = { hiViewModel.getNamesFromServer() }) Text(text = "Hola, hola! ${hiViewModel.names}") // other views that causes recomposition }
  57. This work is licensed under the Apache 2.0 License Remember

    Coroutine Scope
  58. This work is licensed under the Apache 2.0 License private

    suspend fun loadScore() = withContext(Dispatchers.Default) { delay(2000) Random.nextInt(IntRange(0, 1000)) } . . . @Composable private fun ProfileScreen() { val scope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxWidth()) { Text(text = "Score: $score points") Button(onClick = { scope.launch { score = loadScore() } }) { Text(text = "Update profile") } } }
  59. This work is licensed under the Apache 2.0 License private

    suspend fun loadScore() = withContext(Dispatchers.Default) { delay(2000) Random.nextInt(IntRange(0, 1000)) } . . . @Composable private fun ProfileScreen() { val scope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxWidth()) { Text(text = "Score: $score points") Button(onClick = { scope.launch { score = loadScore() } }) { Text(text = "Update profile") } } }
  60. This work is licensed under the Apache 2.0 License Remember

    Updated State
  61. This work is licensed under the Apache 2.0 License @Composable

    private fun AgeView( yearBorn: Int, onYearUpdated: (Int) -> Unit ) { LaunchedEffect(key1 = Unit, block = { delay(3000) // long running operation adultText = if (2022 - yearBorn > 17) { "Eres mayor de edad" } else { "Eres menor de edad" } }) Column { Text(text = adultText) TextField( value = yearBorn.toString(), onValueChange = { onYearUpdated(it.toInt()) } ) } } 1. AgeView es dibujado con un año de nacimiento inicial (2000). 2. El año se coloca en el TextField que es editable. 3. Antes de los 3 segundos, el usuario actualiza el año de nacimiento en el TextField a 2009. 4. Se ejecuta onYearUpdated en el composable padre, que cambia el valor del año de nacimiento 5. Al cambiar el año de nacimiento, AgeView se recompone con este nuevo valor ¿Qué sucede?
  62. This work is licensed under the Apache 2.0 License El

    cambio del valor de yearBorn no se detectó (de 2000 a 2009), por tanto, en la pantalla se mostrará “Eres mayor de edad”
  63. This work is licensed under the Apache 2.0 License @Composable

    private fun AgeView( yearBorn: Int, onYearUpdated: (Int) -> Unit ) { LaunchedEffect(key1 = Unit, block = { delay(3000) // long running operation adultText = if (2022 - yearBorn > 17) { "Eres mayor de edad" } else { "Eres menor de edad" } }) Column { Text(text = adultText) TextField( value = yearBorn.toString(), onValueChange = { onYearUpdated(it.toInt()) } ) } } Se captura el valor inicial de yearBorn. Si AgeView se recompone, el LaunchedEffect sigue manteniendo el valor original.
  64. This work is licensed under the Apache 2.0 License @Composable

    private fun AgeView( yearBorn: Int, onYearUpdated: (Int) -> Unit ) { LaunchedEffect(key1 = Unit, block = { delay(3000) // long running operation adultText = if (2022 - yearBorn > 17) { "Eres mayor de edad" } else { "Eres menor de edad" } }) Column { Text(text = adultText) TextField( value = yearBorn.toString(), onValueChange = { onYearUpdated(it.toInt()) } ) } } Para capturar el último valor “actualizado”, se usa rememberUpdatedState.
  65. This work is licensed under the Apache 2.0 License @Composable

    private fun AgeView( yearBorn: Int, onYearUpdated: (Int) -> Unit ) { val updatedYearOld by rememberUpdatedState(newValue = yearBorn) LaunchedEffect(key1 = Unit, block = { delay(3000) // long running operation adultText = if (2022 - updatedYearOld > 17) { // we use the last value of yearBorn "Eres mayor de edad" } else { "Eres menor de edad" } }) . . . }
  66. en resumen

  67. Repository Interactor | Use Case Data Layer Domain Layer Screen

    UI State Composable Presentation Layer UI Element State ViewModel
  68. This work is licensed under the Apache 2.0 License Screen

    Ui State UI Element State - Independencia del lifecycle - Mantener y modificar datos a mostrar o manejar - Hay una fuente de datos externa a la capa de UI - UI se vuelve compleja - Agregar lógica de UI - Se quiere hacer testing de la lógica que puede tener nuestra UI data class LoginUiState( val errorInfo: LoginError? = null, val loggedIn: Boolean = false ) class LoginFormState( email: String, password: String, . . . ) { var email by mutableStateOf(email) var password by mutableStateOf(password) . . . }
  69. This work is licensed under the Apache 2.0 License Launched

    Effect - Ejecutar algo con independencia de la recomposición o al cambiar el valor de un key. Esto porque ejecuta una coroutine por dentro. - Se inicia con la composición. - Es una función composable.
  70. This work is licensed under the Apache 2.0 License Remember

    Coroutine Scope - Ejecutar coroutines en un composable. - Depende del ciclo de vida del composable que lo contiene. - Podemos controlar su ejecución (iniciarlo bajo un evento o cancelarlo).
  71. This work is licensed under the Apache 2.0 License Remember

    Updated State - Cuando nuestro side effect depende de un valor que puede cambiar en el tiempo (o recompositions). - Si el valor de la variable de la que se depende cambia, nuestro side effect no se reinicia, mantiene la ejecución con el valor actualizado.
  72. This work is licensed under the Apache 2.0 License Jose

    Flavio Quispe Irrazabal Senior Software Engineer @jflavio11 Architecture and State