gpui: Add ImageCache (#27774)

Closes #27414

`ImageCache` is independent of the original image loader and can
actively release its cached images to solve the problem of images loaded
from the network or files not being released.

It has two constructors:

- `ImageCache::new`: Manually manage the cache.
- `ImageCache::max_items`: Remove the least recently used items when the
cache reaches the specified number.

When creating an `img` element, you can specify the cache object with
`Img::cache`, and the image cache will be managed by `ImageCache`.

In the example `crates\gpui\examples\image-gallery.rs`, the
`ImageCache::clear` method is actively called when switching a set of
images, and the memory will no longer continuously increase.


Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Sunli 2025-04-23 04:30:21 +08:00 committed by GitHub
parent a50fbc9b5c
commit abf2b9d7d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 500 additions and 11 deletions

View file

@ -0,0 +1,134 @@
use gpui::{
App, AppContext, Application, Bounds, ClickEvent, Context, Entity, HashMapImageCache,
KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions,
actions, div, image_cache, img, prelude::*, px, rgb, size,
};
use reqwest_client::ReqwestClient;
use std::sync::Arc;
struct ImageGallery {
image_key: String,
items_count: usize,
total_count: usize,
image_cache: Entity<HashMapImageCache>,
}
impl ImageGallery {
fn on_next_image(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.image_cache
.update(cx, |image_cache, cx| image_cache.clear(window, cx));
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
self.image_key = format!("{}", t);
self.total_count += self.items_count;
cx.notify();
}
}
impl Render for ImageGallery {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let image_url: SharedString =
format!("https://picsum.photos/400/200?t={}", self.image_key).into();
image_cache(&self.image_cache).child(
div()
.id("main")
.font_family(".SystemUIFont")
.bg(rgb(0xE9E9E9))
.overflow_y_scroll()
.p_4()
.size_full()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(
div()
.w_full()
.flex()
.flex_row()
.justify_between()
.child(format!(
"Example to show images and test memory usage (Rendered: {} images).",
self.total_count
))
.child(
div()
.id("btn")
.py_1()
.px_4()
.bg(gpui::black())
.hover(|this| this.opacity(0.8))
.text_color(gpui::white())
.text_center()
.w_40()
.child("Next Photos")
.on_click(cx.listener(Self::on_next_image)),
),
)
.child(
div()
.id("image-gallery")
.flex()
.flex_row()
.flex_wrap()
.gap_x_4()
.gap_y_2()
.justify_around()
.children(
(0..self.items_count)
.map(|ix| img(format!("{}-{}", image_url, ix)).size_20()),
),
),
)
}
}
actions!(image, [Quit]);
fn main() {
env_logger::init();
Application::new().run(move |cx: &mut App| {
let http_client = ReqwestClient::user_agent("gpui example").unwrap();
cx.set_http_client(Arc::new(http_client));
cx.activate(true);
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.set_menus(vec![Menu {
name: "Image Gallery".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
let window_options = WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some(SharedString::from("Image Gallery")),
appears_transparent: false,
..Default::default()
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1100.), px(860.)),
cx,
))),
..Default::default()
};
cx.open_window(window_options, |_, cx| {
cx.new(|ctx| ImageGallery {
image_key: "".into(),
items_count: 99,
total_count: 0,
image_cache: HashMapImageCache::new(ctx),
})
})
.unwrap();
});
}

View file

