Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"useGitignore": true,
"enableGlobDot": false,
"words": [
"OpenAPI"
"OpenAPI",
"cacherine"
],
"ignorePaths": [
"**.svg",
Expand Down
1 change: 1 addition & 0 deletions packages/neon_framework/lib/blocs.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export 'package:neon_framework/src/bloc/bloc.dart';
export 'package:neon_framework/src/bloc/result.dart';
export 'package:neon_framework/src/blocs/apps.dart';
export 'package:neon_framework/src/blocs/blur.dart';
export 'package:neon_framework/src/blocs/capabilities.dart';
export 'package:neon_framework/src/blocs/references.dart';
export 'package:neon_framework/src/blocs/timer.dart';
Expand Down
4 changes: 4 additions & 0 deletions packages/neon_framework/lib/neon.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:logging/logging.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/src/app.dart';
import 'package:neon_framework/src/blocs/accounts.dart';
import 'package:neon_framework/src/blocs/blur.dart';
import 'package:neon_framework/src/blocs/first_launch.dart';
import 'package:neon_framework/src/blocs/next_push.dart';
import 'package:neon_framework/src/blocs/push_notifications.dart';
Expand Down Expand Up @@ -120,6 +121,8 @@ Future<void> runNeon({
globalOptions: globalOptions,
);

final blurBloc = BlurBloc();

runApp(
MultiProvider(
providers: [
Expand All @@ -128,6 +131,7 @@ Future<void> runNeon({
NeonProvider<AccountsBloc>.value(value: accountsBloc),
NeonProvider<FirstLaunchBloc>.value(value: firstLaunchBloc),
NeonProvider<NextPushBloc>.value(value: nextPushBloc),
NeonProvider<BlurBloc>.value(value: blurBloc),
Provider<BuiltSet<AppImplementation>>(
create: (_) => appImplementations,
dispose: (_, appImplementations) => appImplementations.disposeAll(),
Expand Down
84 changes: 84 additions & 0 deletions packages/neon_framework/lib/src/blocs/blur.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'dart:async';
import 'dart:ui' as ui;

import 'package:cacherine/cacherine.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:logging/logging.dart';
import 'package:neon_framework/blocs.dart';
import 'package:queue/queue.dart';

/// A bloc that manages the decoding of blur hashes into images.
/// It uses a [Queue] to limit the maximum number of concurrent decoding tasks,
/// and a LRU-Cache to cache the results of previously decoded blur hashes so that they can be reused without re-decoding.
class BlurBloc extends Bloc {
final _blurHashQueue = Queue(parallel: 10);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you do some testing with higher parallel count? Would be nice to try it on a low-end Android device and use it as the baseline.

final _blurHashCash = SimpleLRUCache<String, BlurHashDecodeTask>(1000);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the amount of memory used per cache entry? I'm not sure if there is a benefit to making the cache any larger, but if there is then finding a good value based on the minimum hardware requirements (which are not defined, but basically some low-end Android device) would be nice.


@override
void dispose() {
_blurHashQueue.dispose();
_blurHashCash.clear();
}

@override
Logger get log => Logger('BlurBloc');

/// Gets the decoded image for the given [blurHash] and [size].
/// If the image is already cached, it returns the cached image.
/// Otherwise, it creates a new [BlurHashDecodeTask] to decode the blur hash, and returns the result of that task.
/// If [cache] is `true`, the result will be cached. [cache] does not have any effect on lookup.
Future<ui.Image> getBlurHash(String blurHash, ui.Size size, {bool cache = true}) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever not want to cache it? I don't see any reason why we wouldn't and it can still be added later if ever needed.

final task = BlurHashDecodeTask(blurHash: blurHash, size: size);

if (_blurHashCash.get(task.key) != null) {
return _blurHashCash.get(task.key)!.result.future;
}
Comment on lines +32 to +36
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I already commented it on an earlier version: Can you move the key computation outside of the class? Constructing the object just to get the key is unnecessary work. In fact the key is only used inside this method, so you can easily inline the key computation without adding an extra method.


// We are offloading the decoding process to the schedular to allow for pre-caching of the blur hashes,
// and to ensure that the decoding process does not block UI refreshes.
// Please note that this only works as long as the decoding process is not too heavy,
// as it could potentially still block the UI if it takes longer then a few milliseconds.
unawaited(SchedulerBinding.instance.scheduleTask(task.execute, Priority.animation));

if (cache) {
_blurHashCash.set(task.key, task);
}

return task.result.future;
}
}

/// 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.
class BlurHashDecodeTask {
/// Creates a new [BlurHashDecodeTask] with the given [blurHash] and [size].
/// The result is stored in a [Completer] so that it can be awaited by multiple callers.
BlurHashDecodeTask({
required this.blurHash,
required this.size,
});

/// The blur hash to decode.
final String blurHash;

/// The size of the resulting image.
final ui.Size size;

/// The result of the decoding process, stored in a [Completer] so that it can be awaited by multiple callers.
Completer<ui.Image> result = Completer();

/// Executes the task by decoding the blur hash into an image and completing the [result] completer with the decoded image.
Future<void> execute() async {
result.complete(
blurHashDecodeImage(
blurHash: blurHash,
width: size.width.toInt(),
height: size.height.toInt(),
),
);
}

/// A unique key for this task, based on the blur hash and the size of the resulting image.
/// This is used to ensure that multiple callers can await the same task without creating duplicate tasks.
String get key => '$blurHash-${size.width}x${size.height}';
}
71 changes: 53 additions & 18 deletions packages/neon_framework/lib/src/widgets/image.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/src/bloc/result.dart';
import 'package:neon_framework/src/blocs/blur.dart';
import 'package:neon_framework/src/utils/account_client_extension.dart';
import 'package:neon_framework/src/utils/image_utils.dart';
import 'package:neon_framework/src/utils/provider.dart';
import 'package:neon_framework/src/utils/request_manager.dart';
import 'package:neon_framework/src/widgets/error.dart';
import 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
Expand Down Expand Up @@ -108,32 +110,65 @@ class NeonImage extends StatelessWidget {
// If the data is not UTF-8
}

return Image.memory(
data,
height: size?.height,
width: size?.width,
fit: fit ?? BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (context, error, stacktrace) => _buildError(context, error),
);
}

if (blurHash != null) {
return BlurHash(
hash: blurHash!,
imageFit: fit ?? BoxFit.cover,
decodingHeight: size?.height.toInt() ?? 32,
decodingWidth: size?.width.toInt() ?? 32,
return _buildImageWithBlur(
context,
child: Image.memory(
data,
height: size?.height,
width: size?.width,
fit: fit ?? BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (context, error, stacktrace) => _buildError(context, error),
),
isLoading: imageResult.isLoading,
);
}

if (imageResult.hasError) {
return _buildError(context, imageResult.error);
}

return _buildBlur(context, isLoading: imageResult.isLoading);
},
);
}

/// Replacing the blurhash with the actual image leads to UI flickering when scrolling very fast.
/// To mitigate this, we keep the blurhash underneath the actual image.
Widget _buildImageWithBlur(BuildContext context, {required Widget child, bool isLoading = true}) => Stack(
fit: StackFit.passthrough,
children: [
_buildBlur(context, isLoading: isLoading),
child,
],
);

Widget _buildBlur(BuildContext context, {bool isLoading = true}) {
final blurBloc = NeonProvider.of<BlurBloc>(context);
return FutureBuilder<ui.Image>(
// Key is important to ensure that we can move it without cost in the widget tree.
key: ValueKey(blurHash),
// We are not caching the blurHash result because we do not want to take care of cleanup in here.
// If pre-caching is required, the encapsulating widget should take care of it and also of the cleanup.
future: blurHash != null
? blurBloc.getBlurHash(
blurHash!,
size ?? const Size.square(32),
cache: false,
)
: null,
Comment on lines +153 to +159
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a bit easier to read if you move it outside of the return statement. Also if the blurHash variable is null you don't need to add the FutureBuilder stuff and can just return the SizedBox directly (which is easier if you move it out of the FutureBuilder as well).

builder: (context, snapshot) {
if (snapshot.hasData) {
return RawImage(
image: snapshot.data,
);
}

return SizedBox(
width: size?.width,
child: const NeonLinearProgressIndicator(),
child: NeonLinearProgressIndicator(
visible: isLoading,
),
);
},
);
Expand Down
2 changes: 2 additions & 0 deletions packages/neon_framework/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies:
bloc_concurrency: ^0.3.0
built_collection: ^5.0.0
built_value: ^8.9.0
cacherine: ^1.1.4
collection: ^1.0.0
cookie_store:
path: ../cookie_store
Expand Down Expand Up @@ -56,6 +57,7 @@ dependencies:
path_provider: ^2.1.0
permission_handler: ^12.0.0
provider: ^6.0.0
queue: ^3.0.0
quick_actions: ^1.0.0
rxdart: ^0.28.0
scrollable_positioned_list: ^0.3.0
Expand Down
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.11.1"
cacherine:
dependency: transitive
description:
name: cacherine
sha256: "1921d157632330de4207f689d760e05b37b0a6b29dc7e3633e14cb038ee2388f"
url: "https://pub.dev"
source: hosted
version: "1.1.4"
camera:
dependency: transitive
description:
Expand Down
Loading