Skip to content

Commit eb4166d

Browse files
Merge pull request #3185 from vauvenal5/upstream/blur-hash-handling
feat(neon_framework): optimize blur hash handling for being performan…
2 parents 7eae95f + 3df42f5 commit eb4166d

9 files changed

Lines changed: 211 additions & 21 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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import 'dart:async';
2+
import 'dart:ui' as ui;
3+
4+
import 'package:cacherine/cacherine.dart';
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/scheduler.dart';
7+
import 'package:flutter_blurhash/flutter_blurhash.dart';
8+
import 'package:logging/logging.dart';
9+
import 'package:neon_framework/blocs.dart';
10+
11+
/// A bloc that manages the decoding of blur hashes into images.
12+
/// It uses a [compute] to offload the decoding process to a separate isolate, as it can be CPU intensive.
13+
/// Results are cached in a [SimpleLRUCache] so that they can be reused without re-decoding with a limit of 1000 entries.
14+
class BlurBloc extends Bloc {
15+
final _blurHashCash = SimpleLRUCache<String, BlurHashDecodeTask>(1000);
16+
17+
@override
18+
void dispose() {
19+
_blurHashCash.clear();
20+
}
21+
22+
@override
23+
Logger get log => Logger('BlurBloc');
24+
25+
/// Gets the decoded image for the given [blurHash] and [size].
26+
/// If the image is already cached, it returns the cached [BlurHashDecodeTask].
27+
/// If the image is not cached, it creates a new [BlurHashDecodeTask] to decode the blur hash.
28+
/// The result of the decoding process is stored in a [ValueNotifier] within the [BlurHashDecodeTask].
29+
BlurHashDecodeTask getBlurHash(String blurHash, ui.Size size) {
30+
final key = '$blurHash-${size.width}x${size.height}';
31+
32+
final cachedTask = _blurHashCash.get(key);
33+
if (cachedTask != null) {
34+
return cachedTask;
35+
}
36+
37+
final task = BlurHashDecodeTask(blurHash: blurHash, size: size);
38+
unawaited(task.execute());
39+
_blurHashCash.set(key, task);
40+
return task;
41+
}
42+
}
43+
44+
/// A task to decode a blur hash into an image. The result is stored in a [ValueNotifier].
45+
class BlurHashDecodeTask {
46+
/// Creates a new [BlurHashDecodeTask] with the given [blurHash] and [size].
47+
/// The result is stored in a [ValueNotifier] so that cashed blurs can be displayed without any flickering.
48+
BlurHashDecodeTask({
49+
required String blurHash,
50+
required ui.Size size,
51+
}) : _blurHashMeta = _BlurHashComputeTask(
52+
blurHash: blurHash,
53+
width: size.width.toInt(),
54+
height: size.height.toInt(),
55+
);
56+
57+
/// The blur hash to decode.
58+
final _BlurHashComputeTask _blurHashMeta;
59+
60+
/// The result of the decoding process, stored in a [ValueNotifier] so that cashed blurs can be displayed without any flickering.
61+
ValueNotifier<ui.Image?> blur = ValueNotifier<ui.Image?>(null);
62+
63+
/// Executes the task by computing the blur in an isolate and then decoding the resulting pixels into an image.
64+
Future<void> execute() async {
65+
final pixels = await _computePixels();
66+
67+
// We are using the scheduler to decode the image just to be on the safe side.
68+
await SchedulerBinding.instance.scheduleTask(
69+
() => ui.decodeImageFromPixels(
70+
pixels,
71+
_blurHashMeta.width,
72+
_blurHashMeta.height,
73+
ui.PixelFormat.rgba8888,
74+
(image) => blur.value = image,
75+
),
76+
Priority.animation,
77+
);
78+
}
79+
80+
/// Computes the pixels for the blur image either by using [SchedulerBinding] for web or [compute] for other platforms
81+
/// to offload the decoding process to a separate isolate,
82+
/// as it can be CPU intensive and we don't want to block the UI thread.
83+
Future<Uint8List> _computePixels() async {
84+
if (kIsWeb) {
85+
return SchedulerBinding.instance.scheduleTask(
86+
() => optimizedBlurHashDecode(
87+
blurHash: _blurHashMeta.blurHash,
88+
width: _blurHashMeta.width,
89+
height: _blurHashMeta.height,
90+
),
91+
Priority.animation,
92+
);
93+
}
94+
95+
return compute(
96+
(blurHashMeta) => optimizedBlurHashDecode(
97+
blurHash: blurHashMeta.blurHash,
98+
width: blurHashMeta.width,
99+
height: blurHashMeta.height,
100+
),
101+
_blurHashMeta,
102+
);
103+
}
104+
}
105+
106+
/// Helper object to carry the necessary data into the compute isolate.
107+
class _BlurHashComputeTask {
108+
_BlurHashComputeTask({
109+
required this.blurHash,
110+
required this.width,
111+
required this.height,
112+
});
113+
114+
// The blur hash to decode.
115+
final String blurHash;
116+
117+
// The width of the resulting image.
118+
final int width;
119+
120+
// The height of the resulting image.
121+
final int height;
122+
}

