perf(podcast): cache subscription load + batch feed property reads
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:
Greg Shuflin
2026-06-03 01:20:49 -07:00
parent 43434a1a21
commit a14cb53923
4 changed files with 51 additions and 34 deletions
@@ -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? {
@@ -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)
}
@@ -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
View File
@@ -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)
})
}