项目:CompareRuler|本地演示数据契约 CMP-1009-08
本文展示应用层对照控件的设计与调试方案。配图为生成的 UI / IDE 示意图,不是 DevEco Studio 运行证据;没有在此环境运行超分模型,也没有完成真机编译验证。

有一类图像对比页面,看起来只需要把两张照片叠在一起,拖动一条分割线就行。真要把它用在超分体验里,问题往往不在分割线是否顺滑,而在它有没有指向同一个内容坐标。折叠屏从大视口切到小视口时,拖拽事件还在路上,旧宽度算出的横坐标若直接写进新画布,分割线可能突然越过主体。于是,用户以为看到的是算法细节变化,实际看到的是控件自己的错位。

这篇不写“调用某个超分引擎获得清晰照片”的故事,而是专门处理这个容易被忽略的产品证据链。把原图与增强图交给同一套几何映射;把手势的生命周期与布局的生命周期分开;在尺寸切换后复核:同一条 42% 对照尺是否仍然指向同一片内容。下面数据为可重复构造的测试向量,不声称是真实设备测试结果。

一、对照控件最容易藏起来的不是画质差异,而是坐标误差

先固定一个简单样本:项目名 CompareRuler,页面 ComparePage,图像标识 IMG-078,本地任务 CMP-1009-08。输入图为 960×720 px,参考增强图为 1920×1440 px,两者都是 4:3。这里的“增强图”是预置的演示素材;本文没有生成超分结果,也没有给出未经核实的系统级超分 SDK 调用。它只表示产品接入图像增强能力后,前后两份像素内容应当如何参与显示。

演示先在 720×540 vp 的宽视口中显示,然后缩到 360×270 vp 的紧凑视口。比例尺的位置取 ratio=0.42,布局代次从 6 推进到 7。布局切换时,假设存在两个仍携带代次 6 的拖拽更新;应用层应该拒绝它们,而不是把旧事件当成当前用户的新意图。因此,本例的业务状态是 ALIGN_READY,诊断计数 staleDropped=2。

对齐判断不要只看两张图的外缘。原图可以采用较低分辨率显示,增强图可以采用较高分辨率采样,但是它们所指的源内容区域必须一致。把左图按一个比例填满、右图按另一个比例裁剪,即使二者分辨率比正好是 2,也可能在天空和山体交界处产生“差一截”的假象。更隐蔽的是同一张图内部存在 EXIF 方向或不同裁剪窗口时,显示位置对齐并不能证明像素语义对齐。

因此我会把对照控件拆成三个对象:输入图元信息(像素宽高、旋转归一后的尺寸、内容裁剪区域)、当前视口(以 vp 描述可交互矩形)、用户对照位置(保存在内容域内的 0~1 比例)。只有最后一个对象应当跨布局变化稳定保存。151.2vp 是当前视口下的投影,不是持久化状态;403.2px 和 806.4px 是分别映射到两张图的采样位置,也不该作为可复用业务数据。

如果产品要把某次对照作为质量反馈上传服务端,更应该保留 imageId、两侧图的版本身份、裁剪参数、比例和操作场景,而不是只上传一张分割线截图。没有这些上下文,别人无法分辨变化来自算法、缩放、旋转还是 UI 的自作主张。本文采用单张预置素材的约束,刻意不把不同来源、不同取景范围的图片强行判为可对照。

二、从 42% 反推输入坐标,别把组件宽度当作图像宽度

对一张以 contain 方式放进矩形容器的图片,真正承载内容的矩形可能比容器小。设原图宽高为 Iw,Ih,容器为 Vw,Vh,缩放系数应取 min(Vw/Iw, Vh/Ih)。显示宽高与容器的差额各分一半,就得到上下或左右留白。当触点落在留白上,交互策略需要明确:这里选择夹到最近的内容边界,而不是把留白算作图像内容。

下面的 CoordinateMapper.ets 只实现应用层纯几何映射,不依赖任何尚未验证的图片增强接口。代码要解决的是“不同 viewport 与图像分辨率下,如何生成同一内容位置”,而不是解码或推理。尺寸值先校验,再计算内容矩形;不接受零宽度与非有限数,避免 NaN 混进 ArkUI 的布局状态。

export interface RectSize { width: number; height: number; }
export interface ContentRect { x: number; y: number; width: number; height: number; }
export class CoordinateMapper {
  static contain(source: RectSize, view: RectSize): ContentRect {
    if (source.width <= 0 || source.height <= 0 ||
        view.width <= 0 || view.height <= 0) {
      throw new Error('INVALID_RECT');
    }
    const scale = Math.min(view.width / source.width, view.height / source.height);
    const width = source.width * scale;
    const height = source.height * scale;
    return { x: (view.width - width) / 2,
      y: (view.height - height) / 2, width, height };
  }
  static clamp(value: number): number {
    return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
  }
  static fromTouch(touchX: number, rect: ContentRect): number {
    return this.clamp((touchX - rect.x) / rect.width);
  }
  static project(ratio: number, sourceWidth: number): number {
    return this.clamp(ratio) * sourceWidth;
  }
}

