perf(inventory): lazy list, photo thumbnails, indexes, debounced reload
Scale the local inventory to 10k+ varieties: - Render the list with ListView.builder over a flattened header/item model instead of building every tile upfront. - Store a small regenerable JPEG thumbnail per photo (schema v14) and use it for the 48px list avatar; full bytes stay for offer image hosting. Existing photos are backfilled lazily at startup. Thumbnail is local-only (excluded from CRDT sync and backups by the JSON codec). - Add indexes on varieties(is_deleted,is_draft), attachments(parent_type, parent_id,kind), lots(variety_id) via @TableIndex. - Debounce watchInventoryView (~250ms) so a burst of table writes triggers one reload, not seven. - cacheWidth/cacheHeight on the list avatar decode. - Scale test raised 3k -> 10k; migration test v13 -> v14.
This commit is contained in:
parent
2884ddd3c7
commit
3de01bd948
12 changed files with 5114 additions and 34 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
|
@ -652,13 +654,27 @@ class VarietyRepository {
|
|||
required this.idGen,
|
||||
required this.nodeId,
|
||||
int Function()? nowMillis,
|
||||
Uint8List? Function(Uint8List)? thumbnailBuilder,
|
||||
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
||||
_thumbnailBuilder = thumbnailBuilder,
|
||||
_clock = Hlc.zero(nodeId);
|
||||
|
||||
final AppDatabase _db;
|
||||
final IdGen idGen;
|
||||
final String nodeId;
|
||||
final int Function() _now;
|
||||
|
||||
/// Builds the small list-avatar thumbnail from a full photo. Injected (from
|
||||
/// `services/offer_thumbnail.dart` in production) so the data layer stays free
|
||||
/// of the image codec and unit tests run without decoding. Null → no
|
||||
/// thumbnail is stored and the list falls back to the full photo.
|
||||
final Uint8List? Function(Uint8List)? _thumbnailBuilder;
|
||||
|
||||
/// The thumbnail for [photoBytes], or null when no builder is wired or the
|
||||
/// bytes aren't decodable. Wrapped in a Value for direct use in a companion.
|
||||
Value<Uint8List?> _thumbValue(Uint8List photoBytes) =>
|
||||
Value(_thumbnailBuilder?.call(photoBytes));
|
||||
|
||||
Hlc _clock;
|
||||
|
||||
/// Emits the non-deleted inventory, ordered by category then label, each with
|
||||
|
|
@ -696,7 +712,11 @@ class VarietyRepository {
|
|||
_db.select(_db.species).watch().map((_) {}),
|
||||
_db.select(_db.lots).watch().map((_) {}),
|
||||
]);
|
||||
return triggers.asyncMap(
|
||||
// Coalesce bursts: a single quick-add or handover touches several of these
|
||||
// tables at once, and each would otherwise re-run the full (7-query) load.
|
||||
// Debouncing collapses the burst into one reload — decisive with a large
|
||||
// inventory.
|
||||
return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap(
|
||||
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
|
||||
);
|
||||
}
|
||||
|
|
@ -929,7 +949,10 @@ class VarietyRepository {
|
|||
return rows.map((l) => l.varietyId).toSet();
|
||||
}
|
||||
|
||||
/// Loads the first photo BLOB for each of [varietyIds] (one query).
|
||||
/// Loads the first photo's small [thumbnail] for each of [varietyIds] (one
|
||||
/// query) — for the inventory-list avatar. Falls back to the full-resolution
|
||||
/// [bytes] only when a thumbnail hasn't been generated yet (older rows,
|
||||
/// pending lazy backfill), so the list stays correct meanwhile.
|
||||
Future<Map<String, Uint8List>> _firstPhotosFor(
|
||||
List<String> varietyIds,
|
||||
) async {
|
||||
|
|
@ -950,17 +973,69 @@ class VarietyRepository {
|
|||
.get();
|
||||
final byVariety = <String, Uint8List>{};
|
||||
for (final row in rows) {
|
||||
final bytes = row.bytes;
|
||||
if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
|
||||
final image = row.thumbnail ?? row.bytes;
|
||||
if (image != null) byVariety.putIfAbsent(row.parentId, () => image);
|
||||
}
|
||||
return byVariety;
|
||||
}
|
||||
|
||||
/// Generates the missing list thumbnails for photos that predate the
|
||||
/// thumbnail column (or arrived via sync/backup restore, which never carry
|
||||
/// one). Processes in small batches so decoding doesn't block the UI; safe to
|
||||
/// call at startup as fire-and-forget. No-op when no thumbnail builder is
|
||||
/// wired. Returns how many thumbnails were written.
|
||||
Future<int> backfillThumbnails({int batchSize = 20}) async {
|
||||
final build = _thumbnailBuilder;
|
||||
if (build == null) return 0;
|
||||
var written = 0;
|
||||
while (true) {
|
||||
final batch =
|
||||
await (_db.select(_db.attachments)
|
||||
..where(
|
||||
(a) =>
|
||||
a.kind.equalsValue(AttachmentKind.photo) &
|
||||
a.isDeleted.equals(false) &
|
||||
a.thumbnail.isNull() &
|
||||
a.bytes.isNotNull(),
|
||||
)
|
||||
..limit(batchSize))
|
||||
.get();
|
||||
if (batch.isEmpty) break;
|
||||
for (final row in batch) {
|
||||
final thumb = build(row.bytes!);
|
||||
// No decodable image → store the full bytes as the "thumbnail" so this
|
||||
// row isn't re-scanned forever. It's rare (corrupt photo) and still
|
||||
// bounded by the avatar's cacheWidth at render time.
|
||||
await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id)))
|
||||
.write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes)));
|
||||
written++;
|
||||
}
|
||||
if (batch.length < batchSize) break;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/// The cover photo (lowest `sortOrder`) for a single variety, or null when it
|
||||
/// has none. Used by the publish step to host an offer's image; reuses the
|
||||
/// same "first photo" rule as the inventory avatar.
|
||||
Future<Uint8List?> coverPhotoFor(String varietyId) async =>
|
||||
(await _firstPhotosFor([varietyId]))[varietyId];
|
||||
/// has none. Used by the publish step to host an offer's image, so it returns
|
||||
/// the FULL-resolution [bytes] (not the list thumbnail).
|
||||
Future<Uint8List?> coverPhotoFor(String varietyId) async {
|
||||
final rows =
|
||||
await (_db.select(_db.attachments)
|
||||
..where(
|
||||
(a) =>
|
||||
a.parentId.equals(varietyId) &
|
||||
a.parentType.equalsValue(ParentType.variety) &
|
||||
a.kind.equalsValue(AttachmentKind.photo) &
|
||||
a.isDeleted.equals(false),
|
||||
)
|
||||
..orderBy([
|
||||
(a) => OrderingTerm(expression: a.sortOrder),
|
||||
(a) => OrderingTerm(expression: a.createdAt),
|
||||
])
|
||||
..limit(1))
|
||||
.get();
|
||||
return rows.isEmpty ? null : rows.first.bytes;
|
||||
}
|
||||
|
||||
/// Maps each of [speciesIds] to its scientific name (one query).
|
||||
Future<Map<String, String>> _scientificNamesFor(
|
||||
|
|
@ -1040,6 +1115,7 @@ class VarietyRepository {
|
|||
parentId: varietyId,
|
||||
kind: AttachmentKind.photo,
|
||||
bytes: Value(photoBytes),
|
||||
thumbnail: _thumbValue(photoBytes),
|
||||
mimeType: const Value('image/jpeg'),
|
||||
),
|
||||
);
|
||||
|
|
@ -1080,6 +1156,7 @@ class VarietyRepository {
|
|||
parentId: varietyId,
|
||||
kind: AttachmentKind.photo,
|
||||
bytes: Value(photoBytes),
|
||||
thumbnail: _thumbValue(photoBytes),
|
||||
mimeType: const Value('image/jpeg'),
|
||||
),
|
||||
);
|
||||
|
|
@ -1795,6 +1872,7 @@ class VarietyRepository {
|
|||
parentId: varietyId,
|
||||
kind: AttachmentKind.photo,
|
||||
bytes: Value(bytes),
|
||||
thumbnail: _thumbValue(bytes),
|
||||
mimeType: const Value('image/jpeg'),
|
||||
),
|
||||
);
|
||||
|
|
@ -3006,3 +3084,46 @@ class VarietyRepository {
|
|||
return maxPacked == null ? null : Hlc.parse(maxPacked);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the latest event from [source] only once [duration] has elapsed with
|
||||
/// no newer event — collapsing a burst of rapid change-triggers into a single
|
||||
/// downstream reload. Trailing-edge; single-subscription (matches the merged
|
||||
/// Drift trigger stream it wraps).
|
||||
Stream<T> _debounce<T>(Stream<T> source, Duration duration) {
|
||||
late StreamController<T> controller;
|
||||
StreamSubscription<T>? sub;
|
||||
Timer? timer;
|
||||
T? pending;
|
||||
var hasPending = false;
|
||||
|
||||
void flush() {
|
||||
if (hasPending) {
|
||||
hasPending = false;
|
||||
controller.add(pending as T);
|
||||
}
|
||||
}
|
||||
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
sub = source.listen(
|
||||
(value) {
|
||||
pending = value;
|
||||
hasPending = true;
|
||||
timer?.cancel();
|
||||
timer = Timer(duration, flush);
|
||||
},
|
||||
onError: controller.addError,
|
||||
onDone: () {
|
||||
timer?.cancel();
|
||||
flush();
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
onCancel: () async {
|
||||
timer?.cancel();
|
||||
await sub?.cancel();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue