14|HarmonyOS大图纸分块加载与可见区调度【共创稿事节】
首层机电平面图的批注工程推进到这一章,图纸上的点、线和文字已经有清楚的归属:原始页面4200×2970,工作视口按960×640讨论,给水阀门仍固定在页坐标(2600,1850)。上篇让“给水阀门”和“管道检修口”两段文字使用稳定候选位置避让。接下来遇到的障碍不是排版算法,而是用户把图纸拖到远处时,页面仍要求一次装入所有管线、建筑线条和批注,导致切换视口后旧位置的数据还占着绘制队列。
图纸尺寸在本例里并不夸张,但实际工程图经常含有复杂的线段、填充与文字,数据量和显示面积不是一回事。即使只看到了图纸的一个角落,解析、缓存与绘制整个文件也会给内存和主线程带来压力。需要把逻辑图纸划成稳定分块,始终用同一个页坐标基准选择可见内容,再按当前视口调度分块,并在用户快速移动时丢弃已经过时的请求结果。

一、分块索引绑定图纸版本,不绑定屏幕位置
本系列前五篇一直强调页坐标不能跟随缩放变化而被覆盖。分块也应遵守同一原则:可以把4200×2970的图纸按每块512×512逻辑单位切分,形成横向九列、纵向六行的网格。pageId + drawingRevision + level + col + row组成分块键,保证不同图纸修订、不同细节级别不会误用同一份缓存。分块只负责读取和绘制候选对象,不能改变给水阀门的原始锚点。
每个分块可以包含线段索引、图元包围盒和局部绘制命令。跨越块边界的长管线需要进入所有相交块的空间索引,或者由上层索引返回跨块对象;无论采用哪一种,都要有稳定的图元id,防止相邻块都可见时重复绘制同一条线。对管线本体与文字批注,也可以拆成底图层和批注层,底图采用分块缓存,而批注依据图层显隐状态即时叠加。这样第五篇固定的文字框不会因为底图重载而丢失。
视口坐标和分块坐标之间必须有明确变换。第二篇实现过图纸缩放1.5倍、旋转15°时的逆仿射矩阵,因此不能只用屏幕左上角和右下角推算裁剪区域。旋转后,屏幕矩形在页坐标中是一个斜四边形;工程上先逆变换四个角,再求页坐标轴对齐包围盒,作为保守的分块候选集合,最后再用四边形相交判断过滤多余块即可。
二、从当前可见四角计算候选块
可见区计算是一个纯几何问题,适合独立于ArkUI组件实现。输入为页面到屏幕的仿射矩阵{a,b,c,d,tx,ty},以及视口的宽高;先检查行列式,接近零时拒绝求逆,防止用户把缩放系数压到极小引起数值放大。然后把四个屏幕角映射回原始页坐标,裁剪到页面范围,并向外扩展一个分块作为预读边缘。这样用户开始拖动时,附近块通常已有加载机会。
下面这段Node.js代码返回可见区对应的稳定分块键,遵循前文页坐标规范。它使用向外取整,且把最后一块的索引限制在真实图纸范围内。
function invert(m) {
const det = m.a * m.d - m.b * m.c;
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) throw Error('BAD_TRANSFORM');
return { a: m.d / det, b: -m.b / det, c: -m.c / det,
d: m.a / det, tx: (m.c * m.ty - m.d * m.tx) / det,
ty: (m.b * m.tx - m.a * m.ty) / det };
}
function apply(m, x, y) {
return { x: m.a * x + m.c * y + m.tx,
y: m.b * x + m.d * y + m.ty };
}
function visibleTiles(camera, width = 960, height = 640, pageW = 4200, pageH = 2970, tile = 512) {
const inv = invert(camera);
const corners = [[0,0],[width,0],[width,height],[0,height]]
.map(([x,y]) => apply(inv,x,y));
const loX = Math.max(0, Math.min(...corners.map(p=>p.x)) - tile);
const hiX = Math.min(pageW, Math.max(...corners.map(p=>p.x)) + tile);
const loY = Math.max(0, Math.min(...corners.map(p=>p.y)) - tile);
const hiY = Math.min(pageH, Math.max(...corners.map(p=>p.y)) + tile);
const out=[];
for(let r=Math.floor(loY/tile); r<=Math.min(Math.ceil(pageH/tile)-1,Math.floor(hiY/tile));r++)
for(let c=Math.floor(loX/tile); c<=Math.min(Math.ceil(pageW/tile)-1,Math.floor(hiX/tile));c++)
out.push(`${c}:${r}`);
return out;
}
const keys=visibleTiles({a:1.5,b:0,c:0,d:1.5,tx:-2800,ty:-1900});
if (!keys.length || keys.some(k=>k.includes('NaN'))) throw Error('TILE_SELECTION');
一个细节是hiX恰好落在块边界时,保守循环可能多选一块;这是预读策略中可接受的边缘冗余,严格裁剪时可以用右开区间重新判定。真正需要警惕的是单位混用:设备像素、ArkUI的vp和页逻辑单位是三套概念。只有矩阵与视口宽高处于同一屏幕单位,反算结果才可信;在布局变化后要重新读取视口实际尺寸,不能沿用固定960×640而不做转换。
三、请求代际比快速取消更重要
拖动图纸时,每一帧都可能得到新的可见块集合。若按每次指针移动发起新的异步读取,旧请求即使后来完成,也可能把缓存和界面重新改回前一视口。我的处理是为可见区规划一个generation,只允许与当前代际一致的结果参与可见渲染。缓存可以保留命中的旧分块供下一次使用,但它不能再次覆盖当前选中页面、视口或图层状态。
一个分块可能被多个视口先后需要。相同pageId、drawingRevision、level、row、col只应同时存在一份正在进行的加载;否则即使在画面上没有重复,后台也会重复读取和解码。队列中可先选离视口中心近的块,再请求扩展区域,给用户优先呈现核心线条。对于已进入不可见区域的低优先级操作,可使用取消信号终止读取;无法真正终止的回调也要在提交界面前检查代际。
class TileScheduler {
constructor(loader) { this.loader=loader; this.generation=0; this.inflight=new Map(); }
async updateView(keys) {
const generation=++this.generation;
const unique=[...new Set(keys)];
const results=await Promise.all(unique.map(async key => {
if (!this.inflight.has(key)) {
const p=Promise.resolve().then(()=>this.loader(key))
.finally(()=>this.inflight.delete(key));
this.inflight.set(key,p);
}
return { key, value: await this.inflight.get(key) };
}));
return generation===this.generation ? results : [];
}
clearView() { this.generation++; }
}
(async () => {
const scheduler=new TileScheduler(async key=>({key,segments:[]}));
const visible=await scheduler.updateView(['4:3','4:3','5:3']);
if (visible.length!==2) throw Error('SCHEDULER_CHECK');
})();
这段代码解决的是并发结果归属,不负责生成图纸图元。正式业务仍需把tile loader接到实际文件解析和缓存层,并在应用页面退出时增加代际,使迟到结果不会驱动已经销毁的视图。若加载失败,要记录块键、页版本、请求代际与错误类型,而不是吞掉错误后画一个空白区域,让使用者误以为那里本来就没有管线。

