Unit 4 notes
Architecture Components
2 - Stages of the Activity lifecycle
Activity lifecycle
- Activity -> entry point for interacting with the user (like the main function is the entry point of a programm in cpp)
- 1 activity -> multiple screens
Activity lifecycle:
onCreate()
: One-time initializations (like setContent, which specifies activity’s UI layout). Called only once.onStart()
: Makes the activity visible on screen and prepares it to enter in the foreground and become interactive. Called multiple times.onResume()
: Brings the app to the foreground and the user is now able to interact with it.onPause()
: Called when the activity is no longer in the foreground, but is still visible in multi-window mode. Can happen when a dialog is opened, the device receives a phone call or the screen is turned off.- Use the
onPause()
method to:- Pause or adjust operations that can’t continue, or might continue in moderation, while the Activity is in the Paused state, and that you expect to resume shortly.
- Use the
onStop()
: The activity is no longer visible to the user. If the activity is restarted afteronStop()
is called,onRestart()
is called.onDestroy()
: Called before the activity is destroyed. Only called once. The activity can be destroyed when:finish()
function is called in code- The user force-quits the app
- The user closes the app in Recents screen
- The system shuts down the activity
Logging
Log types:
Log.v()
-> verboseLog.d()
-> debugLog.i()
-> informationLog.w()
-> warningLog.e()
-> error
Log has two arguments:
- TAG: A const string used to find log messages more easily in Logcat.
- Message to be displayed
rememberSaveable
Configuration change -> the state of the device changes radically. Hence, the system shuts down the activity (onDestroy is called) and then, it rebuilds it. Examples of configuration changes:
- user changes the device language
- user plugs the device into a dock/keyboard
- device rotation
rememberSaveable -> Retain the state during a configuration change. Use rememberSaveable to save values during recompositions AND configuration changes.
5 - ViewModel and State in Compose
Rational: Using remember + rememberSaveable keeps logic near or in composables. The solution is to use a ViewModel to move data and logic away from composables.
In ViewModel, the stored data is not lost during configuration changes or other events. It’s only lost if the activity is destroyed (because the process dies).
App architecture
Most common architectural principles:
- Separation of concerns: App is divided into classes of functions, each with separate responsabilities. A complex problem (your app) is divided into lots of simple separate problems that are relatively easy to solve and test.
- Driving UI from a model: You should drive your UI from a model, preferably a persistent model. A model is a set of data classes that handle the data of an app. They are independent from the UI elements and app components since they’re unaffected by the app’s lifecycle and associated concerns.
- UI Layer
- displays the app data on the screen but is independent of the data
- Reflect the changes when data changes due to a user interaction (e.g. press a button)
- It has the following components:
- UI elements: Render the data on the screen (can use Jetpack Compose)
- State holders: Hold the data, expose it to the UI, and handle the app logic. (Example: ViewModel)
- Domain Layer (optional): Simplify and reuse the interactions between the UI and data layers
- Data layer: stores, retrieves, and exposes the app data
ViewModel
- Holds and exposes the state to UI consumers.
- Stores the app-related data that isn’t destroyed when the activity is destroyed and recreated by Android.
- ViewModel objects are not destroyed because the app automatically retains ViewModel objects during configuration changes so that the data they hold is immediately available after the recomposition.
StateFlow (explained better later on course)
- Data holder observable flow that emits the current and new state updates
value
property has the current state value- To update and send the new value to the flow, assign a new value to the
value
property of aMutableStateFlow
StateFlow
works well with classes that must maintain an observable immutable state- A
StateFlow
object can be exposed from the UI state to make the composables listen for UI state updates and make the screen state survive configuration changes. - To make the UI state only editable inside ViewModel, but expose its value to the UI, one can use a Backing property.
- A Backing property lets you return something from a getter other than the exact object.
- The
asStateFlow()
converts aMutableStateFlow
(can be changed) to a read-only state flow (cannot be changed after being set)
Code (in ViewModel):
// Backing property to avoid state updates from other classes
private val _uiState = MutableStateFlow(ExampleUiState())
val uiState: StateFlow<ExampleUiState> = _uiState.asStateFlow()
Updating UI State:
_uiState.update { currentState ->
currentState.copy(...)
}
- Use the
copy()
function to copy an object, allowing you to alter some of its properties while keeping the rest unchanged. - Example (copy the object while changing only the age):
val jack = User(name = "Jack", age = 1)
val olderJack = jack.copy(age = 2)
UI State
- UI state: Application data transformed by ViewModel.
- The UI is what the user sees, and the UI state is what the app says they should see.
- The UI is the visual representation of the UI state. Any changes to the UI state immediately are reflected in the UI.
- Example: data classes.
Immutability
- UI state is immutable, avoiding having multiple sources altering the state of the app at runtime.
- UI role: read state and update its elements accordingly.
- Never modify the UI state on UI directly (unless the UI is the single source of truth of data).
- Consequence of violating this principle: Multiple sources of truth for the same data, which can lead to data inconsistencies and bugs.
Compose UI
- The only way to update the UI in Compose is by changing the state of the app
- Composables can accept state and expose events.
- Example: TextField accepts a value and exposes a callback
onValueChange
that requests the callback handler to change the value
- Example: TextField accepts a value and exposes a callback
- The UI accesses the uiState using
collectAsState()
function, which collects values from aStateFlow
and represents its latest value viaState
. TheStateFlow.value
is used as an initial value. Every time there’s a new value posted into theStateFlow
, the returnedState
updates, causing recomposition of everyState.value
usage.
Unidirectional data flow (UDF)
- Design pattern that says: State flows go down (from ViewModel to UI) and events flow up (from UI to ViewModel).
- Advantage: Decouple composables that display state in the UI from the ViewModel (or other part of the app that stores and changes state).
- The UI update loop for an app using UDF is the following:
- Event: Part of the UI generates an event and passes it upward (example: button click).
- Update state: An event handler might change the state (in ViewModel).
- Display state: The state holder (ViewModel) passes down the state, and the UI displays it.
- UDF implications:
- The ViewModel holds and exposes the state the UI consumes.
- The UI state is application data transformed by the ViewModel.
- The UI notifies the ViewModel of user events.
- The ViewModel handles the user actions and updates the state.
- The updated state is fed back to the UI to render.
- This process repeats for any event that causes a mutation of state.
Navigation in Jetpack Compose
Navigation component: used to build multi-screen apps. It has three main components:
- NavController: Responsible for navigating between destinations - that is, the screens in your app.
- NavGraph: Maps composable destinations to navigate to.
- NavHost: Composable acting as a container for displaying the current destination of the NavGraph.
Within the NavHost
, the destinations for an app’s NavGraph
are defined.
A route is a string that corresponds to a destination. Each route is a unique identifier. Similar to URLs, which maps unique identifiers to web pages, each route maps to a destination. A destination can be a single or a group of Composables. The NavHost
shows a destination based on a given route. Routes are defined in an enum
class.
The NavHost
receives as main arguments:
navController
: Used to navigate between screens withnavigate
function (navController.navigate(route)
where route is the route destination). To obtain anavController
from a composable, callrememberNavController()
startDestination
: Route of the default destination when the app launches
Inside navHost
, composable
function receives as arguments:
route
: name of the routecontent
: Composable to be displayed for the given route
Example of a navigation:
// Defining the routes of the app
enum class AppRoutes() {
ExampleRoute1,
ExampleRoute2,
ExampleRoute3,
ExampleRoute4
}
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = AppRoutes.ExampleRoute1.name,
modifier = ...
) {
composable(route = AppRoutes.ExampleRoute1.name) {
ExampleComposable1(...)
}
composable(route = AppRoutes.ExampleRoute2.name) {
ExampleComposable2(...)
}
...
}
...
// Example of navigate to another route
navController.navigate(AppRoutes.ExampleRoute2.name)
// Navigate to a given route that is in back stack
navController.popBackStack(AppRoutes.ExampleRoute1.name, inclusive = false)
Passing the navController
to each composable is a bad practice because it mixes navigation logic with individual UI (something that NavHost
avoids). Navigation logic separated from individual UI is good because:
- Navigation logic is in one place. Code is easier to maintain and prevents bugs since individual screens cannot navigate through all screens of the app.
- Buttons may or may not trigger navigation, depending on the form factor (like portrait mode phone, foldable phone, or large screen tablet). Individual screens should be self-contained and don’t need to be aware of other screens in the app.
The solution is to pass a function type into each composable for what should happen when a user clicks the button.
- The composable and any of its child composables decide when to call the function.
- Navigation logic is not exposed to individual screens. All the navigation behavior is handled in the NavHost.
When navigating to new screens using navigate()
function, a new screen is added to the back stack of the app, which has the history of screens from the startDestination
. The back button at the bottom removes the most recent screen from the back stack and goes to the previous screen.
To go to a screen in the back stack, one can call navController.popBackStack(route, inclusive = true/false)
where route is the string associated to the destination and inclusive tells if we want to also pop the destination associated to the route or not. popBackStack
pops all destinations until we are in the destination associated to route
or we are in the previous screen of the destination associated to the route.
Navigate to another app
ShareSheet: a user interface component that covers the bottom part of the screen—that shows sharing options.
Intent: request for the system to perform some action, commonly presenting a new activity. There are many different intents.
The basic process for setting up an intent is as follows:
- Create an intent object and specify the intent
- Specify the type of additional data being sent with the intent. For a simple piece of text, you can use
"text/plain"
, though other types, such as"image/*"
or"video/*"
, are available. - Pass any additional data to the intent, such as the text or image to share, by calling the
putExtra()
method. This intent will take two extras:EXTRA_SUBJECT
andEXTRA_TEXT
. - Call the
startActivity()
method of context, passing in an activity created from the intent.
The createChooser
method creates a chooser dialog for the user to choose the application to go to for that specific action. It receives the intent created and a title to be displayed in the chooser dialog.
Optional: Why use Intent.createChooser()?
Example:
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
// Subject to be sent (string like "New Cupcake Order")
putExtra(Intent.EXTRA_SUBJECT, subject)
// The data to be sent
putExtra(Intent.EXTRA_TEXT, summary)
}
context.startActivity(
Intent.createChooser(
intent,
context.getString(R.string.new_cupcake_order)
)
)
Make the app bar respond to navigation
// Add title to each route
enum class CupcakeScreen(@StringRes val title: Int) {
Start(title = R.string.app_name),
Flavor(title = R.string.choose_flavor),
Pickup(title = R.string.choose_pickup_date),
Summary(title = R.string.order_summary)
}
// Adapt the TopAppBar to have a conditional back button
TopAppBar(
title = { Text(stringResource(currentScreen.title)) },
modifier = modifier,
navigationIcon = {
if (canNavigateBack) {
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_button)
)
}
}
}
)
// Pass arguments to the App bar
@Composable
fun CupcakeApp(...){
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = CupcakeScreen.valueOf(
backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
...
// Has the TopAppBar
CupcakeAppBar(
currentScreen = currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
)
...
}
Adapt for different screen sizes
To show different layouts in the same app, one can use conditionals on different states.
To create an adaptive app, you need the layout to change based on screen size. The measurement point where a layout changes is known as a breakpoint.
WindowSizeClass
introduces three categories of sizes: Compact, Medium, and Expanded, for both width and height. It makes the implementation of Material Design breakpoints simpler.
Navigation rail is another navigation component by material design which allows compact navigation options for primary destinations to be accessible from the side of the app. (on left side of the app, there’s a vertical bar with icons).
A persistent/permanent navigation drawer is created by material design as another option to provide ergonomic access for larger screens.
Canonical layouts - a set of commonly-used patterns for large screen displays. You can use the three available layouts to guide how you organize common elements in an app: list-view, supporting panel, and feed.
List-view
Supporting Panel
Feed
For an adaptive app, it is the best practice to create multiple previews to show the app on different screen sizes. With multiple previews, you can see your changes on all screen sizes at once.