Selaa lähdekoodia

board_play 发牌优化

guoziyun 4 kuukautta sitten
vanhempi
sitoutus
0b50b25177

+ 250 - 0
RASTER_JANK_OPTIMIZATION.md

@@ -0,0 +1,250 @@
+# Raster Jank 优化方案
+
+## 问题分析
+
+从 home_screen 转场到 board_play 时出现若干个 Raster Jank 的主要原因:
+
+### 1. **Picture 录制阻塞主线程**
+- `_recordBackground()` 和 `_recordCard()` 在 Board 构造函数中同步执行
+- 这两个方法会进行大量的 Canvas 绘制操作(绘制背景网格和卡片)
+- 导致首帧渲染延迟,用户感知到卡顿
+
+### 2. **Path 对象首次生成开销大**
+- 每个 Piece 在首次绘制时调用 `generatePaths()` 生成复杂的圆角路径
+- 对于 5x5 的拼图,需要生成 25 个 Piece 的路径(每个 3 条 Path)
+- 这些操作在 Raster 线程执行,导致 Jank
+
+### 3. **动画控制器过早初始化**
+- `_initAnimations()` 中创建了 7 个 AnimationController
+- 包括 success 和 hardModeBanner 等可能不会立即使用的动画
+- 增加了初始化时的内存和 CPU 开销
+
+### 4. **Shader 编译延迟**
+- 首次绘制圆角矩形、渐变等效果时,GPU 需要编译 Shader
+- 这是 Flutter 的已知问题,会导致首帧卡顿
+
+### 5. **图片解码和缩放**
+- 大图片的解码和缩放操作可能在主线程执行
+- FilterQuality.high 会增加 GPU 负担
+
+## 已实施的优化
+
+### 1. ✅ 异步化 Picture 录制
+```dart
+// board.dart - Board 构造函数
+Board(...) : finalRect = targetRect {
+  if (json != null) {
+    _restorePieces(json);
+  } else {
+    _initPieces();
+  }
+  rebuildAllGroups();
+  
+  // 异步录制,不阻塞主线程
+  Future.microtask(() {
+    _recordBackground();
+    _recordCard();
+  });
+}
+```
+
+**效果**:
+- 首帧渲染不再等待 Picture 录制完成
+- 用户可以更快看到页面内容
+- Picture 会在后续帧中准备好,不影响后续绘制
+
+### 2. ✅ 降低图片绘制质量
+```dart
+// board_painter.dart - _drawPiece()
+canvas.drawImageRect(
+  board.image, 
+  piece.sourceRect, 
+  dstRect, 
+  Paint()
+    ..isAntiAlias = true
+    ..filterQuality = FilterQuality.low  // 从默认的 medium 降低到 low
+);
+```
+
+**效果**:
+- 减少 GPU 的图片缩放和过滤计算
+- 对于拼图游戏,low 质量在视觉上几乎无差异
+- 显著降低 Raster 线程负担
+
+### 3. ✅ 添加性能监控日志
+```dart
+// board.dart - _recordBackground() 和 _recordCard()
+final stopwatch = Stopwatch()..start();
+// ... 录制逻辑 ...
+stopwatch.stop();
+_log.info('Picture recorded. Time: ${stopwatch.elapsedMilliseconds}ms');
+```
+
+**效果**:
+- 可以量化 Picture 录制的实际耗时
+- 便于后续进一步优化
+
+## 建议的进一步优化
+
+### 1. 🔧 Shader 预热(已在 README 中说明)
+```bash
+# 运行 profile 模式并收集 Shader
+flutter run --profile --cache-sksl --purge-persistent-cache
+# 操作应用,触发所有可能的绘制场景
+# 按 'M' 保存 Shader 缓存
+
+# 构建时使用预热的 Shader
+flutter build apk --bundle-sksl-path flutter_01.sksl.json
+```
+
+**效果**:
+- 消除首次绘制时的 Shader 编译延迟
+- 这是解决 Raster Jank 最有效的方法之一
+
+### 2. 🔧 延迟初始化不必要的动画控制器
+```dart
+// board_play.dart - _initAnimations()
+void _initAnimations() {
+  // 只初始化必要的动画
+  _moveAnimationController = AnimationController(...);
+  _mergeAnimationController = AnimationController(...);
+  _prepareAnimationController = AnimationController(...);
+  dealingAnimationController = AnimationController(...);
+  flipAnimationController = AnimationController(...);
+  
+  // success 和 hardModeBanner 动画延迟到需要时再创建
+  // _successAnimationController = null;
+  // _hardModeBannerController = null;
+}
+
+// 在需要时才初始化
+void _onSuccess() {
+  if (_successAnimationController == null) {
+    _initSuccessAnimation(device);
+  }
+  _successAnimationController!.forward(from: 0.0);
+}
+```
+
+### 3. 🔧 使用 RepaintBoundary 隔离重绘区域
+```dart
+// board_play.dart - _buildPuzzleCanvas()
+Widget _buildPuzzleCanvas(double width, double height) {
+  return RepaintBoundary(  // 已经有了
+    child: CustomPaint(
+      painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
+      size: Size(width, height),
+      child: GestureDetector(...),
+    ),
+  );
+}
+```
+
+**当前状态**:已经使用了 RepaintBoundary ✅
+
+### 4. 🔧 优化 Path 缓存策略
+```dart
+// piece.dart - Piece 类
+class Piece {
+  // 当前已经有缓存机制
+  Path? path;
+  Path? innerLinePath;
+  Path? outLinePath;
+  
+  List<Path> generatePaths({bool forceRecalculate = false}) {
+    // ✅ 已优化:如果没有 group 且路径已缓存,则直接返回
+    if (group == null && !forceRecalculate && 
+        path != null && outLinePath != null && innerLinePath != null) {
+      return [path!, outLinePath!, innerLinePath!];
+    }
+    // ... 生成逻辑
+  }
+}
+```
+
+**当前状态**:已经实现了基本的缓存机制 ✅
+
+### 5. 🔧 使用 Isolate 预处理复杂计算
+对于非常复杂的 Path 生成(如不规则群组路径),可以考虑在 Isolate 中计算:
+
+```dart
+// 示例代码(未实施)
+Future<Path> _generatePathInIsolate(PathGenerationParams params) async {
+  return await compute(_generatePathWorker, params);
+}
+
+static Path _generatePathWorker(PathGenerationParams params) {
+  // 在后台 Isolate 中生成 Path
+  return _generateIrregularGroupPath(...);
+}
+```
+
+**注意**:Path 对象不能直接在 Isolate 间传递,需要序列化为坐标列表。
+
+### 6. 🔧 优化图片加载策略
+```dart
+// board_play.dart - _init()
+// 当前已经使用了 targetWidth 和 targetHeight 进行解码优化
+final ui.Codec cardCodec = await ui.instantiateImageCodec(
+  cardData.buffer.asUint8List(),
+  targetWidth: bestCardImageSize.width.round(),
+  targetHeight: bestCardImageSize.height.round(),
+);
+```
+
+**当前状态**:已经优化 ✅
+
+## 性能测试建议
+
+### 1. 使用 Flutter DevTools
+```bash
+flutter run --profile
+# 打开 DevTools,查看 Performance 面板
+# 重点关注:
+# - Raster 线程的帧时间
+# - Shader 编译事件
+# - 图片解码事件
+```
+
+### 2. 使用 Timeline
+```dart
+import 'dart:developer';
+
+Timeline.startSync('RecordBackground');
+_recordBackground();
+Timeline.finishSync();
+```
+
+### 3. 监控内存使用
+```dart
+// 已经在代码中使用了 MemoryMonitor
+MemoryMonitor.logMemoryUsage('BoardPlay initState');
+```
+
+## 预期效果
+
+实施上述优化后,预期可以:
+1. **减少首帧 Jank**:从 3-5 帧降低到 1-2 帧
+2. **降低 Raster 线程负担**:帧时间从 20-30ms 降低到 10-15ms
+3. **改善用户体验**:转场更流畅,无明显卡顿感
+
+## 监控指标
+
+- **首帧渲染时间**:目标 < 16ms(60fps)
+- **Raster Jank 次数**:目标 < 2 次
+- **Picture 录制时间**:目标 < 50ms
+- **Path 生成总时间**:目标 < 30ms(25 个 Piece)
+
+## 总结
+
+当前已实施的优化主要针对:
+1. ✅ Picture 录制异步化
+2. ✅ 降低图片绘制质量
+3. ✅ 添加性能监控
+
+建议优先实施:
+1. 🔧 Shader 预热(最有效)
+2. 🔧 延迟初始化不必要的动画控制器
+3. 🔧 持续监控和优化
+
+通过这些优化,应该能够显著改善转场时的 Raster Jank 问题。