这个实现故意不使用“增强图宽度除以原图宽度”来换算触点,而是将两张图都投射到各自的内容矩形。这样,无论增强倍率是 2 倍还是其他合理尺度,同一个 ratio 都能在两侧重现。碰到裁剪、旋转或内容域不一致时,还需要先把位置变换到共同的裁剪坐标系,不能照抄这一版简化公式。

用本次 4:3 测试向量,紧凑视口 360×270 vp 恰好没有留白。对照尺 0.42 × 360 = 151.2vp;同一列分别落在原图 0.42 × 960 = 403.2px、增强图 0.42 × 1920 = 806.4px。这些是精确的模型演算数字。它们用于核对图表与日志,而不是某个手机 GPU 的像素采样结果。如果画布发生设备像素密度变更,绘制尺寸与视口 vp 的转换还需要使用当前 UIContext 的单位体系,不能把 px 和 vp 简单互换。

图像源还应当说明像素方向。假如相册读取的源图携带方向标记,但导出增强图已经被物理旋转,两个元信息的宽高就可能在代码里对不上。正确的入口是先确认显示方向被归一,再把图像内容域映射到共同空间。一个可追踪的“无法对齐”状态,通常比悄悄对齐错误更有工程价值。

三、软硬折叠一瞬间,旧事件需要一个明确的拒绝理由

仅保存比例并不足以消灭错位。想象用户在宽视口拖动,对照尺已经走到 42%。系统报告窗口尺寸变化,应用马上建立新内容矩形;同一手势产生的最后两帧更新却可能沿旧几何继续抵达。若每一帧都只是 ratio = x / currentWidth,那么旧事件会用新宽度重新解释,产生突跳。这个“迟到”不必来自网络,它可以来自任何异步事件队列或重排时序。

我更愿意给每一次布局几何计算一个递增的 layoutEpoch。它是应用自己维护的版本号,不是 HarmonyOS 内核公开的“折叠事件代次”。每次真正改变内容矩形时递增,正在处理的拖拽片段绑定它开始时的代次。只有匹配代次的更新才允许写比例;失配事件计入 staleDropped,用户可以在诊断页看到原因。

以下 GestureEpochGate.ets 是第二段代码,用于模拟从 epoch 6 切到 epoch 7 的边界。它不用神秘的系统回调控制其他组件,而是把业务输入的准入条件变成一个显式的、可单元测试的函数。

export class GestureEpochGate {
  private epoch: number = 6;
  private ratio: number = 0.42;
  private staleDropped: number = 0;
  advanceForLayout(): number { this.epoch += 1; return this.epoch; }
  currentEpoch(): number { return this.epoch; }
  update(eventEpoch: number, nextRatio: number): boolean {
    if (eventEpoch !== this.epoch) {
      this.staleDropped += 1;
      return false;
    }
    this.ratio = Math.max(0, Math.min(1, nextRatio));
    return true;
  }
  snapshot(): { epoch: number; ratio: number; staleDropped: number } {
    return { epoch: this.epoch, ratio: this.ratio,
      staleDropped: this.staleDropped };
  }
}

这个门禁不会让旧异步操作自动取消。它只是把写入资格收紧成“结果产生时仍属于现行布局”。如果触摸事件由 onTouch、拖拽手势或 Slider 产生,都应该使用同一准入模型,但不能把不同事件 API 的 offsetX 当成同一个坐标:有些偏移量是手势累计位移,有些是当前接触点的局部位置;必须按官方事件定义转换后再进入 fromTouch。

还需要处理失焦与组件卸载。页面消失、转入其他任务、左右分栏拆合时,可以主动递增代次或结束正在处理的手势,确保旧页面回调不会更新已经重建的控件。只在“折叠”按钮上加版本号不够,窗口拖拽、系统字体比例调整、横竖屏变化和父容器约束重算,都会重新定义输入坐标的解释域。

一个工程取舍是:不在每次尺寸抖动时把 ratio 归零。比例反映用户选择的图像位置;尺寸只是它的展示手段。归零虽然能立刻消除视觉异常,却会让用户在反复折叠时丢掉对比目标。被保留下来的 0.42 只有在图片身份发生变化或来源裁剪不一致时才需要重新判断是否仍然有效。

四、画布只负责呈现,资源归属不能由绘制帧决定

