Skip to content

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 だけ進みます。それが、自分で要求した範囲と歩調を合わせている仕組みです。

自分で動かす ​

sh
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 です。

rust
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>
    }
}
rust
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);
    }
}

MIT または Apache-2.0 ライセンスで公開しています。