perf(app): Phase 2 前端性能优化 5 项 — 8b-D01/D02/D03/M02/N01
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- 8b-D01: Isar 添加 authorId+dateEpoch 复合索引和 dateEpoch 单独索引
- 8b-D02: getJournals 分页改为 DB 层 .offset().limit() 替代 Dart 层 sublist
- 8b-D03: home_bloc monthCount 改用日期范围独立查询(不受分页限制)
- 8b-M02: 笔画光栅化改为 BBox 裁剪 — 短笔画不再创建全画布尺寸图像
  - _CacheEntry 增加 offset 字段记录 BBox 偏移
  - _rasterizeStroke 计算包围盒 + 4px padding
  - _compositeIncremental 使用 offset 定位
- 8b-N01: SyncEngine enqueue 合并同一资源的操作
  - create+update → create(最新数据)
  - update+update → update(最新数据)
  - update+delete → delete
  - create+delete → 取消(不发送)
- 注意: Isar .g.dart 需运行 build_runner 重新生成
This commit is contained in:
iven
2026-06-03 16:05:11 +08:00
parent b6ffc60331
commit 32a91551c4
5 changed files with 142 additions and 40 deletions

View File

@@ -16,7 +16,8 @@ class JournalEntryCollection {
@Index()
String id = '';
/// 作者 ID
/// 作者 ID(索引 + 组合索引 authorId+dateEpoch覆盖按作者查询并按日期排序的场景
@Index(composite: [CompositeIndex('dateEpoch')])
String authorId = '';
/// 班级 ID可选
@@ -25,7 +26,8 @@ class JournalEntryCollection {
/// 日记标题
String title = '';
/// 日记日期epoch milliseconds
/// 日记日期epoch milliseconds— 单独索引支持日期范围查询
@Index()
int dateEpoch = 0;
/// 心情enum → string

View File

@@ -64,19 +64,18 @@ class IsarJournalRepository implements JournalRepository {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列
var results = await query
.sortByDateEpochDesc()
.findAll();
// 分页
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist
if (page != null && pageSize != null) {
final start = (page - 1) * pageSize;
if (start >= results.length) return [];
final end = (start + pageSize).clamp(0, results.length);
results = results.sublist(start, end);
final offset = (page - 1) * pageSize;
final results = await query
.sortByDateEpochDesc()
.offset(offset)
.limit(pageSize)
.findAll();
return results.map(_fromCollection).toList();
}
final results = await query.sortByDateEpochDesc().findAll();
return results.map(_fromCollection).toList();
}

View File

@@ -136,21 +136,77 @@ class SyncEngine {
bool get isSyncing => _status == SyncStatus.syncing;
/// 添加待同步操作到队列尾部
///
/// 合并策略8b-N01同一资源endpoint 相同)的连续操作只保留最新一条。
/// create+update → create使用最新数据
/// update+update → update使用最新数据
/// update+delete → delete资源最终被删除
/// create+delete → 取消(资源从未存在)
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
// 查找队列中同一资源的最后一个操作
PendingOperation? existing;
for (final op in _pendingQueue) {
if (op.endpoint == operation.endpoint) {
existing = op;
}
}
if (existing != null) {
final merged = _mergeOperations(existing, operation);
_pendingQueue.remove(existing);
if (merged != null) {
_pendingQueue.add(merged);
}
// merged == null → create+delete 取消,不添加
} else {
_pendingQueue.add(operation);
}
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 批量添加待同步操作
/// 批量添加待同步操作(每个操作独立走合并逻辑)
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
enqueue(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
/// 合并同一资源的两个操作
///
/// 返回合并后的操作,或 null 表示应取消create+delete
PendingOperation? _mergeOperations(
PendingOperation existing,
PendingOperation incoming,
) {
// create + delete → 取消(资源从未同步到服务端)
if (existing.type == SyncOperationType.create &&
incoming.type == SyncOperationType.delete) {
return null;
}
// create + update → create使用最新数据
if (existing.type == SyncOperationType.create &&
incoming.type == SyncOperationType.update) {
return existing.copyWith(data: incoming.data, version: incoming.version);
}
// update + update → update使用最新数据
if (existing.type == SyncOperationType.update &&
incoming.type == SyncOperationType.update) {
return incoming;
}
// update + delete → delete
if (existing.type == SyncOperationType.update &&
incoming.type == SyncOperationType.delete) {
return incoming;
}
// 其他组合delete+create, create+create 等)不合并
return incoming;
}
/// 检查网络状态并尝试同步全部待处理操作

View File

@@ -20,8 +20,10 @@ import 'stroke_renderer.dart';
class _CacheEntry {
final ui.Image image;
final Stroke stroke;
/// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位
final Offset offset;
const _CacheEntry({required this.image, required this.stroke});
const _CacheEntry({required this.image, required this.stroke, required this.offset});
}
// ===== 光栅化缓存 =====
@@ -76,18 +78,22 @@ class StrokeRasterCache {
/// 添加一条已完成笔画到缓存
///
/// 光栅化该笔画为 ui.Image然后增量合成到 compositeImage。
/// 光栅化该笔画为 ui.Image(仅 BBox 区域),然后增量合成到 compositeImage。
Future<void> addStroke(Stroke stroke) async {
if (_canvasSize == Size.zero) return;
// 光栅化单笔画
final image = await _rasterizeStroke(stroke);
if (image == null) return;
// 光栅化单笔画BBox 裁剪)
final result = await _rasterizeStroke(stroke);
if (result == null) return;
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
_cache[stroke.id] = _CacheEntry(
image: result.image,
stroke: stroke,
offset: result.offset,
);
// 增量合成:将新笔画绘制到现有 compositeImage 之上
await _compositeIncremental(stroke, image);
await _compositeIncremental(stroke, result.image, result.offset);
}
/// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐)
@@ -108,9 +114,13 @@ class StrokeRasterCache {
final toAdd = currentIds.difference(cachedIds);
for (final stroke in strokes) {
if (toAdd.contains(stroke.id)) {
final image = await _rasterizeStroke(stroke);
if (image != null) {
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
final result = await _rasterizeStroke(stroke);
if (result != null) {
_cache[stroke.id] = _CacheEntry(
image: result.image,
stroke: stroke,
offset: result.offset,
);
}
}
}
@@ -155,8 +165,12 @@ class StrokeRasterCache {
// ===== 光栅化 =====
/// 将单条笔画光栅化为 ui.Image
Future<ui.Image?> _rasterizeStroke(Stroke stroke) async {
/// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02
///
/// 计算笔画的包围盒 (bounding box),仅对该区域光栅化,
/// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。
/// 返回 null 表示笔画无有效点。
Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async {
final outlinePoints = pointsToOutline(
stroke.points,
stroke.brushType,
@@ -165,16 +179,38 @@ class StrokeRasterCache {
);
if (outlinePoints.isEmpty) return null;
// 计算笔画包围盒
double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final p in outlinePoints) {
if (p.dx < minX) minX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dx > maxX) maxX = p.dx;
if (p.dy > maxY) maxY = p.dy;
}
// 添加边距(抗锯齿 + 笔触溢出)
const padding = 4.0;
final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width);
final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height);
final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width);
final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height);
final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0);
final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0);
final path = buildStrokePath(outlinePoints);
final paint = createPaintForStroke(stroke);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// 平移坐标系,使 BBox 左上角对齐 (0, 0)
canvas.translate(-bboxLeft, -bboxTop);
// 橡皮擦需要 saveLayer 保护,避免穿透
if (stroke.brushType == BrushType.eraser) {
canvas.saveLayer(
Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height),
Rect.fromLTWH(0, 0, bboxWidth, bboxHeight),
Paint(),
);
}
@@ -186,16 +222,20 @@ class StrokeRasterCache {
}
final picture = recorder.endRecording();
return picture.toImage(
_canvasSize.width.toInt().clamp(1, 4096),
_canvasSize.height.toInt().clamp(1, 4096),
final image = await picture.toImage(
bboxWidth.toInt().clamp(1, 4096),
bboxHeight.toInt().clamp(1, 4096),
);
return (image: image, offset: Offset(bboxLeft, bboxTop));
}
// ===== 合成 =====
/// 增量合成:将新笔画图像绘制到现有 compositeImage 上
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage) async {
///
/// strokeImage 是 BBox 裁剪后的图像offset 是其原始位置偏移。
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
@@ -210,15 +250,15 @@ class StrokeRasterCache {
canvas.drawImage(_compositeImage!, Offset.zero, Paint());
}
// 再绘制新笔画(橡皮擦用 dstOut
// 再绘制新笔画(橡皮擦用 dstOut,使用 BBox offset 定位
if (stroke.brushType == BrushType.eraser) {
canvas.drawImage(
strokeImage,
Offset.zero,
offset,
Paint()..blendMode = BlendMode.dstOut,
);
} else {
canvas.drawImage(strokeImage, Offset.zero, Paint());
canvas.drawImage(strokeImage, offset, Paint());
}
canvas.restore();

View File

@@ -116,9 +116,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
// 推算连续天数
final streakDays = _calculateStreak(journals);
// 本月日记数spec §3.4 quick-stats
final monthCount = journals.where((j) =>
j.date.year == today.year && j.date.month == today.month).length;
// 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03
final monthStart = DateTime(today.year, today.month, 1);
final monthEnd = DateTime(today.year, today.month + 1, 1);
final monthJournals = await _journalRepo.getJournals(
dateFrom: monthStart,
dateTo: monthEnd,
);
final monthCount = monthJournals.length;
// 总日记数 — 使用仓库计数方法(不受分页限制)
final totalCount = await _journalRepo.getJournalCount();