feat: found root cause of slow image process is store them in bytes rather than image object

This commit is contained in:
insleker 2025-09-19 21:55:59 +08:00
parent 8daf5ea3ca
commit 81a352a513
12 changed files with 70 additions and 131 deletions

View File

@ -18,7 +18,7 @@ class SignatureCard {
}); });
SignatureCard copyWith({ SignatureCard copyWith({
double? rotationDeg, double? rotationDeg, //z axis is out of the screen, positive is CCW
SignatureAsset? asset, SignatureAsset? asset,
GraphicAdjust? graphicAdjust, GraphicAdjust? graphicAdjust,
}) => SignatureCard( }) => SignatureCard(

View File

@ -54,8 +54,8 @@ class _DrawCanvasState extends State<DrawCanvas> {
onPressed: () async { onPressed: () async {
// Export signature to PNG bytes first // Export signature to PNG bytes first
final byteData = await _control.toImage( final byteData = await _control.toImage(
width: 1024, width: 512,
height: 512, height: 256,
fit: true, fit: true,
color: Colors.black, color: Colors.black,
background: Colors.transparent, background: Colors.transparent,

View File

@ -11,21 +11,10 @@ class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
required this.controller, required this.controller,
}); });
final Size pageSize; final Size pageSize;
// viewerController removed in migration
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
final PdfViewerController controller; final PdfViewerController controller;
@override @override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState(); ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
@ -156,11 +145,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
if (isContinuous) { if (isContinuous) {
return PdfViewerWidget( return PdfViewerWidget(
pageSize: widget.pageSize, pageSize: widget.pageSize,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
pageKeyBuilder: _pageKey, pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage, scrollToPage: _scrollToPage,
controller: widget.controller, controller: widget.controller,

View File

@ -109,22 +109,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return bytes; return bytes;
} }
void _confirmSignature() {
// In simplified UI, confirmation is a no-op
}
void _onDragSignature(Offset delta) {
// In simplified UI, interactive overlay disabled
}
void _onResizeSignature(Offset delta) {
// In simplified UI, interactive overlay disabled
}
void _onSelectPlaced(int? index) {
// In simplified UI, selection is a no-op for tests
}
Future<Uint8List?> _openDrawCanvas() async { Future<Uint8List?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>( final result = await showModalBottomSheet<Uint8List>(
context: context, context: context,
@ -323,11 +307,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
controller: _viewModel.controller, controller: _viewModel.controller,
key: const ValueKey('pdf_page_area'), key: const ValueKey('pdf_page_area'),
pageSize: _pageSize, pageSize: _pageSize,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay: () {},
onSelectPlaced: _onSelectPlaced,
), ),
), ),
), ),

View File

@ -10,22 +10,12 @@ class PdfViewerWidget extends ConsumerStatefulWidget {
const PdfViewerWidget({ const PdfViewerWidget({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.pageKeyBuilder, this.pageKeyBuilder,
this.scrollToPage, this.scrollToPage,
required this.controller, required this.controller,
}); });
final Size pageSize; final Size pageSize;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
final GlobalKey Function(int page)? pageKeyBuilder; final GlobalKey Function(int page)? pageKeyBuilder;
final void Function(int page)? scrollToPage; final void Function(int page)? scrollToPage;
final PdfViewerController controller; final PdfViewerController controller;
@ -88,11 +78,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
widget.pageKeyBuilder ?? widget.pageKeyBuilder ??
(page) => GlobalKey(debugLabel: 'page_$page'), (page) => GlobalKey(debugLabel: 'page_$page'),
scrollToPage: widget.scrollToPage ?? (page) {}, scrollToPage: widget.scrollToPage ?? (page) {},
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
); );
} }
@ -163,11 +148,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
PdfPageOverlays( PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height), pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber, pageNumber: page.pageNumber,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
), ),
]; ];
}, },

View File