packages/neon_framework/lib/src/testing/mocks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,5 @@ class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements Ur
119119
class MockReferencesBloc extends Mock implements ReferencesBloc {}
120120

121121
class MockNavigatorObserver extends Mock implements NavigatorObserver {}
122+
123+
class MockBlurBloc extends Mock implements BlurBloc {}

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

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import 'dart:convert';
33
import 'dart:typed_data';
44

55
import 'package:flutter/material.dart';
6-
import 'package:flutter_blurhash/flutter_blurhash.dart';
76
import 'package:flutter_svg/flutter_svg.dart';
87
import 'package:http/http.dart' as http;
98
import 'package:meta/meta.dart';
109
import 'package:neon_framework/models.dart';
1110
import 'package:neon_framework/src/bloc/result.dart';
11+
import 'package:neon_framework/src/blocs/blur.dart';
1212
import 'package:neon_framework/src/utils/account_client_extension.dart';
1313
import 'package:neon_framework/src/utils/image_utils.dart';
14+
import 'package:neon_framework/src/utils/provider.dart';
1415
import 'package:neon_framework/src/utils/request_manager.dart';
1516
import 'package:neon_framework/src/widgets/error.dart';
1617
import 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
@@ -108,37 +109,75 @@ class NeonImage extends StatelessWidget {
108109
// If the data is not UTF-8
109110
}
110111

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,
112+
return _buildImageWithBlur(
113+
context,
114+
child: Image.memory(
115+
data,
116+
height: size?.height,
117+
width: size?.width,
118+
fit: fit ?? BoxFit.contain,
119+
gaplessPlayback: true,
120+
errorBuilder: (context, error, stacktrace) => _buildError(context, error),
121+
),
122+
isLoading: imageResult.isLoading,
127123
);
128124
}
129125

130126
if (imageResult.hasError) {
131127
return _buildError(context, imageResult.error);
132128
}
133129

134-
return SizedBox(
135-
width: size?.width,
136-
child: const NeonLinearProgressIndicator(),
130+
return _buildBlur(context, isLoading: imageResult.isLoading);
131+
},
132+
);
133+
}
134+
135+
/// Replacing the blurhash with the actual image leads to UI flickering when scrolling very fast.
136+
/// To mitigate this, we keep the blurhash underneath the actual image.
137+
Widget _buildImageWithBlur(BuildContext context, {required Widget child, bool isLoading = true}) => Stack(
138+
fit: StackFit.passthrough,
139+
children: [
140+
_buildBlur(context, isLoading: isLoading),
141+
child,
142+
],
143+
);
144+
145+
Widget _buildBlur(BuildContext context, {bool isLoading = true}) {
146+
if (blurHash == null) {
147+
return _buildNoBlur(context, isLoading: isLoading);
148+
}
149+
150+
final blurTask = NeonProvider.of<BlurBloc>(context).getBlurHash(
151+
blurHash!,
152+
size ?? const Size.square(32),
153+
);
154+
155+
// [ValueListenableBuilder] is better then [FutureBuilder] here,
156+
// because it allows us to update a cached blur without flickering, which is important when scrolling fast.
157+
// Background is that [FutureBuilder] returns for a short moment null even if the future is already completed.
158+
return ValueListenableBuilder(
159+
// Key is important to ensure that we can move it without cost in the widget tree.
160+
key: ValueKey('$blurHash'),
161+
valueListenable: blurTask.blur,
162+
builder: (context, blur, _) {
163+
if (blur == null) {
164+
return _buildNoBlur(context, isLoading: isLoading);
165+
}
166+
167+
return RawImage(
168+
image: blur,
137169
);
138170
},
139171
);
140172
}
141173

174+
Widget _buildNoBlur(BuildContext context, {bool isLoading = true}) => SizedBox(
175+
width: size?.width,
176+
child: NeonLinearProgressIndicator(
177+
visible: isLoading,
178+
),
179+
);
180+
142181
Widget _buildError(BuildContext context, Object? error) =>
143182
errorBuilder?.call(context, error) ??
144183
NeonError(

packages/neon_framework/packages/neon_rich_text/test/rich_objects/file_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/widgets.dart';
22
import 'package:flutter_test/flutter_test.dart';
33
import 'package:mocktail/mocktail.dart';
4+
import 'package:neon_framework/blocs.dart';
45
import 'package:neon_framework/models.dart';
56
import 'package:neon_framework/testing.dart';
67
import 'package:neon_framework/widgets.dart';
@@ -14,6 +15,7 @@ const validBlurHash = 'LEHLk~WB2yk8pyo0adR*.7kCMdnj';
1415
void main() {
1516
late MockUrlLauncher urlLauncher;
1617
late Account account;
18+
late BlurBloc blurBloc;
1719

1820
setUpAll(() {
1921
FakeNeonStorage.setup();
@@ -25,6 +27,14 @@ void main() {
2527
when(() => urlLauncher.launchUrl(any(), any())).thenAnswer((_) async => true);
2628

2729
UrlLauncherPlatform.instance = urlLauncher;
30+
31+
const fallbackSize = Size(100, 100);
32+
registerFallbackValue(fallbackSize);
33+
34+
blurBloc = MockBlurBloc();
35+
when(() => blurBloc.getBlurHash(validBlurHash, any())).thenReturn(
36+
BlurHashDecodeTask(blurHash: validBlurHash, size: fallbackSize),
37+
);
2838
});
2939

3040
setUp(() {
@@ -65,6 +75,7 @@ void main() {
6575
TestApp(
6676
providers: [
6777
Provider<Account>.value(value: account),
78+
Provider<BlurBloc>.value(value: blurBloc),
6879
],
6980
child: NeonRichObjectFile(
7081
parameter: core.RichObjectParameter(
@@ -216,6 +227,7 @@ void main() {
216227
TestApp(
217228
providers: [
218229
Provider<Account>.value(value: account),
230+
Provider<BlurBloc>.value(value: blurBloc),
219231
],
220232
child: NeonRichObjectFile(
221233
parameter: core.RichObjectParameter(

packages/neon_framework/pubspec.yaml

Lines changed: 1 addition & 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: ^2.4.0
1718
collection: ^1.0.0
1819
cookie_store:
1920
path: ../cookie_store

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.12.5"
188+
cacherine:
189+
dependency: transitive
190+
description:
191+
name: cacherine
192+
sha256: "375bc4baca0560e92bb6ed7543742d6ec65638c4d642c1ba407d4d4bba399658"
193+
url: "https://pub.dev"
194+
source: hosted
195+
version: "2.4.0"
188196
camera:
189197
dependency: transitive
190198
description:

0 commit comments

Comments
 (0)