perf(优化性能): 优化原生资源传输与释放
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-10-17 20:31:26 +08:00
1 parent 253debb3f9
commit e261287baa
5 files changed
+190 -35

No files matched your search

+38 -8
View File
@@ -58,7 +58,11 @@ class NativeHandle {
async createWritable() { async createWritable() {
const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle}); const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle});
return { return {
write: async data => this.host.call("write.data", {writer, data:await this.host.encode(data)}), write: async data => {
const resources = new Set();
try { return await this.host.call("write.data", {writer, data:await this.host.encode(data, "", resources)}); }
finally { for (const file of resources) await this.host.call("release", {file}).catch(() => {}); }
},
close: () => this.host.call("write.commit", {writer}), close: () => this.host.call("write.commit", {writer}),
abort: () => this.host.call("write.abort", {writer}), abort: () => this.host.call("write.abort", {writer}),
}; };
@@ -77,7 +81,7 @@ class NativeHost {
return this.bridge.call(`ofdgo.host.${name}`, value); return this.bridge.call(`ofdgo.host.${name}`, value);
} }
async encode(value, key = "") { async encode(value, key = "", resources = null) {
if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name}; if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name};
if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind}; if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind};
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
@@ -87,12 +91,19 @@ class NativeHost {
} }
const response = await fetch("/__native/input", {method:"POST", body:value}); const response = await fetch("/__native/input", {method:"POST", body:value});
if (!response.ok) throw new Error("传递数据失败"); if (!response.ok) throw new Error("传递数据失败");
return response.json(); const file = await response.json();
resources?.add(file.nativeFile);
return file;
}
if (Array.isArray(value)) {
const results = await Promise.allSettled(value.map(item => this.encode(item, "", resources)));
const failure = results.find(item => item.status === "rejected");
if (failure) throw failure.reason;
return results.map(item => item.value);
} }
if (Array.isArray(value)) return Promise.all(value.map(item => this.encode(item)));
if (value && typeof value === "object") { if (value && typeof value === "object") {
const result = {}; const result = {};
for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key); for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key, resources);
return result; return result;
} }
return value; return value;
@@ -118,12 +129,23 @@ class NativeHost {
return value; return value;
} }
async release(value) {
if (value?.nativeFile && value.temporary) {
await this.call("release", {file:value.nativeFile}).catch(() => {});
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) await this.release(item);
}
}
worker() { worker() {
const host = this; const host = this;
let closed = false; let closed = false;
let unsubscribe; let unsubscribe;
let pending = Promise.resolve(); let pending = Promise.resolve();
const canceled = new Set(); const canceled = new Set();
const resources = new Set();
const emit = data => { if (!closed) worker.onmessage?.({data}); }; const emit = data => { if (!closed) worker.onmessage?.({data}); };
const ready = host.call("engine.open", {}).then(({engine}) => { const ready = host.call("engine.open", {}).then(({engine}) => {
if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; } if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; }
@@ -146,18 +168,26 @@ class NativeHost {
const engine = await ready; const engine = await ready;
if (closed) return; if (closed) return;
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
const args = await host.encode(data.args); const args = await host.encode(data.args, "", resources);
if (closed) return; if (closed) return;
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args}); const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args});
emit({id:data.id, ...await host.decode(result, data.name)}); if (closed) { await host.release(result); return; }
const decoded = await host.decode(result, data.name);
if (closed) { await host.release(result); return; }
emit({id:data.id, ...decoded});
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id)); }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id));
}, },
terminate() { terminate() {
if (closed) return; if (closed) return;
closed = true; closed = true;
unsubscribe?.(); unsubscribe?.();
ready.then(engine => host.call("engine.close", {engine})).catch(() => {}); const closing = ready.then(engine => host.call("engine.close", {engine})).catch(() => {});
pending.finally(async () => {
await closing;
for (const file of resources) await host.call("release", {file}).catch(() => {});
resources.clear();
});
}, },
}; };
ready.catch(() => {}); ready.catch(() => {});
+1 -1
View File
@@ -8,7 +8,7 @@ require (
github.com/go-text/typesetting v0.3.5 github.com/go-text/typesetting v0.3.5
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd
github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1 github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1
+2 -2
View File
@@ -58,8 +58,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f h1:LReZHupfHnvaOvjGMdPShKDArLMOEF5Fwm8HjfOR9n4= github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd h1:IzKVmVXKjcaClHFCou3MQmSuSq+dWIde7m4AwSmuCM0=
github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE= github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE=
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
+93 -13
View File
@@ -17,6 +17,7 @@
package webview package webview
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
@@ -56,9 +57,10 @@ type Host struct {
pendingPaths []string pendingPaths []string
} }
// resourceReader 保持资源独立的读取位置,文件生命周期由窗口管理 // resourceReader 保存资源读取位置及关闭操作
type resourceReader struct { type resourceReader struct {
*io.SectionReader *io.SectionReader
close func() error
} }
// Run 启动共用WebUI的原生入口 // Run 启动共用WebUI的原生入口
@@ -115,27 +117,105 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
} }
// Close 结束本次资源读取 // Close 结束本次资源读取
func (r *resourceReader) Close() error { return nil } // 返回: error 关闭错误
func (r *resourceReader) Close() error {
if r.close != nil {
return r.close()
}
return nil
}
// resource 将大文件直接交给宿主资源流 // resource 将静态资源及文件直接交给宿主资源流
// 入参: r 资源请求
// 返回: *guigo.Resource 资源响应, error 读取错误
func (h *Host) resource(r *http.Request) (*guigo.Resource, error) { func (h *Host) resource(r *http.Request) (*guigo.Resource, error) {
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Header.Get("Range") != "" { if r.Method != http.MethodGet && r.Method != http.MethodHead {
return nil, nil return nil, nil
} }
id := strings.TrimPrefix(r.URL.Path, "/__native/data/") for _, name := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", "If-Range"} {
reader, size, err := h.files.resolve(id) if r.Header.Get(name) != "" {
if err != nil { return nil, nil
return nil, nil }
}
var reader io.ReaderAt
var size int64
var close func() error
transferred := false
defer func() {
if !transferred && close != nil {
close()
}
}()
if strings.HasPrefix(r.URL.Path, "/__native/data/") {
var err error
reader, size, close, err = h.files.resource(strings.TrimPrefix(r.URL.Path, "/__native/data/"))
if err != nil {
return nil, nil
}
} else {
name := strings.TrimPrefix(r.URL.Path, "/")
if name == "" {
name = "index.html"
}
data, err := webuiassets.NativeFS.ReadFile(name)
if err != nil {
return nil, nil
}
reader, size = bytes.NewReader(data), int64(len(data))
} }
header := make([]byte, min(size, 512)) header := make([]byte, min(size, 512))
_, err = reader.ReadAt(header, 0) _, err := reader.ReadAt(header, 0)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return nil, err return nil, err
} }
return &guigo.Resource{ contentType := mime.TypeByExtension(filepath.Ext(r.URL.Path))
Body: &resourceReader{io.NewSectionReader(reader, 0, size)}, if contentType == "" {
Header: http.Header{"Content-Type": {http.DetectContentType(header)}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}}, contentType = http.DetectContentType(header)
}, nil }
headers := http.Header{"Content-Type": {contentType}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}, "Accept-Ranges": {"bytes"}}
start, length, status := int64(0), size, http.StatusOK
if value := r.Header.Get("Range"); value != "" && r.Method == http.MethodGet {
if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") {
return nil, nil
}
first, last, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-")
if !ok {
return nil, nil
}
if first == "" {
count, err := strconv.ParseInt(last, 10, 64)
if err != nil || count <= 0 {
return nil, nil
}
length = min(count, size)
start = size - length
} else {
var err error
start, err = strconv.ParseInt(first, 10, 64)
if err != nil || start < 0 || start >= size {
return nil, nil
}
end := size - 1
if last != "" {
end, err = strconv.ParseInt(last, 10, 64)
if err != nil || end < start {
return nil, nil
}
}
length = min(end, size-1) - start + 1
}
if length == 0 {
return nil, nil
}
status = http.StatusPartialContent
headers.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size))
headers.Set("Content-Length", strconv.FormatInt(length, 10))
}
if r.Method == http.MethodHead {
length = 0
}
transferred = true
return &guigo.Resource{Body: &resourceReader{SectionReader: io.NewSectionReader(reader, start, length), close: close}, StatusCode: status, Header: headers}, nil
} }
// emit 发送当前窗口事件 // emit 发送当前窗口事件
+56 -11
View File
@@ -39,11 +39,13 @@ type fileEntry struct {
file *os.File file *os.File
directory bool directory bool
owned bool owned bool
temporary bool
} }
// fileStore 管理窗口范围内的文件授权和临时数据 // fileStore 管理窗口范围内的文件授权和临时数据
type fileStore struct { type fileStore struct {
mu sync.Mutex mu sync.Mutex
transfers sync.WaitGroup
entries map[string]*fileEntry entries map[string]*fileEntry
writers map[string]*fileWriter writers map[string]*fileWriter
temporary string temporary string
@@ -206,12 +208,43 @@ func (s *fileStore) bytes(data []byte) map[string]any {
return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true} return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true}
} }
// resource 创建独立资源读取句柄
// 入参: id 文件标识
// 返回: io.ReaderAt 随机读取器, int64 文件大小, func() error 关闭函数, error 读取错误
func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, 0, nil, os.ErrNotExist
}
if entry.data != nil {
return bytes.NewReader(entry.data), int64(len(entry.data)), nil, nil
}
file, err := openReadFile(entry.path)
if err != nil {
return nil, 0, nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, nil, err
}
return file, info.Size(), file.Close, nil
}
// release 释放已传递完成的内存结果 // release 释放已传递完成的内存结果
func (s *fileStore) release(id string) { func (s *fileStore) release(id string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if entry := s.entries[id]; entry != nil && entry.data != nil { if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary) {
delete(s.entries, id) delete(s.entries, id)
if entry.file != nil {
entry.file.Close()
}
if entry.temporary {
os.Remove(entry.path)
}
} }
} }
@@ -449,28 +482,39 @@ func (w *fileWriter) release() {
func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) { func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == "/__native/input" && r.Method == http.MethodPost { if r.URL.Path == "/__native/input" && r.Method == http.MethodPost {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
http.Error(w, os.ErrClosed.Error(), 500)
return
}
s.transfers.Add(1)
s.mu.Unlock()
defer s.transfers.Done()
file, err := temporaryFile(s.temporary, "input-") file, err := temporaryFile(s.temporary, "input-")
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
_, err = io.Copy(file, r.Body) size, err := io.Copy(file, r.Body)
err = errors.Join(err, file.Close())
if err != nil { if err != nil {
file.Close()
os.Remove(file.Name()) os.Remove(file.Name())
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
handle, err := s.add(file.Name(), false) s.mu.Lock()
if err != nil { if s.closed {
http.Error(w, err.Error(), 500) s.mu.Unlock()
return file.Close()
} os.Remove(file.Name())
value, err := s.file(handle["nativeHandle"].(string)) http.Error(w, os.ErrClosed.Error(), 500)
if err != nil {
http.Error(w, err.Error(), 500)
return return
} }
id := rand.Text()
s.entries[id] = &fileEntry{path: file.Name(), name: "data", file: file, temporary: true}
s.mu.Unlock()
value := map[string]any{"nativeFile": id, "size": size, "name": "data", "url": "/__native/data/" + id, "temporary": true}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(value) json.NewEncoder(w).Encode(value)
return return
@@ -508,6 +552,7 @@ func (s *fileStore) close() error {
s.writers = make(map[string]*fileWriter) s.writers = make(map[string]*fileWriter)
s.entries = make(map[string]*fileEntry) s.entries = make(map[string]*fileEntry)
s.mu.Unlock() s.mu.Unlock()
s.transfers.Wait()
var err error var err error
for _, writer := range writers { for _, writer := range writers {
err = errors.Join(err, writer.Abort()) err = errors.Join(err, writer.Abort())