同一张商品图,在折叠态看起来主体完整,展开后却只剩半张脸,这类问题经常被误判成图片资源不适合横屏。真正的矛盾不是横竖比例不同,而是 ImageFit.Cover 默认围绕中心裁剪;业务主体却不一定在中心。窗口尺寸变化后,中心裁剪会重新计算,原来位于右上区域的人物、商品或文档标题就可能被截掉。

本文构造一个 FocusFrame 演示工程,页面名为 HeroCropPage。图片原始尺寸 2400×1600,主体焦点以归一化坐标记录为 (0.72, 0.43)。折叠态容器为 320×240,展开态容器为 760×420。任务 FOLD-FOCUS-0086 不保存某个设备上的固定偏移,而是在每次稳定布局后重新求出源图裁剪矩形,并用 layoutEpoch 拒绝迟到提交。

文中的尺寸、时间和统计用于解释工程方法,不代表特定设备的实测表现。配图也是演示界面,不冒充真实运行证据。

一、异常发生在布局完成之后

这个问题有一个很迷惑的现象:页面展开时容器尺寸已经正确,日志里 760×420 也没有问题,但主体仍会跳到画面边缘。原因是布局正确和裁剪正确属于两件事。

ImageFit.Cover 要保证容器被填满。当源图和容器宽高比不一致时,图片会按较大缩放比放大,超出容器的部分被裁掉。中心裁剪没有业务错误,却不知道主体位于 (0.72, 0.43)。如果产品要求主体在画面的 (0.72, 0.45) 附近,就必须把焦点、目标锚点和容器尺寸一起纳入计算。

更麻烦的是,折叠过渡不是一次同步赋值。窗口变化、父布局更新、图片区域回调和异步裁剪可能依次发生。一次展开过程,本例记录到 9 次区域事件;真正进入计算的稳定样本只有 3 次,旧代次提交被拒绝 2 次。如果每次回调都立即重算并写状态,页面会在过渡期间反复抖动。

二、焦点不是像素点,而是内容语义

FocusFrame 没有把主体位置记录成“距离左侧 1728 px”。它保存的是 focusX=0.72、focusY=0.43。归一化坐标可以随原图尺寸转换,也方便后台内容系统在上传素材时标注。

目标锚点同样使用归一化坐标。本例希望主体最终落在容器宽度的 72%、高度的 45% 处。它不是永远居中,也不是盲目贴右。设计人员可以按卡片类型选择不同锚点:头像通常接近中心,带左侧文字的海报可以让人物略偏右,文档标题则可能靠上。

这段代码解决什么问题:把图片主体、目标锚点、容器尺寸和布局代次组织成一份裁剪请求,避免使用设备相关的固定像素偏移。

interface NormalizedPoint {
  x: number;
  y: number;
}

interface PixelSize {
  width: number;
  height: number;
}

interface CropRequest {
  taskId: string;
  layoutEpoch: number;
  sourceSize: PixelSize;
  containerSize: PixelSize;
  subjectFocus: NormalizedPoint;
  targetAnchor: NormalizedPoint;
}

const expandedRequest: CropRequest = {
  taskId: 'FOLD-FOCUS-0086',
  layoutEpoch: 57,
  sourceSize: { width: 2400, height: 1600 },
  containerSize: { width: 760, height: 420 },
  subjectFocus: { x: 0.72, y: 0.43 },
  targetAnchor: { x: 0.72, y: 0.45 }
};

所有归一化值都要限制在 0~1。主体标注缺失时,示例退回 (0.5, 0.5) 的中心策略,并明确标记 FOCUS_FALLBACK,而不是静默伪装成已完成焦点保持。真实项目还要记录焦点来源,例如运营标注、人脸框中心或上一次用户调整,来源不同,可信度也不同。

三、用 onAreaChange 取得布局事实

组件区域变化事件从较早 API 版本就可用。HarmonyOS 7 的 API 26 还提供带 AreaChangeOptions 的重载,可通过 expectedUpdateInterval 设置预期回调间隔。官方文档同时提醒:onAreaChange 只保证与当前组件有关,不保证父子组件回调的固定顺序;由 translate、offset、scale 等绘制变化造成的变化也不会触发它。