在 ArkUI 里,可以使用 Canvas 与 CanvasRenderingContext2D 绘制图片,drawImage 能接收对应类型的图像数据。本文采用两份预先完成方向归一与尺寸确认的 PixelMap:先绘制原图,在裁剪区内叠加增强图,最后画分界线。与“左右两张 Image 各自缩放”相比,这种绘制方式便于把两边统一到同一个内容矩形。华为官方文档给出了 Canvas 的 clip、drawImage 与 PixelMap 相关示例,但并没有替本 Demo 保证对照的视觉质量。Canvas 相关官方说明

第三段代码的目标是表达绘制顺序,而不是把资源申请、解码、交互和业务状态全塞进一个组件。paint 不创建 PixelMap,也不销毁 PixelMap;资源在更外层的加载会话中统一持有。为避免把 drawImage 当成图像处理任务,两个输入参数被显式命名为 original 和 referenceEnhanced。

import { image } from '@kit.ImageKit';
export class ComparePainter {
  private ctx: CanvasRenderingContext2D;
  constructor(context: CanvasRenderingContext2D) { this.ctx = context; }
  paint(original: image.PixelMap, referenceEnhanced: image.PixelMap,
        width: number, height: number, ratio: number): void {
    const x = Math.max(0, Math.min(width, width * ratio));
    this.ctx.clearRect(0, 0, width, height);
    this.ctx.drawImage(original, 0, 0, width, height);
    this.ctx.save();
    this.ctx.beginPath();
    this.ctx.rect(x, 0, width - x, height);
    this.ctx.clip();
    this.ctx.drawImage(referenceEnhanced, 0, 0, width, height);
    this.ctx.restore();
    this.ctx.beginPath();
    this.ctx.moveTo(x, 0);
    this.ctx.lineTo(x, height);
    this.ctx.stroke();
  }
}

代码里只有四个重要顺序:清画布、铺基图、裁剪叠图、画分割线。save/restore 成对出现,防止前一帧的裁剪区域污染下一帧。为了让当前演示与生成图保持一致,当前视口是 360×270 vp,分割位置为 151.2 vp,对应的 ratio=0.42。实际工程需要在画布具备有效尺寸之后绘制,并按实际 UIContext 的像素密度核实 Canvas 单位;示例省略了 PixelMap 加载与格式转换。

更容易埋雷的是资源释放。如果页面正在重绘,而另一个异步任务直接释放了同一个 PixelMap,可能出现不可预期的空白、异常或资源访问冲突。适合的设计是图片加载会话持有图像对象,绘制层只借用;切换图像或离开页面时标记旧会话过期,等待相关绘制与异步工作停止后再成对释放图像对象。不要用 setTimeout 猜测“多少毫秒后可以释放”,也不要因为设备内存充足就省略清理。

上图为生成的 DevEco Studio 风格演示图。左侧标出 ComparePage.ets、MappingAuditPage.ets、CoordinateMapper.ets,右侧仅用于说明布局效果,底部 HiLog 为设计好的示例日志。图片里的 updateSeparatorX=151.2、layoutEpoch=7 与 staleDropped=2 都属于这份演示数据契约;它不代表已连接实际 Pura 设备执行过这些回调。

五、对照结果最好让用户看到“被保留的是什么”

主页面不是调试台,所以没必要把所有内部变量堆出来。它需要能让用户看清楚的,是两张图的关系、当前视口、对照比例和结果能否被信任。示例页面给出原图 960×720、参考增强图 1920×1440、视口 360×270 vp、比例 0.42、布局代次 7 和 ALIGN_READY。这些参数在内容层面表达“同一份视口几何已经对齐”,而不意味着系统超分算法或相机图像质量已经通过任何检验。

考虑一条现场诊断路径:用户说“折起来后山尖跑偏了”。开发者不该马上优化动画帧率,而是先确认有没有做不同的裁剪;再确认展示的增强图是否与输入图对应;然后复算 0.42 × 360 是否等于 151.2。如果三个结论都对,还要观察触点事件是否混入了上一布局代次。最后一步才是研究渲染精度、图像插值和 Canvas 刷新节奏。

这套排查路径比“加几行 console.log”更可靠。日志至少带上 taskId、imageId、epoch、ratio、viewport 与触发原因。不要只打印“onChange happened”。同名事件在窗口切换前后意义不同,没有代次就很难做出可信判断。

六、详情页不是第二个结果页,而是能复算的审计账本

MappingAuditPage 将变化前后的几何参数并排放置:旧视口 720×540 vp,新视口 360×270 vp;布局代次 6→7;比例 0.42 保持。新视口的分割线投影是 151.2 vp,投到原图是 403.2 px,投到增强图是 806.4 px。它同时把两个旧代次事件记为 staleDropped=2,便于后续区分“故意拒绝”与“输入事件漏处理”。