@ -1,10 +1,10 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../../../../utils/rotation_utils.dart' as rot; import '../../../../utils/rotation_utils.dart' as rot;
/// A lightweight widget to render signature bytes with rotation and an /// A lightweight widget to render signature bytes with rotation and an
/// angle-aware scale-to-fit so the rotated image stays within its bounds. /// angle-aware scale-to-fit so the rotated image stays within its bounds.
/// Aware that `decodeImage` large images can be crazily slow, especially on web.
class RotatedSignatureImage extends StatefulWidget { class RotatedSignatureImage extends StatefulWidget {
const RotatedSignatureImage({ const RotatedSignatureImage({
super.key, super.key,
@ -12,6 +12,8 @@ class RotatedSignatureImage extends StatefulWidget {
this.rotationDeg = 0.0, // counterclockwise as positive this.rotationDeg = 0.0, // counterclockwise as positive
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.semanticLabel, this.semanticLabel,
this.cacheWidth,
this.cacheHeight,
}); });
final Uint8List bytes; final Uint8List bytes;
@ -22,6 +24,10 @@ class RotatedSignatureImage extends StatefulWidget {
final Alignment alignment = Alignment.center; final Alignment alignment = Alignment.center;
final bool wrapInRepaintBoundary = true; final bool wrapInRepaintBoundary = true;
final String? semanticLabel; final String? semanticLabel;
// Hint the decoder to decode at a smaller size to reduce memory/latency.
// On some platforms these may be ignored, but they are safe no-ops.
final int? cacheWidth;
final int? cacheHeight;
@override @override
State<RotatedSignatureImage> createState() => _RotatedSignatureImageState(); State<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
@ -67,16 +73,6 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
_setAspectRatio(1.0); // safe fallback _setAspectRatio(1.0); // safe fallback
return; return;
} }
// One-time synchronous header decode to establish aspect ratio quickly.
// This only runs when bytes change (not on rotation), so it's acceptable.
try {
final decoded = img.decodeImage(widget.bytes);
if (decoded != null && decoded.width > 0 && decoded.height > 0) {
_setAspectRatio(decoded.width / decoded.height);
}
} catch (_) {
// ignore decode errors and rely on image stream listener
}
final stream = _provider.resolve(createLocalImageConfiguration(context)); final stream = _provider.resolve(createLocalImageConfiguration(context));
_stream = stream; _stream = stream;
_listener = ImageStreamListener((ImageInfo info, bool sync) { _listener = ImageStreamListener((ImageInfo info, bool sync) {
@ -113,6 +109,9 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
alignment: widget.alignment, alignment: widget.alignment,
semanticLabel: widget.semanticLabel, semanticLabel: widget.semanticLabel,
// Provide at most one dimension to preserve aspect ratio if only one is set
cacheWidth: widget.cacheWidth,
cacheHeight: widget.cacheHeight,
isAntiAlias: false, isAntiAlias: false,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
// Return a placeholder for invalid images // Return a placeholder for invalid images

View File

@ -1,3 +1,4 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain; import 'package:pdf_signature/domain/models/model.dart' as domain;
@ -7,15 +8,14 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import '../view_model/signature_view_model.dart'; import '../view_model/signature_view_model.dart';
import '../view_model/dragging_signature_view_model.dart'; import '../view_model/dragging_signature_view_model.dart';
class SignatureCard extends ConsumerWidget { class SignatureCardView extends ConsumerStatefulWidget {
const SignatureCard({ const SignatureCardView({
super.key, super.key,
required this.asset, required this.asset,
required this.disabled, required this.disabled,
required this.onDelete, required this.onDelete,
this.onTap, this.onTap,
this.onAdjust, this.onAdjust,
this.useCurrentBytesForDrag = false,
this.rotationDeg = 0.0, this.rotationDeg = 0.0,
this.graphicAdjust = const domain.GraphicAdjust(), this.graphicAdjust = const domain.GraphicAdjust(),
}); });
@ -24,9 +24,14 @@ class SignatureCard extends ConsumerWidget {
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onAdjust; final VoidCallback? onAdjust;
final bool useCurrentBytesForDrag;
final double rotationDeg; final double rotationDeg;
final domain.GraphicAdjust graphicAdjust; final domain.GraphicAdjust graphicAdjust;
@override
ConsumerState<SignatureCardView> createState() => _SignatureCardViewState();
}
class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
Uint8List? _lastBytesRef;
Future<void> _showContextMenu(BuildContext context, Offset position) async { Future<void> _showContextMenu(BuildContext context, Offset position) async {
final selected = await showMenu<String>( final selected = await showMenu<String>(
context: context, context: context,
@ -50,22 +55,40 @@ class SignatureCard extends ConsumerWidget {
], ],
); );
if (selected == 'adjust') { if (selected == 'adjust') {
onAdjust?.call(); widget.onAdjust?.call();
} else if (selected == 'delete') { } else if (selected == 'delete') {
onDelete(); widget.onDelete();
} }
} }
void _maybePrecache(Uint8List bytes) {
if (identical(_lastBytesRef, bytes)) return;
_lastBytesRef = bytes;
// Schedule after frame to avoid doing work during build.
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use single-dimension hints to preserve aspect ratio.
final img128 = ResizeImage(MemoryImage(bytes), height: 128);
final img256 = ResizeImage(MemoryImage(bytes), height: 256);
precacheImage(img128, context);
precacheImage(img256, context);
});
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final displayData = ref final displayData = ref
.watch(signatureViewModelProvider) .watch(signatureViewModelProvider)
.getDisplaySignatureData(asset, graphicAdjust); .getDisplaySignatureData(widget.asset, widget.graphicAdjust);
_maybePrecache(displayData.bytes);
// Fit inside 96x64 with 6px padding using the shared rotated image widget // Fit inside 96x64 with 6px padding using the shared rotated image widget
const boxW = 96.0, boxH = 64.0, pad = 6.0; const boxW = 96.0, boxH = 64.0, pad = 6.0;
// Hint decoder with small target size to reduce decode cost.
// The card shows inside 96x64 with 6px padding; request ~128px max.
Widget coreImage = RotatedSignatureImage( Widget coreImage = RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
// Only set one dimension to keep aspect ratio
cacheHeight: 128,
); );
Widget img = Widget img =
(displayData.colorMatrix != null) (displayData.colorMatrix != null)
@ -102,7 +125,7 @@ class SignatureCard extends ConsumerWidget {
top: 0, top: 0,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
onPressed: disabled ? null : onDelete, onPressed: widget.disabled ? null : widget.onDelete,
tooltip: 'Remove', tooltip: 'Remove',
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
), ),
@ -111,33 +134,31 @@ class SignatureCard extends ConsumerWidget {
), ),
), ),
); );
Widget child = onTap != null ? InkWell(onTap: onTap, child: base) : base; Widget child =
widget.onTap != null ? InkWell(onTap: widget.onTap, child: base) : base;
// Add context menu for adjust/delete on right-click or long-press // Add context menu for adjust/delete on right-click or long-press
child = GestureDetector( child = GestureDetector(
key: const Key('gd_signature_card_area'), key: const Key('gd_signature_card_area'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onSecondaryTapDown: onSecondaryTapDown:
disabled widget.disabled
? null ? null
: (details) => _showContextMenu(context, details.globalPosition), : (details) => _showContextMenu(context, details.globalPosition),
onLongPressStart: onLongPressStart:
disabled widget.disabled
? null ? null
: (details) => _showContextMenu(context, details.globalPosition), : (details) => _showContextMenu(context, details.globalPosition),
child: child, child: child,
); );
if (disabled) return child; if (widget.disabled) return child;
return Draggable<SignatureDragData>( return Draggable<SignatureDragData>(
data: data: SignatureDragData(
useCurrentBytesForDrag card: domain.SignatureCard(
? const SignatureDragData() asset: widget.asset,
: SignatureDragData( rotationDeg: widget.rotationDeg,
card: domain.SignatureCard( graphicAdjust: widget.graphicAdjust,
asset: asset, ),
rotationDeg: rotationDeg, ),
graphicAdjust: graphicAdjust,
),
),
onDragStarted: () { onDragStarted: () {
ref.read(isDraggingSignatureViewModelProvider.notifier).state = true; ref.read(isDraggingSignatureViewModelProvider.notifier).state = true;
}, },
@ -166,12 +187,14 @@ class SignatureCard extends ConsumerWidget {
), ),
child: RotatedSignatureImage( child: RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256,
), ),
) )
: RotatedSignatureImage( : RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256,
), ),
), ),
), ),

