货架盘点这类页面,通常同时承载数量、物料信息和下一步操作。手机竖屏里排得整齐,并不意味着在展开折叠屏上也整齐:当应用以浮窗运行,屏幕看起来很宽,真正留给卡片的内容区域却可能只有四百多 vp。若仍以设备宽度或窗口宽度决定展示两列,按钮和数量栏就会互相挤压。

这篇讨论一个范围很窄的工程问题:同一张盘点任务卡放在不同父容器里,如何按卡片可用宽度切换布局,而不把折叠态、展开态、浮窗态都硬编码成设备判断? 我把演示项目称为 StockScope;示例数据固定为任务 INV-2048、物品“机械键盘”、货架 A-01-03、已核对 7/12。这是一组用于讲解结构的模拟数据,不是设备实测结果。

一、先把断点的参照物找对

旧做法喜欢问“这台设备是手机还是折叠屏”。问题在于同一台设备上,内容宽度也会随侧栏、分屏、浮窗和父容器内边距变化。一个 900vp 的应用窗口,左侧固定导航占去一部分后,右侧卡片的可用宽度完全可能不足 480vp。布局所依赖的实际对象应该是卡片所在容器,而不是设备标签。

华为在 ContainerReader 官方文档中把它定义为容器断点能力:从 API 26.0.0 起支持,容器可通过双向绑定把尺寸及断点回传给状态。这个版本边界需要写清楚;不能把新能力直接套到低版本工程,也不能只在标题加 HarmonyOS 7 就默认可用。无法升级到对应 API 时,应采用稳定的窗口变化监听或其他旧版响应式组件并做相容性测试。

StockScope 先定义三段布局。内容宽度小于 480vp 时使用紧凑单列;介于 480vp 和 719vp 时把统计数字压成一行但保留单列操作;从 720vp 起才安排信息摘要和操作面板并排。两个阈值来自这组 Demo 的内容测量假设,不是华为给所有应用规定的统一设计标准,实际项目必须按字号、国际化文案和无障碍放大重新测量。

除了宽度,卡片还要有自己的数据标识。布局从紧凑变为宽屏,只允许改变组件排列,不能重置已核对数量或生成新的任务 ID。为此,“库存任务”与“容器宽度”要分开保存。前者是业务状态,后者是纯展示状态,混在一起就容易出现收起浮窗后任务进度回到初始值的错误。

二、进度先归业务层,避免布局把数据写乱

第一段代码解决的是进度口径。既然 7/12 必须对应 58%,就统一从计数计算展示值;不要在页面里同时维护一个“7”和另一个独立的“58”。真实业务还需要后端版本号和冲突处理,这个示例只覆盖单任务本地展示。

// entry/src/main/ets/model/InventoryTask.ts
export interface InventoryTask {
  id: string;
  name: string;
  shelf: string;
  checked: number;
  total: number;
}

export function progressPercent(task: InventoryTask): number {
  if (task.total <= 0) {
    return 0;
  }
  const safeChecked = Math.max(0, Math.min(task.checked, task.total));
  return Math.round(safeChecked * 100 / task.total);
}

export const DEMO_TASK: InventoryTask = {
  id: 'INV-2048', name: '机械键盘', shelf: 'A-01-03',
  checked: 7, total: 12
};

checked 是事实数据,百分比只是投影。超出范围的计数会被夹到零与总量之间,防止页面出现 108%;但是夹值不能代替服务端校验。如果服务端返回负数、总量变小或者重复扫描,应该记录异常并停止覆盖本地有效版本,不能默默吞掉问题。这里的 Math.round 会把 7÷12×100 显示为 58%,与图中的示意结果一致。

另一个容易忽略的问题是重复触发。扫码成功和手动勾选可能指向同一个库存记录,不能简单执行两次 checked++。更稳妥的实务结构是维护物料明细的唯一键集合,聚合之后再计算总数;当前文章暂时用固定样本隔离这个并发问题,因为主题是布局断点,不应把库存事务模型伪装成已经完成。

三、让卡片只关心自己的宽度

真正触发视图变化的是 ContainerReader。下段代码保留最小可读结构:@State 初始化尺寸和断点,使用 !! 进行文档要求的双向绑定,然后让 UI 根据 contentSize.width 选择密度。这里用文字与数字代替图片资源,因此可以直接观察结构变化,不依赖外部资产。

// entry/src/main/ets/pages/InventoryPage.ets
import { ContainerReader, Size } from '@kit.ArkUI';
import { DEMO_TASK, progressPercent } from '../model/InventoryTask';

@Entry
@Component
struct InventoryPage {
  @State contentSize: Size = { width: 0, height: 0 };
  @State bp: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
  private task = DEMO_TASK;

