Skip to content

Commit 917e12c

Browse files
authored
Merge pull request #1369 from android/mlykotom/optimize-startup
Optimize startup by preventing whole screen recomposing twice
2 parents 7d7549a + b2c7102 commit 917e12c

File tree

4 files changed

+148
-87
lines changed

4 files changed

+148
-87
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
<activity
4343
android:name=".MainActivity"
44+
android:configChanges="uiMode"
4445
android:exported="true">
4546
<intent-filter>
4647
<action android:name="android.intent.action.MAIN" />

app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt

Lines changed: 60 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ import androidx.activity.SystemBarStyle
2222
import androidx.activity.compose.setContent
2323
import androidx.activity.enableEdgeToEdge
2424
import androidx.activity.viewModels
25-
import androidx.compose.foundation.isSystemInDarkTheme
26-
import androidx.compose.runtime.Composable
2725
import androidx.compose.runtime.CompositionLocalProvider
28-
import androidx.compose.runtime.DisposableEffect
2926
import androidx.compose.runtime.getValue
3027
import androidx.compose.runtime.mutableStateOf
3128
import androidx.compose.runtime.setValue
@@ -35,21 +32,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
3532
import androidx.lifecycle.lifecycleScope
3633
import androidx.lifecycle.repeatOnLifecycle
3734
import androidx.metrics.performance.JankStats
35+
import androidx.tracing.trace
3836
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
39-
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
4037
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
4138
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
4239
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
4340
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
4441
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
4542
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
46-
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
47-
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
4843
import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone
4944
import com.google.samples.apps.nowinandroid.ui.NiaApp
5045
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
46+
import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme
5147
import dagger.hilt.android.AndroidEntryPoint
52-
import kotlinx.coroutines.flow.collect
48+
import kotlinx.coroutines.flow.combine
49+
import kotlinx.coroutines.flow.distinctUntilChanged
50+
import kotlinx.coroutines.flow.map
5351
import kotlinx.coroutines.flow.onEach
5452
import kotlinx.coroutines.launch
5553
import javax.inject.Inject
@@ -81,53 +79,60 @@ class MainActivity : ComponentActivity() {
8179
val splashScreen = installSplashScreen()
8280
super.onCreate(savedInstanceState)
8381

84-
var uiState: MainActivityUiState by mutableStateOf(Loading)
82+
// We keep this as a mutable state, so that we can track changes inside the composition.
83+
// This allows us to react to dark/light mode changes.
84+
var themeSettings by mutableStateOf(
85+
ThemeSettings(
86+
darkTheme = resources.configuration.isSystemInDarkTheme,
87+
androidTheme = Loading.shouldUseAndroidTheme,
88+
disableDynamicTheming = Loading.shouldDisableDynamicTheming,
89+
),
90+
)
8591

8692
// Update the uiState
8793
lifecycleScope.launch {
8894
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
89-
viewModel.uiState
90-
.onEach { uiState = it }
91-
.collect()
95+
combine(
96+
isSystemInDarkTheme(),
97+
viewModel.uiState,
98+
) { systemDark, uiState ->
99+
ThemeSettings(
100+
darkTheme = uiState.shouldUseDarkTheme(systemDark),
101+
androidTheme = uiState.shouldUseAndroidTheme,
102+
disableDynamicTheming = uiState.shouldDisableDynamicTheming,
103+
)
104+
}
105+
.onEach { themeSettings = it }
106+
.map { it.darkTheme }
107+
.distinctUntilChanged()
108+
.collect { darkTheme ->
109+
trace("niaEdgeToEdge") {
110+
// Turn off the decor fitting system windows, which allows us to handle insets,
111+
// including IME animations, and go edge-to-edge.
112+
// This is the same parameters as the default enableEdgeToEdge call, but we manually
113+
// resolve whether or not to show dark theme using uiState, since it can be different
114+
// than the configuration's dark theme value based on the user preference.
115+
enableEdgeToEdge(
116+
statusBarStyle = SystemBarStyle.auto(
117+
lightScrim = android.graphics.Color.TRANSPARENT,
118+
darkScrim = android.graphics.Color.TRANSPARENT,
119+
) { darkTheme },
120+
navigationBarStyle = SystemBarStyle.auto(
121+
lightScrim = lightScrim,
122+
darkScrim = darkScrim,
123+
) { darkTheme },
124+
)
125+
}
126+
}
92127
}
93128
}
94129

