1+ import { displaySync } from '@kit.ArkGraphics2D';
12import * as model from '../core/OfficeModel';
23import { RenderUtils } from './RenderUtils';
34
@@ -7,6 +8,23 @@ const GRID_COLOR = '#dce1e5';
78const HEADER_BG = '#f3f5f7';
89const HEADER_TEXT = '#52606d';
910
11+ /**
12+ * 预计算 0..255 列标字母,彻底消除表头绘制时的高频字符串循环拼接
13+ */
14+ const COLUMN_LETTERS: string[] = (() => {
15+ const arr: string[] = new Array(256);
16+ for (let i = 0; i < 256; i++) {
17+ let letter = '';
18+ let temp = i;
19+ while (temp >= 0) {
20+ letter = String.fromCharCode((temp % 26) + 65) + letter;
21+ temp = Math.floor(temp / 26) - 1;
22+ }
23+ arr[i] = letter;
24+ }
25+ return arr;
26+ })();
27+
1028/**
1129 * 轴前缀和与二分查找空间索引
1230 */
@@ -67,8 +85,10 @@ class SheetMergeItem {
6785 * 1. 采用 rowStarts 稀疏索引与二分查找,仅遍历视口内可见单元格,彻底消除全表 O(N) 扫描
6886 * 2. 采用 Uint8Array 记录合并从属关系,消除每帧几十万次字符串分配与垃圾回收 (GC)
6987 * 3. 预先计算无缩放基础前缀和,重绘时零数组分配
88+ * 4. 采用 64 行分桶空间索引与代际去重,避免遍历全表千量级合并单元格
7089 */
7190class SheetLayoutData {
91+ static readonly BUCKET_ROW_SIZE = 64;
7292 readonly sheet: model.Sheet;
7393 readonly rows: SheetAxis;
7494 readonly columns: SheetAxis;
@@ -77,6 +97,9 @@ class SheetLayoutData {
7797 readonly rowStarts: Int32Array;
7898 readonly mergedCells: Uint8Array;
7999 readonly merges: SheetMergeItem[];
100+ readonly mergeBuckets: Int32Array[];
101+ readonly mergeSeenGen: Int32Array;
102+ currentGen: number = 0;
80103
81104 constructor(sheet: model.Sheet) {
82105 this.sheet = sheet;
@@ -123,6 +146,27 @@ class SheetLayoutData {
123146 this.merges.push(new SheetMergeItem(left, top, right, bottom, anchor));
124147 }
125148 }
149+
150+ const bucketCount = Math.max(1, Math.ceil(rowCount / SheetLayoutData.BUCKET_ROW_SIZE) + 1);
151+ const rawBuckets: number[][] = [];
152+ for (let b = 0; b < bucketCount; b++) {
153+ rawBuckets.push([]);
154+ }
155+ if (sheet.merges) {
156+ for (let mIdx = 0; mIdx < this.merges.length; mIdx++) {
157+ const m = sheet.merges[mIdx];
158+ const startB = Math.max(0, Math.min(bucketCount - 1, Math.floor(m.startRow / SheetLayoutData.BUCKET_ROW_SIZE)));
159+ const endB = Math.max(startB, Math.min(bucketCount - 1, Math.floor(m.endRow / SheetLayoutData.BUCKET_ROW_SIZE)));
160+ for (let b = startB; b <= endB; b++) {
161+ rawBuckets[b].push(mIdx);
162+ }
163+ }
164+ }
165+ this.mergeBuckets = new Array(bucketCount);
166+ for (let b = 0; b < bucketCount; b++) {
167+ this.mergeBuckets[b] = new Int32Array(rawBuckets[b]);
168+ }
169+ this.mergeSeenGen = new Int32Array(this.merges.length);
126170 }
127171
128172 // 二分查找:找到指定行中,列号 >= column 的第一个单元格下标
@@ -141,6 +185,13 @@ class SheetLayoutData {
141185 }
142186}
143187
188+ interface CachedCellWrap {
189+ readonly scaleKey: number;
190+ readonly availWKey: number;
191+ readonly maxLines: number;
192+ readonly lines: string[];
193+ }
194+
144195/**
145196 * XLSX 电子表格原生渲染组件:支持多工作表 Sheet 切换、视口虚拟化渲染、物理惯性滑动与手势缩放
146197 */
@@ -175,12 +226,18 @@ export struct SpreadsheetView {
175226 private pinchStartOffsetX: number = 0;
176227 private pinchStartOffsetY: number = 0;
177228
178- // 惯性滑动 (Fling) 物理仿真状态
229+ // 惯性滑动 (Fling) 物理仿真状态与硬件 VSync
179230 private flingTimer: number = -1;
180231 private retryTimer: number = -1;
181232 private flingVx: number = 0;
182233 private flingVy: number = 0;
183234 private lastFlingTime: number = 0;
235+ private vsync: displaySync.DisplaySync | null = null;
236+ private flingRunning: boolean = false;
237+ private lastVsyncTimestamp: number = 0;
238+
239+ // 单元格折行排版 WeakMap 缓存,避免平移滑动时重复进行 measureText 二分折行排版
240+ private readonly cellWrapCache: WeakMap<model.Cell, CachedCellWrap> = new WeakMap();
184241
185242 // 缓存当前 Sheet 的空间索引与几何布局(完全对齐安卓端的 SheetLayout)
186243 private cachedSheet: model.Sheet | null = null;
@@ -262,6 +319,14 @@ export struct SpreadsheetView {
262319 aboutToDisappear(): void {
263320 this.isReady = false;
264321 this.stopFling();
322+ if (this.vsync) {
323+ try {
324+ this.vsync.off('frame');
325+ } catch (_e) {
326+ // ignore
327+ }
328+ this.vsync = null;
329+ }
265330 if (this.retryTimer !== -1) {
266331 clearTimeout(this.retryTimer);
267332 this.retryTimer = -1;
@@ -271,6 +336,15 @@ export struct SpreadsheetView {
271336 }
272337
273338 private stopFling(): void {
339+ this.flingRunning = false;
340+ this.lastVsyncTimestamp = 0;
341+ if (this.vsync) {
342+ try {
343+ this.vsync.stop();
344+ } catch (_e) {
345+ // ignore
346+ }
347+ }
274348 if (this.flingTimer !== -1) {
275349 clearTimeout(this.flingTimer);
276350 this.flingTimer = -1;
@@ -290,15 +364,45 @@ export struct SpreadsheetView {
290364 }
291365 this.flingVx = vx;
292366 this.flingVy = vy;
367+ this.flingRunning = true;
368+ this.lastVsyncTimestamp = 0;
293369 this.lastFlingTime = Date.now();
294- this.stepFling();
370+
371+ if (!this.vsync) {
372+ try {
373+ this.vsync = displaySync.create();
374+ this.vsync.on('frame', (frameInfo: displaySync.IntervalInfo) => {
375+ if (!this.flingRunning) return;
376+ const ts = frameInfo.timestamp;
377+ let dt = 0.016;
378+ if (this.lastVsyncTimestamp > 0 && ts > this.lastVsyncTimestamp) {
379+ dt = Math.min(0.032, Math.max(0.001, (ts - this.lastVsyncTimestamp) / 1e9));
380+ }
381+ this.lastVsyncTimestamp = ts;
382+ this.stepFlingWithDt(dt);
383+ });
384+ } catch (_e) {
385+ this.vsync = null;
386+ }
387+ }
388+
389+ if (this.vsync) {
390+ try {
391+ this.vsync.start();
392+ } catch (_e) {
393+ this.vsync = null;
394+ this.stepFlingFallback();
395+ }
396+ } else {
397+ this.stepFlingFallback();
398+ }
295399 }
296400
297- private stepFling = ( ): void => {
298- if (this.flingVx === 0 && this.flingVy === 0) return;
299- const now = Date.now ();
300- const dt = Math.min(0.032, Math.max(0.001, (now - this.lastFlingTime) / 1000)) ;
301- this.lastFlingTime = now;
401+ private stepFlingWithDt(dt: number ): void {
402+ if (! this.flingRunning || (this. flingVx === 0 && this.flingVy === 0)) {
403+ this.stopFling ();
404+ return ;
405+ }
302406
303407 // 指数阻尼衰减
304408 const friction = 2.8;
@@ -326,7 +430,17 @@ export struct SpreadsheetView {
326430 }
327431
328432 this.redraw();
329- this.flingTimer = setTimeout(this.stepFling, 16);
433+ }
434+
435+ private stepFlingFallback = (): void => {
436+ if (!this.flingRunning) return;
437+ const now = Date.now();
438+ const dt = Math.min(0.032, Math.max(0.001, (now - this.lastFlingTime) / 1000));
439+ this.lastFlingTime = now;
440+ this.stepFlingWithDt(dt);
441+ if (this.flingRunning) {
442+ this.flingTimer = setTimeout(this.stepFlingFallback, 16);
443+ }
330444 };
331445
332446 private getCurrentSheet(): model.Sheet | undefined {
@@ -483,30 +597,57 @@ export struct SpreadsheetView {
483597 }
484598 ctx.stroke();
485599
486- // 1.4 绘制可见的合并单元格(覆盖内部网格线并绘制锚点文本)
600+ // 1.4 绘制可见的合并单元格(覆盖内部网格线并绘制锚点文本,使用行分桶与代际去重 )
487601 const visibleMerges: SheetMergeItem[] = [];
488- for (const m of layout.merges) {
489- if (m.right <= unscaledScrollX || m.left >= unscaledBodyRight ||
490- m.bottom <= unscaledScrollY || m.top >= unscaledBodyBottom) {
491- continue;
492- }
493- visibleMerges.push(m);
494- const left = HEADER_WIDTH + ox + m.left * s;
495- const top = HEADER_HEIGHT + oy + m.top * s;
496- const right = HEADER_WIDTH + ox + m.right * s;
497- const bottom = HEADER_HEIGHT + oy + m.bottom * s;
498- const mergeW = right - left;
499- const mergeH = bottom - top;
500- if (mergeW <= 0 || mergeH <= 0) continue;
501-
502- ctx.fillStyle = '#ffffff';
503- ctx.fillRect(left, top, mergeW, mergeH);
504- if (m.anchor) {
505- this.drawSingleCell(ctx, m.anchor, left, top, mergeW, mergeH, s);
602+ layout.currentGen++;
603+ if (layout.currentGen >= 0x7FFFFFFF) {
604+ layout.currentGen = 1;
605+ layout.mergeSeenGen.fill(0);
606+ }
607+ const currentGen = layout.currentGen;
608+
609+ const startRow = firstRowIdx < rows.visible.length ? rows.visible[firstRowIdx] : 0;
610+ let endRow = startRow;
611+ const lastVisIdx = rows.first(unscaledBodyBottom);
612+ if (lastVisIdx < rows.visible.length) {
613+ endRow = rows.visible[lastVisIdx];
614+ } else if (rows.visible.length > 0) {
615+ endRow = rows.visible[rows.visible.length - 1];
616+ }
617+ const startB = Math.max(0, Math.min(layout.mergeBuckets.length - 1, Math.floor(startRow / SheetLayoutData.BUCKET_ROW_SIZE)));
618+ const endB = Math.max(startB, Math.min(layout.mergeBuckets.length - 1, Math.floor(endRow / SheetLayoutData.BUCKET_ROW_SIZE)));
619+
620+ for (let b = startB; b <= endB; b++) {
621+ const bucket = layout.mergeBuckets[b];
622+ for (let k = 0; k < bucket.length; k++) {
623+ const mIdx = bucket[k];
624+ if (layout.mergeSeenGen[mIdx] === currentGen) {
625+ continue;
626+ }
627+ layout.mergeSeenGen[mIdx] = currentGen;
628+ const m = layout.merges[mIdx];
629+ if (m.right <= unscaledScrollX || m.left >= unscaledBodyRight ||
630+ m.bottom <= unscaledScrollY || m.top >= unscaledBodyBottom) {
631+ continue;
632+ }
633+ visibleMerges.push(m);
634+ const left = HEADER_WIDTH + ox + m.left * s;
635+ const top = HEADER_HEIGHT + oy + m.top * s;
636+ const right = HEADER_WIDTH + ox + m.right * s;
637+ const bottom = HEADER_HEIGHT + oy + m.bottom * s;
638+ const mergeW = right - left;
639+ const mergeH = bottom - top;
640+ if (mergeW <= 0 || mergeH <= 0) continue;
641+
642+ ctx.fillStyle = '#ffffff';
643+ ctx.fillRect(left, top, mergeW, mergeH);
644+ if (m.anchor) {
645+ this.drawSingleCell(ctx, m.anchor, left, top, mergeW, mergeH, s);
646+ }
647+ ctx.strokeStyle = GRID_COLOR;
648+ ctx.lineWidth = 0.5;
649+ ctx.strokeRect(left, top, mergeW, mergeH);
506650 }
507- ctx.strokeStyle = GRID_COLOR;
508- ctx.lineWidth = 0.5;
509- ctx.strokeRect(left, top, mergeW, mergeH);
510651 }
511652
512653 // 1.5 绘制可见单元格边框 (style.borders)
@@ -665,42 +806,55 @@ export struct SpreadsheetView {
665806 rawText = rawText.substring(0, end) + '\u2026';
666807 }
667808
668- const finalLines: string[] = [];
809+ let finalLines: string[] = [];
669810 if (style?.wrap) {
670- const paragraphs = rawText.split('\n');
671- for (const p of paragraphs) {
672- if (finalLines.length >= maxLines) break;
673- if (p.length === 0) {
674- finalLines.push('');
675- continue;
676- }
677- let remaining = p;
678- while (remaining.length > 0 && finalLines.length < maxLines) {
679- const w = ctx.measureText(remaining).width;
680- if (w <= availW) {
681- finalLines.push(remaining);
682- break;
811+ const scaleKey = Math.round(scale * 1000);
812+ const availWKey = Math.round(availW);
813+ const cached = this.cellWrapCache.get(cell);
814+ if (cached && cached.scaleKey === scaleKey && cached.availWKey === availWKey && cached.maxLines === maxLines) {
815+ finalLines = cached.lines;
816+ } else {
817+ const paragraphs = rawText.split('\n');
818+ for (const p of paragraphs) {
819+ if (finalLines.length >= maxLines) break;
820+ if (p.length === 0) {
821+ finalLines.push('');
822+ continue;
683823 }
684- // 二分查找单行最大字符数
685- let low = 1;
686- let high = remaining.length;
687- let best = 1;
688- while (low <= high) {
689- const mid = (low + high) >>> 1;
690- if (ctx.measureText(remaining.substring(0, mid)).width <= availW) {
691- best = mid;
692- low = mid + 1;
693- } else {
694- high = mid - 1;
824+ let remaining = p;
825+ while (remaining.length > 0 && finalLines.length < maxLines) {
826+ const w = ctx.measureText(remaining).width;
827+ if (w <= availW) {
828+ finalLines.push(remaining);
829+ break;
695830 }
831+ // 二分查找单行最大字符数
832+ let low = 1;
833+ let high = remaining.length;
834+ let best = 1;
835+ while (low <= high) {
836+ const mid = (low + high) >>> 1;
837+ if (ctx.measureText(remaining.substring(0, mid)).width <= availW) {
838+ best = mid;
839+ low = mid + 1;
840+ } else {
841+ high = mid - 1;
842+ }
843+ }
844+ // 代理对保护:切到高代理时避免劈半产生非法 Unicode 字符
845+ if (best < remaining.length && remaining.charCodeAt(best - 1) >= 0xD800 && remaining.charCodeAt(best - 1) <= 0xDBFF) {
846+ best = best > 1 ? best - 1 : best + 1;
847+ }
848+ finalLines.push(remaining.substring(0, best));
849+ remaining = remaining.substring(best);
696850 }
697- // 代理对保护:切到高代理时避免劈半产生非法 Unicode 字符
698- if (best < remaining.length && remaining.charCodeAt(best - 1) >= 0xD800 && remaining.charCodeAt(best - 1) <= 0xDBFF) {
699- best = best > 1 ? best - 1 : best + 1;
700- }
701- finalLines.push(remaining.substring(0, best));
702- remaining = remaining.substring(best);
703851 }
852+ this.cellWrapCache.set(cell, {
853+ scaleKey,
854+ availWKey,
855+ maxLines,
856+ lines: finalLines
857+ });
704858 }
705859 } else {
706860 if (rawText.indexOf('\n') !== -1) {
@@ -793,6 +947,9 @@ export struct SpreadsheetView {
793947 }
794948
795949 private indexToColumnLetter(index: number): string {
950+ if (index >= 0 && index < COLUMN_LETTERS.length) {
951+ return COLUMN_LETTERS[index];
952+ }
796953 let letter = '';
797954 let temp = index;
798955 while (temp >= 0) {
0 commit comments