Skip to content

Commit 1ed7bec

Browse files
committed
feat(neon_framework): optimize blur hash handling for being performant in galleries
Signed-off-by: vauvenal5 <vauvenal5.ndgme@slmails.com>
1 parent 93f74b2 commit 1ed7bec

7 files changed

Lines changed: 154 additions & 19 deletions

File tree

cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"useGitignore": true,
55
"enableGlobDot": false,
66
"words": [
7-
"OpenAPI"
7+
"OpenAPI",
8+
"cacherine"
89
],
910
"ignorePaths": [
1011
"**.svg",

packages/neon_framework/lib/blocs.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export 'package:neon_framework/src/bloc/bloc.dart';
22
export 'package:neon_framework/src/bloc/result.dart';
33
export 'package:neon_framework/src/blocs/apps.dart';
4+
export 'package:neon_framework/src/blocs/blur.dart';
45
export 'package:neon_framework/src/blocs/capabilities.dart';
56
export 'package:neon_framework/src/blocs/references.dart';
67
export 'package:neon_framework/src/blocs/timer.dart';

packages/neon_framework/lib/neon.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:logging/logging.dart';
1111
import 'package:neon_framework/models.dart';
1212
import 'package:neon_framework/src/app.dart';
1313
import 'package:neon_framework/src/blocs/accounts.dart';
14+
import 'package:neon_framework/src/blocs/blur.dart';
1415
import 'package:neon_framework/src/blocs/first_launch.dart';
1516
import 'package:neon_framework/src/blocs/next_push.dart';
1617
import 'package:neon_framework/src/blocs/push_notifications.dart';
@@ -120,6 +121,8 @@ Future<void> runNeon({
120121
globalOptions: globalOptions,
121122
);
122123

124+
final blurBloc = BlurBloc();
125+
123126
runApp(
124127
MultiProvider(
125128
providers: [
@@ -128,6 +131,7 @@ Future<void> runNeon({
128131
NeonProvider<AccountsBloc>.value(value: accountsBloc),
129132
NeonProvider<FirstLaunchBloc>.value(value: firstLaunchBloc),
130133
NeonProvider<NextPushBloc>.value(value: nextPushBloc),
134+
NeonProvider<BlurBloc>.value(value: blurBloc),
131135
Provider<BuiltSet<AppImplementation>>(
132136
create: (_) => appImplementations,
133137
dispose: (_, appImplementations) => appImplementations.disposeAll(),
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import 'dart:async';
2+
import 'dart:ui' as ui;
3+
4+
import 'package:cacherine/cacherine.dart';
5+
import 'package:flutter/scheduler.dart';
6+
import 'package:flutter_blurhash/flutter_blurhash.dart';
7+
import 'package:logging/logging.dart';
8+
import 'package:neon_framework/blocs.dart';
9+
import 'package:queue/queue.dart';
10+
11+
/// A bloc that manages the decoding of blur hashes into images.
12+
/// It uses a [Queue] to limit the maximum number of concurrent decoding tasks,
13+
/// and a LRU-Cache to cache the results of previously decoded blur hashes so that they can be reused without re-decoding.
14+
class BlurBloc extends Bloc {
15+
final _blurHashQueue = Queue(parallel: 10);
16+
final _blurHashCash = SimpleLRUCache<String, BlurHashDecodeTask>(1000);
17+
18+
@override
19+
void dispose() {
20+
_blurHashQueue.dispose();
21+
_blurHashCash.clear();
22+
}
23+
24+
@override
25+
Logger get log => Logger('BlurBloc');
26+
27+
/// Gets the decoded image for the given [blurHash] and [size].
28+
/// If the image is already cached, it returns the cached image.
29+
/// Otherwise, it creates a new [BlurHashDecodeTask] to decode the blur hash, and returns the result of that task.
30+
/// If [cache] is `true`, the result will be cached. [cache] does not have any effect on lookup.
31+
Future<ui.Image> getBlurHash(String blurHash, ui.Size size, {bool cache = true}) {
32+
final task = BlurHashDecodeTask(blurHash: blurHash, size: size);
33+
34+
if (_blurHashCash.get(task.key) != null) {
35+
return _blurHashCash.get(task.key)!.result.future;
36+
}
37+
38+
// We are offloading the decoding process to the schedular to allow for pre-caching of the blur hashes,
39+
// and to ensure that the decoding process does not block UI refreshes.
40+
// Please note that this only works as long as the decoding process is not too heavy,
41+
// as it could potentially still block the UI if it takes longer then a few milliseconds.
42+
unawaited(SchedulerBinding.instance.scheduleTask(task.execute, Priority.animation));
43+
44+
if (cache) {
45+
_blurHashCash.set(task.key, task);
46+
}
47+
48+
return task.result.future;
49+
}
50+
}
51+
52+
/// A task to decode a blur hash into an image. The result is stored in a [Completer] so that it can be awaited by multiple callers.
53+
class BlurHashDecodeTask {
54+
/// Creates a new [BlurHashDecodeTask] with the given [blurHash] and [size].
55+
/// The result is stored in a [Completer] so that it can be awaited by multiple callers.
56+
BlurHashDecodeTask({
57+
required this.blurHash,
58+
required this.size,
59+
});
60+
61+
/// The blur hash to decode.
62+
final String blurHash;
63+
64+
/// The size of the resulting image.
65+
final ui.Size size;
66+
67+
/// The result of the decoding process, stored in a [Completer] so that it can be awaited by multiple callers.
68+
Completer<ui.Image> result = Completer();
69+
70+
/// Executes the task by decoding the blur hash into an image and completing the [result] completer with the decoded image.
71+
Future<void> execute() async {
72+
result.complete(
73+
blurHashDecodeImage(
74+
blurHash: blurHash,
75+
width: size.width.toInt(),
76+
height: size.height.toInt(),
77+
),
78+
);
79+
}
80+
81+
/// A unique key for this task, based on the blur hash and the size of the resulting image.
82+
/// This is used to ensure that multiple callers can await the same task without creating duplicate tasks.
83+
String get key => '$blurHash-${size.width}x${size.height}';
84+
}

packages/neon_framework/lib/src/widgets/image.dart

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import 'dart:async';
22
import 'dart:convert';
33
import 'dart:typed_data';
4+
import 'dart:ui' as ui;
45

56
import 'package:flutter/material.dart';
6-
import 'package:flutter_blurhash/flutter_blurhash.dart';
77
import 'package:flutter_svg/flutter_svg.dart';
88
import 'package:http/http.dart' as http;
99
import 'package:meta/meta.dart';
1010
import 'package:neon_framework/models.dart';
1111
import 'package:neon_framework/src/bloc/result.dart';
12+
import 'package:neon_framework/src/blocs/blur.dart';
1213
import 'package:neon_framework/src/utils/account_client_extension.dart';
1314
import 'package:neon_framework/src/utils/image_utils.dart';
15+
import 'package:neon_framework/src/utils/provider.dart';
1416
import 'package:neon_framework/src/utils/request_manager.dart';
1517
import 'package:neon_framework/src/widgets/error.dart';
1618
import 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
@@ -108,32 +110,65 @@ class NeonImage extends StatelessWidget {
108110
// If the data is not UTF-8
109111
}
110112

111-
return Image.memory(
112-
data,
113-
height: size?.height,
114-
width: size?.width,
115-
fit: fit ?? BoxFit.contain,
116-
gaplessPlayback: true,
117-
errorBuilder: (context, error, stacktrace) => _buildError(context, error),
118-
);
119-
}
120-
121-
if (blurHash != null) {
122-
return BlurHash(
123-
hash: blurHash!,
124-
imageFit: fit ?? BoxFit.cover,
125-
decodingHeight: size?.height.toInt() ?? 32,
126-
decodingWidth: size?.width.toInt() ?? 32,
113+
return _buildImageWithBlur(
114+
context,
115+
child: Image.memory(
116+
data,
117+
height: size?.height,
118+
width: size?.width,
119+
fit: fit ?? BoxFit.contain,
120+
gaplessPlayback: true,
121+
errorBuilder: (context, error, stacktrace) => _buildError(context, error),
122+
),
123+
isLoading: imageResult.isLoading,
127124
);
128125
}
129126

130127
if (imageResult.hasError) {
131128
return _buildError(context, imageResult.error);
132129
}
133130

131+
return _buildBlur(context, isLoading: imageResult.isLoading);
132+
},
133+
);
134+
}
135+
136+
/// Replacing the blurhash with the actual image leads to UI flickering when scrolling very fast.
137+
/// To mitigate this, we keep the blurhash underneath the actual image.
138+
Widget _buildImageWithBlur(BuildContext context, {required Widget child, bool isLoading = true}) => Stack(
139+
fit: StackFit.passthrough,
140+
children: [
141+
_buildBlur(context, isLoading: isLoading),
142+
child,
143+
],
144+
);
145+
146+
Widget _buildBlur(BuildContext context, {bool isLoading = true}) {
147+
final blurBloc = NeonProvider.of<BlurBloc>(context);
148+
return FutureBuilder<ui.Image>(
149+
// Key is important to ensure that we can move it without cost in the widget tree.
150+
key: ValueKey(blurHash),
151+
// We are not caching the blurHash result because we do not want to take care of cleanup in here.
152+
// If pre-caching is required, the encapsulating widget should take care of it and also of the cleanup.
153+
future: blurHash != null
154+
? blurBloc.getBlurHash(
155+
blurHash!,
156+
size ?? const Size.square(32),
157+
cache: false,
158+
)
159+
: null,
160+
builder: (context, snapshot) {
161+
if (snapshot.hasData) {
162+
return RawImage(
163+
image: snapshot.data,
164+
);
165+
}
166+
134167
return SizedBox(
135168
width: size?.width,
136-
child: const NeonLinearProgressIndicator(),
169+
child: NeonLinearProgressIndicator(
170+
visible: isLoading,
171+
),
137172
);
138173
},
139174
);

packages/neon_framework/pubspec.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies:
1414
bloc_concurrency: ^0.3.0
1515
built_collection: ^5.0.0
1616
built_value: ^8.9.0
17+
cacherine: ^1.1.4
1718
collection: ^1.0.0
1819
cookie_store:
1920
path: ../cookie_store
@@ -56,6 +57,7 @@ dependencies:
5657
path_provider: ^2.1.0
5758
permission_handler: ^12.0.0
5859
provider: ^6.0.0
60+
queue: ^3.0.0
5961
quick_actions: ^1.0.0
6062
rxdart: ^0.28.0
6163
scrollable_positioned_list: ^0.3.0

pubspec.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ packages:
185185
url: "https://pub.dev"
186186
source: hosted
187187
version: "8.11.1"
188+
cacherine:
189+
dependency: transitive
190+
description:
191+
name: cacherine
192+
sha256: "1921d157632330de4207f689d760e05b37b0a6b29dc7e3633e14cb038ee2388f"
193+
url: "https://pub.dev"
194+
source: hosted
195+
version: "1.1.4"
188196
camera:
189197
dependency: transitive
190198
description:

0 commit comments

Comments
 (0)