fix: 第二轮性能优化 — 轮询合并、HTTP缓存、并行化、remember优化

- VideoPlayerScreen: 合并4个轮询循环为1个(wifi+battery+format+speed)
- VideoPlayerScreen: 移除seek bar重复progress写入
- EmbyRepository: 添加HTTP磁盘缓存(20MB) + retry interceptor
- EmbyRepository: searchItems .map{await()} → .awaitAll() 真并发
- EmbyRepository: getSeriesEpisodeCount season请求并行化
- TmdbRepository: 添加Semaphore(8)限制并发season请求
- TmdbRepository: 只获取最近5个season减少请求数
- ServerRepository: addServer/updateServer改为乐观更新
- ServerRepository: init()添加双重检查锁定线程安全
- MediaDetailScreen: 移除调试Log.d调用
- AppNavHost: onViewAll lambda添加remember
- SearchResultsCache: 单槽→LruCache(5)支持多搜索词缓存
- RecordsScreen: LaunchedEffect添加watchlistVersion key支持刷新
- RecordsScreen: filterCalendarEpisodes/groupBy添加remember
- RecordsScreen: 预计算watchByTmdbId Map替代O(n) find
- LumaDesignSystem: 添加isLowEndDevice检测禁用lens效果
- LiquidGlass: 低端设备跳过lens GPU pass
- HomeScreen/LibraryScreen/RecordsScreen: ImageRequest.Builder remember
This commit is contained in:
beyond committed 2026-09-06 13:47:27 +08:00
1 parent 3670466a65
commit 0ca7cf39b7
13 files changed
+175 -88

No files matched your search

