perf(library): virtualize large album details
Render only the visible track slice for expanded box sets and share physical version caches without cloning every source row. cx
This commit is contained in:
4 files changed
+83
-53
No files matched your search
@@ -320,6 +320,20 @@ Item {
|
||||
readonly property int headerH: 72
|
||||
readonly property int discHeaderH: 46
|
||||
readonly property int loadingBodyH: 50
|
||||
readonly property real bodyHeight: loaded
|
||||
? visibleTracks * 50 + discHeaders * discHeaderH : 0
|
||||
// Keep the album itself as one variable-height outer delegate, but
|
||||
// give its tracks a real viewport. The former nested Repeater
|
||||
// constructed one wrapper and two Loaders for all 247 tracks in a
|
||||
// box set before the first frame could paint. Moving this small
|
||||
// ListView through the visible slice preserves the exact outer
|
||||
// geometry while Qt only instantiates the rows on screen.
|
||||
readonly property real trackViewportStart: Math.max(
|
||||
0, list.contentY - y - headerH - 300)
|
||||
readonly property real trackViewportEnd: Math.min(
|
||||
bodyHeight, list.contentY + list.height - y - headerH + 300)
|
||||
readonly property real trackViewportHeight: Math.max(
|
||||
0, trackViewportEnd - trackViewportStart)
|
||||
function ensureCurrent() {
|
||||
// Delegate creation is only a visibility hint. Let the view's
|
||||
// bounded scheduler choose foreground albums after ListView
|
||||
@@ -329,7 +343,7 @@ Item {
|
||||
width: list.width
|
||||
height: headerH + (expanded
|
||||
? (loaded
|
||||
? visibleTracks * 50 + discHeaders * discHeaderH + 12
|
||||
? bodyHeight + 12
|
||||
: loadingBodyH)
|
||||
: 0)
|
||||
|
||||
@@ -391,45 +405,39 @@ Item {
|
||||
size: 18
|
||||
}
|
||||
|
||||
Column {
|
||||
ListView {
|
||||
id: trackList
|
||||
visible: albumBlock.expanded && albumBlock.loaded
|
||||
&& albumBlock.trackViewportHeight > 0
|
||||
x: 4
|
||||
y: albumBlock.headerH
|
||||
y: albumBlock.headerH + albumBlock.trackViewportStart
|
||||
width: parent.width - 8
|
||||
Repeater {
|
||||
model: albumBlock.tracks
|
||||
delegate: Item {
|
||||
id: trackBlock
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
readonly property bool showDisc: albumBlock.startsDisc(index)
|
||||
readonly property int discNumber: Number(modelData.disc || 1)
|
||||
readonly property bool discFolded:
|
||||
albumBlock.discIsCollapsed(discNumber)
|
||||
height: (showDisc ? albumBlock.discHeaderH : 0)
|
||||
+ (discFolded ? 0 : 50)
|
||||
// The outer ListView virtualizes by ALBUM. A 150-track
|
||||
// box is one delegate, so its nested Repeater would
|
||||
// otherwise build 150 full TrackRows even though only
|
||||
// ~18 fit on screen. Keep cheap spacers for exact
|
||||
// geometry and mount heavy rows in a six-row lookahead.
|
||||
readonly property real contentTop:
|
||||
albumBlock.y + albumBlock.headerH + y
|
||||
readonly property bool inViewportBand:
|
||||
contentTop + height >= list.contentY - 300
|
||||
&& contentTop <= list.contentY + list.height + 300
|
||||
height: albumBlock.trackViewportHeight
|
||||
model: albumBlock.tracks
|
||||
contentY: albumBlock.trackViewportStart
|
||||
interactive: false
|
||||
clip: true
|
||||
reuseItems: true
|
||||
cacheBuffer: 300
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
delegate: Item {
|
||||
id: trackBlock
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
readonly property bool showDisc: albumBlock.startsDisc(index)
|
||||
readonly property int discNumber: Number(modelData.disc || 1)
|
||||
readonly property bool discFolded:
|
||||
albumBlock.discIsCollapsed(discNumber)
|
||||
height: (showDisc ? albumBlock.discHeaderH : 0)
|
||||
+ (discFolded ? 0 : 50)
|
||||
|
||||
// A divider used to be an always-instantiated Item
|
||||
// whose `visible` flag was false on ordinary tracks.
|
||||
// That still constructed its art, six controls and
|
||||
// popup tree once PER TRACK. A 100-track box therefore
|
||||
// paid for 100 dividers to paint five. Loader makes the
|
||||
// object count match the number of actual boundaries.
|
||||
Loader {
|
||||
// A divider exists only on disc boundaries and only
|
||||
// while this virtualized delegate is near the viewport.
|
||||
Loader {
|
||||
width: parent.width
|
||||
height: trackBlock.showDisc ? albumBlock.discHeaderH : 0
|
||||
active: trackBlock.showDisc && trackBlock.inViewportBand
|
||||
active: trackBlock.showDisc
|
||||
sourceComponent: Item {
|
||||
id: discHeader
|
||||
width: parent.width
|
||||
@@ -542,14 +550,13 @@ Item {
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
Loader {
|
||||
y: trackBlock.showDisc ? albumBlock.discHeaderH : 0
|
||||
width: parent.width
|
||||
height: trackBlock.discFolded ? 0 : 50
|
||||
active: !trackBlock.discFolded
|
||||
&& trackBlock.inViewportBand
|
||||
sourceComponent: LocalTrackRow {
|
||||
view: root.view
|
||||
item: trackBlock.modelData
|
||||
@@ -565,7 +572,6 @@ Item {
|
||||
QbzLocal.enqueue("track", trackBlock.modelData.id, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ pub(crate) fn genre_select_version(album_id: &str, index: i32) -> Option<GenreAl
|
||||
state(|s| {
|
||||
s.genre_detail_raw.insert(album_id.to_string(), selected);
|
||||
});
|
||||
Some(genre_detail_doc(album_id, &versions, index))
|
||||
Some(genre_detail_doc(album_id, versions.as_slice(), index))
|
||||
}
|
||||
|
||||
/// The header for ONE version — recomputed per version (the Slint recomputes
|
||||
@@ -1149,8 +1149,10 @@ mod version_tests {
|
||||
];
|
||||
state(|s| {
|
||||
s.genre_detail_raw.insert(album.to_string(), hires);
|
||||
s.genre_detail_versions
|
||||
.insert(album.to_string(), versions.clone());
|
||||
s.genre_detail_versions.insert(
|
||||
album.to_string(),
|
||||
std::sync::Arc::new(versions.clone()),
|
||||
);
|
||||
});
|
||||
|
||||
let doc = genre_select_version(album, 1).expect("second version");
|
||||
|
||||
@@ -1635,12 +1635,18 @@ impl qbz_local::QbzLocal {
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let filtered_rows = tracks.len();
|
||||
let versions = crate::local_album_actions::split_versions(tracks);
|
||||
let versions = std::sync::Arc::new(
|
||||
crate::local_album_actions::split_versions(tracks),
|
||||
);
|
||||
let selected = versions
|
||||
.first()
|
||||
.map(|(_, rows)| rows.clone())
|
||||
.unwrap_or_default();
|
||||
let doc = crate::local_album_actions::genre_detail_doc(&request_id, &versions, 0);
|
||||
let doc = crate::local_album_actions::genre_detail_doc(
|
||||
&request_id,
|
||||
versions.as_slice(),
|
||||
0,
|
||||
);
|
||||
let json = crate::local_rows::to_json(&doc);
|
||||
let project_elapsed = project_started.elapsed();
|
||||
let versions_empty = versions.is_empty();
|
||||
@@ -1726,6 +1732,7 @@ impl qbz_local::QbzLocal {
|
||||
}
|
||||
|
||||
pub fn genre_album_select_version(self: Pin<&mut Self>, album_id: QString, index: i32) {
|
||||
let started = std::time::Instant::now();
|
||||
let album_id = album_id.to_string();
|
||||
let generation = crate::local_state::state(|state| {
|
||||
let generation = state
|
||||
@@ -1754,8 +1761,11 @@ impl qbz_local::QbzLocal {
|
||||
))
|
||||
})?;
|
||||
let selected = versions.get(index)?.1.clone();
|
||||
let doc =
|
||||
crate::local_album_actions::genre_detail_doc(&request_id, &versions, index);
|
||||
let doc = crate::local_album_actions::genre_detail_doc(
|
||||
&request_id,
|
||||
versions.as_slice(),
|
||||
index,
|
||||
);
|
||||
Some((
|
||||
request_id,
|
||||
selected,
|
||||
@@ -1767,6 +1777,7 @@ impl qbz_local::QbzLocal {
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some((request_id, selected, json, filter_json)) = selection {
|
||||
let bytes = json.len();
|
||||
let current = crate::local_state::state(|state| {
|
||||
if state
|
||||
.genre_detail_version_generations
|
||||
@@ -1785,6 +1796,12 @@ impl qbz_local::QbzLocal {
|
||||
if !current {
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"[qbz-qt][perf] genre album detail phase=version id={} bytes={} elapsed={:?}",
|
||||
request_id,
|
||||
bytes,
|
||||
started.elapsed(),
|
||||
);
|
||||
ui(move |mut bridge| {
|
||||
bridge.as_mut().local_genre_album_ready(
|
||||
QString::from(request_id.as_str()),
|
||||
|
||||
@@ -125,7 +125,7 @@ pub struct LocalState {
|
||||
/// Every physical version behind the same expanded albums. The QML
|
||||
/// version picker changes `genre_detail_raw` from this cache, without a
|
||||
/// DB round-trip and without retargeting another expanded album.
|
||||
pub genre_detail_versions: HashMap<String, Vec<(String, Vec<LocalTrack>)>>,
|
||||
pub genre_detail_versions: HashMap<String, Arc<Vec<(String, Vec<LocalTrack>)>>>,
|
||||
/// Last serialized selected-version document for the same bounded set.
|
||||
/// A Genres view destroyed by navigation can republish this immediately
|
||||
/// instead of re-querying every authoritative source on Back.
|
||||
@@ -615,18 +615,23 @@ mod phase_a_tests {
|
||||
#[test]
|
||||
fn genres_details_virtualizes_the_nested_box_set_rows() {
|
||||
// The outer ListView is album-granular: a 150-track box is one visible
|
||||
// delegate. A plain nested Repeater therefore creates every TrackRow,
|
||||
// plus one hidden disc-header tree per track. Keep only cheap geometry
|
||||
// items outside the viewport and instantiate controls in a look-ahead
|
||||
// band. Menus are click-cold for the same reason.
|
||||
// delegate. A plain nested Repeater therefore creates every wrapper and
|
||||
// Loader before the first frame. Keep the album's exact variable-height
|
||||
// geometry, but move a non-interactive inner ListView through the visible
|
||||
// slice so Qt constructs only the rows in its look-ahead band. Menus are
|
||||
// click-cold for the same reason.
|
||||
let details = include_str!("../qml/views/local/LocalGenreDetails.qml");
|
||||
let column = include_str!("../qml/views/local/LocalGenreColumn.qml");
|
||||
let tab = include_str!("../qml/views/local/LocalGenresTab.qml");
|
||||
let versions = include_str!("../qml/views/local/VersionPicker.qml");
|
||||
let album_row = include_str!("../qml/views/local/LocalAlbumRow.qml");
|
||||
assert!(details.contains("readonly property bool inViewportBand:"));
|
||||
assert!(details.contains("active: trackBlock.showDisc && trackBlock.inViewportBand"));
|
||||
assert!(details.contains("&& trackBlock.inViewportBand"));
|
||||
assert!(details.contains("readonly property real trackViewportStart:"));
|
||||
assert!(details.contains("readonly property real trackViewportHeight:"));
|
||||
assert!(details.contains("id: trackList"));
|
||||
assert!(details.contains("contentY: albumBlock.trackViewportStart"));
|
||||
assert!(details.contains("interactive: false"));
|
||||
assert!(details.contains("reuseItems: true"));
|
||||
assert!(!details.contains("readonly property bool inViewportBand:"));
|
||||
assert!(!details.contains("visible: trackBlock.showDisc"));
|
||||
// A cold album is one compact loading row. `trackCount` is metadata,
|
||||
// never permission to reserve hundreds or thousands of blank pixels
|
||||
|
||||
Reference in new issue
Block a user