-
Notifications
You must be signed in to change notification settings - Fork 42
feat(neon_framework): optimize blur hash handling for being performan… #3185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| final _blurHashCash = SimpleLRUCache<String, BlurHashDecodeTask>(1000); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}'; | ||
| } | ||
| 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'; | ||
|
|
@@ -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, | ||
| ], | ||
| ); | ||
provokateurin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ), | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
vauvenal5 marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
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.