这两条边界决定了写法。第一,不能依赖父容器先回调、图片后回调;第二,不能把绘制偏移当成下一轮布局事实。我们只在图片承载容器上采样最终宽高,回调中不立即写裁剪结果,而是把样本交给稳定器。

这段代码解决什么问题:以 API 26 的回调间隔能力降低过渡期采样密度,并把区域事件转换成带代次的布局样本。

@Component
struct HeroCropSurface {
  @State private container: PixelSize = { width: 320, height: 240 };
  @StorageLink('layoutEpoch') private layoutEpoch: number = 56;
  private coordinator: FocusCropCoordinator = FocusCropCoordinator.shared();

  build() {
    Stack() {
      // 图片绘制由已提交的 CropPlan 驱动
      HeroImageLayer()
    }
    .width('100%')
    .height('100%')
    .onAreaChange((_oldArea: Area, newArea: Area) => {
      const size: PixelSize = {
        width: Number(newArea.width),
        height: Number(newArea.height)
      };
      this.container = size;
      this.coordinator.offer({ epoch: this.layoutEpoch, size: size });
    }, { expectedUpdateInterval: 48 })
  }
}

48 ms 是演示工程的采样配置,不是系统建议值。它大约让快速过渡中的密集变化按三帧左右合并一次,仍需结合动画时长和页面复杂度调整。值设置过大,主体会在过渡结束后迟迟不归位;设置为零,又可能让计算与状态写入过密。

另一个易错点是 Area.width、Area.height 的值类型和单位。示例在工程内部统一转成布局使用的数值,并确保源图裁剪计算和容器尺寸使用同一套约定。若把 px、vp 混在一起,公式仍能给出数字,却会在不同密度设备上产生完全不同的偏移。

四、Cover 裁剪公式要允许边界夹紧

计算分三步。

第一步,求 Cover 缩放比:

scale = max(containerWidth / sourceWidth, containerHeight / sourceHeight)

第二步,把源图焦点映射到缩放后画布,再减去目标锚点位置,得到理想裁剪左上角。

第三步,把裁剪偏移限制在 0 到“缩放后尺寸减容器尺寸”的范围内。主体靠近边缘时,目标锚点可能无法完全满足,这不是计算失败,而是可用画布不足。此时边界夹紧优先保证不露底。

这段代码解决什么问题:根据主体焦点和目标锚点计算 Cover 模式的源图裁剪矩形,并在图片边界内夹紧结果。

interface PixelRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function computeCoverCrop(req: CropRequest): PixelRect {
  const sw = req.sourceSize.width;
  const sh = req.sourceSize.height;
  const cw = req.containerSize.width;
  const ch = req.containerSize.height;
  const scale = Math.max(cw / sw, ch / sh);
  const scaledW = sw * scale;
  const scaledH = sh * scale;

  const focusX = req.subjectFocus.x * scaledW;
  const focusY = req.subjectFocus.y * scaledH;
  const desiredX = req.targetAnchor.x * cw;
  const desiredY = req.targetAnchor.y * ch;
  const cropLeft = clamp(focusX - desiredX, 0, scaledW - cw);
  const cropTop = clamp(focusY - desiredY, 0, scaledH - ch);

  return {
    x: Math.round(cropLeft / scale),
    y: Math.round(cropTop / scale),
    width: Math.round(cw / scale),
    height: Math.round(ch / scale)
  };
}

对展开态 760×420 代入公式,缩放比由宽度决定,得到源图裁剪矩形约为 x=0, y=91, width=2400, height=1326。主体焦点最终落在容器 (547, 189) 附近,对应归一化目标 (0.72, 0.45)。

折叠态 320×240 则由高度决定,水平方向存在裁剪。焦点可以向目标位置靠近,但靠近图片边界时会触发夹紧。诊断页应同时显示理想位置和实际位置,避免设计人员看到“没有完全对齐”就以为公式失效。

五、布局代次负责拒绝迟到结果

计算本身很快,仍可能迟到。折叠态的样本进入队列后,窗口马上展开;如果旧样本稍后提交,就会把 320×240 的裁剪方案覆盖到 760×420 容器上。页面看起来会先正确、再跳错,日志却没有异常。

本例每次稳定形态切换都增加 layoutEpoch。开始计算时携带当前代次,提交时再次比较。只有“计算开始”和“提交时刻”都属于同一代次,结果才有资格进入展示状态。

