折叠屏图片标注:归一化坐标与裁切回映的 ArkUI 方案
做图片标注这件事我前后在三个项目里踩过坑最后发现真正难的不是画框而是“坐标到底存在哪”。一张图在折叠屏上展开态显示区域是一块折叠起来又是另一块同一张图在不同状态下用ImageFit.Cover裁掉的部分完全不一样。用户展开态标在图片右上角的一个点折叠起来再打开如果按屏幕像素存坐标这个点轻则跑偏几十个 vp重则直接飞到图片外面去。ArkUI 里的Stack叠层加ImageFit组合就是把这件事彻底解决掉的一套做法图片组件负责显示上面叠一层透明的标注层负责接收手势和绘制两层共用同一套尺寸计算标注数据统一按 0 到 1 的归一化坐标存进存储层渲染时再靠“裁切回映”换算回当前设备的屏幕位置。这样折叠、展开、分屏、旋转、换设备全是同一份数据重算一次绘制区域就够。这篇内容适合正在鸿蒙 ArkTS 上做图片类工具的同行看尤其是做标注、打点、框选、审阅、拍照取证这一类需求的朋友。全文围绕“归一化坐标怎么算、裁切区域怎么回映、折叠屏尺寸突变怎么扛住”三件事展开代码可以直接拿去改。1. 先把问题拆开为什么折叠屏上的标注一定会跑偏1.1 三个独立变量叠在一起才出的问题很多人第一次做图片标注直觉是“我在图片上点了一下记下手指的 x、y 就完事”。在普通手机上这个做法能跑通因为容器尺寸基本不变问题被掩盖了。一旦上了折叠屏变量就从一个变成了三个第一是容器尺寸在变展开态可能 720vp 宽折叠态只剩 340vp第二是图片比例和你给的显示区域比例基本对不上横图塞进竖屏必定触发裁切第三是填充模式决定裁切发生在哪一侧Contain是加黑边、Cover是切内容两种情况的换算公式完全不一样。这三个变量是乘法关系任何一个变了最终位置都会变。屏幕像素坐标只适配了其中一种组合其他组合必然错。我在第一个版本里存的就是绝对像素测试同学折叠屏一开一合五个标注点全挤到左上角去了当时还以为是触摸事件坐标取错了查了半天才发现是容器宽度从 720 掉到 340缩放系数没跟着变。所以问题的本质不是“坐标存得对不对”而是“坐标依附的参照系稳不稳”。参照系一动坐标就废。1.2 归一化坐标是这个方案的地基归一化坐标做的事情很简单不记录“这个点在屏幕第 300 个 vp 上”而是记录“这个点在图片宽度的 42.7% 位置上”。前者是绝对地址后者是相对比例图片怎么缩放、容器怎么折叠比例永远不变。数据结构上一个标注点就是两个 0 到 1 之间的浮点数// 标注点完全与设备无关 export interface LabelPoint { id: string; nx: number; // 0~1相对原图宽度的比例 ny: number; // 0~1相对原图高度的比例 tag: string; color: string; }矩形框要存四个值左上角 nx、ny 加宽高比例 nw、nh多段线要存点数组逻辑一样都是比例。这里有个容易被忽略的点归一化是相对整张原图归一化不是相对“当前可见区域”。这个选择直接影响后面所有逻辑。相对整图存的好处是数据唯一一次标注永久有效代价是Cover模式下会有部分标注落在当前不可见的裁切区里需要专门判断可见性并给出提示——这就是后面要讲的“裁切回映”。注意如果按可见区域归一化折叠状态一变同一个点算出来的比例就变了等于每次都要重写全部数据持久化层直接失控。千万别这么干。1.3 方案选型为什么是 Stack 加自算绘制区市面上做这个功能大概有三条路。第一条是让Image自己缩放到正好完整显示ImageFit.Contain然后用图片组件自身的实际尺寸做映射——这条路简单但会留出大片黑边视觉上很难看用户会觉得“图怎么这么小”。第二条是用ImageFit.Cover让图铺满但直接拿容器尺寸当图片尺寸算坐标——这条路的错误率最高因为图被裁了坐标自然偏。第三条就是本文的做法用Stack把Image和标注层叠在一起自己根据ImageFit的规则反推出图片真正的绘制矩形再在那块矩形上做映射。选第三条的理由有三点。一是视觉不受协Cover铺满屏幕的观感明显优于Contain的两侧黑边二是适配面广Cover、Contain、Fill甚至None都能用同一套公式覆盖切换填充模式不需要重写业务三是可控裁切出去多少、哪些标注看不见全在自己手里算得明明白白能做边缘提示这种细节体验。代价是要自己推导每种ImageFit的绘制矩形。这部分公式不难但必须推对差一个符号就会整片镜像偏移。2. 坐标系与单位px、vp、归一化值之间的关系2.1 ImageFit 各模式下绘制矩形的推导先把变量定义清楚。容器尺寸cw、chvp原图像素尺寸sw、shpx先换算成同一单位iw sw × k、ih sh × kk是1px对应的 vp 数量也就是px2vp(1)的返回值在标准屏上通常接近 1高密度屏则小于 1。Contain的目标是完整塞进容器所以缩放系数取两轴较小的那个scale min(cw/iw, ch/ih)。缩放后的图片尺寸小于等于容器剩下的空间按对齐方式分配默认居中偏移量就是(cw - iw×scale) / 2。Cover的目标是铺满容器不留空所以取两轴较大的那个scale max(cw/iw, ch/ih)。缩放后图片至少有一个方向比容器大超出部分被裁掉所以偏移量会是负数——比如容器 340vp 宽、图片缩放后 600vp 宽偏移就是 -130vp。这个负号是后面理解“裁切回映”的关键很多人算成 0 就是因为忽略了它。Fill是分别拉伸scaleX cw/iw、scaleY ch/ih永远铺满、永不裁切但会变形标注场景里一般不用。None不缩放原图多大就多大居中放图大了就裁图小了就留白。ScaleDown是Contain和原始尺寸取小图比容器小的时候不放大避免糊图。2.2 一张表看懂六种填充模式的实际绘制区填充模式缩放系数实际绘制宽实际绘制高偏移特征标注场景适用度Containmin(cw/iw, ch/ih)iw × scaleih × scale偏移 ≥ 0两侧或上下留白高全图可见无信息丢失Covermax(cw/iw, ch/ih)iw × scaleih × scale至少一轴偏移为负内容被裁高观感好但有裁切盲区Fillcw/iw与ch/ih各算等于容器宽等于容器高偏移为 0比例失真低坐标对但图形变形None1原图宽 × k原图高 × k居中可能双向裁切中适合需要 1:1 看细节ScaleDownmin(1, Contain 系数)同上同上图小时居中留白中图小的时候有优势TopStart同Contain同Contain同Contain偏移为 0靠左上低除非 UI 强制靠角表里能看出来真正需要重点处理的只有两种Contain的留白偏移为正点击可能落在图外和Cover的裁切偏移为负标注可能落在屏幕外。这两种情况的判断条件正好相反必须分别写。2.3 单位统一的两种做法与取舍单位这件事我犹豫过一段时间。Image组件尺寸报出来的是 vp图片原始尺寸报出来的是 px混着算必然出错。两种统一方式一是把容器尺寸从 vp 换算成 pxvp2px全部在 px 体系里算完输出时再换回 vp二是把原图尺寸从 px 换算成 vppx2vp全部在 vp 体系里算。我选的是第二种理由是 ArkUI 里触摸事件的坐标、组件布局尺寸、Canvas 绘制坐标全都是 vp 体系。如果为了计算方便把容器转成 px那触摸点的坐标也得转Canvas 画的时候还得再转回来来回转换的环节越多越容易漏。统一在 vp 里算onTouch拿到的 localX、localY 直接能用Canvas 拿到坐标直接画中间零转换。唯一需要注意的是px2vp这个换算不是纯比例它和屏幕密度相关而且在某些多窗口场景下会随窗口所处的屏幕变化。所以不要在初始化时算一次存成全局常量要在尺寸发生变化的时机重新取。// 拿到当前窗口下的换算系数注意随窗口变化重取 const ratio: number px2vp(1); // 1px 对应多少 vp实测心得折叠屏从内屏切到外屏时如果内外屏像素密度不同px2vp的结果会变。把这个系数放在onAreaChange里跟着重算比放在aboutToAppear里算一次稳得多。3. 核心实现绘制矩形求解与正逆变换3.1 数据模型与求解器先写一个只做数学运算的类不碰任何 UI方便单测。它的职责只有一个给定容器尺寸、原图尺寸和填充模式算出图片真正画在哪块区域。export interface RectF { x: number; y: number; w: number; h: number; } export class FitSolver { /** * cw/ch 容器尺寸(vp) * iw/ih 原图尺寸(vp已换算) */ static solve(cw: number, ch: number, iw: number, ih: number, fit: ImageFit): RectF { const out: RectF { x: 0, y: 0, w: cw, h: ch }; // 任一维度非法就直接退化成整容器杜绝 NaN 扩散 if (cw 0 || ch 0 || iw 0 || ih 0) { return out; } if (fit ImageFit.Fill) { return out; // 拉伸模式绘制区就是容器本身 } let scale: number 1; if (fit ImageFit.Contain) { scale Math.min(cw / iw, ch / ih); } else if (fit ImageFit.Cover) { scale Math.max(cw / iw, ch / ih); } else if (fit ImageFit.None) { scale 1; } else if (fit ImageFit.ScaleDown) { scale Math.min(1, Math.min(cw / iw, ch / ih)); } else { scale Math.min(cw / iw, ch / ih); } const dw: number iw * scale; const dh: number ih * scale; out.w dw; out.h dh; out.x (cw - dw) / 2; // 默认居中对齐 out.y (ch - dh) / 2; return out; } }这个类里加了一个防御分支任一维度非法就返回整容器。折叠屏在折叠动画的那几帧里组件高度有可能瞬间报 0如果不拦ch/ih会算出Infinity后面所有坐标全部变成NaN而且NaN会污染整个标注数组重启都修不回来。这个坑我在测试阶段遇到过屏幕上一片空白日志里全是NaN。3.2 正变换把手指位置换算成归一化坐标有了绘制矩形正变换就是一步归一化export class AnnotViewport { containerW: number 0; containerH: number 0; rect: RectF { x: 0, y: 0, w: 0, h: 0 }; srcW: number 0; srcH: number 0; fit: ImageFit ImageFit.Cover; valid: boolean false; refresh(): void { if (this.containerW 0 || this.containerH 0) return; if (this.srcW 0 || this.srcH 0) return; const k: number px2vp(1); this.rect FitSolver.solve( this.containerW, this.containerH, this.srcW * k, this.srcH * k, this.fit ); this.valid this.rect.w 0 this.rect.h 0; } toNorm(lx: number, ly: number): LabelPoint | undefined { if (!this.valid) return undefined; const nx: number (lx - this.rect.x) / this.rect.w; const ny: number (ly - this.rect.y) / this.rect.h; if (nx 0 || nx 1 || ny 0 || ny 1) return undefined; return { id: , nx: nx, ny: ny, tag: , color: #FF4D4F }; } }这段里最关键的是最后那个边界判断。Contain模式下容器比图片高上下有黑边用户点到黑边上算出来的ny会小于 0 或大于 1必须丢掉否则会存进一堆无效标注。而Cover模式下绘制矩形比容器大容器内任意一点代入都会落在 0 到 1 之间这个判断永远不会触发——这不是 bug是数学上的必然因为Cover保证了图覆盖整个容器。也就是说同一段判断在两种模式下起的作用完全不同。这点我一开始没想明白总觉得Cover下点哪里都合法有点怪后来画了个草图才通Cover下用户看到的就是图片的一部分他点的任何位置当然都在图片上。3.3 逆变换把归一化坐标回映到当前屏幕逆变换是正变换的镜像一次性乘回去toScreen(nx: number, ny: number): RectF { return { x: this.rect.x nx * this.rect.w, y: this.rect.y ny * this.rect.h, w: 0, h: 0 }; } isVisible(nx: number, ny: number): boolean { const p this.toScreen(nx, ny); return p.x 0 p.x this.containerW p.y 0 p.y this.containerH; }看起来简单但“裁切回映”的味道全在这两行里。举个例子一张 4000×3000 的横图Cover模式塞进 340×600 的竖屏容器。缩放系数取max(340/4000k, 600/3000k)假设 k≈0.5则 iw2000vp、ih1500vpmax(0.17, 0.4) 0.4绘制尺寸变成 800×600水平偏移(340-800)/2 -230。用户标在图片最左边的点nx 0回映出来x -230在屏幕外看不见。用户标在图片中间的点nx 0.5回映出来x -230 400 170正好在屏幕中央看得见。折叠屏一展开容器变成 720×800重新算max(720/2000, 800/1500) max(0.36, 0.533) 0.533绘制尺寸 1066×800水平偏移(720-1066)/2 -173。同一个nx 0的点回映位置从 -230 变成 -173还是看不见但nx 0.2的点折叠态是-230 160 -70看不见展开态是-173 213 40看见了。坐标一个字节都没改可见性却翻转了。这就是为什么标注数据必须脱离屏幕存在——数据的“存在性”和“可见性”是两回事混在一起就会互相污染。3.4 Stack 叠层结构与 Canvas 绘制标注层组件结构很直白外层Stack底层Image上层Canvas。Image用objectFit指定填充模式Canvas铺满整个Stack专门负责画标注和接收触摸。Entry Component struct ImageAnnotPage { private vp: AnnotViewport new AnnotViewport(); private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(new RenderingContextSettings(true)); State labels: LabelPoint[] []; State ready: boolean false; private lastW: number 0; private lastH: number 0; private pendingKey: number -1; build() { Stack({ alignContent: Alignment.Center }) { Image($r(app.media.sample)) .width(100%) .height(100%) .objectFit(ImageFit.Cover) .draggable(false) .interpolation(ImageInterpolation.High) .onComplete((evt: ImageCompleteEvent) { // evt.width / evt.height 是图片解码后的像素尺寸 this.vp.srcW evt.width; this.vp.srcH evt.height; this.vp.refresh(); this.redraw(); }) Canvas(this.ctx) .width(100%) .height(100%) .onReady(() { this.ready true; this.redraw(); }) .onTouch((e: TouchEvent) { if (e.type ! TouchType.Down) return; const t: TouchObject e.touches[0]; const p this.vp.toNorm(t.x, t.y); if (p undefined) return; p.id ${Date.now()}; p.tag 默认; this.labels this.labels.concat([p]); this.redraw(); }) } .width(100%) .height(100%) .onAreaChange((oldA: Area, newA: Area) { const cw: number Number(newA.width); const ch: number Number(newA.height); if (cw this.lastW ch this.lastH) return; this.lastW cw; this.lastH ch; this.vp.containerW cw; this.vp.containerH ch; this.vp.refresh(); this.redraw(); }) } redraw(): void { if (!this.ready) return; const cw: number this.vp.containerW; const ch: number this.vp.containerH; if (cw 0 || ch 0) return; this.ctx.clearRect(0, 0, cw, ch); for (const lb of this.labels) { const p this.vp.toScreen(lb.nx, lb.ny); if (this.vp.isVisible(lb.nx, lb.ny)) { this.ctx.beginPath(); this.ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); this.ctx.fillStyle lb.color; this.ctx.fill(); this.ctx.strokeStyle #FFFFFF; this.ctx.lineWidth 2; this.ctx.stroke(); } else { this.drawEdgeHint(p.x, p.y, cw, ch); } } } }几个细节值得单独说。draggable(false)关掉图片拖拽否则用户一按就变成拖动整个Image触摸事件被上层拦走标注点永远落不下来。interpolation(High)是给大图降采样时用的缩小显示会明显更清楚代价是略微增加内存。onComplete里拿到的width、height是图片解码后的像素尺寸而且已经应用过方向信息不用自己再判断旋转。这点很省事——如果自己用图片解码接口读图横竖方向要额外处理一轮稍不注意就会出现标注整体旋转 90 度的怪现象。还有一点onReady只触发一次Canvas尺寸变了不会重新触发所以尺寸变化后必须手动调redraw()。我第一次写的时候只在这两个回调里各自重绘结果折叠展开后标注全丢查了半天才发现是绘制回调顺序问题。4. 折叠屏适配尺寸突变、悬停态与可见性判断4.1 用 onAreaChange 而不是只监听折叠状态适配折叠屏最直接的思路是监听折叠状态变化事件但这个思路有个盲区它只告诉你“折叠了”或“展开了”不告诉你“现在多大”。折叠屏还有多种形态——半折叠悬停时上下两半的内容区尺寸跟完全展开又不一样分屏多任务时应用窗口可能只占屏幕一半平板上还对接着键盘配件。这些场景折叠状态事件覆盖不全。组件级的onAreaChange覆盖面最广不管是折叠、展开、旋转、分屏、键盘弹起导致的高度变化、还是应用窗口拖动缩放只要组件实际尺寸变了它就触发。我现在的做法是把它当成唯一的尺寸真相来源折叠状态事件只用来做业务层的辅助判断比如展开态要不要自动切到双栏布局。不过onAreaChange有个副作用折叠动画期间它会连续触发很多次每次触发都重算绘制区加重绘Canvas上的标注一多就会掉帧。加个轻量节流onAreaChange((oldA: Area, newA: Area) { const cw: number Number(newA.width); const ch: number Number(newA.height); if (cw 0 || ch 0) return; // 动画中间态直接跳过 this.lastW cw; this.lastH ch; clearTimeout(this.pendingKey); this.pendingKey setTimeout(() { this.vp.containerW cw; this.vp.containerH ch; this.vp.refresh(); this.redraw(); }, 40); })40ms 是我实测下来比较舒服的值折叠动画大概 300ms节流到 40ms 大约只重绘七八次视觉上看不出卡顿如果拖到 120ms动画结束后会有一小段标注“迟到”的现象用户感觉得到。注意onAreaChange里拿到的width、height是Length类型不能直接当数字用必须Number()转一下。直接做强转在某些版本上会拿到undefined而这个错误极难发现因为不报错只是尺寸一直是 0。4.2 裁切可见性判定与边缘提示回到Cover模式的裁切问题。当标注点被裁到屏幕外时不能简单地丢掉——用户明明标过数据还在只是当前视角看不到直接隐藏会让他以为标注丢了。也不能不管白白占渲染开销。我的处理分三档。完全在容器内正常画实心圆点加白色描边。被裁到边缘外在最近的那条容器边上画一个半透明的小三角或者小方块位置沿边缘 clamp 到[0, cw]或[0, ch]范围用来提示“这个方向还有标注”。离得特别远比如归一化坐标在裁切区深处、距离边缘超过半个屏幕只加个总数角标不逐点提示避免边缘堆满小三角。drawEdgeHint(x: number, y: number, cw: number, ch: number): void { const cx: number Math.max(8, Math.min(cw - 8, x)); const cy: number Math.max(8, Math.min(ch - 8, y)); this.ctx.beginPath(); this.ctx.arc(cx, cy, 5, 0, Math.PI * 2); this.ctx.fillStyle rgba(255,77,79,0.35); this.ctx.fill(); }矩形框的裁剪不需要额外写逻辑因为标注画布和容器一样大超出容器的部分会被画布自动裁掉视觉上正好是“只看到框的一部分”。这个效果符合直觉用户看到半截框就知道图被裁了稍微挪一挪视角就能看全。4.3 防止尺⼨归零与数据污染折叠屏在完全合上的一瞬间、或者应用切后台再回来的一瞬间组件尺寸有可能报 0。这时候如果照常跑一遍求解Container 0 × 0会让缩放系数变成 0 或无穷绘制的图片尺寸变成 0回映出来的坐标全部坍缩到原点。更麻烦的是如果骨架屏或者加载态把用户点击也接了这时候的空点击会带着一堆NaN坐标进数组。我的三道防线一是FitSolver.solve开头的非法值判断直接返回整容器二是refresh()里判断容器尺寸小于等于 0 就直接返回保留上一次的有效rect三是toNorm里判断valid标志无效期间直接拒绝接收新标注。最后一道最容易被漏但它恰恰是最重要的那道——数据污染是不可逆的一旦NaN进了存储只能清库。另外数据持久化之前记得做一次兜底清洗遍历所有标注凡是坐标不是有限数、或者不在[0,1]区间的直接丢弃。这属于脏数据防御代价很小能避免线上出现“某个用户打开页面就白屏”这种查不到原因的事故。5. 常见问题排查实录5.1 典型症状与根因对照症状表现大概率根因处理方式展开折叠后标注整体等比例缩放存的是绝对像素坐标改成归一化重算绘制区标注位置固定偏移一段距离vp 与 px 混用换算系数缺失统一到 vppx2vp随窗口重取换填充模式后标注整体镜像偏移量符号写反忽略了负偏移按公式重推rect.x、rect.y首次进入标注全部堆在左上角onComplete未触发就渲染了加ready标志图片信息到位才画折叠动画期间相机闪一下空白中间态尺寸为 0 触发清屏尺寸 ≤0 直接跳过本轮重绘同一张图不同设备标注对不上一处在 px 体系算一处在 vp 体系算全链路统一单位标注越标越偏关掉重开就正常每次都用屏幕坐标覆盖了归一化值写入前检查数据来源只存比例大图标注卡顿、滑动掉帧Canvas每帧全量重绘加节流标注层与图片层分离刷新表里前三条占了线上问题的绝大多数。特别是第二条混用单位这件事的隐蔽性极高——它在标准密度屏幕上可能完全正常因为px2vp(1)接近 1一到高密度屏或折叠屏外屏就暴露测试阶段很容易漏掉。5.2 几个不太容易想到的坑第一个坑是图片方向。相机拍的照片经常带旋转标记有些解码路径会自动应用有些不应用。判断依据统一以onComplete回调里的尺寸为准不要自己另外去读图片元数据算两边的结论可能不一致最后标注整体歪 90 度。第二个坑是大图内存。一张 8000×6000 的图直接解码内存占用会到几百兆折叠屏上同时开几个应用直接触发回收。稳妥做法是显示用降采样后的图标注计算仍然按原图尺寸走——因为归一化坐标只跟比例有关降采样不影响比例所以画质和精度可以分开处理。第三个坑是标注的层级顺序。用Stack叠层时Canvas一定放在Image后面否则图片会盖住标注。这条看起来是废话但如果用Stack的alignContent或者后来插入了LoadingProgress之类的组件顺序很容易被打乱。第四个坑是手势冲突。如果外层有Swiper或Scroll横向滑动切图和图片上打点会打架。解决办法是给标注层加gesture的优先级控制或者在按下时临时禁用外层滚动。我在一个翻页看图的项目里就遇到过用户想打点结果页面滑走了。第五个坑是归一化坐标的精度。存四位小数0.0001在绝大多数场景够用但对超大图比如 20000px 宽的扫描件来说0.0001 对应的实际距离是 2 个像素可能不够。地图类或医疗影像类场景建议存 6 位数据库字段用decimal或直接存整数比例 × 1000000避免浮点误差累积。实操心得调试这类坐标问题别靠眼睛猜。写个临时按钮把当前rect的 x、y、w、h 和几个关键标注点的屏幕坐标打到日志里折叠前后各打印一次对着数字看一遍比截图对比快十倍。6. 我个人在这套方案上的一些体会这套结构我从最初版本一路改过来最大的感受是把数学和界面彻底分开是最值钱的一步。FitSolver是纯函数输入尺寸输出矩形不依赖任何组件状态AnnotViewport只管正逆变换和可见性组件层只负责把事件喂进去、把结果画出来。分开之后折叠屏上的各种奇葩场景都能在单测里复现——直接把容器尺寸从 720 改成 340 再调一遍求解不用真的拿设备折来折去。还有一个反直觉的体会Cover模式虽然会有裁切盲区但它带来的体验提升远大于适配成本。用户真正在意的是“图要大、要清楚”而不是“必须一眼看全”。只要把被裁掉的标注用边缘提示标出来让他知道往哪个方向找接受度就很高。反倒是Contain模式下两侧黑边用户第一反应往往是“是不是没适配好”。最后留一个可扩展的方向现在的裁切回映只处理了点如果要做框选的批量操作比如框选一块区域然后放大查看需要把归一化矩形和可视窗口做一次矩形求交求交后的结果再回映到屏幕。思路完全一样只是把点的判断换成矩形相交判断多几行代码的事。