+ 3 - 3
lib/config/device.dart

@@ -95,9 +95,9 @@ class Device {
 
   /// ✅ 建议的图片质量(优先基于屏幕尺寸,避免依赖 androidDeviceInfo)
   String get suggestedQuality {
-    if (isTablet) return "1600";  // 2400 → 1600 (减少56%内存)
-    if (isLowEndDevice) return "1000";  // 1200 → 1000 (减少44%内存)
-    return "1200";  // 1800 → 1200 (减少56%内存)
+    if (isTablet) return "2400";
+    if (isLowEndDevice) return "1200";
+    return "1800";
   }
 
   /// safeArea高度 Z

+ 123 - 50
lib/homepage/home_board_play.dart

@@ -64,6 +64,7 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
   // 每个卡片移动时间(ms)
   final int _dealingPieceDuration = 200;
 
+
   // 发牌动画总时长(需要考虑到最后一个卡片的动画)
   // 共有 board.rows * board.cols 个卡片
   int get _totalDealingDuration => (board.rows * board.cols - 1) * _dealingPieceInterval + _dealingPieceDuration;
@@ -75,6 +76,9 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
   // ✅ 优化:跟踪上次录制的关卡,实现增量更新(只更新新完成的碎片)
   int? _lastRecordedLevel;
 
+  // ✅ 优化:发牌动画每个卡片的预录制 Picture
+  List<ui.Picture>? _dealingPictures;
+
   @override
   void initState() {
     super.initState();
@@ -115,7 +119,19 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
           _overlayEntry?.remove();
           _overlayEntry = null;
           widget.onCollectionDone?.call();
-          _startDealingAnimation();
+          // 如果是低端设备,直接跳过发牌动画
+          if (board.device.isLowEndDevice) {
+            _log.info('低端设备,跳过发牌动画');
+            switchToNextCollection();
+            if (mounted) {
+              setState(() {
+                board.status = HomeBoardStatus.playing;
+                board.invalidate();
+              });
+            }
+          } else {
+            _startDealingAnimation();
+          }
         }
       });
 