本图所列的时间线——10:24:01 EPOCH 6,10:24:02 RESIZE,同一分钟的 DISCARD,10:24:03 ALIGN_READY——全部是示例日志。它们不是外部截图或实际设备采样数据。测试时可以通过一个同步的纯函数夹具构造相同场景:先喂给 epoch 6 的 0.42,切换 viewport 使 epoch 变为 7,再喂两个 epoch 6 的更新,最后输入一个 epoch 7 的更新。断言比例未偏移、拒绝次数等于 2,且显示 X 坐标恰为 151.2。

除了这条“成功保真”的示例,最好保留另一组失败断言。例如,给其中一张图换成 16:9 裁切但忘了通知映射器,应用应该进入“内容域不一致”状态,而不是继续 ALIGN_READY;当传入未知尺寸、零宽度或非有限数时应该拒绝计算;当图像身份从 IMG-078 换成别的 ID 时应清理已绑定旧图片的手势记录。这些边界测试不会让展示更炫,但能防止把对照控件做成质量判断里的噪声源。

布局性能也别只看流畅度。持续快速拖动会触发多次重绘;如果每次重绘都新建 PixelMap,内存曲线容易在长时间操作后上升。反过来,如果为省内存过早释放增强图,画布则可能读到无效对象。应该把“是否需要重绘”和“是否需要重新解码”拆成两个信号,前者属于高频视觉操作,后者属于低频资源版本变化。只要图像身份没有变化,移动分割线通常不需要重复解码。

七、把验收拆成几层,避免一张好看的截图掩盖问题

第一层是数学验收。对 0、0.25、0.42、0.5、0.75、1 六个比例,分别计算分割线、原图列、增强图列,确认小数投影以及左右边界都符合预期。第二层是事件验收。固定几种视口序列,先大后小、先小后大、正在拖动时切换、短时间内连续切换,检查旧代次事件不会写回。

第三层是资源验收。重复打开比较页、切换图片、隐藏页面、重新显示、退出页面,核对 PixelMap 是否只在明确的所有权结束后释放;检查是否因旧异步操作回调晚到而重新显示已经被替换的图片。第四层才是设备与表现验收:不同实际宽度、密度、折叠姿态、方向和字体缩放设置下,确认触摸区域、分割线、图片裁剪一致。这四层不能用一张模拟器示意图来替代。

即使所有数学断言通过,图像内容还可能出现“增强纹理并非原纹理”的现象。那是算法与产品主张要回答的问题,不该由本对照控件替它背书。应用可以允许用户放大查看,但缩放操作之后也必须同步两侧内容域与分割基准;如果两侧照片经过不同裁剪,先让用户确认或重新选择样本,而不是用插值把差异涂平。

如果把这个组件移植到图片编辑器,可能需要支持拖动裁剪框、长按放大镜和对比区域快照;如果放进手机相册,可能更重视滑动手感和资源占用。在这些扩展里,我建议坚持一条规矩:任何可报告的结果都要能回答“哪个图片版本、哪个内容区域、哪个视口代次、哪个分割比例”。这四个维度齐了,后续争论才有真正可复算的依据。

八、最终取舍:一条线的职责要比一个按钮窄

当前方案没有声称任何超分模型的分辨率、耗时、支持设备或质量分数。它只解决本地两张预置图片的对照坐标,把 42% 这个用户动作从布局尺寸中独立出来,并通过应用层代次门禁过滤迟到输入。对于文图一致的模型示例,我们能推出:CMP-1009-08 应保存 IMG-078 的比例 0.42,视口从 720×540 收到 360×270 时把映射更新为 151.2 vp,epoch 推到 7,旧事件计数为 2,显示业务状态 ALIGN_READY。

真正落入生产项目后仍需补全真实图像源的方向归一、PixelMap 持有者、触摸 API 的精确坐标语义、不同设备像素密度、性能与异常日志,尤其需要在 DevEco Studio 和真机上重放折叠与拖动的竞态。也不要在没有证据的情况下,把模拟页面中的 ALIGN_READY 写成“已通过实机验证”。这套抽象最有价值的地方,是不论后面接哪一种增强技术,都不必让增强模型替 UI 证明它本来就应该负责的坐标一致性。

参考一手资料:华为开发者文档《如何让图片显示为圆形》关于 CanvasRenderingContext2D、clip 与 drawImage 的示例(2026-06-26 更新);《图像开发常见错误》关于异步图像操作与资源释放;图像增强能力本身以实际目标设备和最新公开文档为准。https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-688、https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/image-common-mistakes。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