fix: 第三轮优化 — @Immutable覆盖、rememberImageRequest辅助函数、LazyRow keys

- EmbyApiModels: 8个嵌套类型添加@Immutable(EmbyUserData/EmbyPerson/EmbyNamedReference/EmbyMediaStream/EmbyItemCounts/EmbyResourceStats/EmbyChapter/EmbyMediaSource)
- LumaComponents: 新增rememberImageRequest()辅助函数避免ImageRequest.Builder重复分配
- HomeScreen/HomeLibraryAllScreen/FavoritesAllScreen/SearchScreen/SearchAllScreen/RecordsScreen/LibraryScreen: 17处ImageRequest.Builder替换为rememberImageRequest()
- MediaDetailScreen: episodes/genres/qualities/streams LazyRow添加stable key
- LibraryScreen: servers LazyVerticalGrid添加stable key
This commit is contained in:
beyond committed 2026-09-06 14:02:04 +08:00
1 parent 0ca7cf39b7
commit f44bc036e8
10 files changed
+52 -68

No files matched your search

@@ -110,12 +110,14 @@ data class EmbyItem(
}
}
@Immutable
data class EmbyUserData(
@SerializedName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0L,
@SerializedName("Played") val played: Boolean = false,
@SerializedName("LastPlayedDate") val lastPlayedDate: String? = null,
)
@Immutable
data class EmbyPerson(
@SerializedName("Id") val id: String = "",
@SerializedName("Name") val name: String = "",
@@ -124,12 +126,14 @@ data class EmbyPerson(
@SerializedName("PrimaryImageTag") val primaryImageTag: String? = null,
)
@Immutable
data class EmbyNamedReference(
@SerializedName("Name") val name: String = "",
)
/* ---------- Detail ---------- */
@Immutable
data class EmbyMediaStream(
@SerializedName("DisplayTitle") val displayTitle: String? = null,
@SerializedName("DisplayLanguage") val displayLanguage: String? = null,
@@ -161,12 +165,14 @@ data class EmbyMediaStream(
@SerializedName("IsForced") val isForced: Boolean = false,
)
@Immutable
data class EmbyItemCounts(
@SerializedName("MovieCount") val movieCount: Int = 0,
@SerializedName("SeriesCount") val seriesCount: Int = 0,
@SerializedName("EpisodeCount") val episodeCount: Int = 0,
)
@Immutable
data class EmbyResourceStats(
val withVersions: EmbyItemCounts = EmbyItemCounts(),
val withoutVersions: EmbyItemCounts = EmbyItemCounts(),
@@ -181,6 +187,7 @@ data class EmbyResourceStats(
)
}
@Immutable
data class EmbyChapter(
@SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L,
@SerializedName("Name") val name: String? = null,
@@ -196,6 +203,7 @@ data class EmbyPlaybackInfoResponse(
@SerializedName("PlaySessionId") val playSessionId: String? = null,
)
@Immutable
data class EmbyMediaSource(
@SerializedName("Id") val id: String,
@SerializedName("PlaySessionId") val playSessionId: String? = null,
@@ -33,14 +33,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.FavoriteEntity
@@ -108,10 +108,7 @@ fun FavoritesAllScreen(
) {
if (server != null && server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(server.iconUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(server.iconUrl),
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -227,10 +224,7 @@ private fun FavoriteGridCard(
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(imageUrl),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -29,11 +29,11 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
@@ -286,7 +286,7 @@ private fun HomeLibraryMediaCard(
) {
LumaMediaCard(
model = EmbyRepository.buildImageUrl(serverUrl, item)?.let {
ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build()
rememberImageRequest(it)
},
title = item.name,
modifier = Modifier.fillMaxWidth(),
@@ -69,6 +69,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.TextUnit
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import coil.imageLoader
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
@@ -343,7 +344,7 @@ private fun FeaturedItemBanner(
Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) {
if (logoUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(),
model = rememberImageRequest(logoUrl),
contentDescription = null,
contentScale = ContentScale.Fit,
placeholder = ColorPainter(Color.Transparent),
@@ -110,6 +110,7 @@ import com.example.myapplication.ui.design.LumaGlassLevel
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.lumaGlass
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import kotlinx.coroutines.delay
@@ -381,7 +382,7 @@ fun LibraryScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Servers section
items(servers.size) { index ->
items(servers.size, key = { servers[it].id }) { index ->
val server = servers[index]
val tint = serverTints[index % serverTints.size]
if (isGrid) {
@@ -872,7 +873,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) {
contentAlignment = Alignment.Center,
) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
model = rememberImageRequest(icon.url),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(6.dp)),
@@ -1014,7 +1015,7 @@ private fun ManageIconLibraryDialog(
contentAlignment = Alignment.Center,
) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
model = rememberImageRequest(icon.url),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
@@ -1213,7 +1214,7 @@ private fun AddIconLibraryDialog(
contentAlignment = Alignment.Center,
) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
model = rememberImageRequest(icon.url),
contentDescription = icon.name,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
@@ -1429,7 +1430,7 @@ private fun IconPickerDialog(
contentAlignment = Alignment.Center,
) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
model = rememberImageRequest(icon.url),
contentDescription = icon.name,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
@@ -583,7 +583,7 @@ fun MediaDetailScreen(
if (detail.genres.isNotEmpty()) {
Spacer(Modifier.height(18.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(detail.genres.size) { index ->
items(detail.genres.size, key = { detail.genres[it] }) { index ->
val genre = detail.genres[index]
Box(
Modifier
@@ -648,7 +648,7 @@ fun MediaDetailScreen(
val episodeSpacing = 9.dp
val episodeWidth = (maxWidth - episodeSpacing) / 2
LazyRow(horizontalArrangement = Arrangement.spacedBy(episodeSpacing)) {
items(episodes.size) { index ->
items(episodes.size, key = { episodes[it].id }) { index ->
val episode = episodes[index]
Box(Modifier.width(episodeWidth)) {
EpisodeCard(
@@ -1186,7 +1186,7 @@ private fun ResourceSection(
if (qualities.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(qualities.size) { index ->
items(qualities.size, key = { qualities[it] }) { index ->
Box(
Modifier
.height(36.dp)
@@ -1562,7 +1562,7 @@ private fun MediaInformationSection(item: EmbyItem, loading: Boolean) {
fontSize = 12.sp,
)
else -> LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
items(streams.size) { index ->
items(streams.size, key = { "${streams[it].type}_${streams[it].codec}_${streams[it].language}" }) { index ->
val stream = streams[index]
val typeIndex = streams.take(index + 1).count { it.type.equals(stream.type, ignoreCase = true) }
val typeCount = streams.count { it.type.equals(stream.type, ignoreCase = true) }
@@ -1774,7 +1774,7 @@ private fun EpisodeListDialog(
modifier = Modifier.fillMaxWidth().heightIn(max = 440.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(episodes.size) { index ->
items(episodes.size, key = { episodes[it].id }) { index ->
val episode = episodes[index]
CompactEpisodeRow(
item = episode,
@@ -41,7 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
@@ -254,13 +254,7 @@ private fun ServerHeader(server: EmbyServer) {
contentAlignment = Alignment.Center,
) {
if (server.iconUrl.isNotBlank()) {
val ctx = LocalContext.current
val iconRequest = remember(server.iconUrl) {
ImageRequest.Builder(ctx)
.data(server.iconUrl)
.crossfade(true)
.build()
}
val iconRequest = rememberImageRequest(server.iconUrl)
AsyncImage(
model = iconRequest,
contentDescription = server.name,
@@ -396,10 +390,7 @@ private fun FavoritePosterCard(
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(imageUrl),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -792,10 +783,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni
) {
if (posterUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(posterUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(posterUrl),
contentDescription = episode.seriesName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -834,10 +822,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni
if (episode.logoPath != null) {
Spacer(Modifier.width(6.dp))
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(episode.logoPath)
.crossfade(true)
.build(),
model = rememberImageRequest(episode.logoPath),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
@@ -870,10 +855,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni
Spacer(Modifier.width(4.dp))
if (episode.networkLogoPath != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(episode.networkLogoPath)
.crossfade(true)
.build(),
model = rememberImageRequest(episode.networkLogoPath),
contentDescription = episode.networkName,
contentScale = ContentScale.Fit,
modifier = Modifier
@@ -32,14 +32,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
@@ -119,10 +119,7 @@ fun SearchAllScreen(
) {
if (server != null && server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(server.iconUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(server.iconUrl),
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -252,10 +249,7 @@ private fun SearchAllMediaCard(
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(imageUrl),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -50,7 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.ui.design.rememberImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
@@ -551,10 +551,7 @@ private fun ServerSearchSection(
) {
if (server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(server.iconUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(server.iconUrl),
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -759,10 +756,7 @@ private fun SearchMediaPoster(
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
model = rememberImageRequest(imageUrl),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.ContentScale
@@ -70,6 +71,15 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import top.yukonga.miuix.kmp.basic.Text
// P2: Reusable helper — creates a remembered ImageRequest to avoid allocation on every recomposition
@Composable
fun rememberImageRequest(modelData: Any?): ImageRequest? {
val context = LocalContext.current
return remember(modelData) {
modelData?.let { ImageRequest.Builder(context).data(it).crossfade(true).build() }
}
}
private enum class LumaImageState {
Loading,
Success,