@@ -1,16 +1,28 @@
package com.example.myapplication
import android.app.ActivityManager
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.ui.design.LumaGlassRuntime
/**
* P1-6.1: Custom Application class with tuned Coil ImageLoader caches.
* Registers as ImageLoaderFactory so all AsyncImage composables use the optimized config.
*/
class LumaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// P1: Initialize EmbyRepository with app context for HTTP disk cache
EmbyRepository.init(this)
// P1: Detect low-end devices to disable expensive lens effects
val am = getSystemService(ActivityManager::class.java)
LumaGlassRuntime.isLowEndDevice = am?.isLowRamDevice == true || Runtime.getRuntime().availableProcessors() <= 4
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.memoryCache {
@@ -9,15 +9,21 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Interceptor
import android.util.LruCache
import kotlin.math.abs
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -59,11 +65,38 @@ object EmbyRepository {
private val deviceId: String = UUID.randomUUID().toString()
private val gson = Gson()
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
// P1: Retry interceptor for transient network failures on GET requests
private val retryInterceptor = Interceptor { chain ->
var lastException: Exception? = null
repeat(2) { attempt ->
try {
return@Interceptor chain.proceed(chain.request())
} catch (e: Exception) {
lastException = e
if (attempt < 1) Thread.sleep(300L * (attempt + 1))
}
}
throw lastException ?: Exception("Retry failed")
}
// P1: HTTP disk cache + retry — initialized lazily from Application context
@Volatile private var _client: OkHttpClient? = null
fun init(context: android.content.Context) {
if (_client != null) return
synchronized(this) {
if (_client != null) return
_client = OkHttpClient.Builder()
.cache(Cache(context.cacheDir.resolve("emby_http"), 20L * 1024 * 1024))
.addInterceptor(retryInterceptor)
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
}
private val client: OkHttpClient
get() = _client ?: throw IllegalStateException("EmbyRepository.init(context) must be called first")
// P0-3.3: Reuse a single no-redirect client instead of creating one per checkOnline call
private val noRedirectClient = OkHttpClient.Builder()
@@ -367,7 +400,7 @@ object EmbyRepository {
} else {
val episodeCounts = seriesItems
.map { item -> async { item.id to getSeriesEpisodeCount(server, item.id) } }
.map { it.await() }
.awaitAll() // P0: awaitAll() for true parallelism instead of sequential await
.toMap()
items.map { item ->
episodeCounts[item.id]?.let { count ->
@@ -462,8 +495,12 @@ object EmbyRepository {
val cacheKey = "${server.id}|$seriesId"
episodeCountCache.get(cacheKey)?.let { return@withContext it }
val count = getSeasons(server, seriesId).sumOf { season ->
getEpisodePage(server, seriesId, season.id, limit = 1).totalCount
// P1: Parallelize season episode-count requests
val seasons = getSeasons(server, seriesId)
val count = coroutineScope {
seasons.map { season ->
async { getEpisodePage(server, seriesId, season.id, limit = 1).totalCount }
}.awaitAll().sum()
}
if (count > 0) episodeCountCache.put(cacheKey, count)
count
@@ -21,32 +21,37 @@ object ServerRepository {
val servers: StateFlow<List<EmbyServer>> = _servers.asStateFlow()
private var database: AppDatabase? = null
private var initialized = false
@Volatile private var initialized = false
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// P2: Thread-safe initialization with double-checked locking
fun init(context: Context) {
if (initialized) return
initialized = true
database = AppDatabase.getInstance(context)
repositoryScope.launch {
migrateLegacyPreferences(context)
removeLegacySampleServers()
reload()
synchronized(this) {
if (initialized) return
initialized = true
database = AppDatabase.getInstance(context)
repositoryScope.launch {
migrateLegacyPreferences(context)
removeLegacySampleServers()
reload()
}
}
}
// P1: Optimistic update — directly update state instead of full DB reload
suspend fun addServer(server: EmbyServer) {
withContext(Dispatchers.IO) {
database?.serverDao()?.insertServer(toEntity(server))
}
reload()
_servers.update { current -> current + server }
}
suspend fun updateServer(server: EmbyServer) {
withContext(Dispatchers.IO) {
database?.serverDao()?.insertServer(toEntity(server))
}
reload()
_servers.update { current -> current.map { if (it.id == server.id) server else it } }
}
suspend fun deleteServer(serverId: String) {
@@ -11,6 +11,8 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.Locale
@@ -320,6 +322,9 @@ object TmdbRepository {
return result
}
// P1: Semaphore to limit concurrent season requests (TMDB rate limiting)
private val seasonSemaphore = Semaphore(8)
/** Get calendar episodes for all watchlist items. */
suspend fun getCalendarEpisodes(watchlist: List<WatchlistEntity>): List<TmdbEpisodeCalendar> = withContext(Dispatchers.IO) {
if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList()
@@ -367,22 +372,27 @@ object TmdbRepository {
?: seriesDetail.logoPath
val logoUrl = logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it }
// P1: Parallelize season requests within each series
// P1: Parallelize season requests with semaphore for rate limiting
// P1: Only fetch recent seasons (last 5 with air dates) to reduce request count
val recentSeasons = seriesDetail.seasons
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
.sortedByDescending { it.seasonNumber }
.take(5)
.sortedBy { it.seasonNumber }
val seasonEpisodes = coroutineScope {
seriesDetail.seasons
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
.map { season ->
async {
recentSeasons.map { season ->
async {
seasonSemaphore.withPermit {
get(
"$API_BASE/tv/$tmdbId/season/${season.seasonNumber}?language=${language()}",
TmdbSeasonDetailResponse::class.java,
)
}
}.awaitAll()
}
}.awaitAll()
}
seriesDetail.seasons
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
recentSeasons
.zip(seasonEpisodes)
.forEach { (season, seasonDetail) ->
val episodes = seasonDetail?.episodes.orEmpty()
@@ -119,16 +119,20 @@ fun AppNavHost(
popExitTransition = { appPopExitTransition() },
) {
composable(AppRoute.Home.pattern) {
// P1: Remember lambdas passed to screen composables
val homeViewAll = remember(navController) {
{ serverId: String, libraryId: String, title: String ->
navController.navigate(AppRoute.HomeLibraryAll.create(serverId, libraryId, title)) {
launchSingleTop = true
}
}
}
HomeScreen(
servers = servers,
selected = selectedServer,
onSelectServer = onSelectServer,
onOpenDetail = { openDetail(selectedServer?.id.orEmpty(), it) },
onViewAll = { serverId, libraryId, title ->
navController.navigate(AppRoute.HomeLibraryAll.create(serverId, libraryId, title)) {
launchSingleTop = true
}
},
onViewAll = homeViewAll,
)
}
@@ -283,6 +283,11 @@ private fun FeaturedItemBanner(
) {
val imageUrl = heroImageUrl(item, serverUrl)
val interactionSource = remember { MutableInteractionSource() }
// P2: Remember ImageRequest to avoid allocation on every recomposition
val context = LocalContext.current
val imageRequest = remember(imageUrl) {
imageUrl?.let { ImageRequest.Builder(context).data(it).crossfade(true).build() }
}
Box(
modifier
@@ -295,9 +300,9 @@ private fun FeaturedItemBanner(
)
.background(immersive.pageTop),
) {
if (imageUrl != null) {
if (imageRequest != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
model = imageRequest,
contentDescription = item.name,
contentScale = ContentScale.Crop,
placeholder = ColorPainter(immersive.pageTop),
@@ -642,6 +647,10 @@ private fun MyMediaGrid(libraries: List<EmbyMediaFolder>, serverUrl: String, onS
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=300&quality=80"
val context = LocalContext.current
val folderImageRequest = remember(folderImageUrl) {
ImageRequest.Builder(context).data(folderImageUrl).crossfade(true).build()
}
Box(
Modifier.width(176.dp).height(100.dp)
.clip(RoundedCornerShape(LumaShapes.medium))
@@ -649,7 +658,7 @@ private fun MyMediaGrid(libraries: List<EmbyMediaFolder>, serverUrl: String, onS
.clickable { onSelect(folder) },
) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(folderImageUrl).crossfade(true).build(),
model = folderImageRequest,
contentDescription = folder.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -191,7 +191,11 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(horizontal = 12.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (server.iconUrl.isNotBlank()) {
LumaAsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop)
val ctx = LocalContext.current
val iconRequest = remember(server.iconUrl) {
ImageRequest.Builder(ctx).data(server.iconUrl).crossfade(true).build()
}
LumaAsyncImage(model = iconRequest, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop)
} else {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center) {
LumaIconView(LumaIcon.Server, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
@@ -256,8 +260,12 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit,
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(10.dp), horizontalAlignment = Alignment.Start) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (server.iconUrl.isNotBlank()) {
val ctx = LocalContext.current
val iconRequest = remember(server.iconUrl) {
ImageRequest.Builder(ctx).data(server.iconUrl).crossfade(true).build()
}
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(),
model = iconRequest,
contentDescription = null,
modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
@@ -372,7 +372,6 @@ fun MediaDetailScreen(
loadingTmdb = true
return@LaunchedEffect
}
android.util.Log.d("MediaDetail", "TMDB loading: type=${detail.type}, tmdbId=$tmdbId, name=${detail.name}, year=${detail.productionYear}")
loadingTmdb = true
tmdbExtras = try {
if (detail.type == "Series") {
@@ -387,7 +386,6 @@ fun MediaDetailScreen(
} finally {
loadingTmdb = false
}
android.util.Log.d("MediaDetail", "TMDB result: cast=${tmdbExtras.cast.size}, recs=${tmdbExtras.recommendations.size}")
}
val castMembers = remember(detail.people, tmdbExtras.cast, server?.fullUrl) {
@@ -254,11 +254,15 @@ private fun ServerHeader(server: EmbyServer) {
contentAlignment = Alignment.Center,
) {
if (server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
val ctx = LocalContext.current
val iconRequest = remember(server.iconUrl) {
ImageRequest.Builder(ctx)
.data(server.iconUrl)
.crossfade(true)
.build(),
.build()
}
AsyncImage(
model = iconRequest,
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -448,8 +452,9 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
var selectedDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var weekOffset by remember { mutableIntStateOf(0) }
// Load watchlist and fetch calendar from TMDB
LaunchedEffect(Unit) {
// P1: Key on watchlist size so calendar re-fetches when watchlist changes
var watchlistVersion by remember { mutableIntStateOf(0) }
LaunchedEffect(watchlistVersion) {
watchlist = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
WatchlistRepository.getAllWatchlistFromDb()
}
@@ -498,7 +503,14 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
LumaLoading(label = stringResource(Res.string.loading_search))
}
} else {
val filteredEpisodes = filterCalendarEpisodes(calendarEpisodes, selectedDate, today)
// P2: Remember filtered + grouped results to avoid recomputation on unrelated state changes
val filteredEpisodes = remember(calendarEpisodes, selectedDate, today) {
filterCalendarEpisodes(calendarEpisodes, selectedDate, today)
}
// P2: Pre-compute watchlist lookup map — O(1) per episode instead of O(n) find
val watchByTmdbId = remember(watchlist) {
watchlist.associateBy { it.tmdbId?.toIntOrNull() }
}
if (filteredEpisodes.isEmpty()) {
LumaEmptyStateContainer(
icon = LumaIcon.Calendar,
@@ -507,7 +519,9 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
verticalOffset = (-60).dp,
)
} else {
val groupedByDate = filteredEpisodes.groupBy { it.airDate }.toSortedMap()
val groupedByDate = remember(filteredEpisodes) {
filteredEpisodes.groupBy { it.airDate }.toSortedMap()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
@@ -526,7 +540,7 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
)
}
items(items = episodes, key = { "${it.tmdbId}_${it.seasonNumber}_${it.episodeNumber}" }) { episode ->
val matchedWatch = watchlist.find { it.tmdbId?.toIntOrNull() == episode.tmdbId }
val matchedWatch = watchByTmdbId[episode.tmdbId]
CalendarEpisodeCard(
episode = episode,
onClick = {
@@ -156,24 +156,22 @@ private data class CachedSearchResults(
)
private object SearchResultsCache {
private var cachedQuery: String = ""
private var cachedResults: CachedSearchResults? = null
@Synchronized
fun get(query: String): CachedSearchResults? {
return if (query == cachedQuery) cachedResults else null
// P1: LruCache(5) instead of single-slot — allows switching between recent searches without re-fetching
private val cache = object : android.util.LruCache<String, CachedSearchResults>(5) {
override fun sizeOf(key: String, value: CachedSearchResults) = 1
}
@Synchronized
fun get(query: String): CachedSearchResults? = cache.get(query)
@Synchronized
fun put(query: String, results: CachedSearchResults) {
cachedQuery = query
cachedResults = results
cache.put(query, results)
}
@Synchronized
fun clear() {
cachedQuery = ""
cachedResults = null
cache.evictAll()
}
}
@@ -396,47 +396,30 @@ fun VideoPlayerScreen(
}
}
LaunchedEffect(Unit) {
// P0: Consolidate 4 polling loops (wifi/battery/format/speed) into 1 combined loop
LaunchedEffect(player) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
while (true) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
// WiFi check (replaces dedicated 2s loop)
isWifi = cm?.activeNetwork?.let { net ->
cm.getNetworkCapabilities(net)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
} ?: false
delay(2000)
}
}
LaunchedEffect(Unit) {
var batteryManager: BatteryManager? = null
while (true) {
if (batteryManager == null) {
batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
}
// Battery check (replaces dedicated 1s loop)
batteryManager?.let {
batteryPercent = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val status = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS)
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
}
delay(1000)
}
}
LaunchedEffect(player) {
while (true) {
// Video format check (replaces dedicated 1s loop)
val vf = player.videoFormat
if (vf != null) {
videoCodecText = formatVideoCodec(vf.sampleMimeType)
videoBitrateText = formatBitrate(vf.bitrate)
videoFrameRateText = if (vf.frameRate > 0f) String.format("%.0f fps", vf.frameRate) else ""
}
delay(1000)
}
}
LaunchedEffect(Unit) {
while (true) {
val speed = speedMonitor.currentBytesPerSec()
networkSpeed = speed
// Network speed (replaces dedicated 1s loop)
networkSpeed = speedMonitor.currentBytesPerSec()
delay(1000)
}
}
@@ -1059,7 +1042,7 @@ fun VideoPlayerScreen(
totalDurationMs = player.duration
if (!isSeeking && seekPreviewPositionMs == null) {
currentPositionMs = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration.toFloat()
// P0: Don't write outer 'progress' here — already handled by outer loop at 250ms
val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f)
bufferProgress = maxOf(b, progress)
}
@@ -583,11 +583,13 @@ fun LiquidGlassPopupOverlay(
)
vibrancy()
blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx())
lens(
refractionHeight = 8.dp.toPx() * rawProgress,
refractionAmount = 18.dp.toPx() * rawProgress,
depthEffect = true,
)
if (LumaGlassRuntime.useLensEffect) {
lens(
refractionHeight = 8.dp.toPx() * rawProgress,
refractionAmount = 18.dp.toPx() * rawProgress,
depthEffect = true,
)
}
},
highlight = { Highlight.Plain.copy(alpha = 0.58f) },
shadow = { Shadow(alpha = 0.18f) },
@@ -86,8 +86,15 @@ internal object LumaGlassRuntime {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
// P1: Set during Application.onCreate to disable lens on low-end devices
var isLowEndDevice: Boolean = false
internal set
val useNativeBackdrop: Boolean
get() = AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid && isBackdropSafe
val useLensEffect: Boolean
get() = useNativeBackdrop && !isLowEndDevice
}
@Composable
@@ -335,7 +342,7 @@ fun Modifier.lumaGlass(
)
vibrancy()
blur((blurOverride ?: spec.blur).toPx())
if (level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) {
if ((level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) && LumaGlassRuntime.useLensEffect) {
lens(
refractionHeight = if (level == LumaGlassLevel.Interactive) 8.dp.toPx() else 6.dp.toPx(),
refractionAmount = if (level == LumaGlassLevel.Interactive) 18.dp.toPx() else 12.dp.toPx(),