perf(性能优化): 优化保存快照与页面缓存
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-19 20:57:44 +08:00
1 parent 66aa87304b
commit 4f530eb5b8
5 files changed
+142 -29

No files matched your search

+44 -8
View File
@@ -7,6 +7,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300;
const PAGE_CACHE_LIMIT = 16;
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
const svgFontBytes = new WeakMap();
const PAGE_LAYOUT_GAP = 16;
const SCROLL_SEGMENT_SIZE = 1000000;
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
@@ -139,6 +140,8 @@ const state = {
documentSelection: null,
pageInFlight: new Map(),
pageRenderQueue: [],
pagePrefetchIndex: null,
pagePrefetchDirection: 1,
pageRenderRunning: false,
pageRenderWorkers: 0,
pageObserver: null,
@@ -8982,6 +8985,8 @@ function resetPageLoading() {
task.resolve(null);
}
state.pageRenderQueue = [];
state.pagePrefetchIndex = null;
state.pagePrefetchDirection = 1;
state.pageInFlight.clear();
resetThumbnails();
if (state.pageObserver) {
@@ -9209,17 +9214,21 @@ function trimPageCache() {
for (const task of state.pageInFlight.values()) {
protectedPages.add(task.index);
}
const references = new Map();
const references = new Map(), fontReferences = new Map();
let bytes = 0;
for (const page of state.pageCache.values()) {
bytes += page.cacheBytes;
for (const name of page.imageNames) {
references.set(name, (references.get(name) || 0) + 1);
}
for (const font of page.fonts || []) {
fontReferences.set(font.name, (fontReferences.get(font.name) || 0) + 1);
}
}
for (const image of state.svgImages.values()) {
bytes += image.size;
}
for (const face of state.svgFonts.values()) bytes += svgFontBytes.get(face) || 0;
const budget = nativeHost?.cacheBytes || PAGE_CACHE_BYTES;
for (const [index, page] of state.pageCache) {
if (state.pageCache.size <= PAGE_CACHE_LIMIT * Math.max(1, budget / PAGE_CACHE_BYTES) && bytes <= budget) {
@@ -9235,9 +9244,23 @@ function trimPageCache() {
references.set(name, count);
if (count === 0 && !state.pageInFlight.size) {
const image = state.svgImages.get(name);
bytes -= image.size;
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
if (image) {
bytes -= image.size;
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
}
}
}
for (const font of page.fonts || []) {
const count = fontReferences.get(font.name) - 1;
fontReferences.set(font.name, count);
if (count === 0 && !state.pageInFlight.size) {
const face = state.svgFonts.get(font.name);
if (face) {
bytes -= svgFontBytes.get(face) || 0;
document.fonts.delete(face);
state.svgFonts.delete(font.name);
}
}
}
}
@@ -9274,6 +9297,7 @@ async function loadSVGFont(font, openSeq) {
return;
}
document.fonts.add(face);
svgFontBytes.set(face, data.bytes.byteLength);
state.svgFonts.set(font.name, face);
}
@@ -9889,10 +9913,22 @@ function markFlowPageError(index, err) {
function queueNearbyPages(index, openSeq = state.openSeq) {
const start = pageGroupStart(index);
const before = nativeHost ? state.pageLayout : 1;
const after = nativeHost ? Math.max(1, Math.min(nativeHost.parallelism, state.pageLayout * 2)) : 1;
for (let i = Math.max(0, start - before); i <= Math.min((state.doc?.pageCount || 1) - 1, start + state.pageLayout + after); i += 1) {
renderFlowPage(i, { openSeq, priority: 2 });
if (state.pagePrefetchIndex !== null && start !== state.pagePrefetchIndex) state.pagePrefetchDirection = Math.sign(start - state.pagePrefetchIndex);
const direction = state.pagePrefetchDirection;
state.pagePrefetchIndex = start;
let bytes = 0;
for (const page of state.pageCache.values()) bytes += page.cacheBytes || 0;
for (const image of state.svgImages.values()) bytes += image.size;
for (const face of state.svgFonts.values()) bytes += svgFontBytes.get(face) || 0;
const budget = nativeHost?.cacheBytes || PAGE_CACHE_BYTES;
const cost = Math.max(1 << 20, bytes / Math.max(1, state.pageCache.size));
const count = Math.max(0, Math.min(nativeHost?.parallelism || 1, state.pageLayout * 2, Math.floor((budget - bytes) / cost)));
for (let i = start; i < Math.min(state.doc?.pageCount || 1, start + state.pageLayout); i += 1) {
renderFlowPage(i, { openSeq, priority: 1 });
}
for (let offset = 1; offset <= count; offset += 1) {
const next = direction > 0 ? start + state.pageLayout + offset - 1 : start - offset;
if (next >= 0 && next < (state.doc?.pageCount || 1)) renderFlowPage(next, { openSeq, priority: 6 });
}
}
+1
View File
@@ -74,6 +74,7 @@ type Editor struct {
encryption *encryptionState
output *outputOptimization
saveTarget Output
snapshotArchive *editorSnapshotArchive
validatedPaths *renderCache[string, bool]
richIndex *richTextIndex
}
+1 -1
View File
@@ -817,7 +817,7 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re
files = make(map[string][]byte)
}
maps.Copy(files, parts)
reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion}
reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion, pageContents: e.source.reader.pageContents}
if err := reader.initRootContext(ctx); err != nil {
return nil, err
}
+42 -4
View File
@@ -18,6 +18,7 @@ import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/xml"
"fmt"
"io"
@@ -48,6 +49,7 @@ type Reader struct {
drawParamCache map[string]*DrawParam
compositeGraphicUnitCache map[string]*CompositeGraphicUnit
pageHeaderCache map[string]PageContent
pageContents *renderCache[pageContentKey, PageContent]
doc *Document
relatedPending bool
documentIndex int
@@ -63,6 +65,12 @@ type Reader struct {
encryption *encryptionState
}
// pageContentKey 按页面原文及解析模式区分缓存,不包含文档资源和区域继承
type pageContentKey struct {
digest [32]byte
textOnly bool
}
// documentPageList 仅读取文档中的页面索引,统计页数时不构建其他结构
type documentPageList struct {
Pages Pages
@@ -104,6 +112,9 @@ func (r *Reader) Fork(ctx context.Context) (*Reader, error) {
if _, err := r.DocContext(ctx); err != nil {
return nil, err
}
if r.pageContents == nil {
r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20}
}
next := *r
next.Closer = nil
next.resourcesRead = maps.Clone(r.resourcesRead)
@@ -768,6 +779,9 @@ func (r *Reader) initRootContext(ctx context.Context) error {
return err
}
r.fileIndex, r.fileNames = view.fileIndex, view.fileNames
if r.pageContents == nil {
r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20}
}
r.OFD = &ofd
r.doc, r.versionInfo = nil, nil
r.documentRoot, r.RootDir = "", ""
@@ -1085,12 +1099,21 @@ func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, templ
if err != nil {
return nil, err
}
if r.pageContents == nil {
r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20}
}
var content PageContent
decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if textOnly {
err = xml.NewTokenDecoder(&textPageTokens{Decoder: decoder}).Decode(&content)
if len(data) > (r.pageContents.limit/4-4096)/16 {
content, err = decodePageContent(ctx, data, textOnly)
} else {
err = decoder.Decode(&content)
var parsed PageContent
parsed, err = r.pageContents.load(ctx, pageContentKey{sha256.Sum256(data), textOnly}, func() (PageContent, int, error) {
content, err := decodePageContent(ctx, data, textOnly)
return content, len(data)*16 + 4096, err
})
if err == nil {
content = cloneEditorData(parsed)
}
}
if err != nil {
return nil, fmt.Errorf("failed to unmarshal page content: %w", err)
@@ -1114,6 +1137,21 @@ func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, templ
return &content, nil
}
// decodePageContent 解析未应用页面资源和区域继承的原始内容
// 入参: ctx 取消上下文, data 页面XML, textOnly 是否仅读取文字
// 返回: PageContent 页面内容, error 解析错误
func decodePageContent(ctx context.Context, data []byte, textOnly bool) (PageContent, error) {
var content PageContent
decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
var err error
if textOnly {
err = xml.NewTokenDecoder(&textPageTokens{Decoder: decoder}).Decode(&content)
} else {
err = decoder.Decode(&content)
}
return content, err
}
// loadPageResources 加载页面资源,不解析图元
// 入参: page 页面引用
func (r *Reader) loadPageResources(page Page) {
+54 -16
View File
@@ -30,10 +30,14 @@ import (
"strconv"
"strings"
"time"
"weak"
basefile "github.com/xiaoqidun/ofdgo/internal/base/file"
)
// editorSnapshotArchiveLimit 限制编辑器常驻的快照压缩包字节数
const editorSnapshotArchiveLimit = 32 << 20
// ErrSaveTarget 表示编辑器尚未绑定保存目标
var ErrSaveTarget = errors.New("save target is not set")
@@ -65,6 +69,13 @@ type fileOutput struct {
target *basefile.Target
}
// editorSnapshotArchive 复用独立压缩包,超预算时仅在已有快照存活期间共享
type editorSnapshotArchive struct {
files []*zip.File
archive weak.Pointer[zip.Reader]
retained *zip.Reader
}
// editorProgress 编辑与保存阶段的进度回调,nil表示不回报进度
type editorProgress func(stage string, completed, total int) error
@@ -91,7 +102,7 @@ type ofdAttrs []xml.Attr
// Snapshot 创建独立编辑快照,完成后可与原编辑器并行使用
// 创建过程需与原实例串行,保留修订及保存目标,不复制撤销记录和进度回调
// 保存目标与原实例共享,由输出实现协调并发事务及提交版本
// 原包压缩数据直接复制,快照不依赖原阅读器及文件句柄,内存随快照释放
// 原包压缩数据独立复制并在快照间复用,超预算数据随最后一个快照释放
// 自定义后端及字体文件系统仍由调用方提供,需支持并行读取
// 入参: ctx 取消上下文
// 返回: *Editor 独立快照, error 准备或取消错误
@@ -106,21 +117,7 @@ func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) {
next.encryption = &encryptionState{info: cloneEditorData(reader.encryption.info), options: cloneEncryptionOptions(reader.encryption.options)}
}
if reader.Zip != nil {
var data bytes.Buffer
var count int64
archive := zip.NewWriter(convertWriter{ctx: ctx, writer: &data, count: &count})
for _, file := range reader.Zip.File {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := archive.Copy(file); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
next.Zip, err = zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len()))
next.Zip, err = e.copySnapshotArchive(ctx, reader.Zip)
if err != nil {
return nil, err
}
@@ -143,6 +140,47 @@ func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) {
return snapshot, nil
}
// copySnapshotArchive 复用条目未变的独立压缩包,取消或失败不发布缓存
// 入参: ctx 取消上下文, source 原压缩包
// 返回: *zip.Reader 独立压缩包, error 复制错误
func (e *Editor) copySnapshotArchive(ctx context.Context, source *zip.Reader) (*zip.Reader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if cached := e.snapshotArchive; cached != nil && slices.Equal(cached.files, source.File) {
if archive := cached.archive.Value(); archive != nil {
return archive, nil
}
}
var data bytes.Buffer
var count int64
archive := zip.NewWriter(convertWriter{ctx: ctx, writer: &data, count: &count})
for _, file := range source.File {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := archive.Copy(file); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
reader, err := zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len()))
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
cached := &editorSnapshotArchive{files: slices.Clone(source.File), archive: weak.Make(reader)}
if data.Cap() <= editorSnapshotArchiveLimit {
cached.retained = reader
}
e.snapshotArchive = cached
return reader, nil
}
// NewFileOutput 创建本地保存目标并记录当前文件状态,不创建或修改文件
// 同一目标应复用返回值,以便后续保存检测外部修改
// 替换前比较文件标识、大小和修改时间,不提供跨进程排他锁