list-10k
1 万行を、全部描くとどれだけかかるか。
<VirtualList> はビューポートに見えている行だけを描き、残りの高さは確保だけします。だからフレーム時間は行数に依存しません。render は画面にある各行のインデックスとともに呼ばれます。react-virtualized がrowRenderer を呼ぶのと同じです。アプリが持つのはデータで、filtered をメモ化しているのは、毎フレーム 1 万個の String を作るほうが、描くより高くつくからです。
これは正直なサンプルでもあります。<VirtualList> が包んでいるのはegui::ScrollArea::show_rows で、隣の素の egui 版が呼んでいるのもまさにそれです。だから 2 つのタブは同じくらいの長さになりますし、仮想化はライブラリの機能ではありません。ここでライブラリが足しているのは、行がフックを持てることです。行はそれぞれ専用のスコープの中で描かれます。for に key={i} を付けたのと同じです。全行を描いた場合のこのリポジトリ自身の計測は、tests/scenarios.rs とdocs/tasks/list-perf/ にあります。
見ているものについて 2 つ。埋め込み版は 1,000 行から始まります。隣の素の egui 版に合わせるためです。単体のバイナリは 10 万行で開きます。行は仮想化されているので、どちらでもスライダーはそこまで届きます。それから、どの行も同じ高さでなければいけません。行が何を描こうと、リストはきっかり row_h だけ進みます。それが、自分で要求した範囲と歩調を合わせている仕組みです。
自分で動かす
cargo run -p list-10k
cargo run -p list-10k --bin list-10k-plain # 素の egui 版
trunk serve --config examples/list-10k/Trunk.tomlソースは examples/list-10k/src/lib.rsと plain.rs です。
use std::collections::BTreeSet;
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use plain::PlainState;
/// A word per row, so the filter has something to match on.
pub const WORDS: [&str; 8] = [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
];
/// How many rows the example opens with.
///
/// A hundred thousand: ten times the name, because the point is that the count
/// does not matter to the frame. It matters to the filter, which rebuilds the
/// row list on every keystroke (about 20 ms at this size). The gallery and the
/// snapshot pass a smaller number to match the plain column next to them.
pub const DEFAULT_COUNT: usize = 100_000;
/// The height of one row, and the gap under it. The plain version needs both as
/// numbers; here they are the row's natural height and a `gap` attribute.
pub const ROW_H: f32 = 18.0;
pub const ROW_GAP: f32 = 2.0;
/// The width of the index column, so the names line up.
pub const INDEX_W: f32 = 64.0;
/// Row `i`'s text. Deterministic, so both versions and every machine agree.
pub fn row_name(i: usize) -> String {
format!("row {i} {}", WORDS[i % WORDS.len()])
}
/// The rows that survive `removed` and `filter`, as `(index, text)`.
pub fn rows(count: usize, filter: &str, removed: &BTreeSet<usize>) -> Vec<(usize, String)> {
(0..count)
.filter(|i| !removed.contains(i))
.map(|i| (i, row_name(i)))
.filter(|(_, name)| filter.is_empty() || name.contains(filter))
.collect()
}
/// `initial_count` is the row count to open with — a hundred thousand by
/// default; the gallery and the tests pass something smaller.
#[component]
pub fn App(cx: &mut Cx, #[prop(default = DEFAULT_COUNT)] initial_count: usize) {
let mut count = use_state(cx, move || initial_count);
let mut filter = use_state(cx, String::new);
let mut removed = use_state(cx, BTreeSet::<usize>::new);
let frame_ms = cx.ui().input(|i| i.stable_dt) * 1000.0;
// Building ten thousand strings on every frame would be a bigger cost than
// drawing them. The deps are what the list depends on; removals only ever
// grow, so their count is enough to notice one.
let filtered = use_memo(cx, (*count, filter.as_str(), removed.len()), || {
rows(*count, filter.as_str(), &removed)
});
let shown = filtered.len();
rsx! {
<View direction="column" gap={8} p={12} grow={1.0}>
<Text size={22.0} strong>"list-10k"</Text>
<Slider bind={count.bind()} range={100..=100_000} label="rows"/>
<TextEdit w={220.0} bind={filter.bind()} hint="filter"/>
<View direction="row" gap={8} align="center">
<Text>{format!("showing {shown}")}</Text>
// Not a benchmark: one frame, as egui measured it, including
// whatever else the machine was doing.
<Text>{format!("last frame {frame_ms:.1} ms ({:.0} fps)", 1000.0 / frame_ms.max(0.001))}</Text>
</View>
// Render by index: only the rows in view are ever built, and the
// element decides which those are.
<VirtualList
grow={1.0}
rows={shown}
row_h={ROW_H + ROW_GAP}
render={|cx: &mut Cx<'_, '_>, row: usize| {
let (i, name) = &filtered[row];
rsx! { <Row index={*i} name={name.as_str()} on_remove={|| {
removed.insert(*i);
}}/> }
.show(cx);
}}
/>
</View>
}
}
/// One row, built by `<VirtualList>` for each row in view.
#[component]
pub fn Row(cx: &mut Cx, index: usize, name: &str, #[event] on_remove: ()) {
rsx! {
<View direction="row" gap={8} align="center" w="100%" h={ROW_H}>
<Text w={INDEX_W}>{format!("#{index}")}</Text>
<Text grow={1.0}>{name}</Text>
<Button label="remove" on_click={|| on_remove.emit(())}>"x"</Button>
</View>
}
}use std::collections::BTreeSet;
use crate::{DEFAULT_COUNT, INDEX_W, ROW_GAP, ROW_H, rows};
/// Everything the plain version keeps between frames, including the cache.
pub struct PlainState {
pub count: usize,
pub filter: String,
pub removed: BTreeSet<usize>,
/// The rows as last built, and what they were built from.
cache: Vec<(usize, String)>,
built_from: (usize, String, usize),
}
impl Default for PlainState {
fn default() -> Self {
Self::with_count(DEFAULT_COUNT)
}
}
impl PlainState {
pub fn with_count(count: usize) -> Self {
Self {
count,
filter: String::new(),
removed: BTreeSet::new(),
cache: Vec::new(),
built_from: (usize::MAX, String::new(), usize::MAX),
}
}
/// `use_memo`, by hand: compare the inputs, rebuild only on a change.
fn rows(&mut self) -> &[(usize, String)] {
let now = (self.count, self.filter.clone(), self.removed.len());
if self.built_from != now {
self.cache = rows(self.count, &self.filter, &self.removed);
self.built_from = now;
}
&self.cache
}
}
pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
let frame_ms = ui.input(|i| i.stable_dt) * 1000.0;
let mut remove = None;
egui::Frame::new().inner_margin(12.0).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
ui.label(egui::RichText::new("list-10k").size(22.0).strong());
ui.add(egui::Slider::new(&mut state.count, 100..=100_000).text("rows"));
ui.add(
egui::TextEdit::singleline(&mut state.filter)
.desired_width(220.0)
.hint_text("filter"),
);
let shown = state.rows().len();
ui.horizontal(|ui| {
ui.label(format!("showing {shown}"));
ui.label(format!(
"last frame {frame_ms:.1} ms ({:.0} fps)",
1000.0 / frame_ms.max(0.001)
));
});
// The gap between rows is the scroll area's own item spacing, because
// `show_rows` adds it to the row height when it works out the range.
ui.spacing_mut().item_spacing.y = ROW_GAP;
let filtered = state.rows();
egui::ScrollArea::vertical().show_rows(ui, ROW_H, filtered.len(), |ui, range| {
for (i, name) in &filtered[range] {
ui.horizontal(|ui| {
// A column of its own width, like the other version's
// `<Text w={INDEX_W}>`. `add_sized` would centre the text
// in the box, and an allocation with no minimum would
// shrink to the text, so the names would not line up.
ui.allocate_ui_with_layout(
egui::vec2(INDEX_W, ROW_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.set_min_width(INDEX_W);
ui.label(format!("#{i}"))
},
);
ui.label(name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let button = ui.button("x");
// `<Button label="remove">`, by hand.
ui.ctx()
.accesskit_node_builder(button.id, |node| node.set_label("remove"));
if button.clicked() {
remove = Some(*i);
}
});
});
}
});
});
if let Some(i) = remove {
state.removed.insert(i);
}
}