ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

折叠屏图片标注:归一化坐标与裁切回映的 ArkUI 方案

折叠屏图片标注:归一化坐标与裁切回映的 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、ch(vp),原图像素尺寸sw、sh(px),先换算成同一单位:iw = sw × k、ih = sh × k,k是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 换算成 px(vp2px),全部在 px 体系里算完,输出时再换回 vp;二是把原图尺寸从 px 换算成 vp(px2vp),全部在 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,则 iw=2000vp、ih=1500vp,max(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 混用,换算系数缺失统一到 vp,px2vp随窗口重取
换填充模式后标注整体镜像偏移量符号写反,忽略了负偏移按公式重推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模式下两侧黑边,用户第一反应往往是“是不是没适配好”。

最后留一个可扩展的方向:现在的裁切回映只处理了点,如果要做框选的批量操作(比如框选一块区域然后放大查看),需要把归一化矩形和可视窗口做一次矩形求交,求交后的结果再回映到屏幕。思路完全一样,只是把点的判断换成矩形相交判断,多几行代码的事。

返回列表