View File

@ -1,6 +1,6 @@
import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/domain/models/model.dart';
class SignatureDragData { class SignatureDragData {
final SignatureCard? card; // null means use current processed signature final SignatureCard card; // null means use current processed signature
const SignatureDragData({this.card}); const SignatureDragData({required this.card});
} }

View File

@ -6,9 +6,9 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart' hide SignatureCard; import 'package:pdf_signature/domain/models/signature_asset.dart';
import 'image_editor_dialog.dart'; import 'image_editor_dialog.dart';
import 'signature_card.dart'; import 'signature_card_view.dart';
import '../../pdf/view_model/pdf_view_model.dart'; import '../../pdf/view_model/pdf_view_model.dart';
/// Data for drag-and-drop is in signature_drag_data.dart /// Data for drag-and-drop is in signature_drag_data.dart
@ -49,7 +49,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: SignatureCard( child: SignatureCardView(
key: ValueKey('sig_card_${library.indexOf(card)}'), key: ValueKey('sig_card_${library.indexOf(card)}'),
asset: card.asset, asset: card.asset,
rotationDeg: card.rotationDeg, rotationDeg: card.rotationDeg,

View File

@ -42,11 +42,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: Size(676, 400), pageSize: Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -75,6 +70,4 @@ void main() {
}); });
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}

View File

@ -43,11 +43,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: Size(676, 400), pageSize: Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -106,6 +101,4 @@ void main() {
); );
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}

View File

@ -43,11 +43,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: const Size(676, 400), pageSize: const Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -91,11 +86,6 @@ void main() {
// Keep aspect ratio consistent with uiPageSize // Keep aspect ratio consistent with uiPageSize
child: PdfPageArea( child: PdfPageArea(
pageSize: uiPageSize, pageSize: uiPageSize,
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -170,6 +160,4 @@ void main() {
}); });
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}