相册筛选页有个很容易被当成视觉适配的小问题:用户已经勾选“仅看离线”,又输入了“海边”,这时展开折叠屏,抽屉变成侧栏,结果列表却突然跟着刷新。再折回窄屏,取消按钮也失去了原来的含义。这不是媒体查询判断错了,而是把“筛选控件重新布局”和“用户确认筛选”写成了同一件事。这篇使用一个可复现的最小工程 FilterDesk。只讨论同一页面实例内的宽窄布局切换、草稿隔离与生命周期管理;演示画面和日志是按场景设计的示意数据,不是设备实测记录。## 一、先确认哪一次变化不应该触发查询我把业务拆成两份值:draft 保存用户正在编辑的关键词和离线开关,applied 保存上一次点击“应用”后真正用于列表查询的条件。用来切换布局的 isWide 是第三份值,它与筛选条件没有天然关系。窄屏时编辑器是一张抽屉,宽屏时编辑器落到常驻侧栏;改变的应该是容器,而不是被编辑的数据。本篇固定演示批次为 FILTER_20261017_04。初始已应用关键词是“山谷”,离线开关为关;用户在编辑器里把草稿改成“海边”,离线开关为开。界面按 NARROW → WIDE → NARROW 切换,仍应保持 dirty=true、applyCount=0。直到点击“应用”,列表才允许接收新条件。具体阈值取应用可绘制区域宽度 600vp,这是示例产品选定的断点,不是华为强制要求的折叠屏分界线。这里有一个容易忽略的前提:折叠屏的展开状态不等于应用窗口一定够宽。用户可能分屏使用,也可能把自由窗口缩到很窄。官方媒体查询文档区分了 width(应用页面可绘制区域)和设备物理宽度;布局决策应优先看当前窗口空间,不应根据“设备正在展开”的自定义布尔值猜测。## 二、把编辑事务写在布局逻辑之外先解决条件被误提交的问题。下面的业务模型不调用系统权限,也不承担 UI 渲染;它仅定义用户何时改变草稿、何时提交与撤销。两份对象在应用时拷贝,防止后续改动引用同一个对象导致“尚未提交却已生效”。ts// entry/src/main/ets/model/FilterTransaction.etsexport interface FilterValues { keyword: string; offline: boolean;}export class FilterTransaction { draft: FilterValues = { keyword: '海边', offline: true }; applied: FilterValues = { keyword: '山谷', offline: false }; applyCount: number = 0; get dirty(): boolean { return this.draft.keyword !== this.applied.keyword || this.draft.offline !== this.applied.offline; } apply(): FilterValues { this.applied = { ...this.draft }; this.applyCount += 1; return { ...this.applied }; } cancel(): void { this.draft = { ...this.applied }; }}````dirty` 不是系统提供的状态,而是两个业务快照是否不同的计算结果。`applyCount` 记录用户明确提交的次数,媒体查询回调不准修改它。取消意味着把草稿回到已应用条件,不等于“关闭抽屉”;产品可以另外提供一个关闭按钮,避免把关闭动作偷偷解释为提交。实际项目若关键词来自输入法组合输入,查询应由应用按钮或明确的即时搜索策略触发,不能依赖每次输入事件都重新查询。模型这里使用普通类,方便单独检查状态转换。放入 ArkUI 页面时,渲染所使用的值还需要由 `@State` 或团队选用的状态管理机制驱动更新;普通类内部的原地修改不会自动保证每个组件重绘。下面的页面示例直接把用于展示的草稿和已应用值声明为 `@State`,把模型中的“复制而非共享引用”规则落实到页面级。## 三、只让 MediaQuery 修改布局开关关键的系统接口是 `this.getUIContext().getMediaQuery().matchMediaSync('(width >= 600vp)')`。它返回监听句柄,`matches` 提供初始化时的匹配状态;通过 `on('change', callback)` 接收之后的变化。以下代码展示页面骨架,省略了无关样式,但保留了注册与释放动作。ts// entry/src/main/ets/pages/FilterDeskPage.ets(关键部分)import { mediaquery } from ‘@kit.ArkUI’;@Entry@Componentstruct FilterDeskPage { @State isWide: boolean = false; @State draftKeyword: string = ‘海边’; @State draftOffline: boolean = true; @State appliedKeyword: string = ‘山谷’; @State appliedOffline: boolean = false; @State applyCount: number = 0; @State panelOpen: boolean = true; private listener?: mediaquery.MediaQueryListener; private onWidthChanged = (r: mediaquery.MediaQueryResult): void => { this.isWide = r.matches; console.info([FilterDesk] layout=${this.isWide ? 'WIDE' : 'NARROW'}); }; aboutToAppear(): void { this.listener = this.getUIContext().getMediaQuery() .matchMediaSync(‘(width >= 600vp)’); this.isWide = this.listener.matches; this.listener.on(‘change’, this.onWidthChanged); } aboutToDisappear(): void { this.listener?.off(‘change’, this.onWidthChanged); this.listener = undefined; } build() { Column() { Text(已应用:${this.appliedKeyword}) if (this.isWide) { Row() { this.filterEditor() Text(‘结果列表(右栏)’) } } else { Text(‘结果列表(窄屏)’) if (this.panelOpen) { this.filterEditor() } } }.width(‘100%’) } // filterEditor() 构建方法见下一段}页面层的重点不是 Row 或 Column 哪个更高级,而是它们的切换不会重新创建 `draftKeyword`。这些字段都定义在相同页面实例上,而不是定义在分支内部的子组件里。否则每次分支销毁再创建时,子组件默认值可能把用户的编辑内容抹掉。`onWidthChanged` 仅写 `isWide` 并记录布局事件,不触碰 `applyCount` 或已应用查询。`aboutToDisappear` 中解除监听是必要的清理边界。若页面导航离开又回来,每次都新增一个回调,日志可能出现重复的 `WIDE`、`NARROW`,更严重时过期页面还试图更新状态。此处故意保存同一个回调引用,以便 `off` 能精确解除对应监听。真实工程还要根据页面是否缓存、是否继续可见等生命周期约定确认重新注册时机。![](https://i-blog.csdnimg.cn/direct/8eeeb0061c0a4af2b918c673cb79beaa.png)## 四、应用与取消要对应两个不同按钮有了稳定的布局容器,还要确保用户意图不会被 UI 事件混淆。这个编辑器在宽屏侧栏与窄屏抽屉内复用同一段构建逻辑。下面方法放在上述 `FilterDeskPage` 组件内部,与 `@State` 字段配套使用。ts@BuilderfilterEditor() { Column({ space: 12 }) { Text(‘筛选草稿’) TextInput({ text: this.draftKeyword }) .onChange((value: string) => { this.draftKeyword = value; }) Row() { Text(‘仅看离线’) Checkbox({ name: ‘offline’, group: ‘filter’ }) .select(this.draftOffline) .onChange((value: boolean) => { this.draftOffline = value; }) } Button(‘应用筛选’).onClick(() => { this.appliedKeyword = this.draftKeyword; this.appliedOffline = this.draftOffline; this.applyCount += 1; console.info([FilterDesk] APPLY count=${this.applyCount}); }) Button(‘取消修改’).onClick(() => { this.draftKeyword = this.appliedKeyword; this.draftOffline = this.appliedOffline; console.info(‘[FilterDesk] CANCEL draft restored’); }) }.padding(16)}```这里的两个赋值看似平常,却定义了行为边界。“应用筛选”先把草稿复制给已应用值,再递增次数;在真实列表页,应把查询请求绑定在这一明确操作之后,同时为异步查询设置序号,避免旧请求覆盖新结果。“取消修改”只回滚编辑器,不发起额外网络请求。如果产品要求关闭抽屉时保留未提交草稿,就不要在关闭动作里调用取消方法。Checkbox 的 select 与 onChange 都是 ArkUI 组件能力,但“离线筛选”是业务属性,并非系统媒体权限。模板中使用同一个 group 名称只是为控件配置分组,并不代表跨设备同步。对于筛选条件复杂的产品,可以把关键字、日期范围、素材类型组织成不可变值对象,提交时一次性生成查询快照。## 五、验证时看三列,而不是盯着界面是否变宽我给这个示例安排四个检查点。第一,在窄屏进入页面,草稿显示“海边/仅看离线开”,已应用仍是“山谷/关闭”;第二,把窗口拉到大于断点,编辑器进入侧栏;第三,再缩回窄屏,草稿保留;第四,点“应用筛选”之后列表条件才变。前面三步的期望日志是布局事件两条,不存在 APPLY 日志。演示画面把第三步固定为 mode=NARROW、dirty=true、applyCount=0。如果截图里已应用条件也变成“海边”,说明实现把草稿与已应用值复用了,不能把它解释成成功。若两次宽窄切换产生四条甚至更多日志,则优先检查重复订阅,而不是先改阈值。实际设备验证还要区分折叠状态、应用分屏与悬浮窗口大小,记录物理设备、系统与 SDK 版本、窗口宽度、变化事件顺序和页面实例标识。本文没有执行这组真机测试,因此图中的 WIDE → NARROW 是为了说明排查方法的预设轨迹,不是性能指标或实测结论。## 六、适用边界:布局变化不是提交事件这套方法适合筛选抽屉、商品过滤侧栏、相册搜索条件、编辑器设置页等“先编辑后确认”的场景。它不负责解决应用进程被系统终止后的持久化;若需要跨进程重建,草稿应根据产品策略写入 Preferences 或数据库,并明确恢复和过期机制。它也不能替代折叠屏多窗口的完整适配测试,600vp 只是本 Demo 的设计选择。对我来说,这个实现最重要的判断是:媒体查询只决定布局如何呈现,用户提交才决定查询如何执行。 一旦把两者拆开,折叠或拉伸窗口就不再是隐式提交按钮,取消、恢复与日志也都有了可解释的语义。参考资料:华为开发者官方《媒体查询(@ohos.mediaquery)》:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/arkts-layout-development-media-query-V5;官方 Checkbox API:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/api/ts-basic-components-checkbox。接口已按官方文档核对,但本示例未经过 DevEco 编译或真机运行;图片为设计演示。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