这段代码解决什么问题:把裁剪计算与状态提交分开,在提交点拒绝旧 layoutEpoch,避免折叠态方案覆盖展开态页面。

interface AreaSample {
  epoch: number;
  size: PixelSize;
}

class FocusCropCoordinator {
  private static instance: FocusCropCoordinator = new FocusCropCoordinator();
  private currentEpoch: number = 57;
  private committed?: PixelRect;

  static shared(): FocusCropCoordinator {
    return FocusCropCoordinator.instance;
  }

  async computeAndCommit(sample: AreaSample, base: CropRequest): Promise<boolean> {
    const request: CropRequest = {
      ...base,
      layoutEpoch: sample.epoch,
      containerSize: sample.size
    };
    const rect = computeCoverCrop(request);
    await Promise.resolve(); // 代表实际工程中的异步测量或图片信息获取
    if (sample.epoch !== this.currentEpoch) {
      return false;
    }
    this.committed = rect;
    return true;
  }

  beginEpoch(epoch: number): void {
    this.currentEpoch = epoch;
  }
}

示例中的 Promise.resolve() 只是让提交边界可见,不是假装系统有一个异步裁剪 API。真实工程可能要等待 ImageSource.getImageInfo()、内容标注加载或页面状态恢复,那些才是迟到的来源。

提交失败时无需回滚 UI,因为旧结果根本没有进入已提交状态。日志记录 drop epoch=56 current=57,计数增加,但页面继续使用上一份有效裁剪计划,直到新代次完成。

项目结构、公式和右侧模拟器中的焦点位置见下面的演示配图。图内 DevEco Studio 为白色主题,模拟器固定在右侧,底部日志使用本文同一批数据。

六、运行页要同时展示容器和源图坐标

只画一个红色焦点圆点,无法说明它为什么正确。HeroCropPage 在展开态显示商品主图,旁边列出源图 2400×1600、容器 760×420、焦点 (0.72,0.43)、锚点 (0.72,0.45)、裁剪矩形 (0,91,2400,1326) 和 layoutEpoch 57。

状态栏时间固定为 17:21,Wi-Fi、5G、信号和 74% 电量完整显示。页面状态为 EXPANDED_LOCKED。红色箭头指向主体眼部附近的焦点,另一条细线标出裁剪上边界;没有在所有卡片外加红框。

这里的“锁定”不是禁止后续变化,而是表示当前代次已经形成稳定布局事实并提交了裁剪计划。一旦窗口继续变化,状态马上进入 RESIZING,旧计划可以暂时展示,却不能被标记为当前布局的最终方案。

七、诊断页沿着时间线解释两次拒绝

本例的状态序列为:

COMPACT_LOCKED → RESIZING → MEASURED → CROP_COMPUTED → EXPANDED_LOCKED

区域事件共 9 次,稳定器接收 3 个样本,旧提交拒绝 2 次,最终提交 1 次。诊断时间线记录:17:21:12.084 收到折叠态末尾样本,17:21:12.116 进入新代次 57,17:21:12.164 测得 760×420,17:21:12.188 拒绝 epoch 56,17:21:12.207 再次拒绝 epoch 56,17:21:12.236 提交 epoch 57。

两次拒绝不代表发生两次故障,而是说明门禁确实挡住了两个迟到结果。诊断页把它们标成 STALE_DROP,不进入错误弹窗。若把可预期的并发淘汰都上报为异常,线上告警会很快失去价值。

页面还显示 expectedUpdateInterval=48 ms,目的是让调试者知道为什么 9 次布局变化只形成 3 个计算样本。这个参数影响采样频率,不替代代次校验;即使回调被节流,也仍可能出现旧异步结果。

八、三类常见错误写法

第一类是在 onAreaChange 中直接修改组件宽高。回调由布局变化触发,回调又修改布局,容易形成反馈循环。正确做法是采样容器事实,计算结果只影响图片绘制或裁剪计划。

第二类是把 translate 后的视觉位置当成新区域。官方文档已经说明绘制变换通常不会触发区域变化事件。若业务需要拖动后保存焦点,应从手势状态读取,而不是等待 onAreaChange 告诉你。

第三类是只在折叠/展开两个标签上分支。多窗口、分屏、自由窗口会产生大量中间尺寸。算法应该由真实容器尺寸驱动,形态标签只用于决定目标锚点或布局策略。

