perf(podcast): cache subscription load + batch feed property reads
Build Debug APK / build (push) Successful in 10m57s
Build Debug APK / build (push) Successful in 10m57s
The detail screen reloaded the full subscription list from the database on every open, and list_podcast_documents read each feed property with its own SQLite connection. Both are redundant work on a hot path. Add PodcastRepository.ensurePodcastsLoaded() (load-once, with subscribe/ unsubscribe still forcing a refresh) and use it from the detail and library screens instead of reloading unconditionally. Batch list_podcast_documents' property reads through list_properties_for_documents (~2 connection opens instead of ~5 per feed). Verified on emulator: opening the detail screen twice triggers the database load only once, with no network access on cached opens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,16 @@ class PodcastRepository(private val nativeLib: NativeLib) {
|
||||
|
||||
private val tag = "PodcastRepository"
|
||||
|
||||
// Set once the subscription map has been successfully loaded from the database. Mutations
|
||||
// (subscribe/unsubscribe) refresh via loadPodcastsFromDatabase(), so callers that only need the
|
||||
// current subscription state can use ensurePodcastsLoaded() to avoid redundant reloads.
|
||||
@Volatile private var podcastsLoaded = false
|
||||
|
||||
/** Load the subscription map from disk only if it hasn't been loaded yet. */
|
||||
suspend fun ensurePodcastsLoaded() {
|
||||
if (!podcastsLoaded) loadPodcastsFromDatabase()
|
||||
}
|
||||
|
||||
suspend fun loadPodcastsFromDatabase() =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "Loading podcasts from native database")
|
||||
@@ -44,6 +54,7 @@ class PodcastRepository(private val nativeLib: NativeLib) {
|
||||
Log.d(tag, "Converted ${podcastMap.size} items to podcasts")
|
||||
_subscribedPodcasts.value = podcastMap
|
||||
_podcasts.value = _podcasts.value + podcastMap
|
||||
podcastsLoaded = true
|
||||
}
|
||||
|
||||
private fun convertItemToPodcast(item: PodcastItem): Podcast? {
|
||||
|
||||
+2
-5
@@ -66,7 +66,7 @@ fun PodcastDetailScreen(
|
||||
episodesError = null
|
||||
allLoaded = false
|
||||
try {
|
||||
podcastRepository.loadPodcastsFromDatabase()
|
||||
podcastRepository.ensurePodcastsLoaded()
|
||||
val docId = podcastRepository.subscribedFeedDocumentId(podcast.id)
|
||||
feedDocId = docId
|
||||
|
||||
@@ -129,10 +129,7 @@ fun PodcastDetailScreen(
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Back button row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
BackButton(onClick = onBack)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ fun PodcastLibraryScreen(
|
||||
// Load podcasts from repository when the screen is first displayed
|
||||
val podcasts by podcastRepository.subscribedPodcasts.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) { podcastRepository.loadPodcastsFromDatabase() }
|
||||
LaunchedEffect(Unit) { podcastRepository.ensurePodcastsLoaded() }
|
||||
|
||||
var selectedMode by remember { mutableStateOf(PodcastLibraryMode.Subscriptions) }
|
||||
|
||||
|
||||
+37
-28
@@ -1538,36 +1538,45 @@ impl SyncNode {
|
||||
|
||||
pub fn list_podcast_documents(&self) -> Result<Vec<PodcastItem>, SyncError> {
|
||||
self.with_store(|store| {
|
||||
store
|
||||
.doc_store
|
||||
.list_documents_by_type(&DocumentType::PodcastFeed)?
|
||||
.iter()
|
||||
let feed_docs = store.doc_store.list_documents_by_type(&DocumentType::PodcastFeed)?;
|
||||
|
||||
// One batched query for every feed's properties rather than a SQLite
|
||||
// connection per property read.
|
||||
let ids: Vec<DocumentID> = feed_docs.iter().map(|doc| doc.id.clone()).collect();
|
||||
let props_by_doc = store.doc_store.list_properties_for_documents(&ids)?;
|
||||
|
||||
let items = feed_docs
|
||||
.into_iter()
|
||||
.map(|doc| {
|
||||
let id = &doc.id;
|
||||
let cover =
|
||||
get_text_prop(&store.doc_store, id, &PropertyKind::PodcastFeedCoverArtUrl);
|
||||
Ok(PodcastItem {
|
||||
id: id.to_string(),
|
||||
podcast_api_id: get_text_prop(
|
||||
&store.doc_store,
|
||||
id,
|
||||
&PropertyKind::PodcastFeedRssUrl,
|
||||
),
|
||||
title: get_text_prop(&store.doc_store, id, &PropertyKind::PodcastFeedTitle),
|
||||
description: get_text_prop(
|
||||
&store.doc_store,
|
||||
id,
|
||||
&PropertyKind::PodcastFeedDescription,
|
||||
),
|
||||
author: get_text_prop(
|
||||
&store.doc_store,
|
||||
id,
|
||||
&PropertyKind::PodcastFeedAuthor,
|
||||
),
|
||||
cover_art_url: if cover.is_empty() { None } else { Some(cover) },
|
||||
})
|
||||
let mut item = PodcastItem {
|
||||
id: doc.id.to_string(),
|
||||
podcast_api_id: String::new(),
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
cover_art_url: None,
|
||||
};
|
||||
if let Some(props) = props_by_doc.get(&doc.id) {
|
||||
for p in props {
|
||||
let PropertyValue::Text(s) = &p.value else { continue };
|
||||
match p.kind {
|
||||
PropertyKind::PodcastFeedRssUrl => item.podcast_api_id = s.clone(),
|
||||
PropertyKind::PodcastFeedTitle => item.title = s.clone(),
|
||||
PropertyKind::PodcastFeedDescription => {
|
||||
item.description = s.clone()
|
||||
}
|
||||
PropertyKind::PodcastFeedAuthor => item.author = s.clone(),
|
||||
PropertyKind::PodcastFeedCoverArtUrl if !s.is_empty() => {
|
||||
item.cover_art_url = Some(s.clone())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
item
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
Ok(items)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user