Compare commits

...
2 Commits
Author SHA1 Message Date
OneWay 76ea8ba9a0 Make GalleryUiState derivations lazy and drop dead sorts
copy() ran on every state update (each drag-select step included) and eagerly
recomputed five derived properties: two unused O(N log N) sorts, an O(N)
selection filter, an O(N) selectable-URI set and its containsAll check.

- Remove sortedPhotos/sortedSubfolders: never read (the screen sorts with its
  own remember() calls)
- selectedPhotos/selectablePhotoUris/allPhotosSelected become by lazy:
  computed at most once per instance, only when actually read; body delegates
  stay out of generated copy()/equals(), @Immutable semantics hold
- Memoize selectablePhotoUris across drag-select steps in the ViewModel keyed
  by photos list identity, so a drag step no longer recomputes the O(N) set
2026-10-14 16:06:12 +08:00
OneWay c2ddfdf89d Apply Compose performance audit fixes
- FolderTreePickerSheet: add contentType to lazy folder items for slot reuse
- GalleryScreen: use plain remember instead of derivedStateOf over non-state
  inputs; defer photo fade alpha to Draw phase via graphicsLayer; react to
  zoom threshold with snapshotFlow instead of per-frame effect relaunch
- GalleryViewModel: drop @Immutable from MovePhotoSheetState so Strong
  Skipping compares the lazy feed by O(1) reference equality
- SystemCameraCapture: move temp-file disk I/O off the main thread
2026-10-14 16:06:12 +08:00
4 changed files with 75 additions and 57 deletions

No files matched your search

