perf: fix Discover screen load and catalog switch latency

- AddonRepository: cache meta detail lookups with in-flight de-dup, and
  race addons instead of fanning out to all of them unconditionally,
  cutting redundant requests to slow/duplicate addon instances.
- HomeCatalogFeedCoordinator: publish each background catalog row as it
  arrives instead of waiting for a whole batch, and support cancelling
  in-flight remaining-catalog loads.
- HomeBillboardRuntime: add a pause for rotation/prefetch/trailer work.
- HomeViewModel: pause Home's background billboard/catalog work when
  Discover becomes active so it stops competing for the same addons.
- AndroidDiscoverDataSource: skip redundant addon-list recomputation on
  catalog-only filter changes, and move card mapping off the main thread.
- FluxaHeadlessEffectRunner/FluxaHeadlessAppRuntime: narrow the runtime
  mutex to only guard Rust engine calls, not the network effects they
  trigger, so one screen's dispatch can no longer block another's for
  the duration of a slow effect chain.
- NetworkModule: raise the shared OkHttp dispatcher's per-host/total
  request ceilings.
This commit is contained in:
KhooLy 2026-07-28 00:17:59 +03:00
parent b29cd3ebf4
commit c030fd66b2
8 changed files with 218 additions and 53 deletions

View file

@ -4,8 +4,6 @@ import java.io.Closeable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Android-side owner for the Rust headless app runtime.
@ -19,19 +17,18 @@ class FluxaHeadlessAppRuntime(
private val environment: HeadlessPlatformEnvironment,
private val maxEffectsPerDispatch: Int = 64
) : Closeable {
private val mutex = Mutex()
private val runner = FluxaHeadlessEffectRunner(engine, environment, maxEffectsPerDispatch)
private val _state = MutableStateFlow<Map<String, Any?>>(emptyMap())
val state: StateFlow<Map<String, Any?>> = _state.asStateFlow()
suspend fun dispatch(action: Any): NativeHeadlessEngineResult {
return mutex.withLock { runner.dispatchAndDrain(action) }.also { result ->
return runner.dispatchAndDrain(action).also { result ->
_state.value = result.state
}
}
suspend fun complete(result: HeadlessEffectCompletion): NativeHeadlessEngineResult {
return mutex.withLock { runner.completeAndDrain(result) }.also { next ->
return runner.completeAndDrain(result).also { next ->
_state.value = next.state
}
}

View file

@ -39,7 +39,72 @@ object NetworkModule {
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideNetTimingEventListenerFactory(): okhttp3.EventListener.Factory {
return okhttp3.EventListener.Factory { call ->
if (!BuildConfig.DEBUG) return@Factory okhttp3.EventListener.NONE
object : okhttp3.EventListener() {
var callStartNs = 0L
fun elapsedMs() = (System.nanoTime() - callStartNs) / 1_000_000
override fun callStart(call: okhttp3.Call) {
callStartNs = System.nanoTime()
android.util.Log.d("NetTiming", "callStart url=${call.request().url}")
}
override fun dnsStart(call: okhttp3.Call, domainName: String) {
android.util.Log.d("NetTiming", "dnsStart +${elapsedMs()}ms host=$domainName")
}
override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<java.net.InetAddress>) {
android.util.Log.d("NetTiming", "dnsEnd +${elapsedMs()}ms host=$domainName resolved=$inetAddressList")
}
override fun connectStart(call: okhttp3.Call, inetSocketAddress: java.net.InetSocketAddress, proxy: java.net.Proxy) {
android.util.Log.d("NetTiming", "connectStart +${elapsedMs()}ms addr=$inetSocketAddress")
}
override fun secureConnectStart(call: okhttp3.Call) {
android.util.Log.d("NetTiming", "secureConnectStart +${elapsedMs()}ms")
}
override fun secureConnectEnd(call: okhttp3.Call, handshake: okhttp3.Handshake?) {
android.util.Log.d("NetTiming", "secureConnectEnd +${elapsedMs()}ms")
}
override fun connectEnd(call: okhttp3.Call, inetSocketAddress: java.net.InetSocketAddress, proxy: java.net.Proxy, protocol: okhttp3.Protocol?) {
android.util.Log.d("NetTiming", "connectEnd +${elapsedMs()}ms")
}
override fun connectFailed(call: okhttp3.Call, inetSocketAddress: java.net.InetSocketAddress, proxy: java.net.Proxy, protocol: okhttp3.Protocol?, ioe: java.io.IOException) {
android.util.Log.d("NetTiming", "connectFailed +${elapsedMs()}ms addr=$inetSocketAddress error=$ioe")
}
override fun requestHeadersEnd(call: okhttp3.Call, request: okhttp3.Request) {
android.util.Log.d("NetTiming", "requestHeadersEnd +${elapsedMs()}ms")
}
override fun responseHeadersStart(call: okhttp3.Call) {
android.util.Log.d("NetTiming", "responseHeadersStart +${elapsedMs()}ms (time to first byte)")
}
override fun responseBodyEnd(call: okhttp3.Call, byteCount: Long) {
android.util.Log.d("NetTiming", "responseBodyEnd +${elapsedMs()}ms bytes=$byteCount")
}
override fun callEnd(call: okhttp3.Call) {
android.util.Log.d("NetTiming", "callEnd +${elapsedMs()}ms url=${call.request().url}")
}
override fun callFailed(call: okhttp3.Call, ioe: java.io.IOException) {
android.util.Log.d("NetTiming", "callFailed +${elapsedMs()}ms url=${call.request().url} error=$ioe")
}
}
}
}
@ -54,17 +119,27 @@ object NetworkModule {
@Provides
@Singleton
fun provideDispatcher(): okhttp3.Dispatcher = okhttp3.Dispatcher().apply {
maxRequests = 64
maxRequestsPerHost = 16
maxRequests = 96
maxRequestsPerHost = 32
}
@Provides
@Singleton
@Named("StremioClient")
fun provideStremioOkHttpClient(logging: HttpLoggingInterceptor, connectionPool: okhttp3.ConnectionPool, dispatcher: okhttp3.Dispatcher): OkHttpClient {
fun provideStremioOkHttpClient(
logging: HttpLoggingInterceptor,
connectionPool: okhttp3.ConnectionPool,
dispatcher: okhttp3.Dispatcher,
netTimingEventListenerFactory: okhttp3.EventListener.Factory
): OkHttpClient {
val ipv4OnlyDns = object : okhttp3.Dns {
override fun lookup(hostname: String): List<java.net.InetAddress> {
return okhttp3.Dns.SYSTEM.lookup(hostname).filter { it is java.net.Inet4Address }
val all = okhttp3.Dns.SYSTEM.lookup(hostname)
val filtered = all.filter { it is java.net.Inet4Address }
if (BuildConfig.DEBUG) {
android.util.Log.d("NetTiming", "dns host=$hostname all=$all filteredToIpv4=$filtered")
}
return filtered
}
}
@ -72,6 +147,7 @@ object NetworkModule {
.dns(ipv4OnlyDns)
.connectionPool(connectionPool)
.dispatcher(dispatcher)
.eventListenerFactory(netTimingEventListenerFactory)
.addInterceptor { chain ->
chain.proceed(HttpRequestSecurity.upgradeRemoteHttpRequest(chain.request()))
}

View file

@ -10,9 +10,11 @@ import com.fluxa.app.shared.feature.discover.DiscoverFilterOptionUiModel
import com.fluxa.app.shared.feature.discover.DiscoverFiltersUiModel
import com.fluxa.app.shared.feature.discover.DiscoverUiState
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
class AndroidDiscoverDataSource(
private val homeViewModel: HomeViewModel,
@ -61,15 +63,21 @@ class AndroidDiscoverDataSource(
},
isLoading = state.isLoading
)
}
}.flowOn(Dispatchers.Default)
override suspend fun updateFilters(filters: DiscoverFiltersUiModel) {
val previousFilters = this.filters.value
if (filters != previousFilters) homeViewModel.clearDiscoverResults()
homeViewModel.setDiscoverLoading(true)
val availableCatalogs = homeViewModel.discoverCatalogOptions(filters.contentType)
catalogOptions.value = availableCatalogs
contentTypes.value = homeViewModel.discoverContentTypes()
val contentTypeUnchanged = filters.contentType == previousFilters.contentType
val availableCatalogs = if (contentTypeUnchanged && catalogOptions.value.isNotEmpty()) {
catalogOptions.value
} else {
homeViewModel.discoverCatalogOptions(filters.contentType).also { catalogOptions.value = it }
}
if (!contentTypeUnchanged || contentTypes.value.isEmpty()) {
contentTypes.value = homeViewModel.discoverContentTypes()
}
val selectedCatalogKey = filters.catalogKey
?.takeIf { key -> availableCatalogs.any { it.key == key } }
?: availableCatalogs.firstOrNull()?.key

View file

@ -74,6 +74,12 @@ internal class HomeBillboardRuntime(
rotationJob?.cancel()
}
fun pauseBackgroundWork() {
rotationJob?.cancel()
prefetchJob?.cancel()
trailerJob?.cancel()
}
fun next() {
pauseRotation()
val items = pool()

View file

@ -16,10 +16,13 @@ import com.fluxa.app.domain.discovery.metadataFeedHomeTitle
import com.fluxa.app.domain.discovery.orderedMetadataFeeds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal class HomeCatalogFeedCoordinator(
private val repository: StremioRepository,
@ -33,6 +36,8 @@ internal class HomeCatalogFeedCoordinator(
private val setCategories: (List<HomeCategory>) -> Unit,
private val currentCategories: () -> List<HomeCategory>
) {
private var remainingCatalogsJob: Job? = null
suspend fun buildInitialCategories(profile: UserProfile?): List<HomeCategory> {
val lang = profile?.safeLanguage ?: "en"
val categories = mutableListOf<HomeCategory>()
@ -272,9 +277,10 @@ internal class HomeCatalogFeedCoordinator(
}
fun loadRemainingCatalogs(profile: UserProfile?) {
remainingCatalogsJob?.cancel()
val lang = profile?.safeLanguage ?: "en"
scope.launch(Dispatchers.IO) {
remainingCatalogsJob = scope.launch(Dispatchers.IO) {
try {
val enabledFeeds = getMetadataFeeds(profile)
.let { orderedMetadataFeeds(it, profile?.homeFeedOrder) }
@ -284,21 +290,27 @@ internal class HomeCatalogFeedCoordinator(
feeds.filter { isMetadataFeedEnabled(selectedKeys, it.key) }
}
.drop(2)
val firstWave = enabledFeeds.take(8).map { feed ->
async { fetchAddonFeedCategory(feed, lang) }
}
setCategories(optimizeHomeCategories(currentCategories() + firstWave.awaitAll().filterNotNull(), lang))
val secondWave = enabledFeeds.drop(8).map { feed ->
async { fetchAddonFeedCategory(feed, lang) }
}
setCategories(optimizeHomeCategories(currentCategories() + secondWave.awaitAll().filterNotNull(), lang))
val publishMutex = Mutex()
enabledFeeds.map { feed ->
async {
val category = fetchAddonFeedCategory(feed, lang) ?: return@async
publishMutex.withLock {
setCategories(optimizeHomeCategories(currentCategories() + category, lang))
}
}
}.awaitAll()
} catch (e: Exception) {
Log.e("HomeViewModel", "Remaining catalogs failed", e)
}
}
}
fun pauseRemainingCatalogs() {
remainingCatalogsJob?.cancel()
remainingCatalogsJob = null
}
suspend fun getMetadataFeeds(profile: UserProfile?): List<MetadataFeedOption> {
val addons = userAddons().ifEmpty {
addonRepository.getUserAddons(profile?.authKey ?: "", profile?.safeLocalAddons)

View file

@ -906,7 +906,13 @@ class HomeViewModel @Inject constructor(
browseCoordinator.setLoading(isLoading)
}
fun pauseHomeBackgroundWork() {
billboardRuntime.pauseBackgroundWork()
feedCoordinator.pauseRemainingCatalogs()
}
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
pauseHomeBackgroundWork()
browseCoordinator.discover(type, catalogKey, genre, year, rating, provider, region)
}

View file

@ -1,5 +1,8 @@
package com.fluxa.app.core.rust
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class NativeHeadlessEngineResult(
val effects: List<NativeHeadlessEffect> = emptyList(),
stateProvider: () -> Map<String, Any?> = { emptyMap() }
@ -17,10 +20,13 @@ class FluxaHeadlessEffectRunner(
private val environment: HeadlessPlatformEnvironment,
private val maxEffectsPerDispatch: Int = 64
) {
suspend fun dispatchAndDrain(action: Any): NativeHeadlessEngineResult = drain(engine.dispatch(action))
private val engineMutex = Mutex()
suspend fun dispatchAndDrain(action: Any): NativeHeadlessEngineResult =
drain(engineMutex.withLock { engine.dispatch(action) })
suspend fun completeAndDrain(result: HeadlessEffectCompletion): NativeHeadlessEngineResult {
return drain(engine.completeEffect(result))
return drain(engineMutex.withLock { engine.completeEffect(result) })
}
private suspend fun drain(initial: NativeHeadlessEngineResult): NativeHeadlessEngineResult {
@ -33,7 +39,8 @@ class FluxaHeadlessEffectRunner(
}
var remaining = maxEffectsPerDispatch
while (pending.isNotEmpty() && remaining > 0) {
current = engine.completeEffect(environment.execute(pending.removeFirst()))
val executed = environment.execute(pending.removeFirst())
current = engineMutex.withLock { engine.completeEffect(executed) }
patches += current
current.effects.forEach { effect ->
if (queuedIds.add(effect.id)) pending.addLast(effect)

View file

@ -3,22 +3,34 @@ package com.fluxa.app.data.repository
import android.util.Log
import com.fluxa.app.data.remote.*
import com.fluxa.app.domain.discovery.supportsStremioResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
private const val ADDON_META_TIMEOUT_MS = 3000L
private const val ADDON_META_HEDGE_DELAY_MS = 500L
private const val META_DETAIL_CACHE_TTL_MS = 5 * 60 * 1000L
@Singleton
class AddonRepository @Inject constructor(
private val addonManifestClient: StremioAddonManifestClient,
private val addonResourceClient: StremioAddonResourceClient
) {
private val metaDetailRepositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val metaDetailCache = ConcurrentHashMap<String, Pair<Long, MetaDetail?>>()
private val metaDetailInFlight = ConcurrentHashMap<String, Deferred<MetaDetail?>>()
suspend fun getAddonManifest(
transportUrl: String,
forceRefresh: Boolean = false
@ -29,43 +41,84 @@ class AddonRepository @Inject constructor(
id: String,
authKey: String,
localAddons: List<String>? = emptyList()
): MetaDetail? {
val cacheKey = "$type:$id"
metaDetailCache[cacheKey]?.let { (cachedAt, value) ->
if (System.currentTimeMillis() - cachedAt < META_DETAIL_CACHE_TTL_MS) return value
}
val deferred = metaDetailInFlight.computeIfAbsent(cacheKey) {
metaDetailRepositoryScope.async {
try {
fetchAddonMetaDetailUncached(type, id, authKey, localAddons)
} finally {
metaDetailInFlight.remove(cacheKey)
}
}
}
val result = deferred.await()
metaDetailCache[cacheKey] = System.currentTimeMillis() to result
return result
}
private suspend fun fetchAddonMetaDetailUncached(
type: String,
id: String,
authKey: String,
localAddons: List<String>? = emptyList()
): MetaDetail? {
val allAddons = getUserAddons(authKey, localAddons)
val addons = allAddons.filter { it.supportsStremioResource("meta") }
Log.d("MetaFetch", "getAddonMetaDetail type=$type id=${id.take(30)}: total=${allAddons.size} supported=${addons.size} names=${addons.map { it.manifest.name }}")
if (addons.isEmpty()) return null
val results = supervisorScope {
addons.map { addon ->
async {
withTimeoutOrNull(ADDON_META_TIMEOUT_MS) {
addonResourceClient.getMetaDetailFromAddonResult(addon.transportUrl, type, id)
} ?: AddonResourceResult.NetworkError(
url = addon.transportUrl,
cause = null
return coroutineScope {
fun launchLookup(transportUrl: String) = async {
withTimeoutOrNull(ADDON_META_TIMEOUT_MS) {
addonResourceClient.getMetaDetailFromAddonResult(transportUrl, type, id)
} ?: AddonResourceResult.NetworkError(url = transportUrl, cause = null)
}
val primaryDeferred = launchLookup(addons.first().transportUrl)
val hedgedResult = withTimeoutOrNull(ADDON_META_HEDGE_DELAY_MS) { primaryDeferred.await() }
if (hedgedResult is AddonResourceResult.Success) {
return@coroutineScope hedgedResult.value
}
val remainingAddons = addons.drop(1)
if (remainingAddons.isEmpty()) {
val result = if (hedgedResult != null) hedgedResult else primaryDeferred.await()
return@coroutineScope (result as? AddonResourceResult.Success)?.value
}
val pending = mutableListOf(primaryDeferred)
pending += remainingAddons.map { addon -> launchLookup(addon.transportUrl) }
var found: MetaDetail? = null
while (pending.isNotEmpty() && found == null) {
val (finished, result) = select<Pair<Deferred<AddonResourceResult<MetaDetail>>, AddonResourceResult<MetaDetail>>> {
pending.forEach { deferred -> deferred.onAwait { value -> deferred to value } }
}
pending.remove(finished)
when (result) {
is AddonResourceResult.Success -> found = result.value
is AddonResourceResult.Empty,
is AddonResourceResult.AddonUnsupported -> Unit
is AddonResourceResult.NetworkError -> Log.w(
"AddonRepository",
"Meta request failed: ${result.url} status=${result.statusCode}",
result.cause
)
is AddonResourceResult.ParseError -> Log.w(
"AddonRepository",
"Meta parse failed: ${result.url}",
result.cause
)
}
}.awaitAll()
}
results.forEach { result ->
when (result) {
is AddonResourceResult.Success -> return result.value
is AddonResourceResult.Empty,
is AddonResourceResult.AddonUnsupported -> Unit
is AddonResourceResult.NetworkError -> Log.w(
"AddonRepository",
"Meta request failed: ${result.url} status=${result.statusCode}",
result.cause
)
is AddonResourceResult.ParseError -> Log.w(
"AddonRepository",
"Meta parse failed: ${result.url}",
result.cause
)
}
pending.forEach { it.cancel() }
found
}
return null
}
suspend fun getMetaDetailFromSpecificAddon(