九、焦点来源也需要治理

内容系统提供人工焦点时,通常最稳定;人脸检测或目标检测自动生成焦点时,需要保存置信度;用户手动拖动后形成的焦点,则应优先于默认标注。工程中可定义 MANUAL > USER_ADJUSTED > DETECTED > CENTER_FALLBACK 的来源优先级。

焦点不是永远有效。商品换图、图片方向被纠正、服务端裁剪资源更新后,旧焦点可能指向错误位置。建议把焦点与资源摘要或版本号绑定。资源版本不匹配时回退中心裁剪,并在内容后台提示重新标注。

隐私场景也需要克制。人脸焦点可以只保存归一化中心和资源版本,不必保存完整人脸框或识别信息。本文的目的只是保持构图,不涉及身份识别。

十、资源与生命周期怎么收口

页面进入后台时,可以保留最近一次已提交的 PixelRect,但应停止继续消费高频区域样本。页面恢复后重新读取当前容器尺寸,开启新的 layoutEpoch,不能假设后台前后的窗口形态未变。

若页面持有 ImageSource 或临时 PixelMap 用于预裁剪,创建与释放要成对处理。本文示例优先让显示层按裁剪计划绘制,不强制为每次尺寸变化创建新 PixelMap;否则折叠动画期间会产生连续位图分配。只有导出文件或下游算法确实需要物理裁剪结果时,才生成新的像素资源。

十一、验收不能只看一次展开

建议按以下顺序验证:折叠态冷启动、展开、快速合拢、连续往返、分屏拖动、后台切换后恢复、内容图片替换、焦点缺失、焦点靠近四角、超宽与超高图片。每个场景都记录容器尺寸、代次、裁剪矩形、焦点实际落点和拒绝次数。

自动化断言可以检查:裁剪矩形不越界;宽高为正;主体实际落点尽量接近目标锚点;边界夹紧时有明确标记;旧代次提交为零;最终稳定态只有一个有效计划。视觉回归图则用于确认没有露底、拉伸或主体截断。

十二、边界与结论

onAreaChange 提供的是组件布局事实,不提供业务裁剪策略。ImageFit.Cover 提供的是填满容器的通用规则,不知道哪一部分内容最重要。把两者连接起来,需要一份归一化主体焦点、一个目标锚点、一套边界夹紧公式,以及能够拒绝迟到结果的布局代次。

在 FOLD-FOCUS-0086 中,源图 2400×1600 从 320×240 容器过渡到 760×420,最终计算得到 (0,91,2400,1326) 的源图裁剪矩形。9 次区域事件经采样形成 3 个候选,2 个 epoch 56 的迟到提交被拒绝,epoch 57 成为唯一有效结果。这个数字组合不是设备性能结论,而是一份可重复阅读的状态合同。

真正值得保留的做法是:用真实区域驱动计算,用内容语义决定构图,用代次保护提交,用诊断页解释夹紧与淘汰。这样,折叠屏适配就不只是“布局能铺开”,而是图片中的关键内容在每种形态下都仍然被看见。

十三、边界夹紧不是次要细节

主体靠近四角时,理想锚点经常无法满足。例如焦点接近源图右边缘,却希望它落在容器左侧,意味着右侧还需要凭空出现更多图像。算法只能在“保持锚点”和“不露底”之间选择。本文把不露底设为硬约束,把锚点设为软目标,因此要对 cropLeft 和 cropTop 做夹紧。

诊断信息应记录 idealOffset、clampedOffset 和 clampReason。如果右边界触发夹紧,页面可显示 CLAMP_RIGHT。设计人员看到主体实际位置没有达到 (0.72,0.45) 时,就能确认是素材可用范围不足,而不是程序计算错误。若大量图片都触发同一侧夹紧,说明锚点策略或运营构图规范需要调整。

超宽、超高图片还会暴露另一个问题:裁剪矩形可能只保留很窄的一条内容。除了不越界,业务还可以设置最小保留比例。例如源图被裁掉超过宽度的 45% 时,不再坚持 Cover,而是切换到 Contain 加背景填充。这个策略属于产品设计,不能被公式擅自决定;因此 CropPlan 最好同时返回裁剪比例,让上层选择显示模式。