@@ -191,6 +207,23 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
   // !!! 改造点 2: 启动发牌动画
   void _startDealingAnimation() {
     if (!mounted) return;
+
+    // 低端设备无需发牌动画,直接进入下一合集状态
+    if (board.device.isLowEndDevice) {
+      _log.info('低端设备,跳过发牌动画 (_startDealingAnimation)');
+      switchToNextCollection();
+      if (mounted) {
+        setState(() {
+          board.status = HomeBoardStatus.playing;
+          board.invalidate();
+        });
+      }
+      return;
+    }
+
+    // 录制每张卡片图像(包括文字和边框)
+    _recordDealingPictures();
+
     setState(() {
       board.status = HomeBoardStatus.dealing;
     });
@@ -237,6 +270,7 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
     _flipController.dispose();
     _unlockController.dispose();
     _dealingController.dispose();
+    _dealingPictures = null;
     collectionSubscription?.cancel();
     MemoryMonitor.logMemoryUsage('HomeBoardPlay dispose (after)');
     super.dispose();
@@ -364,6 +398,54 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
     _lastRecordedLevel = data.currentLevel;
   }
 
+  // ✅ 新增:录制发牌动画所需的每张卡片 Picture
+  void _recordDealingPictures() {
+    if (board.cardImage == null) return;
+    final int collectionIndex = data.currentCollectionIndex;
+    final img = board.cardImage!;
+    final w = widget.canvasWidth / board.cols;
+    final h = widget.canvasHeight / board.rows;
+    _dealingPictures = [];
+
+    for (var i = 0; i < board.rows; i++) {
+      for (var j = 0; j < board.cols; j++) {
+        final int curIndex = i * board.cols + j;
+        final int displayIndex = collectionIndex * board.count + curIndex;
+        final recorder = ui.PictureRecorder();
+        final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, w, h));
+
+        // draw card image
+        final cardSourceRect = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
+        final rect = Rect.fromLTWH(0, 0, w, h);
+        canvas.drawImageRect(img, cardSourceRect, rect, Paint()..isAntiAlias = true);
+
+        // number text (include collection offset)
+        final (textPainter, textWidth, textHeight) = CanvasPainter._getTextPainterWithLayout((displayIndex + 1).toString(), h * 0.25, w);
+        textPainter.paint(canvas, Offset((w - textWidth) / 2, (h - textHeight) / 2));
+
+        // borders
+        canvas.drawRRect(
+          RRect.fromRectAndRadius(rect.deflate(0.5), const Radius.circular(4.0)),
+          Paint()
+            ..color = SkinHelper.outLineBorderColor
+            ..style = PaintingStyle.stroke
+            ..strokeWidth = 1.0
+            ..isAntiAlias = true,
+        );
+        canvas.drawRRect(
+          RRect.fromRectAndRadius(rect.deflate(1.5), const Radius.circular(4.0)),
+          Paint()
+            ..color = SkinHelper.innerLineBorderColor
+            ..style = PaintingStyle.stroke
+            ..strokeWidth = 1.0
+            ..isAntiAlias = true,
+        );
+
+        _dealingPictures!.add(recorder.endRecording());
+      }
+    }
+  }
+
   // ✅ 辅助方法:绘制单个碎片到 PictureRecorder
   void _drawStaticPieceToRecorder(Canvas canvas, int row, int col, bool flipped, int curIndex) {
     final img = board.image;
@@ -434,6 +516,7 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
     // ✅ 清理 Picture 缓存,因为集合改变了
     _staticBackgroundPicture = null;
     _lastRecordedLevel = null;
+    _dealingPictures = null;
     board.switchToNextCollection(currentCollectionItem!);
   }
 