四、缓存按字节预算淘汰,不能只数块数
不同分块的体积差异很大。机械机房可能有密集的线段、标注和填充,走廊分块却很稀疏;相同块数并不意味着占用相同内存。因此缓存应累计每份已解码图元或位图实际估计字节数,必要时区分CPU侧对象与GPU侧纹理。图形缓存可采用LRU,正在当前视口绘制的块临时加pin,避免刚加载就因全局淘汰而重新读取。
下面给一个简单的业务层缓存,release由调用方按具体资源类型注入。淘汰前先排除pin集合,达到预算仍无法腾出空间时,返回失败让界面降低预读范围,而不是无限扩大内存。它适合配合上节调度器,在新的视口确认后再逐步释放离屏块。
class ByteBudgetCache {
constructor(limitBytes) { this.limit=limitBytes; this.used=0; this.items=new Map(); }
put(key, value, bytes, pinned=new Set(), release=()=>{}) {
if (!Number.isSafeInteger(bytes) || bytes<0 || bytes>this.limit) return false;
if (this.items.has(key)) this.delete(key);
for (const [old,record] of this.items) {
if (this.used+bytes<=this.limit) break;
if (!pinned.has(old)) this.delete(old,record);
}
if (this.used+bytes>this.limit) return false;
this.items.set(key,{value,bytes,release}); this.used+=bytes; return true;
}
get(key) { const item=this.items.get(key); if (!item) return undefined;
this.items.delete(key); this.items.set(key,item); return item.value; }
delete(key, record=this.items.get(key)) {
if (!record) return; this.items.delete(key); this.used-=record.bytes; record.release(); }
close() { for(const key of [...this.items.keys()]) this.delete(key); }
}
const cache=new ByteBudgetCache(4096);
cache.put('4:3',{segments:[]},2048); cache.put('5:3',{segments:[]},2048);
cache.put('6:3',{segments:[]},2048,new Set(['5:3']));
if (!cache.get('5:3') || cache.used>4096) throw Error('CACHE_CHECK');
cache.close();
实际接入时要注意替换同名块时的释放顺序,页面不应在绘制命令仍持有旧对象时提前销毁它。一次图纸切换可能产生绘制命令仍在等待处理、缓存却已更新的竞态。较稳妥的做法是先把本帧所用的块集合固定,等渲染管线完成相应生命周期,再释放其底层资源。图纸修订变化时,包含旧revision的块全部失效;只修改某个批注文字时,则可保留底图块,仅重新组织受影响的批注层。
五、如何定位空白块、闪烁和错位
出现空白块,先区分是没有命中空间索引、异步读取失败,还是视口代际过期而被有意丢弃。记录可见块键列表、请求开始与完成代际、资源版本及每块对象数量,通常比一条笼统的“渲染异常”更有用。若发生边界断线,检查跨块图元的裁剪和去重规则;若整张图随旋转漂移,优先核对第二篇的逆矩阵及画布变换顺序,而不是修改每块内部坐标。
还需检查屏幕横竖布局调整之后的视口宽高是否刷新。一个很常见的疏漏,是图纸容器尺寸更新了,分块选择仍用旧矩形。对于快速缩放场景,可以临时保留上一代的低清晰度底图作为过渡,但新的批注选择逻辑应继续使用当前矩阵,不能依赖旧底图提供触点坐标。用户正在编辑的文字框与笔迹草稿应在独立层保存,分块淘汰不应抹去这些交互状态。

华为开发者官方的ArkGraphics 2D画布获取与显示、画布裁剪和矩阵操作及ArkGraphics 2D能力介绍提供了相关绘制背景。图纸的分块索引、请求代际、字节缓存和淘汰次序属于应用自身数据层,本文使用独立Node.js代码表达这些规则。
分块方案最重要的收获不是把图纸切得越碎越好,而是让“需要加载哪些内容”和“当前视口应该显示哪些内容”保持可核对的关系。下一篇会在这套缓存与锚点模型之上引入批注变更日志与撤销;届时即使图纸块被淘汰,仍可以通过独立的批注记录恢复操作历史。
更多推荐



所有评论(0)