图片方向同样要在焦点计算前统一。若资源带有方向元数据,解码后的逻辑宽高和焦点坐标必须处在同一方向空间。否则图像已经旋转,焦点仍按原始文件坐标解释,会出现稳定而错误的偏移。资源管线应先完成方向归一,再绑定焦点版本。

十四、从单个 Hero 图扩展到列表时要克制

单张头图可以在每次稳定布局后计算一次,列表中的几十张卡片却不能全部高频监听。更合适的方式是让列表容器提供当前布局类别和卡片宽度,卡片只在首次可见、宽度档位变化或资源版本变化时计算裁剪计划。相同尺寸、相同焦点和相同目标锚点可以复用纯计算结果。

复用键应包含资源摘要、源尺寸、容器尺寸、焦点、锚点和算法版本。只用图片 URL 作为缓存键会在折叠后拿到旧矩形;只用容器尺寸又会让不同焦点的图片共享错误结果。缓存对象只存 PixelRect 等轻量数据,不持有页面或 PixelMap 引用,避免列表回收后仍保留大对象。

当列表快速滚动时,不可见卡片的迟到计算应该被可见性令牌拒绝。这里的令牌与 layoutEpoch 解决不同问题:epoch 表示布局代次,可见性令牌表示组件实例是否仍有资格接收结果。二者缺一,都会出现复用组件短暂显示上一项裁剪方案的现象。

十五、多窗口场景不应继续使用“折叠态/展开态”二分法

自由窗口、分屏和横竖屏组合会产生许多介于 320×240 与 760×420 之间的尺寸。如果代码只判断 isExpanded,同一标签下仍可能有完全不同的宽高比。本文将形态标签降级为策略提示,真正的裁剪始终使用 onAreaChange 得到的容器事实。

形态标签仍有价值。它可以决定目标锚点:窄容器中主体略偏上,为下方标题留空间;宽容器中主体略偏右,为左侧文案留空间。但锚点变化也必须生成新的 layoutEpoch,否则旧锚点结果可能通过尺寸校验后被提交。

跨设备适配时,还要考虑图片内容本身是否一致。手机使用竖版资源、平板使用横版资源时,两者应有各自焦点和资源版本,不能把手机的 (0.72,0.43) 直接套到横版图。焦点是“某个内容资源中的语义位置”,不是商品 ID 的通用属性。

十六、性能预算应该落在状态变化点

裁剪公式只是十几次浮点运算,真正容易放大的成本来自状态写入、图片重新解码和位图重建。示例只更新轻量 PixelRect,让显示层基于已有资源调整视窗,不在每次区域回调中创建新 PixelMap。

状态稳定器应避免定时器泄漏。页面消失或进入新 epoch 时,取消旧稳定任务;定时回调触发后再次检查 epoch 和页面令牌。一个常见问题是只在 aboutToDisappear 清理,却没有在容器被复用时取消旧任务,导致列表卡片切换内容后仍收到上一项回调。

日志也需要节制。9 次区域事件全部输出完整 JSON,在复杂页面里会造成大量字符串分配。开发版本可以记录每次样本,发布版本只记录 epoch 开始、稳定尺寸、拒绝原因和最终提交。诊断开关打开时再扩展明细。

十七、视觉验收要覆盖“主体仍可见”之外的细节

主体没有被裁掉只是最低标准。还要观察裁剪过渡是否跳跃、边缘是否露底、圆角遮罩是否和图片同步、占位图切换到真实图时焦点是否突变、无障碍放大字体导致容器高度变化时是否重新计算。

对于人物图,可以检查眼睛或面部中心是否落在安全区;商品图检查关键品牌和主体完整度;文档封面检查标题是否仍在可读区域。安全区不要与某个设备的物理折痕硬编码在一起,它应该来自当前卡片的设计约束。

截图回归时,同一资源至少保留折叠态、展开态、分屏窄窗和分屏宽窗四种基准图。每张图附带容器尺寸、焦点、锚点和 cropRect,避免以后只凭肉眼比较两张没有上下文的截图。算法版本变化时,基准图也要明确更新原因。

十八、参考资料

  • HarmonyOS 多设备自适应应用:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • ArkUI 组件区域变化事件:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-component-area-change-event
  • ArkUI Image 组件与 ImageFit:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-components-image
Logo

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

更多推荐