@@ -454,6 +537,7 @@ class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMi
               dealingPieceDuration: _dealingPieceDuration,
               dealingPieceInterval: _dealingPieceInterval,
               staticBackgroundPicture: _staticBackgroundPicture,
+              dealingPictures: _dealingPictures,
             ),
       child: board.status == HomeBoardStatus.loading
           ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)))
@@ -475,6 +559,8 @@ class CanvasPainter extends CustomPainter {
 
   // ✅ 优化:Picture 缓存,flip 动画期间使用预录制的背景
   final ui.Picture? staticBackgroundPicture;
+  // ✅ 优化:缓存发牌碎片 Picture
+  final List<ui.Picture>? dealingPictures;
 
   // ✅ 优化:缓存 Matrix4 变换矩阵,避免每帧重新计算
   static double _lastFlipProgress = -1.0;
@@ -497,6 +583,7 @@ class CanvasPainter extends CustomPainter {
     required this.dealingPieceDuration,
     required this.dealingPieceInterval,
     this.staticBackgroundPicture,
+    this.dealingPictures,
   }) : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation, dealingAnimation]));
 
   static TextPainter _getOrCreateTextPainter(String text, double fontSize) {
@@ -556,70 +643,56 @@ class CanvasPainter extends CustomPainter {
   }
 
   void _paintDealing(Canvas canvas, Size size) {
+    final pictures = dealingPictures;
     if (board.cardImage == null) return; // 防御性检查
 
     final totalPieces = board.rows * board.cols;
     final totalAnimationDuration = dealingPieceInterval * (totalPieces - 1) + dealingPieceDuration;
 
+    final double w = board.pieceLogicalWidth;
+    final double h = board.pieceLogicalHeight;
     final startX = board.cols - 1;
     final startY = board.rows - 1;
-    final startPieceCenterX = startX * board.pieceLogicalWidth + board.pieceLogicalWidth / 2;
-    final startPieceCenterY = startY * board.pieceLogicalHeight + board.pieceLogicalHeight / 2;
-
-    for (var i = 0; i < board.rows; i++) {
-      for (var j = 0; j < board.cols; j++) {
-        final pieceIndex = i * board.cols + j;
-        final animationStartTime = pieceIndex * dealingPieceInterval;
-        final animationEndTime = animationStartTime + dealingPieceDuration;
-        final currentGlobalTime = dealingAnimation.value * totalAnimationDuration;
-
-        double progress;
-        if (currentGlobalTime < animationStartTime) {
-          progress = 0.0;
-        } else if (currentGlobalTime >= animationEndTime) {
-          progress = 1.0;
-        } else {
-          progress = (currentGlobalTime - animationStartTime) / dealingPieceDuration;
-        }
-
-        double startOffX = startPieceCenterX - (j * board.pieceLogicalWidth + board.pieceLogicalWidth / 2);
-        double startOffY = startPieceCenterY - (i * board.pieceLogicalHeight + board.pieceLogicalHeight / 2);
+    final startPieceCenterX = startX * w + w / 2;
+    final startPieceCenterY = startY * h + h / 2;
 
-        _drawDealingPiece(canvas, size, i, j, startOffX, startOffY, progress);
-      }
-    }
-  }
+    final currentGlobalTime = dealingAnimation.value * totalAnimationDuration;
 
-  void _drawDealingPiece(Canvas canvas, Size size, int row, int col, double startOffsetX, double startOffsetY, double progress) {
-    final cardImg = board.cardImage;
-    if (cardImg == null) return;
+    for (int idx = 0; idx < totalPieces; idx++) {
+      final int i = idx ~/ board.cols;
+      final int j = idx % board.cols;
+      final double startTime = idx * dealingPieceInterval.toDouble();
+      if (currentGlobalTime < startTime) continue;
+      double progress = (currentGlobalTime - startTime) / dealingPieceDuration;
+      progress = progress.clamp(0.0, 1.0);
 
-    final curIndex = collectionIndex * board.count + row * board.cols + col;
+      final double targetLeft = j * w;
+      final double targetTop = i * h;
+      final double currentOffsetX = ui.lerpDouble(startPieceCenterX - (j * w + w / 2), 0.0, progress) ?? 0.0;
+      final double currentOffsetY = ui.lerpDouble(startPieceCenterY - (i * h + h / 2), 0.0, progress) ?? 0.0;
 
-    final w = board.pieceLogicalWidth;
-    final h = board.pieceLogicalHeight;
+      canvas.save();
+      canvas.translate(targetLeft + currentOffsetX, targetTop + currentOffsetY);
 
-    final targetLeft = col * w;
-    final targetTop = row * h;
-
-    final currentOffsetX = ui.lerpDouble(startOffsetX, 0.0, progress) ?? 0.0;
-    final currentOffsetY = ui.lerpDouble(startOffsetY, 0.0, progress) ?? 0.0;
-
-    canvas.save();
-    canvas.translate(targetLeft + currentOffsetX, targetTop + currentOffsetY);
-
-    final cardSourceRect = Rect.fromLTWH(0, 0, cardImg.width.toDouble(), cardImg.height.toDouble());
-    final rect = Rect.fromLTWH(0, 0, w, h);
-    canvas.drawImageRect(cardImg, cardSourceRect, rect, Paint()..isAntiAlias = true);
-
-    // ✅ 优化:使用缓存的 TextPainter 和布局结果
-    final (textPainter, textWidth, textHeight) = _getTextPainterWithLayout((curIndex + 1).toString(), h * 0.25, w);
-    textPainter.paint(canvas, Offset((w - textWidth) / 2, (h - textHeight) / 2));
-    canvas.restore();
+      if (pictures != null && idx < pictures.length) {
+        canvas.drawPicture(pictures[idx]);
+      } else {
+        // fallback to old drawing path
+        final cardImg = board.cardImage!;
+        final cardSourceRect = Rect.fromLTWH(0, 0, cardImg.width.toDouble(), cardImg.height.toDouble());
+        final rect = Rect.fromLTWH(0, 0, w, h);
+        canvas.drawImageRect(cardImg, cardSourceRect, rect, Paint()..isAntiAlias = true);
+        final int displayIdx = collectionIndex * board.count + idx;
+        final (textPainter, textWidth, textHeight) = CanvasPainter._getTextPainterWithLayout((displayIdx + 1).toString(), h * 0.25, w);
+        textPainter.paint(canvas, Offset((w - textWidth) / 2, (h - textHeight) / 2));
+        _drawBorders(canvas, 0, 0, w, h);
+      }
 
-    _drawBorders(canvas, targetLeft + currentOffsetX, targetTop + currentOffsetY, w, h);
+      canvas.restore();
+    }
   }
 
+
   void _paintUnlocking(Canvas canvas, Size size) {
     final img = board.image;
     if (img == null) return;

+ 4 - 4
lib/models/data.dart

@@ -18,10 +18,10 @@ class Data {
 
   Future<void> loadDataFromPersistence() async {
     // for test 为了测试合集完成动画
-    // var works = _persistence.completedWorks;
-    // works = works.sublist(0, works.length - 1);
-    // _persistence.completedWorks = works;
-    // _persistence.completedCollections = [];
+    var works = _persistence.completedWorks;
+    works = works.sublist(0, works.length - 1);
+    _persistence.completedWorks = works;
+    _persistence.completedCollections = [];
 
     // 1. 先初始化内置索引(独立、确定、不依赖缓存判断)
     await _initBuiltinRegistry();

+ 65 - 13
lib/play/board_play.dart

@@ -100,6 +100,10 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   Timer? _dealingPeriodicTimer;
   int _dealingCount = 0;
 
+  // Precomputed transforms for dealing animation to avoid per-frame allocations
+  List<vmath.Matrix4>? _dealingStartTransforms;
+  List<vmath.Matrix4>? _dealingEndTransforms;
+
   late AnimationController _successAnimationController;
   late Animation<double> _offsetAnimation;
   late Animation<double> _bottomSlideAnimation;
@@ -257,10 +261,18 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
 
   // ✅ 优化后的 _init() 方法 - 保留图片损坏检测和重试逻辑
   _init() async {
+    if (!mounted) return;
+
     Device device = context.read<Device>();
 
     setState(() => _isLoading = true);
 
+    // // 如果是低端设备,跳过发牌动画以节省资源(借鉴 homepage 优化)
+    // if (device.isLowEndDevice) {
+    //   showDealing = false;
+    //   _log.info('Low-end device detected, skipping dealing animation');
+    // }
+
     try {
       final dpr = device.effectivePixelRatio;
       final targetRect = device.targetRect;
@@ -483,17 +495,41 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
   void _dealingAnimationListener() {
     if (board == null) return;
     final currentTime = _dealingAnimation.value * _totalDealingDuration;
-    for (int i = 0; i < board!.pieces.length - 1; i++) {
-      final piece = board!.pieces[i];
-      final startTime = i * _dealingPieceInterval;
-      final duration = _dealingPieceDuration;
-      if (currentTime < startTime) continue;
-      double progress = (currentTime - startTime) / duration;
-      progress = progress.clamp(0.0, 1.0);
-      final startTransform = board!.getBottomRightTransform();
-      final endTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
-      final tween = Matrix4Tween(begin: startTransform, end: endTransform);
-      piece.transform = tween.lerp(progress);
+    final pieces = board!.pieces;
+    // 使用预计算的起始/结束变换进行插值,避免在帧中创建大量 Matrix4Tween/Matrix4 对象
+    if (_dealingStartTransforms != null && _dealingEndTransforms != null) {
+      for (int i = 0; i < pieces.length; i++) {
+        final piece = pieces[i];
+        final startTime = i * _dealingPieceInterval;
+        final duration = _dealingPieceDuration;
+        if (currentTime < startTime) continue;
+        double progress = (currentTime - startTime) / duration;
+        progress = progress.clamp(0.0, 1.0);
+
+        final begin = _dealingStartTransforms![i];
+        final end = _dealingEndTransforms![i];
+
+        // 直接在 double 数组上做线性插值,减少中间对象分配
+        final List<double> lerped = List<double>.filled(16, 0.0);
+        for (int k = 0; k < 16; k++) {
+          lerped[k] = ui.lerpDouble(begin.storage[k], end.storage[k], progress)!;
+        }
+        piece.transform = vmath.Matrix4.fromList(lerped);
+      }
+    } else {
+      // 兼容回退:老逻辑(尽量不会走到这里)
+      for (int i = 0; i < board!.pieces.length - 1; i++) {
+        final piece = board!.pieces[i];
+        final startTime = i * _dealingPieceInterval;
+        final duration = _dealingPieceDuration;
+        if (currentTime < startTime) continue;
+        double progress = (currentTime - startTime) / duration;
+        progress = progress.clamp(0.0, 1.0);
+        final startTransform = board!.getBottomRightTransform();
+        final endTransform = board!.getTransformByCoordinate(piece.curRow, piece.curCol);
+        final tween = Matrix4Tween(begin: startTransform, end: endTransform);
+        piece.transform = tween.lerp(progress);
+      }
     }
     board!.invalidate();
   }
@@ -504,6 +540,9 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
       board!.shuffle(ShuffleStep.flipping);
       flipAnimationController.forward(from: 0.0);
       audio.playSfx(SfxType.flip);
+      // 清理预计算的 transform 缓存
+      _dealingStartTransforms = null;
+      _dealingEndTransforms = null;
     }
   }
 
@@ -614,8 +653,21 @@ class _BoardPlayState extends AdsState<BoardPlay> with TickerProviderStateMixin
       }
       board!.setAllPieceToBottomRight();
       board!.shuffle(ShuffleStep.dealing);
-      dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
-      dealingAnimationController.forward(from: 0.0);
+      // 如果发牌动画需要播放,预计算每个碎片的起始/结束变换,避免在动画回调中大量分配对象
+      if (showDealing) {
+        _dealingStartTransforms = [];
+        _dealingEndTransforms = [];
+        final vmath.Matrix4 startTransform = board!.getBottomRightTransform();
+        for (final piece in board!.pieces) {
+          _dealingStartTransforms!.add(startTransform);
+          _dealingEndTransforms!.add(board!.getTransformByCoordinate(piece.curRow, piece.curCol));
+        }
+        dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
+        dealingAnimationController.forward(from: 0.0);
+      } else {
+        // 如果跳过发牌,直接进入翻牌/播放状态
+        board!.start();
+      }
       audio.playSfx(SfxType.card);
       _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 130), (timer) {
         if (mounted) {