From 0a87382b30a02a8fad5a2c0a8ffb0ceeef2baef5 Mon Sep 17 00:00:00 2001 From: vicanso Date: Sat, 24 Oct 2026 18:04:47 +0800 Subject: [PATCH 1/2] macos: Let an app draw a canvas at once with drawsAsynchronously macOS draws every canvas asynchronously, which keeps the main thread free for canvases that change every frame (the Stocks demo). Each asynchronous drawing also holds GPU memory of its own while it is drawn: about 97 MB on macOS 27 ("Owned physical footprint (unmapped) (graphics)" in `footprint`), however little the canvas draws. An app with a few small charts redrawn every couple of seconds pays that on every redraw and gains no frame time. `desktop={{ drawsAsynchronously: false }}` on a `` (`.draws_asynchronously(false)` in Rust) draws it at once on the main thread instead. The default is unchanged; the other platforms ignore the prop. Measured in a menu-bar app with three 340x40 charts redrawn every 2 s (debug build, Apple M4 Pro, macOS 27): - Default: footprint 162 MB at rest, CPU 1.70%. - `drawsAsynchronously: false`: footprint 43 MB at rest (48 MB peak while charts mount), CPU 1.60%. --- crates/nsh-core/src/core/mod.rs | 7 ++++ crates/nsh-core/src/core/tests.rs | 38 +++++++++++++++++++++ crates/nsh-core/src/js/host.rs | 12 +++---- crates/nsh-core/src/ops.rs | 9 +++++ crates/nsh-core/src/props.rs | 4 +++ crates/nsh-core/src/ui/host.rs | 13 +++++++ crates/nsh-js/src/canvas.rs | 5 ++- crates/nsh-platform-macos/src/mount.rs | 1 + crates/nsh-platform-macos/src/view.rs | 29 ++++++++++++++-- docs/dev/js-modules.md | 2 +- tests/compat/cases/macos/App.tsx | 2 ++ tests/compat/cases/macos/desktop-canvas.tsx | 25 ++++++++++++++ tests/compat/cases/macos/script.txt | 6 ++++ website/elements/canvas.md | 1 + website/zh-CN/elements/canvas.md | 1 + 15 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 tests/compat/cases/macos/desktop-canvas.tsx diff --git a/crates/nsh-core/src/core/mod.rs b/crates/nsh-core/src/core/mod.rs index 1958ef3f4..ecd7e9156 100644 --- a/crates/nsh-core/src/core/mod.rs +++ b/crates/nsh-core/src/core/mod.rs @@ -3008,6 +3008,13 @@ impl Core { flags |= dirty::VIEW_PROPS; } } + Prop::DrawsAsynchronously(value) => { + let props = node.props.get_or_insert_default(); + if props.draws_asynchronously != value { + props.draws_asynchronously = value; + flags |= dirty::VIEW_PROPS; + } + } Prop::Focusable(value) => { let props = node.props.get_or_insert_default(); if props.focusable != value { diff --git a/crates/nsh-core/src/core/tests.rs b/crates/nsh-core/src/core/tests.rs index 6787642a7..3de746251 100644 --- a/crates/nsh-core/src/core/tests.rs +++ b/crates/nsh-core/src/core/tests.rs @@ -9607,6 +9607,44 @@ fn window_drag_is_a_view_prop() { )); } +/// `desktop={{ drawsAsynchronously: false }}` reaches the platform in a +/// canvas's view props, and clearing it gives the platform its default +/// again. +#[test] +fn draws_asynchronously_is_a_canvas_view_prop() { + let mut c = core(); + let draws = |value| Op::SetProp { + id: 2, + prop: Prop::DrawsAsynchronously(value), + }; + apply( + &mut c, + vec![ + Op::Create { + id: 2, + tag: Tag::Canvas, + }, + draws(Some(false)), + Op::Insert { + parent: crate::ROOT_ID, + child: 2, + before: None, + }, + ], + ); + let batch = c.commit().unwrap(); + assert!(batch.items.iter().any(|i| matches!( + i, + MountItem::ViewProps { id: 2, props } if props.draws_asynchronously == Some(false) + ))); + apply(&mut c, vec![draws(None)]); + let batch = c.commit().unwrap(); + assert!(batch.items.iter().any(|i| matches!( + i, + MountItem::ViewProps { id: 2, props } if props.draws_asynchronously.is_none() + ))); +} + /// `focusable={false}` reaches the platform in the view's props. #[test] fn focusable_is_a_view_prop() { diff --git a/crates/nsh-core/src/js/host.rs b/crates/nsh-core/src/js/host.rs index f8e9f75ef..589191066 100644 --- a/crates/nsh-core/src/js/host.rs +++ b/crates/nsh-core/src/js/host.rs @@ -115,13 +115,8 @@ pub fn element_bits(tag: crate::Tag) -> u32 { Tag::WebView => on::WEBVIEW, Tag::Video => on::VIDEO, Tag::InputAccessory => on::INPUT_ACCESSORY, - Tag::View - | Tag::Cell - | Tag::Stack - | Tag::HeaderItem - | Tag::Canvas - | Tag::Native - | Tag::Overlay => 0, + Tag::Canvas => on::CANVAS, + Tag::View | Tag::Cell | Tag::Stack | Tag::HeaderItem | Tag::Native | Tag::Overlay => 0, } } @@ -163,6 +158,8 @@ pub mod on { pub const INPUT_ACCESSORY: u32 = 1 << 11; /// ``. pub const OVERLAY: u32 = 1 << 12; + /// ``. + pub const CANVAS: u32 = 1 << 13; /// Set by the runtime itself, not part of the elements' types. pub const INTERNAL: u32 = 1 << 31; } @@ -883,6 +880,7 @@ const GROUPS: &[(u32, &str, &str)] = &[ (on::VIDEO, "Video", "videos"), (on::INPUT_ACCESSORY, "InputAccessory", "input accessories"), (on::OVERLAY, "Overlay", "``"), + (on::CANVAS, "Canvas", "``"), ]; /// The declarations of the host elements' props: the named types they use diff --git a/crates/nsh-core/src/ops.rs b/crates/nsh-core/src/ops.rs index d8c1d9537..58a116855 100644 --- a/crates/nsh-core/src/ops.rs +++ b/crates/nsh-core/src/ops.rs @@ -2112,6 +2112,15 @@ pub enum Prop { /// a draggable view stay clickable without it. #[prop(desktop = "windowDrag")] WindowDrag(Option), + /// `drawsAsynchronously` (macOS canvases): Core Animation draws the + /// canvas on its own threads (the default), or at once on the main + /// thread (`false`). Drawing asynchronously keeps the main thread free + /// for a canvas that changes every frame; each such drawing also holds + /// GPU memory of its own while it is drawn (about 97 MB on macOS 27, + /// however little it draws), which a small canvas that changes now and + /// then draws at once without. Ignored elsewhere. + #[prop(desktop = "drawsAsynchronously", on = CANVAS)] + DrawsAsynchronously(Option), /// `tooltip`: a hint about the view, shown above it on the desktops /// while the pointer rests on it (after half a second) until the /// pointer leaves or presses it, never on touch screens; it never takes diff --git a/crates/nsh-core/src/props.rs b/crates/nsh-core/src/props.rs index e7051888c..f5822c734 100644 --- a/crates/nsh-core/src/props.rs +++ b/crates/nsh-core/src/props.rs @@ -265,6 +265,10 @@ pub struct ViewProps { /// `windowDrag` (desktops): pressing the view drags the window; `None` /// leaves it to the views around it. pub window_drag: Option, + /// `drawsAsynchronously` (macOS canvases): `Some(false)` draws the + /// canvas at once on the main thread instead of on Core Animation's + /// threads; `None` is the platform's way. + pub draws_asynchronously: Option, /// `focusable`: `Some(false)` keeps a view that takes presses out of /// the keyboard focus's stops (Tab); `None` is the default, a stop. pub focusable: Option, diff --git a/crates/nsh-core/src/ui/host.rs b/crates/nsh-core/src/ui/host.rs index f3e0be150..d3a936f59 100644 --- a/crates/nsh-core/src/ui/host.rs +++ b/crates/nsh-core/src/ui/host.rs @@ -339,6 +339,19 @@ pub fn canvas(painter: impl IntoReactive) -> CanvasView { crate::static_reactive!(Painter: Painter, Drawing); +impl CanvasView { + /// Whether Core Animation draws it on its own threads (macOS, the + /// default) or at once on the main thread (`false`): at once, a small + /// canvas that changes now and then holds no GPU memory for its + /// drawings ([`Prop::DrawsAsynchronously`]). Other platforms ignore + /// it. + pub fn draws_asynchronously(self, enabled: impl IntoReactive) -> Self { + self.bind_prop(enabled, |enabled: bool| { + Prop::DrawsAsynchronously(Some(enabled)) + }) + } +} + /// A web view loading `url` (a bound one loads again when it changes). pub fn web_view(url: impl IntoReactive) -> WebView { WebView(ElNode::new(Tag::WebView)).bind_prop(url, |url: String| { diff --git a/crates/nsh-js/src/canvas.rs b/crates/nsh-js/src/canvas.rs index f856ca878..a313d4f97 100644 --- a/crates/nsh-js/src/canvas.rs +++ b/crates/nsh-js/src/canvas.rs @@ -1544,7 +1544,10 @@ export interface CanvasSize { height: number; } -export interface CanvasProps extends ViewProps { +export interface CanvasProps extends Omit { + /** Desktop-only props: `drawsAsynchronously` (macOS) draws the canvas + * on Core Animation's threads (the default) or, `false`, at once. */ + desktop?: DesktopProps & DesktopCanvasHostProps; /** * Draws the canvas. Runs again when signals it reads change and when the * canvas resizes (it first runs once the size is known); animated values diff --git a/crates/nsh-platform-macos/src/mount.rs b/crates/nsh-platform-macos/src/mount.rs index a8030ced8..750b9cc5c 100644 --- a/crates/nsh-platform-macos/src/mount.rs +++ b/crates/nsh-platform-macos/src/mount.rs @@ -334,6 +334,7 @@ impl Mounter { MountItem::ViewProps { id, props } => { let Some(view) = self.get(*id) else { return }; view.state_mut().props = props.clone(); + view.props_changed(); crate::input::props_changed(surface, view); crate::access::props_changed(view); } diff --git a/crates/nsh-platform-macos/src/view.rs b/crates/nsh-platform-macos/src/view.rs index 2d6feee71..c3708efa8 100644 --- a/crates/nsh-platform-macos/src/view.rs +++ b/crates/nsh-platform-macos/src/view.rs @@ -1181,6 +1181,28 @@ impl NshView { } } + /// Whether its content is drawn on Core Animation's threads: a + /// canvas's, unless the app drew it at once + /// (`desktop={{ drawsAsynchronously: false }}`). Every asynchronous + /// drawing holds GPU memory of its own while it is drawn (about 97 MB + /// on macOS 27 for a 340×40 canvas, however little it draws), which a + /// small canvas that changes now and then has no use for. + fn draws_asynchronously(&self) -> bool { + let state = self.state(); + state.canvas.is_some() && state.props.draws_asynchronously != Some(false) + } + + /// Its props changed: a content layer already made follows + /// `drawsAsynchronously` (a new one reads it when it is made). + pub(crate) fn props_changed(&self) { + if let Some((layer, _)) = &*self.ivars().content.borrow() { + let asynchronous = self.draws_asynchronously(); + if layer.drawsAsynchronously() != asynchronous { + layer.setDrawsAsynchronously(asynchronous); + } + } + } + /// The layer its content is drawn in (made on first use). fn content_layer(&self) -> Option> { if let Some((layer, _)) = &*self.ivars().content.borrow() { @@ -1199,9 +1221,10 @@ impl NshView { layer.setNeedsDisplayOnBoundsChange(true); // Canvases are drawn on Core Animation's threads (paths and // gradients are costly; measured on the Stocks demo, main-thread - // time per frame went from 3.5 to 1.8 ms); text is cheap to draw - // and drawn at once, which was faster. - layer.setDrawsAsynchronously(self.state().canvas.is_some()); + // time per frame went from 3.5 to 1.8 ms) unless the app draws one + // at once (`drawsAsynchronously`); text is cheap to draw and drawn + // at once, which was faster. + layer.setDrawsAsynchronously(self.draws_asynchronously()); layer.setContentsScale(self.backing_scale()); // Above the decoration behind it (fills, gradients, inset // shadows), below the children and the borders in front. diff --git a/docs/dev/js-modules.md b/docs/dev/js-modules.md index 29b847c66..4b25b7e12 100644 --- a/docs/dev/js-modules.md +++ b/docs/dev/js-modules.md @@ -75,7 +75,7 @@ the chunks of a split bundle (`Screen.js`: a name that is no native module, with `accessibilityLiveRegion`, Dialog `statusBarTranslucent`. iOS: `backdropMaterial` (the system material of a backdrop blur), input `inputAccessoryViewID` / `textContentType` / `keyboardAppearance`, Dialog `supportedOrientations`, WebView `allowsBackForwardNavigationGestures`. Desktop: - `windowDrag`, WebView `allowsBackForwardNavigationGestures` (macOS' trackpad swipes, Windows' touchscreen swipes; a + `windowDrag`, Canvas `drawsAsynchronously` (macOS), WebView `allowsBackForwardNavigationGestures` (macOS' trackpad swipes, Windows' touchscreen swipes; a prop of several platforms names each: `#[prop(ios = "x", desktop = "x")]`). What two platforms do is a top-level prop, ignored where it does not apply (`presentation`, `headerLargeTitle`, `headerBackTitle`, AlertDialog's `closeOnEscape` / `closeOnBackdropPress`; `api-naming.md`). - A module's exports are made when a bundle first imports it; what must always happen (the root's direction, the diff --git a/tests/compat/cases/macos/App.tsx b/tests/compat/cases/macos/App.tsx index f678d1c48..4fbde854e 100644 --- a/tests/compat/cases/macos/App.tsx +++ b/tests/compat/cases/macos/App.tsx @@ -4,12 +4,14 @@ // the steps after it are that case's. import { type JSX, View, mount, signal } from "nsh"; import BaseDock from "./base-dock"; +import DesktopCanvas from "./desktop-canvas"; import DesktopProps from "./desktop-props"; import ModStandardSystem from "./mod-standard-system"; import ModStandardFiles from "./mod-standard-files"; const cases: Record JSX.Element> = { "base-dock": BaseDock, + "desktop-canvas": DesktopCanvas, "desktop-props": DesktopProps, "mod-standard-system": ModStandardSystem, "mod-standard-files": ModStandardFiles, diff --git a/tests/compat/cases/macos/desktop-canvas.tsx b/tests/compat/cases/macos/desktop-canvas.tsx new file mode 100644 index 000000000..08642624e --- /dev/null +++ b/tests/compat/cases/macos/desktop-canvas.tsx @@ -0,0 +1,25 @@ +// A case of the API's test suite (docs/dev/compatibility.md): a canvas's +// `desktop={{ drawsAsynchronously }}`, run as on macOS (`platform macos`), +// beside one that leaves Core Animation its default. +import { Canvas, Text, View } from "nsh"; + +function App() { + return ( + + Two canvases + cx.fillRect(0, 0, size.width / 2, size.height)} + /> + cx.fillRect(0, 0, size.width, size.height / 2)} + /> + + ); +} + +export default App; diff --git a/tests/compat/cases/macos/script.txt b/tests/compat/cases/macos/script.txt index 16978d00f..dbc500f7b 100644 --- a/tests/compat/cases/macos/script.txt +++ b/tests/compat/cases/macos/script.txt @@ -7,6 +7,12 @@ prop id:bar desktop.windowDrag true prop id:body windowDrag false covers [props] desktop covers [props] desktop.windowDrag +# desktop-canvas +tap id:case-desktop-canvas +expect Two canvases +prop id:at-once desktop.drawsAsynchronously false +noprop id:default drawsAsynchronously +covers [props] desktop.drawsAsynchronously # mod-standard-system tap id:case-mod-standard-system expect console.identity=true diff --git a/website/elements/canvas.md b/website/elements/canvas.md index 1e7d900dc..cbba30aea 100644 --- a/website/elements/canvas.md +++ b/website/elements/canvas.md @@ -78,6 +78,7 @@ plus: | Prop | Rust | Type | Description | |---|---|---|---| | `draw` | `canvas(painter)` | `(cx: CanvasRenderingContext2D, size: { width: number; height: number }) => void` | Draws the canvas. Runs once the size is known, again when the canvas resizes, and again when signals it reads change. | +| `desktop={{ drawsAsynchronously }}` | `.draws_asynchronously(enabled)` | `boolean` | macOS draws a canvas on Core Animation's threads (`true`, the default), which keeps the main thread free for a canvas that changes every frame. `false` draws it at once on the main thread, without GPU memory for each drawing: the lighter choice for a small canvas that changes now and then, such as a chart updated every few seconds. The other platforms draw every canvas their own way. | Give the canvas a size through its styles (width / height, `flex`, an aspect ratio): its size is the one its styles give it. diff --git a/website/zh-CN/elements/canvas.md b/website/zh-CN/elements/canvas.md index 6c68180e0..d8c33dfb0 100644 --- a/website/zh-CN/elements/canvas.md +++ b/website/zh-CN/elements/canvas.md @@ -71,6 +71,7 @@ mount(() => ); | 属性 | Rust | 类型 | 说明 | |---|---|---|---| | `draw` | `canvas(painter)` | `(cx: CanvasRenderingContext2D, size: { width: number; height: number }) => void` | 绘制画布。尺寸确定后执行一次,画布尺寸变化时、读取的 signal 变化时再次执行。 | +| `desktop={{ drawsAsynchronously }}` | `.draws_asynchronously(enabled)` | `boolean` | macOS 在 Core Animation 的线程上绘制画布(`true`,默认),让每帧都在变的画布不占用主线程。设为 `false` 则在主线程上立即绘制,每次绘制不额外占用 GPU 内存:更适合偶尔才变化的小画布,比如每隔几秒更新一次的图表。其他平台按各自的方式绘制所有画布。 | 请通过样式给画布一个尺寸(宽 / 高、`flex`、宽高比):它的尺寸由样式决定。 From d5c2ea7ec4a2116cb10766471c5ae747a3bc7f3c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 24 Oct 2026 21:12:58 +0800 Subject: [PATCH 2/2] macos: Draw a canvas at once unless it paints often Every canvas drew asynchronously, which holds graphics memory for the process (~50-60 MB of "Owned physical footprint (unmapped) (graphics)") and Metal work per layer: worth it for a canvas that changes every frame, wasted on one redrawn every few seconds, such as a menu-bar panel's charts. The previous commit let the app choose with a desktop={{ drawsAsynchronously }} prop; the platform can tell on its own, so the prop goes again and no API is added. A canvas now draws at once on the main thread and moves to Core Animation's threads once it paints 10 times in a second; a slower second, or a pause, brings it back. A canvas that keeps its static part as a layer draws at once however often it paints, as before. Three 340x40 charts redrawn every 2 s: footprint ~83 -> ~21 MB, CPU time over 40 s ~0.27 -> ~0.19 s. Gallery tour (perf.py): frame time and CPU unchanged on every page; peak memory of the pages with canvases drops (Canvas scrolling ~383 -> ~310 MB, Animations ~290 -> ~232). Co-Authored-By: Claude Opus 5.5 --- crates/nsh-core/src/core/mod.rs | 7 -- crates/nsh-core/src/core/tests.rs | 38 --------- crates/nsh-core/src/js/host.rs | 12 +-- crates/nsh-core/src/ops.rs | 9 -- crates/nsh-core/src/props.rs | 4 - crates/nsh-core/src/ui/host.rs | 13 --- crates/nsh-js/src/canvas.rs | 5 +- crates/nsh-platform-macos/src/canvas.rs | 94 +++++++++++++++++++++ crates/nsh-platform-macos/src/mount.rs | 1 - crates/nsh-platform-macos/src/view.rs | 40 ++------- docs/dev/canvas.md | 5 +- docs/dev/js-modules.md | 2 +- docs/dev/macos.md | 17 ++-- docs/dev/performance.md | 5 ++ tests/compat/cases/macos/App.tsx | 2 - tests/compat/cases/macos/desktop-canvas.tsx | 25 ------ tests/compat/cases/macos/script.txt | 6 -- website/docs/performance-report.md | 8 +- website/elements/canvas.md | 1 - website/zh-CN/docs/performance-report.md | 4 +- website/zh-CN/elements/canvas.md | 1 - 21 files changed, 138 insertions(+), 161 deletions(-) delete mode 100644 tests/compat/cases/macos/desktop-canvas.tsx diff --git a/crates/nsh-core/src/core/mod.rs b/crates/nsh-core/src/core/mod.rs index ecd7e9156..1958ef3f4 100644 --- a/crates/nsh-core/src/core/mod.rs +++ b/crates/nsh-core/src/core/mod.rs @@ -3008,13 +3008,6 @@ impl Core { flags |= dirty::VIEW_PROPS; } } - Prop::DrawsAsynchronously(value) => { - let props = node.props.get_or_insert_default(); - if props.draws_asynchronously != value { - props.draws_asynchronously = value; - flags |= dirty::VIEW_PROPS; - } - } Prop::Focusable(value) => { let props = node.props.get_or_insert_default(); if props.focusable != value { diff --git a/crates/nsh-core/src/core/tests.rs b/crates/nsh-core/src/core/tests.rs index 3de746251..6787642a7 100644 --- a/crates/nsh-core/src/core/tests.rs +++ b/crates/nsh-core/src/core/tests.rs @@ -9607,44 +9607,6 @@ fn window_drag_is_a_view_prop() { )); } -/// `desktop={{ drawsAsynchronously: false }}` reaches the platform in a -/// canvas's view props, and clearing it gives the platform its default -/// again. -#[test] -fn draws_asynchronously_is_a_canvas_view_prop() { - let mut c = core(); - let draws = |value| Op::SetProp { - id: 2, - prop: Prop::DrawsAsynchronously(value), - }; - apply( - &mut c, - vec![ - Op::Create { - id: 2, - tag: Tag::Canvas, - }, - draws(Some(false)), - Op::Insert { - parent: crate::ROOT_ID, - child: 2, - before: None, - }, - ], - ); - let batch = c.commit().unwrap(); - assert!(batch.items.iter().any(|i| matches!( - i, - MountItem::ViewProps { id: 2, props } if props.draws_asynchronously == Some(false) - ))); - apply(&mut c, vec![draws(None)]); - let batch = c.commit().unwrap(); - assert!(batch.items.iter().any(|i| matches!( - i, - MountItem::ViewProps { id: 2, props } if props.draws_asynchronously.is_none() - ))); -} - /// `focusable={false}` reaches the platform in the view's props. #[test] fn focusable_is_a_view_prop() { diff --git a/crates/nsh-core/src/js/host.rs b/crates/nsh-core/src/js/host.rs index 589191066..f8e9f75ef 100644 --- a/crates/nsh-core/src/js/host.rs +++ b/crates/nsh-core/src/js/host.rs @@ -115,8 +115,13 @@ pub fn element_bits(tag: crate::Tag) -> u32 { Tag::WebView => on::WEBVIEW, Tag::Video => on::VIDEO, Tag::InputAccessory => on::INPUT_ACCESSORY, - Tag::Canvas => on::CANVAS, - Tag::View | Tag::Cell | Tag::Stack | Tag::HeaderItem | Tag::Native | Tag::Overlay => 0, + Tag::View + | Tag::Cell + | Tag::Stack + | Tag::HeaderItem + | Tag::Canvas + | Tag::Native + | Tag::Overlay => 0, } } @@ -158,8 +163,6 @@ pub mod on { pub const INPUT_ACCESSORY: u32 = 1 << 11; /// ``. pub const OVERLAY: u32 = 1 << 12; - /// ``. - pub const CANVAS: u32 = 1 << 13; /// Set by the runtime itself, not part of the elements' types. pub const INTERNAL: u32 = 1 << 31; } @@ -880,7 +883,6 @@ const GROUPS: &[(u32, &str, &str)] = &[ (on::VIDEO, "Video", "videos"), (on::INPUT_ACCESSORY, "InputAccessory", "input accessories"), (on::OVERLAY, "Overlay", "``"), - (on::CANVAS, "Canvas", "``"), ]; /// The declarations of the host elements' props: the named types they use diff --git a/crates/nsh-core/src/ops.rs b/crates/nsh-core/src/ops.rs index 58a116855..d8c1d9537 100644 --- a/crates/nsh-core/src/ops.rs +++ b/crates/nsh-core/src/ops.rs @@ -2112,15 +2112,6 @@ pub enum Prop { /// a draggable view stay clickable without it. #[prop(desktop = "windowDrag")] WindowDrag(Option), - /// `drawsAsynchronously` (macOS canvases): Core Animation draws the - /// canvas on its own threads (the default), or at once on the main - /// thread (`false`). Drawing asynchronously keeps the main thread free - /// for a canvas that changes every frame; each such drawing also holds - /// GPU memory of its own while it is drawn (about 97 MB on macOS 27, - /// however little it draws), which a small canvas that changes now and - /// then draws at once without. Ignored elsewhere. - #[prop(desktop = "drawsAsynchronously", on = CANVAS)] - DrawsAsynchronously(Option), /// `tooltip`: a hint about the view, shown above it on the desktops /// while the pointer rests on it (after half a second) until the /// pointer leaves or presses it, never on touch screens; it never takes diff --git a/crates/nsh-core/src/props.rs b/crates/nsh-core/src/props.rs index f5822c734..e7051888c 100644 --- a/crates/nsh-core/src/props.rs +++ b/crates/nsh-core/src/props.rs @@ -265,10 +265,6 @@ pub struct ViewProps { /// `windowDrag` (desktops): pressing the view drags the window; `None` /// leaves it to the views around it. pub window_drag: Option, - /// `drawsAsynchronously` (macOS canvases): `Some(false)` draws the - /// canvas at once on the main thread instead of on Core Animation's - /// threads; `None` is the platform's way. - pub draws_asynchronously: Option, /// `focusable`: `Some(false)` keeps a view that takes presses out of /// the keyboard focus's stops (Tab); `None` is the default, a stop. pub focusable: Option, diff --git a/crates/nsh-core/src/ui/host.rs b/crates/nsh-core/src/ui/host.rs index d3a936f59..f3e0be150 100644 --- a/crates/nsh-core/src/ui/host.rs +++ b/crates/nsh-core/src/ui/host.rs @@ -339,19 +339,6 @@ pub fn canvas(painter: impl IntoReactive) -> CanvasView { crate::static_reactive!(Painter: Painter, Drawing); -impl CanvasView { - /// Whether Core Animation draws it on its own threads (macOS, the - /// default) or at once on the main thread (`false`): at once, a small - /// canvas that changes now and then holds no GPU memory for its - /// drawings ([`Prop::DrawsAsynchronously`]). Other platforms ignore - /// it. - pub fn draws_asynchronously(self, enabled: impl IntoReactive) -> Self { - self.bind_prop(enabled, |enabled: bool| { - Prop::DrawsAsynchronously(Some(enabled)) - }) - } -} - /// A web view loading `url` (a bound one loads again when it changes). pub fn web_view(url: impl IntoReactive) -> WebView { WebView(ElNode::new(Tag::WebView)).bind_prop(url, |url: String| { diff --git a/crates/nsh-js/src/canvas.rs b/crates/nsh-js/src/canvas.rs index a313d4f97..f856ca878 100644 --- a/crates/nsh-js/src/canvas.rs +++ b/crates/nsh-js/src/canvas.rs @@ -1544,10 +1544,7 @@ export interface CanvasSize { height: number; } -export interface CanvasProps extends Omit { - /** Desktop-only props: `drawsAsynchronously` (macOS) draws the canvas - * on Core Animation's threads (the default) or, `false`, at once. */ - desktop?: DesktopProps & DesktopCanvasHostProps; +export interface CanvasProps extends ViewProps { /** * Draws the canvas. Runs again when signals it reads change and when the * canvas resizes (it first runs once the size is known); animated values diff --git a/crates/nsh-platform-macos/src/canvas.rs b/crates/nsh-platform-macos/src/canvas.rs index f0986bf95..d6d9f8b9e 100644 --- a/crates/nsh-platform-macos/src/canvas.rs +++ b/crates/nsh-platform-macos/src/canvas.rs @@ -20,6 +20,15 @@ //! does, and rasterizing one runs on the main thread, where a full replay //! is only recorded for Core Animation's drawing threads. //! +//! Where a canvas draws ([`CanvasState::draws_asynchronously`]): at once on +//! the main thread, unless it paints often ([`FREQUENT`] times a second or +//! more: an animation, live data) and keeps no layer, when Core Animation +//! replays it on its threads, which keeps the main thread free. Drawing +//! there costs graphics memory (~50 MB for the process on macOS 27, held +//! while any canvas draws so) and Metal work per layer, which a canvas +//! redrawn now and then (a chart updated every few seconds) gains nothing +//! from. +//! //! Images are loaded through [`crate::image::load`] when a drawing names //! them, kept per canvas and drawn once loaded, at their natural size in //! pixels as points (as a canvas bitmap's CSS pixels). @@ -29,6 +38,7 @@ use std::{ f64::consts::TAU, rc::Rc, sync::Arc, + time::{Duration, Instant}, }; use nsh_core::{ @@ -81,6 +91,48 @@ pub(crate) struct CanvasState { prefix: RefCell>, /// How often the drawing was painted since it was set. redrawn: Cell, + /// How often it paints ([`CanvasState::draws_asynchronously`]). + cadence: Cadence, +} + +/// How many paints a second make a canvas draw on Core Animation's threads. +const FREQUENT: u32 = 10; + +/// How often a canvas paints, counted a second at a time. +#[derive(Default)] +struct Cadence { + /// When the current second began, and its paints so far. + since: Cell>, + paints: Cell, + /// [`FREQUENT`] paints in the current second, or in the last one. + frequent: Cell, +} + +impl Cadence { + /// A paint at `now`. + fn paint(&self, now: Instant) { + match self.since.get() { + Some(since) if now.duration_since(since) < Duration::from_secs(1) => { + let paints = self.paints.get() + 1; + self.paints.set(paints); + if paints >= FREQUENT { + self.frequent.set(true); + } + } + since => { + // A new second: the last one was slow, or a second without + // paints came between. + if since.is_some_and(|since| { + self.paints.get() < FREQUENT + || now.duration_since(since) >= Duration::from_secs(2) + }) { + self.frequent.set(false); + } + self.since.set(Some(now)); + self.paints.set(1); + } + } + } } /// How often a drawing is painted unchanged before its static part is kept @@ -235,9 +287,16 @@ impl CanvasState { 0 < self.split && self.redrawn.get() >= STABLE } + /// Whether Core Animation draws it on its threads + /// (`drawsAsynchronously`): when it paints often and keeps no layer. + pub(crate) fn draws_asynchronously(&self) -> bool { + self.cadence.frequent.get() && !self.keeps_prefix() + } + /// [`draw`]'s replay; `asynchronous`: `cx` records for Core Animation's /// drawing threads. fn paint(&self, cx: &CGContext, dirty: CGRect, size: CGSize, scale: f64, asynchronous: bool) { + self.cadence.paint(Instant::now()); let canvas = self; let Some(drawing) = canvas.drawing.as_ref() else { return; @@ -1355,6 +1414,41 @@ mod tests { canvas } + #[test] + fn a_canvas_painting_often_draws_asynchronously() { + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + // Redrawn every 2 s: at once. + let rare = Cadence::default(); + for n in 0..5 { + rare.paint(at(n * 2000)); + } + assert!(!rare.frequent.get()); + // An animation at 120 Hz: on Core Animation's threads from its + // tenth frame. + let animated = Cadence::default(); + for n in 0..9 { + animated.paint(at(n * 8)); + } + assert!(!animated.frequent.get()); + animated.paint(at(72)); + assert!(animated.frequent.get()); + for n in 10..240 { + animated.paint(at(n * 8)); + } + assert!(animated.frequent.get()); + // It stops: the next paint, a second on, draws at once again. + animated.paint(at(240 * 8 + 3000)); + assert!(!animated.frequent.get()); + // A canvas keeping its static part draws at once however often. + let mut canvas = sparkline_canvas(5.0); + canvas.cadence.frequent.set(true); + canvas.split = MIN_PREFIX; + assert!(!canvas.draws_asynchronously()); + canvas.redrawn.set(0); + assert!(canvas.draws_asynchronously()); + } + #[test] fn the_static_part_is_drawn_from_its_layer() { // Not before the drawing has been painted unchanged a while. diff --git a/crates/nsh-platform-macos/src/mount.rs b/crates/nsh-platform-macos/src/mount.rs index 750b9cc5c..a8030ced8 100644 --- a/crates/nsh-platform-macos/src/mount.rs +++ b/crates/nsh-platform-macos/src/mount.rs @@ -334,7 +334,6 @@ impl Mounter { MountItem::ViewProps { id, props } => { let Some(view) = self.get(*id) else { return }; view.state_mut().props = props.clone(); - view.props_changed(); crate::input::props_changed(surface, view); crate::access::props_changed(view); } diff --git a/crates/nsh-platform-macos/src/view.rs b/crates/nsh-platform-macos/src/view.rs index c3708efa8..7633627bc 100644 --- a/crates/nsh-platform-macos/src/view.rs +++ b/crates/nsh-platform-macos/src/view.rs @@ -375,10 +375,10 @@ define_class!( crate::canvas::draw(cx, &state, dirty, size, scale, asynchronous); } CGContext::restore_g_state(c); - // A canvas that keeps its static part redraws little: drawn at - // once, without the drawing threads' cost per layer. + // Where the next paint happens (`canvas.rs`): Core Animation's + // threads for a canvas painting often, at once for the rest. if let Some(canvas) = &state.canvas - && canvas.keeps_prefix() == asynchronous + && canvas.draws_asynchronously() != asynchronous { layer.setDrawsAsynchronously(!asynchronous); } @@ -1181,28 +1181,6 @@ impl NshView { } } - /// Whether its content is drawn on Core Animation's threads: a - /// canvas's, unless the app drew it at once - /// (`desktop={{ drawsAsynchronously: false }}`). Every asynchronous - /// drawing holds GPU memory of its own while it is drawn (about 97 MB - /// on macOS 27 for a 340×40 canvas, however little it draws), which a - /// small canvas that changes now and then has no use for. - fn draws_asynchronously(&self) -> bool { - let state = self.state(); - state.canvas.is_some() && state.props.draws_asynchronously != Some(false) - } - - /// Its props changed: a content layer already made follows - /// `drawsAsynchronously` (a new one reads it when it is made). - pub(crate) fn props_changed(&self) { - if let Some((layer, _)) = &*self.ivars().content.borrow() { - let asynchronous = self.draws_asynchronously(); - if layer.drawsAsynchronously() != asynchronous { - layer.setDrawsAsynchronously(asynchronous); - } - } - } - /// The layer its content is drawn in (made on first use). fn content_layer(&self) -> Option> { if let Some((layer, _)) = &*self.ivars().content.borrow() { @@ -1219,12 +1197,12 @@ impl NshView { layer.setDelegate(Some(objc2::runtime::ProtocolObject::from_ref(&*drawer))); layer.setAnchorPoint(CGPoint::new(0.0, 0.0)); layer.setNeedsDisplayOnBoundsChange(true); - // Canvases are drawn on Core Animation's threads (paths and - // gradients are costly; measured on the Stocks demo, main-thread - // time per frame went from 3.5 to 1.8 ms) unless the app draws one - // at once (`drawsAsynchronously`); text is cheap to draw and drawn - // at once, which was faster. - layer.setDrawsAsynchronously(self.draws_asynchronously()); + // Drawn at once: text is cheap to draw, which measured faster than + // asynchronously, and a canvas moves to Core Animation's threads + // once it paints often (`canvas.rs`; paths and gradients are + // costly: on the Stocks demo main-thread time per frame went from + // 3.5 to 1.8 ms). + layer.setDrawsAsynchronously(false); layer.setContentsScale(self.backing_scale()); // Above the decoration behind it (fills, gradients, inset // shadows), below the children and the borders in front. diff --git a/docs/dev/canvas.md b/docs/dev/canvas.md index 25ec305b4..c8cdefe67 100644 --- a/docs/dev/canvas.md +++ b/docs/dev/canvas.md @@ -58,8 +58,9 @@ that operation on runs again (the operations before it still set the state; thei animated part begins a path of its own). macOS keeps that part too, as a `CGLayer` (`canvas.rs` `Prefix`), when it is long (128 operations or more: drawing a layer costs about what replaying a sparkline does) and the drawing has been painted unchanged 30 times (a drawing that keeps changing would rasterize it on the main thread more often than it -draws from it); such a canvas then draws at once instead of asynchronously, where its layer is made, and goes back to -Core Animation's threads when its drawing changes. On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not +draws from it); such a canvas draws at once, where its layer is made. Other canvases draw at once too unless they +paint 10 times a second or more (`canvas.rs` `Cadence`), when Core Animation replays them on its threads +(`docs/dev/macos.md`). On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not drawn until it comes into sight: its animations ask for no frames. Android, the web runtime and the GPUI backend (which rasterizes the drawing, text outlines included, with tiny-skia into a pixmap in device pixels: `nsh-platform-gpui` `canvas.rs`) still redraw the whole canvas. Numbers: diff --git a/docs/dev/js-modules.md b/docs/dev/js-modules.md index 4b25b7e12..29b847c66 100644 --- a/docs/dev/js-modules.md +++ b/docs/dev/js-modules.md @@ -75,7 +75,7 @@ the chunks of a split bundle (`Screen.js`: a name that is no native module, with `accessibilityLiveRegion`, Dialog `statusBarTranslucent`. iOS: `backdropMaterial` (the system material of a backdrop blur), input `inputAccessoryViewID` / `textContentType` / `keyboardAppearance`, Dialog `supportedOrientations`, WebView `allowsBackForwardNavigationGestures`. Desktop: - `windowDrag`, Canvas `drawsAsynchronously` (macOS), WebView `allowsBackForwardNavigationGestures` (macOS' trackpad swipes, Windows' touchscreen swipes; a + `windowDrag`, WebView `allowsBackForwardNavigationGestures` (macOS' trackpad swipes, Windows' touchscreen swipes; a prop of several platforms names each: `#[prop(ios = "x", desktop = "x")]`). What two platforms do is a top-level prop, ignored where it does not apply (`presentation`, `headerLargeTitle`, `headerBackTitle`, AlertDialog's `closeOnEscape` / `closeOnBackdropPress`; `api-naming.md`). - A module's exports are made when a bundle first imports it; what must always happen (the root's direction, the diff --git a/docs/dev/macos.md b/docs/dev/macos.md index 328090c7a..5c828459e 100644 --- a/docs/dev/macos.md +++ b/docs/dev/macos.md @@ -57,12 +57,17 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m every frame of a scroll (~165 `viewWillDraw` and backing-store checks per frame in the gallery's Lists demo, ~70% of the frame's main-thread time). Plain sublayers are not visible-rect managed. A content layer is drawn again when its content changes and when its size does (`needsDisplayOnBoundsChange`), not when the view moves (transitions - redrew every moving text on every frame before). Canvases draw asynchronously (`drawsAsynchronously`: Core - Animation replays the drawing on its threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU - ~60% → ~30%); text draws at once, which measured faster than asynchronously. A canvas whose long static part is - kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged 30 times) draws at once instead: each frame draws the - layer and replays the animated rest, and Core Animation's drawing threads, their Metal work and their IOSurface - backing stores cost more than that (the gallery's Canvas page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB). + redrew every moving text on every frame before). Text draws at once, which measured faster than asynchronously. + A canvas draws at once too until it paints 10 times a second or more (an animation, live data: `canvas.rs` + `Cadence`), when it draws asynchronously (`drawsAsynchronously`: Core Animation replays the drawing on its + threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU ~60% → ~30%), and at once again after + a slow second. Drawing asynchronously costs graphics memory (`footprint`'s "Owned physical footprint (unmapped) + (graphics)", ~50–60 MB for the process while any canvas draws so) and Metal work per layer, which a canvas redrawn + every few seconds gains nothing from: three 340×40 charts redrawn every 2 s hold ~21 MB at once against ~83 MB + asynchronously. A canvas whose long static part is kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged + 30 times) draws at once however often it paints: each frame draws the layer and replays the animated rest, which + costs less than the drawing threads, their Metal work and their IOSurface backing stores (the gallery's Canvas + page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB). Contents stay in the screen's format: 8-bit contents (`kCAContentsFormatRGBA8Uint`) measured slower on the ProMotion display, which converts them on every frame. - **No masks on what scrolls, where avoidable** (`visual.rs`): the window server pays for a mask on every frame its diff --git a/docs/dev/performance.md b/docs/dev/performance.md index e69069b54..d271fe6a7 100644 --- a/docs/dev/performance.md +++ b/docs/dev/performance.md @@ -245,6 +245,11 @@ draw a panel. every canvas at once (Stocks p95 ~3.3 → ~7.5 ms, 2% of frames over a refresh: the main thread fills the screen's RGBA16F backing stores), 8-bit contents (canvas phase ~0.45 → ~1.6 ms). + Canvases drawn at once unless they paint often (`canvas.rs` `Cadence`: 10 paints a second): three 340×40 line + charts redrawn every 2 s (a menu-bar panel's) hold ~83 MB of footprint asynchronously and ~21 MB at once, with + ~30% less CPU time over 40 s. The gallery's tour is unchanged in frame time and CPU; peak memory on the pages with + canvases drops (Canvas scrolling ~383 → ~310 MB, Animations ~290 → ~232, Gestures ~279 → ~221). + List binds while scrolling (release host, 120 Hz MacBook Pro; the list scrolled by chained native `scrollToOffset({ animated: true })` every 300 ms, no input; each display link refresh logged with its link timestamp and main-thread time; three runs each, refreshes missed = link gaps of two periods or more). Before = diff --git a/tests/compat/cases/macos/App.tsx b/tests/compat/cases/macos/App.tsx index 4fbde854e..f678d1c48 100644 --- a/tests/compat/cases/macos/App.tsx +++ b/tests/compat/cases/macos/App.tsx @@ -4,14 +4,12 @@ // the steps after it are that case's. import { type JSX, View, mount, signal } from "nsh"; import BaseDock from "./base-dock"; -import DesktopCanvas from "./desktop-canvas"; import DesktopProps from "./desktop-props"; import ModStandardSystem from "./mod-standard-system"; import ModStandardFiles from "./mod-standard-files"; const cases: Record JSX.Element> = { "base-dock": BaseDock, - "desktop-canvas": DesktopCanvas, "desktop-props": DesktopProps, "mod-standard-system": ModStandardSystem, "mod-standard-files": ModStandardFiles, diff --git a/tests/compat/cases/macos/desktop-canvas.tsx b/tests/compat/cases/macos/desktop-canvas.tsx deleted file mode 100644 index 08642624e..000000000 --- a/tests/compat/cases/macos/desktop-canvas.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// A case of the API's test suite (docs/dev/compatibility.md): a canvas's -// `desktop={{ drawsAsynchronously }}`, run as on macOS (`platform macos`), -// beside one that leaves Core Animation its default. -import { Canvas, Text, View } from "nsh"; - -function App() { - return ( - - Two canvases - cx.fillRect(0, 0, size.width / 2, size.height)} - /> - cx.fillRect(0, 0, size.width, size.height / 2)} - /> - - ); -} - -export default App; diff --git a/tests/compat/cases/macos/script.txt b/tests/compat/cases/macos/script.txt index dbc500f7b..16978d00f 100644 --- a/tests/compat/cases/macos/script.txt +++ b/tests/compat/cases/macos/script.txt @@ -7,12 +7,6 @@ prop id:bar desktop.windowDrag true prop id:body windowDrag false covers [props] desktop covers [props] desktop.windowDrag -# desktop-canvas -tap id:case-desktop-canvas -expect Two canvases -prop id:at-once desktop.drawsAsynchronously false -noprop id:default drawsAsynchronously -covers [props] desktop.drawsAsynchronously # mod-standard-system tap id:case-mod-standard-system expect console.identity=true diff --git a/website/docs/performance-report.md b/website/docs/performance-report.md index 7fe8e5549..b7487e324 100644 --- a/website/docs/performance-report.md +++ b/website/docs/performance-report.md @@ -213,7 +213,7 @@ Monitor shows); *resident* adds the pages of the system's shared libraries: Through the gallery's tour the process holds 84–230 MB up to the Canvas page and ~350–360 MB from the Text View page on, with what it caches on purpose (decoded images, bounded at 128 MB; the Text View page's documents) and the -frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~380 MB with all three +frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~310 MB with all three canvases on screen, go when the page closes. ### App size @@ -238,8 +238,10 @@ What makes these numbers: - **Frames only for changes**: the display link ticks while a native animation runs and stops with it; an animation stops with its page (its view's deletion ends its keyframes, a loop stops in its component's `cleanup`). -- **Canvases on Core Animation's threads**: canvases draw asynchronously (`drawsAsynchronously`), and an animated value - redraws only the area the operations reading it cover. +- **Canvases drawn where they cost least**: a canvas that paints often (an animation, live data) draws on Core + Animation's threads (`drawsAsynchronously`), which keeps the main thread free; one redrawn now and then draws at + once, without the graphics memory asynchronous drawing holds. An animated value redraws only the area the + operations reading it cover. - **Static canvas parts kept**: once a drawing stays unchanged, a long part before its first animated operation is kept as a layer; each frame draws that layer and replays only the animated rest, at once on the main thread (the Canvas page's gauge, clock and dot matrix: no GPU work and half the memory). diff --git a/website/elements/canvas.md b/website/elements/canvas.md index cbba30aea..1e7d900dc 100644 --- a/website/elements/canvas.md +++ b/website/elements/canvas.md @@ -78,7 +78,6 @@ plus: | Prop | Rust | Type | Description | |---|---|---|---| | `draw` | `canvas(painter)` | `(cx: CanvasRenderingContext2D, size: { width: number; height: number }) => void` | Draws the canvas. Runs once the size is known, again when the canvas resizes, and again when signals it reads change. | -| `desktop={{ drawsAsynchronously }}` | `.draws_asynchronously(enabled)` | `boolean` | macOS draws a canvas on Core Animation's threads (`true`, the default), which keeps the main thread free for a canvas that changes every frame. `false` draws it at once on the main thread, without GPU memory for each drawing: the lighter choice for a small canvas that changes now and then, such as a chart updated every few seconds. The other platforms draw every canvas their own way. | Give the canvas a size through its styles (width / height, `flex`, an aspect ratio): its size is the one its styles give it. diff --git a/website/zh-CN/docs/performance-report.md b/website/zh-CN/docs/performance-report.md index 2a5750d62..ac7a022e1 100644 --- a/website/zh-CN/docs/performance-report.md +++ b/website/zh-CN/docs/performance-report.md @@ -199,7 +199,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 × 浏览 gallery 的过程中,进程在 Canvas 页之前占用 84–230 MB,从 Text View 页起约 350–360 MB,增长的是它有意保留的缓存(解码后的图片, 上限 128 MB;Text View 页的文档)以及首次使用时加载的框架(网页视图用的 WebKit);Canvas 页的栅格在三个 canvas 都可见时最多约 -380 MB,离开页面即释放。 +310 MB,离开页面即释放。 ### 应用体积 @@ -221,7 +221,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 × 这些数字来自: - **只为变化绘制帧**:display link 在原生动画运行时跳动,并随动画一起停止;动画随页面一起停止(视图删除时它的关键帧动画结束,循环在组件的 `cleanup` 中停止)。 -- **Canvas 在 Core Animation 的线程上绘制**:canvas 异步绘制(`drawsAsynchronously`),动画值变化时只重绘读取它的操作覆盖的区域。 +- **Canvas 在开销最小的地方绘制**:频繁重绘的 canvas(动画、实时数据)在 Core Animation 的线程上异步绘制(`drawsAsynchronously`),不占用主线程;偶尔才重绘的 canvas 直接在主线程上绘制,省下异步绘制占用的显存。动画值变化时只重绘读取它的操作覆盖的区域。 - **保留 canvas 的静态部分**:绘制内容稳定后,第一个动画操作之前较长的部分保留为一个图层;每帧绘制这个图层,只重放动画的其余部分,并直接在主线程上完成(Canvas 页的仪表盘、时钟和点阵:不占 GPU,内存减半)。 - **内容变化时才绘制**:文本和 canvas 绘制在各自的 layer 里,内容或尺寸变化时才重绘,移动或滚动时不重绘。 diff --git a/website/zh-CN/elements/canvas.md b/website/zh-CN/elements/canvas.md index d8c33dfb0..6c68180e0 100644 --- a/website/zh-CN/elements/canvas.md +++ b/website/zh-CN/elements/canvas.md @@ -71,7 +71,6 @@ mount(() => ); | 属性 | Rust | 类型 | 说明 | |---|---|---|---| | `draw` | `canvas(painter)` | `(cx: CanvasRenderingContext2D, size: { width: number; height: number }) => void` | 绘制画布。尺寸确定后执行一次,画布尺寸变化时、读取的 signal 变化时再次执行。 | -| `desktop={{ drawsAsynchronously }}` | `.draws_asynchronously(enabled)` | `boolean` | macOS 在 Core Animation 的线程上绘制画布(`true`,默认),让每帧都在变的画布不占用主线程。设为 `false` 则在主线程上立即绘制,每次绘制不额外占用 GPU 内存:更适合偶尔才变化的小画布,比如每隔几秒更新一次的图表。其他平台按各自的方式绘制所有画布。 | 请通过样式给画布一个尺寸(宽 / 高、`flex`、宽高比):它的尺寸由样式决定。