95130
// Keep the splash screen on-screen until the UI state is loaded. This condition is
96131
// evaluated each time the app needs to be redrawn so it should be fast to avoid blocking
97132
// the UI.
98-
splashScreen.setKeepOnScreenCondition {
99-
when (uiState) {
100-
Loading -> true
101-
is Success -> false
102-
}
103-
}
104-
105-
// Turn off the decor fitting system windows, which allows us to handle insets,
106-
// including IME animations, and go edge-to-edge
107-
// This also sets up the initial system bar style based on the platform theme
108-
enableEdgeToEdge()
133+
splashScreen.setKeepOnScreenCondition { viewModel.uiState.value.shouldKeepSplashScreen() }
109134

110135
setContent {
111-
val darkTheme = shouldUseDarkTheme(uiState)
112-
113-
// Update the edge to edge configuration to match the theme
114-
// This is the same parameters as the default enableEdgeToEdge call, but we manually
115-
// resolve whether or not to show dark theme using uiState, since it can be different
116-
// than the configuration's dark theme value based on the user preference.
117-
DisposableEffect(darkTheme) {
118-
enableEdgeToEdge(
119-
statusBarStyle = SystemBarStyle.auto(
120-
android.graphics.Color.TRANSPARENT,
121-
android.graphics.Color.TRANSPARENT,
122-
) { darkTheme },
123-
navigationBarStyle = SystemBarStyle.auto(
124-
lightScrim,
125-
darkScrim,
126-
) { darkTheme },
127-
)
128-
onDispose {}
129-
}
130-
131136
val appState = rememberNiaAppState(
132137
networkMonitor = networkMonitor,
133138
userNewsResourceRepository = userNewsResourceRepository,
@@ -141,9 +146,9 @@ class MainActivity : ComponentActivity() {
141146
LocalTimeZone provides currentTimeZone,
142147
) {
143148
NiaTheme(
144-
darkTheme = darkTheme,
145-
androidTheme = shouldUseAndroidTheme(uiState),
146-
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
149+
darkTheme = themeSettings.darkTheme,
150+
androidTheme = themeSettings.androidTheme,
151+
disableDynamicTheming = themeSettings.disableDynamicTheming,
147152
) {
148153
NiaApp(appState)
149154
}
@@ -162,47 +167,6 @@ class MainActivity : ComponentActivity() {
162167
}
163168
}
164169

165-
/**
166-
* Returns `true` if the Android theme should be used, as a function of the [uiState].
167-
*/
168-
@Composable
169-
private fun shouldUseAndroidTheme(
170-
uiState: MainActivityUiState,
171-
): Boolean = when (uiState) {
172-
Loading -> false
173-
is Success -> when (uiState.userData.themeBrand) {
174-
ThemeBrand.DEFAULT -> false
175-
ThemeBrand.ANDROID -> true
176-
}
177-
}
178-
179-
/**
180-
* Returns `true` if the dynamic color is disabled, as a function of the [uiState].
181-
*/
182-
@Composable
183-
private fun shouldDisableDynamicTheming(
184-
uiState: MainActivityUiState,
185-
): Boolean = when (uiState) {
186-
Loading -> false
187-
is Success -> !uiState.userData.useDynamicColor
188-
}
189-
190-
/**
191-
* Returns `true` if dark theme should be used, as a function of the [uiState] and the
192-
* current system context.
193-
*/
194-
@Composable
195-
private fun shouldUseDarkTheme(
196-
uiState: MainActivityUiState,
197-
): Boolean = when (uiState) {
198-
Loading -> isSystemInDarkTheme()
199-
is Success -> when (uiState.userData.darkThemeConfig) {
200-
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme()
201-
DarkThemeConfig.LIGHT -> false
202-
DarkThemeConfig.DARK -> true
203-
}
204-
}
205-
206170
/**
207171
* The default light scrim, as defined by androidx and the platform:
208172
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598
@@ -214,3 +178,13 @@ private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
214178
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598
215179
*/
216180
private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)
181+
182+
/**
183+
* Class for the system theme settings.
184+
* This wrapping class allows us to combine all the changes and prevent unnecessary recompositions.
185+
*/
186+
data class ThemeSettings(
187+
val darkTheme: Boolean,
188+
val androidTheme: Boolean,
189+
val disableDynamicTheming: Boolean,
190+
)

app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import androidx.lifecycle.viewModelScope
2121
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
2222
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
2323
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
24+
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
25+
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
2426
import com.google.samples.apps.nowinandroid.core.model.data.UserData
2527
import dagger.hilt.android.lifecycle.HiltViewModel
2628
import kotlinx.coroutines.flow.SharingStarted
@@ -44,5 +46,40 @@ class MainActivityViewModel @Inject constructor(
4446

4547
sealed interface MainActivityUiState {
4648
data object Loading : MainActivityUiState
47-
data class Success(val userData: UserData) : MainActivityUiState
49+
50+
data class Success(val userData: UserData) : MainActivityUiState {
51+
override val shouldDisableDynamicTheming = !userData.useDynamicColor
52+
53+
override val shouldUseAndroidTheme: Boolean = when (userData.themeBrand) {
54+
ThemeBrand.DEFAULT -> false
55+
ThemeBrand.ANDROID -> true
56+
}
57+
58+
override fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) =
59+
when (userData.darkThemeConfig) {
60+
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemDarkTheme
61+
DarkThemeConfig.LIGHT -> false
62+
DarkThemeConfig.DARK -> true
63+
}
64+
}
65+
66+
/**
67+
* Returns `true` if the state wasn't loaded yet and it should keep showing the splash screen.
68+
*/
69+
fun shouldKeepSplashScreen() = this is Loading
70+
71+
/**
72+
* Returns `true` if the dynamic color is disabled.
73+
*/
74+
val shouldDisableDynamicTheming: Boolean get() = true
75+
76+
/**
77+
* Returns `true` if the Android theme should be used.
78+
*/
79+
val shouldUseAndroidTheme: Boolean get() = false
80+
81+
/**
82+
* Returns `true` if dark theme should be used.
83+
*/
84+
fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = isSystemDarkTheme
4885
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2024 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.util
18+
19+
import android.content.res.Configuration
20+
import androidx.activity.ComponentActivity
21+
import androidx.core.util.Consumer
22+
import kotlinx.coroutines.channels.awaitClose
23+
import kotlinx.coroutines.flow.callbackFlow
24+
import kotlinx.coroutines.flow.conflate
25+
import kotlinx.coroutines.flow.distinctUntilChanged
26+
27+
/**
28+
* Convenience wrapper for dark mode checking
29+
*/
30+
val Configuration.isSystemInDarkTheme
31+
get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
32+
33+
/**
34+
* Registers listener for configuration changes to retrieve whether system is in dark theme or not.
35+
* Immediately upon subscribing, it sends the current value and then registers listener for changes.
36+
*/
37+
fun ComponentActivity.isSystemInDarkTheme() = callbackFlow {
38+
channel.trySend(resources.configuration.isSystemInDarkTheme)
39+
40+
val listener = Consumer<Configuration> {
41+
channel.trySend(it.isSystemInDarkTheme)
42+
}
43+
44+
addOnConfigurationChangedListener(listener)
45+
46+
awaitClose { removeOnConfigurationChangedListener(listener) }
47+
}
48+
.distinctUntilChanged()
49+
.conflate()

0 commit comments

Comments
 (0)