@ -35,7 +35,7 @@ use crate::{
AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId,
EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke,
LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay,
PlatformKeyboardLayout, Point, PromptBuilder, PromptHandle, PromptLevel, Render,
PlatformKeyboardLayout, Point, PromptBuilder, PromptHandle, PromptLevel, Render, RenderImage,
RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet,
Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId,
WindowInvalidator, current_platform, hash, init_app_menus,
@ -1616,6 +1616,22 @@ impl App {
pub fn can_select_mixed_files_and_dirs(&self) -> bool {
self.platform.can_select_mixed_files_and_dirs()
}
/// Removes an image from the sprite atlas on all windows.
///
/// If the current window is being updated, it will be removed from `App.windows``, you can use `current_window` to specify the current window.
/// This is a no-op if the image is not in the sprite atlas.
pub fn drop_image(&mut self, image: Arc<RenderImage>, current_window: Option<&mut Window>) {
// remove the texture from all other windows
for window in self.windows.values_mut().flatten() {
_ = window.drop_image(image.clone());
}
// remove the texture from the current window
if let Some(window) = current_window {
_ = window.drop_image(image);
}
}
}
impl AppContext for App {

View file

@ -0,0 +1,286 @@
use crate::{
AnyElement, AnyEntity, App, AppContext, Asset, AssetLogger, Bounds, Element, ElementId, Entity,
GlobalElementId, ImageAssetLoader, ImageCacheError, IntoElement, LayoutId, ParentElement,
Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window, hash,
};
use futures::{FutureExt, future::Shared};
use refineable::Refineable;
use smallvec::SmallVec;
use std::{collections::HashMap, fmt, sync::Arc};
/// An image cache element, all its child img elements will use the cache specified by this element.
pub fn image_cache<I: ImageCache>(image_cache: &Entity<I>) -> ImageCacheElement {
ImageCacheElement {
image_cache: image_cache.clone().into(),
style: StyleRefinement::default(),
children: SmallVec::default(),
}
}
/// A dynamically typed image cache, which can be used to store any image cache
#[derive(Clone)]
pub struct AnyImageCache {
image_cache: AnyEntity,
load_fn: fn(
image_cache: &AnyEntity,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>>,
}
impl<I: ImageCache> From<Entity<I>> for AnyImageCache {
fn from(image_cache: Entity<I>) -> Self {
Self {
image_cache: image_cache.into_any(),
load_fn: any_image_cache::load::<I>,
}
}
}
impl AnyImageCache {
/// Load an image given a resource
/// returns the result of loading the image if it has finished loading, or None if it is still loading
pub fn load(
&self,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
(self.load_fn)(&self.image_cache, resource, window, cx)
}
}
mod any_image_cache {
use super::*;
pub(crate) fn load<I: 'static + ImageCache>(
image_cache: &AnyEntity,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
let image_cache = image_cache.clone().downcast::<I>().unwrap();
return image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx));
}
}
/// An image cache element.
pub struct ImageCacheElement {
image_cache: AnyImageCache,
style: StyleRefinement,
children: SmallVec<[AnyElement; 2]>,
}
impl ParentElement for ImageCacheElement {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Styled for ImageCacheElement {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl IntoElement for ImageCacheElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ImageCacheElement {
type RequestLayoutState = SmallVec<[LayoutId; 4]>;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
window.with_image_cache(self.image_cache.clone(), |window| {
let child_layout_ids = self
.children
.iter_mut()
.map(|child| child.request_layout(window, cx))
.collect::<SmallVec<_>>();
let mut style = Style::default();
style.refine(&self.style);
let layout_id = window.request_layout(style, child_layout_ids.iter().copied(), cx);
(layout_id, child_layout_ids)
})
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
for child in &mut self.children {
child.prepaint(window, cx);
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.with_image_cache(self.image_cache.clone(), |window| {
for child in &mut self.children {
child.paint(window, cx);
}
})
}
}
type ImageLoadingTask = Shared<Task<Result<Arc<RenderImage>, ImageCacheError>>>;
enum CacheItem {
Loading(ImageLoadingTask),
Loaded(Result<Arc<RenderImage>, ImageCacheError>),
}
impl CacheItem {
fn get(&mut self) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
match self {
CacheItem::Loading(task) => {
let res = task.now_or_never()?;
*self = CacheItem::Loaded(res.clone());
Some(res)
}
CacheItem::Loaded(res) => Some(res.clone()),
}
}
}
/// An object that can handle the caching and unloading of images.
/// Implementations of this trait should ensure that images are removed from all windows when they are no longer needed.
pub trait ImageCache: 'static {
/// Load an image given a resource
/// returns the result of loading the image if it has finished loading, or None if it is still loading
fn load(
&mut self,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>>;
}
/// An implementation of ImageCache, that uses an LRU caching strategy to unload images when the cache is full
pub struct HashMapImageCache(HashMap<u64, CacheItem>);
impl fmt::Debug for HashMapImageCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HashMapImageCache")
.field("num_images", &self.0.len())
.finish()
}
}
impl HashMapImageCache {
/// Create a new image cache.
#[inline]
pub fn new(cx: &mut App) -> Entity<Self> {
let e = cx.new(|_cx| HashMapImageCache(HashMap::new()));
cx.observe_release(&e, |image_cache, cx| {
for (_, mut item) in std::mem::replace(&mut image_cache.0, HashMap::new()) {
if let Some(Ok(image)) = item.get() {
cx.drop_image(image, None);
}
}
})
.detach();
e
}
/// Load an image from the given source.
///
/// Returns `None` if the image is loading.
pub fn load(
&mut self,
source: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
let hash = hash(source);
if let Some(item) = self.0.get_mut(&hash) {
return item.get();
}
let fut = AssetLogger::<ImageAssetLoader>::load(source.clone(), cx);
let task = cx.background_executor().spawn(fut).shared();
self.0.insert(hash, CacheItem::Loading(task.clone()));
let entity = window.current_view();
window
.spawn(cx, {
async move |cx| {
_ = task.await;
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
}
})
.detach();
None
}
/// Clear the image cache.
pub fn clear(&mut self, window: &mut Window, cx: &mut App) {
for (_, mut item) in std::mem::replace(&mut self.0, HashMap::new()) {
if let Some(Ok(image)) = item.get() {
cx.drop_image(image, Some(window));
}
}
}
/// Remove the image from the cache by the given source.
pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) {
let hash = hash(source);
if let Some(mut item) = self.0.remove(&hash) {
if let Some(Ok(image)) = item.get() {
cx.drop_image(image, Some(window));
}
}
}
/// Returns the number of images in the cache.
pub fn len(&self) -> usize {
self.0.len()
}
/// Returns true if the cache is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl ImageCache for HashMapImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
HashMapImageCache::load(self, resource, window, cx)
}
}