@@ -37,7 +37,9 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import com.example.onecamera.R import com.example.onecamera.R
import com.example.onecamera.ui.components.OneCameraEmptyState import com.example.onecamera.ui.components.OneCameraEmptyState
import com.example.onecamera.ui.components.OneCameraScreenPadding import com.example.onecamera.ui.components.OneCameraScreenPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -111,23 +113,26 @@ fun SystemCameraCapture(
LaunchedEffect(hasLaunchedCamera) { LaunchedEffect(hasLaunchedCamera) {
if (!hasLaunchedCamera) { if (!hasLaunchedCamera) {
viewModel.clearLastSaveResult() viewModel.clearLastSaveResult()
val tempDir = File(context.cacheDir, "camera_captures").apply { mkdirs() } val (preparedPath, preparedUri) = withContext(Dispatchers.IO) {
val tempDir = File(context.cacheDir, "camera_captures").apply { mkdirs() }
tempDir.listFiles()?.forEach { runCatching { it.delete() } } tempDir.listFiles()?.forEach { runCatching { it.delete() } }
val tempFile = File(tempDir, "capture_${System.currentTimeMillis()}.jpg").apply { val tempFile = File(tempDir, "capture_${System.currentTimeMillis()}.jpg").apply {
createNewFile() createNewFile()
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile,
)
tempFile.absolutePath to uri.toString()
} }
val uri = FileProvider.getUriForFile( tempPhotoUriString = preparedUri
context, tempPhotoPath = preparedPath
"${context.packageName}.fileprovider",
tempFile,
)
tempPhotoUriString = uri.toString()
tempPhotoPath = tempFile.absolutePath
hasLaunchedCamera = true hasLaunchedCamera = true
hasReceivedCameraResult = false hasReceivedCameraResult = false
launchUptimeMillis = SystemClock.elapsedRealtime() launchUptimeMillis = SystemClock.elapsedRealtime()
takePictureLauncher.launch(uri) takePictureLauncher.launch(Uri.parse(preparedUri))
} }
} }
@@ -143,7 +148,7 @@ fun SystemCameraCapture(
tempPhotoUriString != null tempPhotoUriString != null
) { ) {
val tempLength = tempPhotoPath?.let { path -> val tempLength = tempPhotoPath?.let { path ->
runCatching { File(path).length() }.getOrNull() runCatching { withContext(Dispatchers.IO) { File(path).length() } }.getOrNull()
} ?: 0L } ?: 0L
val tempPhotoUri = tempPhotoUriString?.let(Uri::parse) val tempPhotoUri = tempPhotoUriString?.let(Uri::parse)
if (tempLength > 0L && tempPhotoUri != null) { if (tempLength > 0L && tempPhotoUri != null) {
@@ -105,7 +105,11 @@ fun FolderTreePickerSheet(
.fillMaxWidth() .fillMaxWidth()
.weight(1f, fill = false), .weight(1f, fill = false),
) { ) {
items(state.folders, key = { it.uri }) { folder -> items(
items = state.folders,
key = { it.uri },
contentType = { "folder" },
) { folder ->
val isCurrent = currentFolderUri == folder.uri.toString() val isCurrent = currentFolderUri == folder.uri.toString()
val isSelected = selectedFolderUri == folder.uri.toString() val isSelected = selectedFolderUri == folder.uri.toString()
ListItem( ListItem(
@@ -41,16 +41,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -86,6 +86,7 @@ import com.example.onecamera.ui.folders.RenameFolderDialog
import com.github.panpf.zoomimage.CoilZoomAsyncImage import com.github.panpf.zoomimage.CoilZoomAsyncImage
import com.github.panpf.zoomimage.rememberCoilZoomState import com.github.panpf.zoomimage.rememberCoilZoomState
import com.github.panpf.zoomimage.zoom.GestureType import com.github.panpf.zoomimage.zoom.GestureType
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -198,10 +199,8 @@ fun GalleryScreen(
uiState.subfolders.sortedByDescending { it.name.lowercase() } uiState.subfolders.sortedByDescending { it.name.lowercase() }
} }
} }
val sortedGalleryPhotos by remember(sortedPhotos) { val sortedGalleryPhotos = remember(sortedPhotos) {
derivedStateOf { sortedPhotos.filter { it.kind == GalleryFileKind.Photo }
sortedPhotos.filter { it.kind == GalleryFileKind.Photo }
}
} }
when (val dialog = uiState.dialog) { when (val dialog = uiState.dialog) {
@@ -537,10 +536,8 @@ internal fun FullscreenPhotoPagerContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDelete: (Photo) -> Unit, onDelete: (Photo) -> Unit,
) { ) {
val initialPage by remember(photos, initialPhoto.uri) { val initialPage = remember(photos, initialPhoto.uri) {
derivedStateOf { photos.indexOfFirst { it.uri == initialPhoto.uri }.coerceAtLeast(0)
photos.indexOfFirst { it.uri == initialPhoto.uri }.coerceAtLeast(0)
}
} }
val pagerState = rememberPagerState(initialPage = initialPage) { photos.size.coerceAtLeast(1) } val pagerState = rememberPagerState(initialPage = initialPage) { photos.size.coerceAtLeast(1) }
@@ -646,7 +643,6 @@ private fun ZoomablePhoto(
animationSpec = tween(durationMillis = fadeMs), animationSpec = tween(durationMillis = fadeMs),
label = "fullAlpha", label = "fullAlpha",
) )
val scale by remember { derivedStateOf { zoomState.zoomable.transform.scaleX } }
LaunchedEffect(photo.uri) { LaunchedEffect(photo.uri) {
@@ -663,25 +659,27 @@ private fun ZoomablePhoto(
} }
} }
LaunchedEffect(scale) { LaunchedEffect(zoomState) {
val zoomed = scale > 1.01f snapshotFlow { zoomState.zoomable.transform.scaleX > 1.01f }
.distinctUntilChanged()
onZoomChange(zoomed) .collect { zoomed ->
zoomState.subsampling.setDisabled(!zoomed) onZoomChange(zoomed)
zoomState.subsampling.setDisabled(!zoomed)
}
} }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
if (fullAlpha < 1f) { AsyncImage(
AsyncImage( model = remember(photo.uri, context, fadeMs) {
model = remember(photo.uri, context, fadeMs) { thumbnailDraftRequest(context, photo.uri, crossfadeMillis = fadeMs)
thumbnailDraftRequest(context, photo.uri, crossfadeMillis = fadeMs) },
}, contentDescription = null,
contentDescription = null, contentScale = ContentScale.Fit,
contentScale = ContentScale.Fit, modifier = Modifier
modifier = Modifier.fillMaxSize(), .fillMaxSize()
) .graphicsLayer { alpha = 1f - fullAlpha },
} )
CoilZoomAsyncImage( CoilZoomAsyncImage(
model = fullImageRequest, model = fullImageRequest,
contentDescription = photo.name, contentDescription = photo.name,
@@ -690,7 +688,7 @@ private fun ZoomablePhoto(
onError = { isFullReady = false }, onError = { isFullReady = false },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.alpha(fullAlpha), .graphicsLayer { alpha = fullAlpha },
zoomState = zoomState, zoomState = zoomState,
) )
} }
@@ -45,7 +45,6 @@ sealed interface GalleryDialog {
data class DeleteSubfolder(val folder: FolderNode) : GalleryDialog data class DeleteSubfolder(val folder: FolderNode) : GalleryDialog
} }
@Immutable
data class MovePhotoSheetState( data class MovePhotoSheetState(
val file: Photo? = null, val file: Photo? = null,
val files: List<Photo> = emptyList(), val files: List<Photo> = emptyList(),
@@ -96,22 +95,20 @@ data class GalleryUiState(
val error: String? = null, val error: String? = null,
) { ) {
val isSelectionMode: Boolean = selectedPhotoUris.isNotEmpty() val isSelectionMode: Boolean = selectedPhotoUris.isNotEmpty()
val selectedPhotos: List<Photo> = photos.filter { it.uri.toString() in selectedPhotoUris }
val sortedSubfolders: List<FolderNode> = if (sortAscending) { val selectedPhotos: List<Photo> by lazy {
subfolders.sortedBy { it.name.lowercase() } photos.filter { it.uri.toString() in selectedPhotoUris }
} else {
subfolders.sortedByDescending { it.name.lowercase() }
} }
val sortedPhotos: List<Photo> = if (sortAscending) {
photos.sortedBy { it.name.lowercase() } val selectablePhotoUris: Set<String> by lazy {
} else { photos.asSequence()
photos.sortedByDescending { it.name.lowercase() } .filter { it.kind == GalleryFileKind.Photo }
.mapTo(HashSet()) { it.uri.toString() }
}
val allPhotosSelected: Boolean by lazy {
selectablePhotoUris.isNotEmpty() && selectedPhotoUris.containsAll(selectablePhotoUris)
} }
val selectablePhotoUris: Set<String> = photos
.filter { it.kind == GalleryFileKind.Photo }
.map { it.uri.toString() }
.toSet()
val allPhotosSelected: Boolean = selectablePhotoUris.isNotEmpty() && selectedPhotoUris.containsAll(selectablePhotoUris)
} }
@HiltViewModel @HiltViewModel
@@ -343,18 +340,31 @@ class GalleryViewModel @Inject constructor(
fun clearSelection() { fun clearSelection() {
dragSelectionBaseline = emptySet() dragSelectionBaseline = emptySet()
dragSelectableCache = null
uiState.value = uiState.value.copy(selectedPhotoUris = emptySet()) uiState.value = uiState.value.copy(selectedPhotoUris = emptySet())
} }
private var dragSelectionBaseline: Set<String> = emptySet() private var dragSelectionBaseline: Set<String> = emptySet()
private var dragSelectableCache: Pair<List<Photo>, Set<String>>? = null
private fun currentSelectablePhotoUris(): Set<String> {
val photos = uiState.value.photos
dragSelectableCache?.let { (cachedPhotos, uris) ->
if (cachedPhotos === photos) return uris
}
val uris = uiState.value.selectablePhotoUris
dragSelectableCache = photos to uris
return uris
}
fun beginDragSelection(anchorUri: String) { fun beginDragSelection(anchorUri: String) {
dragSelectionBaseline = uiState.value.selectedPhotoUris + anchorUri dragSelectionBaseline = uiState.value.selectedPhotoUris + anchorUri
uiState.value = uiState.value.copy(selectedPhotoUris = dragSelectionBaseline) uiState.value = uiState.value.copy(selectedPhotoUris = dragSelectionBaseline)
} }
fun updateDragSelection(rangeUris: Set<String>) { fun updateDragSelection(rangeUris: Set<String>) {
val selectable = uiState.value.selectablePhotoUris val selectable = currentSelectablePhotoUris()
uiState.value = uiState.value.copy( uiState.value = uiState.value.copy(
selectedPhotoUris = dragSelectionBaseline + rangeUris.intersect(selectable), selectedPhotoUris = dragSelectionBaseline + rangeUris.intersect(selectable),
) )
@@ -362,6 +372,7 @@ class GalleryViewModel @Inject constructor(
fun endDragSelection() { fun endDragSelection() {
dragSelectionBaseline = emptySet() dragSelectionBaseline = emptySet()
dragSelectableCache = null
} }
fun zipSelectedPhotosAndShare() { fun zipSelectedPhotosAndShare() {