  build() {
    Column() {
      Text('StockScope · 货架盘点').fontSize(22)
      ContainerReader({
        size: this.contentSize!!,
        widthBreakpoint: this.bp!!
      }) {
        Column({ space: 12 }) {
          Text(`任务 ${this.task.id}`)
          Text(`${this.task.name} · ${this.task.shelf}`)
          Text(`已核对 ${this.task.checked}/${this.task.total}`)
          Text(`盘点进度 ${progressPercent(this.task)}%`)
          if (this.contentSize.width < 480) {
            Text('单列紧凑布局').fontColor('#2563EB')
          } else if (this.contentSize.width < 720) {
            Text('单列舒展布局').fontColor('#2563EB')
          } else {
            Row() {
              Text('摘要区').layoutWeight(1)
              Text('操作区').layoutWeight(1)
            }
          }
          Button('继续核对')
        }.width('100%').padding(16)
      }
      .breakpointConfig({ width: [480, 720] })
      .width('100%').layoutWeight(1)
    }.width('100%').height('100%')
  }
}

这里最重要的不是 if,而是 size: this.contentSize!!。官方要求绑定对象必须是状态变量,并通过 !! 触发双向更新;若直接传普通对象,布局计算后的尺寸不一定会进入当前 UI 状态。父容器还必须提供明确的尺寸约束,不能同时指望父节点由子节点撑开、子节点又依靠父节点确定尺寸,那会形成测量依赖。

breakpointConfig 中的数组必须单调递增。系统支持的宽度断点个数也有限制,超过文档规定的数量可能退回默认配置。因此这段 Demo 只配置两个明确阈值。注意 WidthBreakpoint 的枚举表示系统计算的断点区间,而当前示例继续使用 contentSize.width 做业务展示分支,是为了把 480 和 720 这两个产品约束写得更直观;两者不可被理解为两套相互独立的真实屏幕宽度。

组件真正落地时,我会把任务摘要、计数、操作按钮分别拆成子组件,再在紧凑模式调整排列顺序;不会在三个分支中复制三份具有不同状态的扫码表单。只有一套任务状态,布局重建才不会误伤用户已经处理的条目。批量扫码、网络同步和数据冲突属于另一个服务层,不能通过容器断点解决。

四、图里的 468vp 与 344vp,并不矛盾

开发界面示意图把窗口内容宽度设为 468vp,HiLog 区写入拟用于检查的诊断行:task=INV-2048 checked=7 total=12 和 width=468vp layout=compact。这张图用于说明应该观察什么,不是 DevEco Studio 的真实运行截图,也不能当作程序已经编译成功的证据。若开发者复现,日志应来自自己的尺寸观测和业务层,并按项目的正式日志规范过滤敏感内容。

为了先核对阈值边界,可以把布局决策拆成不依赖 UI 的纯函数;对 344、468、720 三个值做断点检查,尤其注意 720 落在右侧新区间,而 479.9 仍是紧凑布局。下面是独立的预期行为示例,不宣称已经在模拟器执行。

// entry/src/main/ets/utils/LayoutDecision.ts
export type CardDensity = 'compact' | 'regular' | 'wide';

export function resolveDensity(widthVp: number): CardDensity {
  if (widthVp < 480) return 'compact';
  if (widthVp < 720) return 'regular';
  return 'wide';
}

// 预期:compact / compact / wide
console.info(resolveDensity(344));
console.info(resolveDensity(468));
console.info(resolveDensity(720));

可以用三类输入验证:窗口宽度变化、父容器侧栏展开、系统字号放大。前两类都可能改变可用内容宽度;第三类即使尺寸不变,也可能让文本换行,应该检查操作按钮有没有被遮挡。不要把“进入展开态”等同于“马上进入 wide”:展开屏幕只改变潜在空间,不保证右侧业务容器真的拿到了 720vp。

手机效果示意图的内容宽度采用 344vp,因此同样落在紧凑分支。图中显示 INV-2048、机械键盘、货架 A-01-03、已核对 7/12、待核对 5 和 58%,与代码样本和开发界面诊断文案保持一致;差别只是图二以 468vp 表现浮窗示意,图三以 344vp 表现竖屏视图。

五、需要单独验证的能力边界

ContainerReader 感知的是其布局容器的尺寸,不是折叠铰链角度、传感器的折叠状态,也不是用户希望使用的交互模式。若业务需要跨折叠区域排版,还应结合设备形态和显示区域的相应官方能力,不能从一个宽度数字反推出铰链位置。分屏、浮窗、键盘弹出以及系统栏对可用高度的影响,也需要分别看布局树。

还有一个版本问题值得提前写进技术评审:本文引用的是官方 API 26.0.0 起提供的 ContainerReader 文档示例。低版本 SDK 缺少该组件时,项目必须降级到支持的响应式方案,不能靠编译器条件和一段未验证的动态反射“兼容”。迁移前先在目标 SDK 中检查导出类型和设备支持矩阵,再做源码编译、窗口尺寸和语言环境回归。

最终目标不是得到某个漂亮的“宽屏截图”,而是把两类变化拆开:业务状态随任务持续,布局状态随容器测量变化。

资料: HarmonyOS 容器断点 ContainerReader 官方文档;多窗口布局适配官方文档。

Logo

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

更多推荐