Skip to content

Commit 2147d2e

Browse files
authored
refactor(ui): Optimize image thumbnail loading and caching (#2444)
1 parent 3b5e410 commit 2147d2e

File tree

10 files changed

+519
-151
lines changed

10 files changed

+519
-151
lines changed

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
🐞 Fixed
1515

1616
- Fixed mistakenly passing the hyperlink text to the `onLinkTap` callback instead of the actual `href`.
17+
- Fixed high memory usage when displaying multiple image
18+
attachments. [[#2228]](https://github.com/GetStream/stream-chat-flutter/issues/2228)
1719

1820
## 9.19.0
1921

packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class StreamImageAttachment extends StatelessWidget {
1212
required this.image,
1313
this.shape,
1414
this.constraints = const BoxConstraints(),
15-
this.imageThumbnailSize = const Size(400, 400),
15+
this.imageThumbnailSize,
1616
this.imageThumbnailResizeType = 'clip',
1717
this.imageThumbnailCropType = 'center',
1818
});
@@ -32,7 +32,7 @@ class StreamImageAttachment extends StatelessWidget {
3232
final BoxConstraints constraints;
3333

3434
/// Size of the attachment image thumbnail.
35-
final Size imageThumbnailSize;
35+
final Size? imageThumbnailSize;
3636

3737
/// Resize type of the image attachment thumbnail.
3838
///

packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart

Lines changed: 6 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import 'package:cached_network_image/cached_network_image.dart';
21
import 'package:flutter/material.dart';
3-
import 'package:shimmer/shimmer.dart';
42
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
53
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
6-
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
74
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
85

96
/// {@template giphyAttachmentThumbnail}
@@ -58,46 +55,16 @@ class StreamGiphyAttachmentThumbnail extends StatelessWidget {
5855

5956
@override
6057
Widget build(BuildContext context) {
61-
// If the giphy info is not available, use the image attachment thumbnail
62-
// instead.
58+
// Get the giphy info based on the selected type.
6359
final info = giphy.giphyInfo(type);
64-
if (info == null) {
65-
return StreamImageAttachmentThumbnail(
66-
image: giphy,
67-
width: width,
68-
height: height,
69-
fit: fit,
70-
);
71-
}
72-
73-
return CachedNetworkImage(
74-
imageUrl: info.url,
60+
// Build the image attachment thumbnail using the giphy info url if
61+
// available or fallback to the original giphy url.
62+
return StreamImageAttachmentThumbnail(
63+
image: giphy.copyWith(imageUrl: info?.url),
7564
width: width,
7665
height: height,
7766
fit: fit,
78-
placeholder: (context, __) {
79-
final image = Image.asset(
80-
'lib/assets/images/placeholder.png',
81-
width: width,
82-
height: height,
83-
fit: BoxFit.cover,
84-
package: 'stream_chat_flutter',
85-
);
86-
87-
final colorTheme = StreamChatTheme.of(context).colorTheme;
88-
return Shimmer.fromColors(
89-
baseColor: colorTheme.disabled,
90-
highlightColor: colorTheme.inputBg,
91-
child: image,
92-
);
93-
},
94-
errorWidget: (context, url, error) {
95-
return errorBuilder(
96-
context,
97-
error,
98-
StackTrace.current,
99-
);
100-
},
67+
errorBuilder: errorBuilder,
10168
);
10269
}
10370
}

packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
44
import 'package:flutter/material.dart';
55
import 'package:shimmer/shimmer.dart';
66
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
7+
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart';
78
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
89
import 'package:stream_chat_flutter/src/utils/utils.dart';
910
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
@@ -72,43 +73,64 @@ class StreamImageAttachmentThumbnail extends StatelessWidget {
7273

7374
@override
7475
Widget build(BuildContext context) {
75-
final file = image.file;
76-
if (file != null) {
77-
return _LocalImageAttachment(
78-
file: file,
79-
width: width,
80-
height: height,
81-
fit: fit,
82-
errorBuilder: errorBuilder,
83-
);
84-
}
76+
return LayoutBuilder(
77+
builder: (context, constraints) {
78+
// Calculate optimal thumbnail size once for all paths
79+
final effectiveThumbnailSize = switch (thumbnailSize) {
80+
final thumbnailSize? => thumbnailSize,
81+
_ => ThumbnailSizeCalculator.calculate(
82+
targetSize: constraints.biggest,
83+
originalSize: image.originalSize,
84+
pixelRatio: MediaQuery.devicePixelRatioOf(context),
85+
),
86+
};
87+
88+
final cacheWidth = effectiveThumbnailSize?.width.round();
89+
final cacheHeight = effectiveThumbnailSize?.height.round();
90+
91+
// If the remote image URL is available, we can directly show it using
92+
// the _RemoteImageAttachment widget.
93+
if (image.thumbUrl ?? image.imageUrl case final imageUrl?) {
94+
var resizedImageUrl = imageUrl;
95+
if (effectiveThumbnailSize case final thumbnailSize?) {
96+
resizedImageUrl = imageUrl.getResizedImageUrl(
97+
crop: thumbnailCropType,
98+
resize: thumbnailResizeType,
99+
width: thumbnailSize.width,
100+
height: thumbnailSize.height,
101+
);
102+
}
103+
104+
return _RemoteImageAttachment(
105+
url: resizedImageUrl,
106+
width: width,
107+
height: height,
108+
fit: fit,
109+
cacheWidth: cacheWidth,
110+
cacheHeight: cacheHeight,
111+
errorBuilder: errorBuilder,
112+
);
113+
}
114+
115+
// Otherwise, we try to show the local image file.
116+
if (image.file case final file?) {
117+
return _LocalImageAttachment(
118+
file: file,
119+
width: width,
120+
height: height,
121+
fit: fit,
122+
cacheWidth: cacheWidth,
123+
cacheHeight: cacheHeight,
124+
errorBuilder: errorBuilder,
125+
);
126+
}
85127

86-
var imageUrl = image.thumbUrl ?? image.imageUrl ?? image.assetUrl;
87-
if (imageUrl != null) {
88-
final thumbnailSize = this.thumbnailSize;
89-
if (thumbnailSize != null) {
90-
imageUrl = imageUrl.getResizedImageUrl(
91-
width: thumbnailSize.width,
92-
height: thumbnailSize.height,
93-
resize: thumbnailResizeType,
94-
crop: thumbnailCropType,
128+
return errorBuilder(
129+
context,
130+
'Image attachment is not valid',
131+
StackTrace.current,
95132
);
96-
}
97-
98-
return _RemoteImageAttachment(
99-
url: imageUrl,
100-
width: width,
101-
height: height,
102-
fit: fit,
103-
errorBuilder: errorBuilder,
104-
);
105-
}
106-
107-
// Return error widget if no image is found.
108-
return errorBuilder(
109-
context,
110-
'Image attachment is not valid',
111-
StackTrace.current,
133+
},
112134
);
113135
}
114136
}
@@ -119,12 +141,16 @@ class _LocalImageAttachment extends StatelessWidget {
119141
required this.errorBuilder,
120142
this.width,
121143
this.height,
144+
this.cacheWidth,
145+
this.cacheHeight,
122146
this.fit,
123147
});
124148

125149
final AttachmentFile file;
126150
final double? width;
127151
final double? height;
152+
final int? cacheWidth;
153+
final int? cacheHeight;
128154
final BoxFit? fit;
129155
final ThumbnailErrorBuilder errorBuilder;
130156

@@ -136,6 +162,8 @@ class _LocalImageAttachment extends StatelessWidget {
136162
bytes,
137163
width: width,
138164
height: height,
165+
cacheWidth: cacheWidth,
166+
cacheHeight: cacheHeight,
139167
fit: fit,
140168
errorBuilder: errorBuilder,
141169
);
@@ -147,6 +175,8 @@ class _LocalImageAttachment extends StatelessWidget {
147175
File(path),
148176
width: width,
149177
height: height,
178+
cacheWidth: cacheWidth,
179+
cacheHeight: cacheHeight,
150180
fit: fit,
151181
errorBuilder: errorBuilder,
152182
);
@@ -167,12 +197,16 @@ class _RemoteImageAttachment extends StatelessWidget {
167197
required this.errorBuilder,
168198
this.width,
169199
this.height,
200+
this.cacheWidth,
201+
this.cacheHeight,
170202
this.fit,
171203
});
172204

173205
final String url;
174206
final double? width;
175207
final double? height;
208+
final int? cacheWidth;
209+
final int? cacheHeight;
176210
final BoxFit? fit;
177211
final ThumbnailErrorBuilder errorBuilder;
178212

@@ -182,6 +216,8 @@ class _RemoteImageAttachment extends StatelessWidget {
182216
imageUrl: url,
183217
width: width,
184218
height: height,
219+
memCacheWidth: cacheWidth,
220+
memCacheHeight: cacheHeight,
185221
fit: fit,
186222
placeholder: (context, __) {
187223
final image = Image.asset(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'dart:ui';
2+
3+
/// Utility class for calculating optimal thumbnail sizes for image
4+
/// attachments.
5+
///
6+
/// This calculator ensures that images are decoded and cached at
7+
/// appropriate sizes based on display constraints, maintaining aspect
8+
/// ratio while accounting for device pixel density.
9+
class ThumbnailSizeCalculator {
10+
ThumbnailSizeCalculator._();
11+
12+
/// Calculates the optimal thumbnail size for an image attachment.
13+
///
14+
/// Returns `null` if:
15+
/// - Both [targetSize] dimensions are infinite
16+
/// - [originalSize] is not available (needed for aspect ratio)
17+
///
18+
/// The calculation:
19+
/// 1. Handles infinite constraints by calculating from the finite
20+
/// dimension
21+
/// 2. Maintains aspect ratio to prevent image distortion
22+
/// 3. Applies [pixelRatio] for device-appropriate resolution
23+
///
24+
/// Example:
25+
/// ```dart
26+
/// final size = ThumbnailSizeCalculator.calculate(
27+
/// originalSize: Size(1920, 1080),
28+
/// targetSize: Size(400, 300),
29+
/// pixelRatio: 2.0,
30+
/// );
31+
/// // Returns: Size(800, 450) - maintains 16:9 aspect ratio,
32+
/// // scaled for 2x display
33+
/// ```
34+
static Size? calculate({
35+
Size? originalSize,
36+
required Size targetSize,
37+
required double pixelRatio,
38+
}) {
39+
final originalAspectRatio = originalSize?.aspectRatio;
40+
// If original aspect ratio is not available, skip optimization
41+
// We need the aspect ratio to avoid incorrect cropping
42+
if (originalAspectRatio == null) return null;
43+
44+
// Invalid aspect ratio indicates invalid original size
45+
if (originalAspectRatio.isInfinite || originalAspectRatio <= 0) {
46+
return null;
47+
}
48+
49+
var thumbnailWidth = targetSize.width;
50+
var thumbnailHeight = targetSize.height;
51+
52+
// Cannot calculate optimal size with infinite constraints
53+
if (thumbnailWidth.isInfinite && thumbnailHeight.isInfinite) {
54+
return null;
55+
}
56+
57+
if (thumbnailWidth.isInfinite) {
58+
// Width is infinite, calculate from height
59+
thumbnailWidth = thumbnailHeight * originalAspectRatio;
60+
}
61+
if (thumbnailHeight.isInfinite) {
62+
// Height is infinite, calculate from width
63+
thumbnailHeight = thumbnailWidth / originalAspectRatio;
64+
}
65+
66+
// Calculate size that maintains aspect ratio within constraints
67+
final targetAspectRatio = thumbnailWidth / thumbnailHeight;
68+
if (originalAspectRatio > targetAspectRatio) {
69+
// Image is wider than container - fit to width
70+
thumbnailHeight = thumbnailWidth / originalAspectRatio;
71+
} else {
72+
// Image is taller than container - fit to height
73+
thumbnailWidth = thumbnailHeight * originalAspectRatio;
74+
}
75+
76+
// Apply pixel ratio to get physical pixel dimensions
77+
return Size(thumbnailWidth * pixelRatio, thumbnailHeight * pixelRatio);
78+
}
79+
}

packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import 'package:cached_network_image/cached_network_image.dart';
21
import 'package:flutter/material.dart';
32
import 'package:shimmer/shimmer.dart';
3+
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
44
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
55
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
66
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
@@ -54,36 +54,16 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget {
5454

5555
@override
5656
Widget build(BuildContext context) {
57-
final thumbUrl = video.thumbUrl;
58-
if (thumbUrl != null) {
59-
return CachedNetworkImage(
60-
imageUrl: thumbUrl,
57+
final containsThumbnail = video.thumbUrl != null;
58+
// If thumbnail is available, we can directly show it using the
59+
// StreamImageAttachmentThumbnail widget.
60+
if (containsThumbnail) {
61+
return StreamImageAttachmentThumbnail(
62+
image: video,
6163
width: width,
6264
height: height,
6365
fit: fit,
64-
placeholder: (context, __) {
65-
final image = Image.asset(
66-
'lib/assets/images/placeholder.png',
67-
width: width,
68-
height: height,
69-
fit: BoxFit.cover,
70-
package: 'stream_chat_flutter',
71-
);
72-
73-
final colorTheme = StreamChatTheme.of(context).colorTheme;
74-
return Shimmer.fromColors(
75-
baseColor: colorTheme.disabled,
76-
highlightColor: colorTheme.inputBg,
77-
child: image,
78-
);
79-
},
80-
errorWidget: (context, url, error) {
81-
return errorBuilder(
82-
context,
83-
error,
84-
StackTrace.current,
85-
);
86-
},
66+
errorBuilder: errorBuilder,
8767
);
8868
}
8969

0 commit comments

Comments
 (0)