View file

@ -1,9 +1,9 @@
use crate::{
AbsoluteLength, AnyElement, App, Asset, AssetLogger, Bounds, DefiniteLength, Element,
ElementId, GlobalElementId, Hitbox, Image, InteractiveElement, Interactivity, IntoElement,
LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, SMOOTH_SVG_SCALE_FACTOR,
SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task, Window, px,
swap_rgba_pa_to_bgra,
AbsoluteLength, AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength,
Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InteractiveElement,
Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task,
Window, px, swap_rgba_pa_to_bgra,
};
use anyhow::{Result, anyhow};
@ -190,6 +190,7 @@ pub struct Img {
interactivity: Interactivity,
source: ImageSource,
style: ImageStyle,
image_cache: Option<AnyImageCache>,
}
/// Create a new image element.
@ -198,6 +199,7 @@ pub fn img(source: impl Into<ImageSource>) -> Img {
interactivity: Interactivity::default(),
source: source.into(),
style: ImageStyle::default(),
image_cache: None,
}
}
@ -210,6 +212,23 @@ impl Img {
"hdr", "exr", "pbm", "pam", "ppm", "pgm", "ff", "farbfeld", "qoi", "svg",
]
}
/// Sets the image cache for the current node.
///
/// If the `image_cache` is not explicitly provided, the function will determine the image cache by:
///
/// 1. Checking if any ancestor node of the current node contains an `ImageCacheElement`, If such a node exists, the image cache specified by that ancestor will be used.
/// 2. If no ancestor node contains an `ImageCacheElement`, the global image cache will be used as a fallback.
///
/// This mechanism provides a flexible way to manage image caching, allowing precise control when needed,
/// while ensuring a default behavior when no cache is explicitly specified.
#[inline]
pub fn image_cache<I: ImageCache>(self, image_cache: &Entity<I>) -> Self {
Self {
image_cache: Some(image_cache.clone().into()),
..self
}
}
}
impl Deref for Stateful<Img> {
@ -276,7 +295,13 @@ impl Element for Img {
|mut style, window, cx| {
let mut replacement_id = None;
match self.source.use_data(window, cx) {
match self.source.use_data(
self.image_cache
.clone()
.or_else(|| window.image_cache_stack.last().cloned()),
window,
cx,
) {
Some(Ok(data)) => {
if let Some(state) = &mut state {
let frame_count = data.frame_count();
@ -421,7 +446,13 @@ impl Element for Img {
window,
cx,
|style, window, cx| {
if let Some(Ok(data)) = source.use_data(window, cx) {
if let Some(Ok(data)) = source.use_data(
self.image_cache
.clone()
.or_else(|| window.image_cache_stack.last().cloned()),
window,
cx,
) {
let new_bounds = self
.style
.object_fit
@ -474,11 +505,18 @@ impl StatefulInteractiveElement for Img {}
impl ImageSource {
pub(crate) fn use_data(
&self,
cache: Option<AnyImageCache>,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
match self {
ImageSource::Resource(resource) => window.use_asset::<ImgResourceLoader>(&resource, cx),
ImageSource::Resource(resource) => {
if let Some(cache) = cache {
cache.load(resource, window, cx)
} else {
window.use_asset::<ImgResourceLoader>(resource, cx)
}
}
ImageSource::Custom(loading_fn) => loading_fn(window, cx),
ImageSource::Render(data) => Some(Ok(data.to_owned())),
ImageSource::Image(data) => window.use_asset::<AssetLogger<ImageDecoder>>(data, cx),

View file

@ -4,6 +4,7 @@ mod canvas;
mod common;
mod deferred;
mod div;
mod image_cache;
mod img;
mod list;
mod surface;
@ -17,6 +18,7 @@ pub use canvas::*;
pub use common::*;
pub use deferred::*;
pub use div::*;
pub use image_cache::*;
pub use img::*;
pub use list::*;
pub use surface::*;

View file

@ -1518,7 +1518,7 @@ impl Image {
cx: &mut App,
) -> Option<Arc<RenderImage>> {
ImageSource::Image(self)
.use_data(window, cx)
.use_data(None, window, cx)
.and_then(|result| result.ok())
}

View file

@ -1,5 +1,5 @@
use crate::{
Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, AppContext, Arena, Asset,
Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset,
AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Context,
Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, DispatchNodeId,
DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, FontId,
@ -617,6 +617,7 @@ pub struct Window {
pub(crate) element_opacity: Option<f32>,
pub(crate) content_mask_stack: Vec<ContentMask<Pixels>>,
pub(crate) requested_autoscroll: Option<Bounds<Pixels>>,
pub(crate) image_cache_stack: Vec<AnyImageCache>,
pub(crate) rendered_frame: Frame,
pub(crate) next_frame: Frame,
pub(crate) next_hitbox_id: HitboxId,
@ -933,6 +934,7 @@ impl Window {
pending_input_observers: SubscriberSet::new(),
prompt: None,
client_inset: None,
image_cache_stack: Vec::new(),
})
}
@ -2857,6 +2859,17 @@ impl Window {
result
}
/// Executes the provided function with the specified image cache.
pub(crate) fn with_image_cache<F, R>(&mut self, image_cache: AnyImageCache, f: F) -> R
where
F: FnOnce(&mut Self) -> R,
{
self.image_cache_stack.push(image_cache);
let result = f(self);
self.image_cache_stack.pop();
result
}
/// Sets an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the
/// platform to receive textual input with proper integration with concerns such
/// as IME interactions. This handler will be active for the upcoming frame until the following frame is